diff --git a/.vscode/launch.json b/.vscode/launch.json index 99dd8ee..ac4bf38 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,8 +10,23 @@ "request": "launch", "program": "${file}", "console": "integratedTerminal", - "justMyCode": true, - "cwd": "${workspaceFolder}/src" + "justMyCode": true + }, + { + "name": "Python: makebook.py", + "type": "python", + "request": "launch", + "program": "${workspaceFolder}/makebook.py", + "args": [ + "--program=1kg8GQnJvyT8vmpLibgRernYgnokbrILXI0UWlIIX5tE", + "--title=Test title", + "email", + "--target=chairgroup", + "--outfile=/tmp/ISAP-program.mbox", + "--email=M.M.vanPaassen@tudelft.nl", + "--html-template=${userHome}/TUDelft/community/ISAP2023/templates/mailtemplate-session01.html", + "--txt-template=${userHome}/TUDelft/community/ISAP2023/templates/mailtemplate-session01.txt" + ] } ] -} \ No newline at end of file +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..bc92af5 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "python.linting.pylintEnabled": false, + "python.linting.enabled": true, + "python.linting.flake8Enabled": true +} \ No newline at end of file diff --git a/src/.vscode/launch.json b/src/.vscode/launch.json new file mode 100644 index 0000000..3cd3633 --- /dev/null +++ b/src/.vscode/launch.json @@ -0,0 +1,41 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python: sessionmail", + "type": "python", + "request": "launch", + "program": "${workspaceFolder}/makebook.py", + "args": [ + "--program=1kg8GQnJvyT8vmpLibgRernYgnokbrILXI0UWlIIX5tE", + "--title=Test Title", + "email", + "--target=chairgroup", + "--format=PAR", + "--outfile=/tmp/ISAP-programtest.mbox", + "--email=M.M.vanPaassen@TUDelft.nl", + "--html-template=/home/repa/TUDelft/community/ISAP2023/templates/mailtemplate-session01.html", + "--txt-template=/home/repa/TUDelft/community/ISAP2023/templates/mailtemplate-session01.txt" + ], + "console": "integratedTerminal", + "justMyCode": true + }, + { + "name": "Python: sheet", + "type": "python", + "request": "launch", + "program": "${workspaceFolder}/makebook.py", + "args": [ + "--program=ISAP 2023 schedule data", + "--title=Test Title", + "sheet", + "--accountfile=${userHome}/TUDelft/community/ISAP2023/service_account.json" + ], + "console": "integratedTerminal", + "justMyCode": true + }, + ] +} \ No newline at end of file diff --git a/src/.vscode/settings.json b/src/.vscode/settings.json new file mode 100644 index 0000000..bc92af5 --- /dev/null +++ b/src/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "python.linting.pylintEnabled": false, + "python.linting.enabled": true, + "python.linting.flake8Enabled": true +} \ No newline at end of file diff --git a/src/authorparse.py b/src/authorparse.py index 27b1450..b74f395 100644 --- a/src/authorparse.py +++ b/src/authorparse.py @@ -24,6 +24,8 @@ def dprint(*argv, **argkw): # functions to assemble different parts + + def setFirst(toks): toks['firstname'] = ' '.join(toks) dprint(f"firstname {toks}") @@ -86,10 +88,11 @@ def completeAuthor(toks): asKeyword=True).set_parse_action(setLastCap) author = (lastcaps + firstname + Opt(titlepost)) | \ - (Opt(titlepre) + (initials | firstname) + lastname + Opt(titlepost)) + (Opt(titlepre) + (initials | firstname) + lastname + Opt(titlepost)) | \ + (lastname + Literal(',') + firstname) -separator = Literal(',') | Literal('&') | Literal('\n') +separator = Literal('&') | Literal('\n') | Literal(';') author_line = (author + Opt(Literal(',') + Regex(r'[^\n]*').set_parse_action(setAffiliation)) @@ -159,9 +162,13 @@ def _from_parts(cls, firstname, lastname, orcid, program): obj.firstname = firstname obj.orcid = orcid obj._items = [] + obj._chairing = [] program.authors[obj.key()] = obj return obj + def addChairRole(self, event): + self._chairing.append(event) + @classmethod def _from_dict(cls, data, program): obj = cls._from_parts(data.get('firstname'), @@ -193,6 +200,18 @@ def _from_iterable(cls, row, data, program): def __str__(self): return f'{self.lastname}, {self.firstname}' + def __eq__(self, o): + return self.lastname == o.lastname and self.firstname == o.firstname + + def __lt__(self, o): + if self.lastname < o.lastname: + return True + if self.firstname < o.firstname: + return True + + def __hash__(self): + return hash((self.lastname, self.firstname)) + @classmethod def find(cls, **kw): try: @@ -218,6 +237,8 @@ def getEventCodes(self): res = [] for it in self._items: res.extend(it.getEvents()) + for ch in self._chairing: + res.extend((f"{ch.event} (chair)",)) return sorted(res, key=daysort) diff --git a/src/makebook.py b/src/makebook.py index 5302f0a..0c618c7 100644 --- a/src/makebook.py +++ b/src/makebook.py @@ -11,6 +11,7 @@ from programpdf import WritePDF from programdocx import WriteDocx from programmail import WriteEmail +from programsheet import WriteSheet import argparse import os import sys @@ -109,7 +110,7 @@ def args(cls, subparsers): def __call__(self, ns): # process the program spec - program = Program(ns.program, ns.title) + program = Program(ns.program, title=ns.title) # figure out template arguments if ns.author_template is None: @@ -171,7 +172,7 @@ def args(cls, subparsers): def __call__(self, ns): # process the program spec - program = Program(ns.program, ns.title) + program = Program(ns.program, title=ns.title) # figure out template arguments if ns.author_template is None: @@ -348,17 +349,48 @@ def __call__(self, ns): ProgramEmail.args(subparsers) + +class ProgramSheet: + + command = 'sheet' + + @classmethod + def args(cls, subparsers): + parser = subparsers.add_parser( + cls.command, + help="Add a datasheet to a google spreadsheet book") + parser.add_argument( + '--accountfile', type=str, default='', + help='Access through Google API') + parser.set_defaults(handler=cls) + + def __call__(self, ns): + + # process the program spec + program = Program(ns.program, accountfile=ns.accountfile) + + # create a writer + writer = WriteSheet(program) + + # write the events + writer.eventList(ns.title) + + +ProgramSheet.args(subparsers) + + # default arguments argvdef = ( '--title="The Nonsense Conference"', - f'--program={base}/../example/exampledata.xlsx', - 'email', - '--outfile=Nonsense.mbox', - '--email=M.M.vanPaassen@TUDelft.nl', - '--testmail=rene_vanpaassen@yahoo.com' + f'--program=/home/repa/Downloads/ISAP 2025(2).ods', + 'pdf', + '--outfile=/tmp/Nonsense.pdf', + '--authorout=/tmp/Authors.pdf' ) + + if __name__ == '__main__': if len(sys.argv) > 1: diff --git a/src/program.py b/src/program.py index 854d40b..44b2f8c 100644 --- a/src/program.py +++ b/src/program.py @@ -9,15 +9,23 @@ from authorparse import Author, AuthorList from datetime import time, datetime from spreadbook import BookOfSheets +import itertools +import sys from emailaddress import EmailAddress, parseEmails import re class Item: + """Read from the sheet an item; presentation, poster, contribution + or similar + + Raises: + ValueError: Data lacking from the sheet, will skil a row + """ _members = ('item', 'title', 'abstract', 'email', 'corresponding', 'session', 'presenter', 'requested_format') - _required = ('author_list', 'item', 'title', 'email', 'corresponding', + _required = ('item', 'title', 'corresponding', 'session') def __init__(self, row, data, program): @@ -30,14 +38,25 @@ def __init__(self, row, data, program): # these are directly coupled, make empty string cells void for m in Item._members: - if isinstance(data[m], str) and data[m].strip() == '': - v = None - elif data[m] is not None: - v = str(data[m]) - else: + try: + if isinstance(data[m], str) and data[m].strip() == '': + v = None + elif data[m] is not None: + v = str(data[m]) + else: + v = None + except KeyError: v = None setattr(self, m, v) + # author_list may be empty if corresponding is filled + if data['author_list'] is None or not str(data['author_list']).strip(): + data['author_list'] = data['corresponding'] + + # formats? + if self.requested_format: + self.requested_format = list(map( + str.strip, self.requested_format.split(','))) # an item may be presented in multiple sessions, for example the # best student paper candidates self.session = self.session.split(',') @@ -46,10 +65,12 @@ def __init__(self, row, data, program): self._session = [] try: self._session = [program.sessions[s] for s in self.session] + except Exception: raise ValueError( f"Item: check items row {row}, cannot find session" f" {self.session}") + for s in self._session: s._items.append(self) @@ -57,7 +78,7 @@ def __init__(self, row, data, program): try: self.authors = list(AuthorList(data['author_list'], program)) except Exception: - print(f"Cannot get authors from author_list in row {row}") + print(f"Cannot get authors from author_list '{data['author_list']}'in row {row}") self.authors = [Author(dict(firstname='', lastname='Anonymous'), program)] @@ -82,11 +103,22 @@ def printAuthors(self): res.append(f"{a.firstname} {a.lastname}") return ', '.join(res) + def isRemote(self): + if self.requested_format is not None and \ + 'Zoom' in self.requested_format: + print(f"Remote session for {self.title}") + return True + return False + def getFieldDetails(self): return dict( recipient=self.email, recipientname=self.corresponding, title=self.title, + daysandtimes=' and on '.join( + [f"{s._event.printDay()} at {s._event.printStart()}" + for s in self._session]), + day=', '.join([s._event.printDayFull() for s in self._session]), time=' and on '.join( [ f"{s._event.printDay()} at {s._event.printStart()}" @@ -95,8 +127,11 @@ def getFieldDetails(self): session=' and in session'.join([ f"{s._event.title}: {s._event._session.title}" for s in self._session]), + sessiontitle=', '.join([s._event._session.title + for s in self._session]), authors=self.printAuthors(), poster=('POSTER' in [s.session for s in self._session]), + remote=('Zoom' in self.requested_format), chair=[dict(name=s.chair, email=s.chair_email, session=s._event._session.title) @@ -206,6 +241,9 @@ def getEventClass(self): def printDay(self): return self.day.strftime("%a") + def printDayFull(self): + return self.day.strftime("%A, %B %e") + def printStart(self): return self.start.strftime("%H:%M") @@ -255,9 +293,9 @@ def printChair(self): def printTitle(self): try: - return self._session.title + return self._session.title or '' except AttributeError: - return self.title + return self.title or '' class Session: @@ -283,6 +321,12 @@ def __init__(self, row, data, program): f"Cannot find event {self.event} for session {self.session}" f", check event in row {row}") self._items = [] + self.program = program + + if self.chair: + self.chairs = list(AuthorList(data['chair'], program)) + for c in self.chairs: + c.addChairRole(self._event) def __str__(self): return str(self.__dict__) @@ -290,6 +334,15 @@ def __str__(self): def key(self): return self.session + def allAuthors(self, withChair=True): + if self.chair: + res = set(AuthorList(self.chair, self.program)) + else: + res = set() + for i in self._items: + res |= set(i.authors) + return res + def chairEmails(self): return parseEmails(self.chair, self.chair_email) @@ -375,11 +428,10 @@ class Program: and a check on overlap for appearing in parallel sessions """ - def __init__(self, file, title=''): + def __init__(self, file, title='', accountfile='', check_overlap=True): self.title = title - # read the file or online sheet - book = BookOfSheets(file) + self.book = BookOfSheets(file, accountfile) # prepare for filling self.authors = dict() @@ -387,21 +439,21 @@ def __init__(self, file, title=''): # events dict is returned by the call, sorted by event id # this also fills the slots, keyed by slot start time - self.events = processSheet(book.events, Event, self) + self.events = processSheet(self.book.events, Event, self) # the sessions dict is returned by the processSheet call # a session is linked to an event, and thereby to a time slot - self.sessions = processSheet(book.sessions, Session, self) + self.sessions = processSheet(self.book.sessions, Session, self) # the items are linked to a session; they will be added to # the list of items there - self.items = processSheet(book.items, Item, self) + self.items = processSheet(self.book.items, Item, self) # read the full definitions from the authors tab for authors with # further details # # this may also further fill the authors dict - processSheet(book.authors, Author, self) + processSheet(self.book.authors, Author, self) # make an organization per day self.days = dict() @@ -417,6 +469,9 @@ def __init__(self, file, title=''): au[1] for au in sorted(self.authors.items(), key=lambda s: s[0][0].casefold())] + if check_overlap: + self.checkAuthorEventOverlap() + def getEvents(self): res = [] for k, slot in sorted(self.slots.items()): @@ -429,10 +484,28 @@ def getDays(self): def getAssignedItems(self): return [it for k, it in self.items.items() if len(it._session)] + def checkAuthorEventOverlap(self): + for k, slot in sorted(self.slots.items()): + if len(slot.getEvents()) > 1: + # create sets of authors + authorsets = [ + (e.event, e._session.allAuthors()) + for e in slot.getEvents()] + + for s1, s2 in itertools.combinations(authorsets, 2): + common_authors = s1[1].intersection(s2[1]) + if common_authors: + ca = ' and '.join([str(a) for a in common_authors]) + print(f"Authors for events {s1[0]} and {s2[0]}", + f" overlap\n common authors: {ca}", + file=sys.stderr) + if __name__ == '__main__': - pr = Program('../../../TUDelft/community/ISAP2023/collated_abstracts.xlsx') + pr = Program( + '../../../TUDelft/community/ISAP2023/' + 'ISAP 2023 shedule data230417b.xlsx') kl = sorted(pr.authors.keys(), key=lambda s: s[0].casefold()) for ak in kl: print(ak, pr.authors[ak]._items) diff --git a/src/programdocx.py b/src/programdocx.py index 87cbb44..ca0a7f8 100644 --- a/src/programdocx.py +++ b/src/programdocx.py @@ -35,13 +35,13 @@ def eventList(self, fname): for event in day.events: t = doc.add_table(2, 3, style="Table Grid") - t.cell(0, 1).merge(t.cell(0,2)) t.cell(0, 0).text = \ f'{event.printDay()} {event.printStart()} - {event.printEnd()}' t.cell(1, 0).text = event.venue t.cell(1, 1).text = event.printEventCode() t.cell(0, 1).text = event.printTitle() t.cell(1, 2).text = event.printChair() + t.cell(0, 1).merge(t.cell(0,2)) if event.hasSession(): for item in event._session._items: diff --git a/src/programsheet.py b/src/programsheet.py new file mode 100644 index 0000000..de9cb4c --- /dev/null +++ b/src/programsheet.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Created on Sat Feb 11 08:49:33 2023 + +@author: repa +""" + + +class WriteSheet: + + def __init__(self, project): + + self.pr = project + + def eventList(self, fname): + + book = self.pr.book + res = [ + ["day", "start", "end", "code", "room", "title", + "authors/chair"]] + for day in self.pr.getDays(): + for event in day.events: + res.append([ + event.printDay(), event.printStart(), event.printEnd(), + event.printEventCode(), event.venue, + event.printTitle(), event.printChair()]) + if event.hasSession(): + for item in event._session._items: + res.append([ + None, None, None, None, None, + item.title, item.printAuthors()]) + book.addSheet(fname, res) \ No newline at end of file diff --git a/src/spreadbook.py b/src/spreadbook.py index 8f45d6f..f5d8ce0 100644 --- a/src/spreadbook.py +++ b/src/spreadbook.py @@ -1,15 +1,23 @@ #!/usr/bin/env python3 # -*- coding: utf-8 -*- + +import gspread +import pandas as pd +import numpy as np +import os +import sys + """ Created on Thu Feb 23 09:10:46 2023 @author: repa """ -import gspread -import pandas as pd -import numpy as np -import os +""" +For opening a spreadsheet with gspread google API: +https://codesolid.com/google-sheets-in-python-and-pandas/ +""" + class BookOfSheets: @@ -19,11 +27,17 @@ def __init__(self, url, accountfile=''): # assume google sheets; not tested yet gc = gspread.service_account(filename=accountfile) - book = gc.open_by_url(url) + try: + self.book = gc.open(url) + except gspread.SpreadsheetNotFound: + print(f"Could not open as sheet: {url}") + self.book = gc.open_by_url(url) - for s in ('items', 'sessions', 'event', 'authors'): - ws = book.worksheet(s) + for s in ('items', 'sessions', 'events', 'authors'): + ws = self.book.worksheet(s) setattr(self, s, pd.DataFrame(ws.get_all_records())) + self.events['day'] = pd.to_datetime(self.events['day'], + dayfirst=True) elif not os.path.exists(url): @@ -37,8 +51,8 @@ def __init__(self, url, accountfile=''): setattr(self, s, pd.read_csv( f'{url}/gviz/tq?tqx=out:csv&sheet={s}'). replace(np.nan, None)) - self.events['day'] = pd.to_datetime(self.events['day'], - dayfirst=True) + self.events['day'] = pd.to_datetime(self.events['day'], + dayfirst=True) else: @@ -48,10 +62,23 @@ def __init__(self, url, accountfile=''): url, sheet_name=s). replace(np.nan, None)) + def addSheet(self, name: str, data): + + if getattr(self, 'book', None) is None: + print("Works for now only with gspread account", file=sys.stderr) + return + rows = len(data) + cols = len(data[0]) + if name in [sh.title for sh in self.book.worksheets()]: + print(f"A sheet named {name} already exists, not overwriting", + file=sys.stderr) + ws = self.book.add_worksheet(title=name, rows=rows, cols=cols) + c2 = chr(ord('A') + cols - 1) + r2 = rows + ws.update(f"A1:{c2}{r2}", data) + if __name__ == '__main__': base = os.path.dirname(__file__) b1 = BookOfSheets(f'{base}/../example/exampledata.xlsx') - sheet_id = '1bfvtA3tBdtqd_M8TpKA1pY_vuov1SiMdtywJ8G20lw0' - b2 = BookOfSheets(f'https://docs.google.com/spreadsheets/d/{sheet_id}/edit?usp=sharing') \ No newline at end of file diff --git a/src/templates/eventlist.html b/src/templates/eventlist.html index 7cd4daa..a8ce565 100644 --- a/src/templates/eventlist.html +++ b/src/templates/eventlist.html @@ -61,7 +61,10 @@

{{ day.printDate() }}

{% for item in event._session._items %}
- {{ item.title }} + {{ item.title }} + {% if item.isRemote() %} + (Zoom) + {% endif %}
{{ item.printAuthors() }}