#!/usr/bin/env python3 from datetime import date,timedelta import sys, argparse def str_to_date(string_date): return date(*[int(s) for s in string_date.split('-')]) class CryspCalendar: """Representation of a CrySP weekly meeting schedule for a term""" def __init__(self, start, end=None, weeks=None, names=[], constraints=[], practice_talks=[], half_practices=[], notes=[], no_repeat=False): self.dates = self.meeting_dates(start, end, weeks) self.names = names self.constraints = self.parse_constraints(constraints) self.practice_talks = self.parse_practice_talks(practice_talks) self.practice_talks.update(self.parse_practice_talks(half_practices, half=True)) self.notes = self.parse_notes(notes) self.notes.update(self.parse_notes(practice_talks)) self.notes.update(self.parse_notes(half_practices)) self.verify_constraints() self.no_repeat = no_repeat def meeting_dates(self, start, end=None, weeks=None): first_meeting = str_to_date(start) last_meeting = None if end: last_meeting = str_to_date(end) if weeks: last_meeting = max(first_meeting + timedelta(weeks=weeks), last_meeting) ret = [] next_meeting = first_meeting one_week = timedelta(weeks=1) while next_meeting <= last_meeting: ret.append(next_meeting) next_meeting += one_week return ret def parse_constraints(self, constraints): constraint_dict = {} for constraint in constraints: name = constraint[0] date_strs = constraint[1:] dates = [] for date_str in date_strs: dates.append(str_to_date(date_str)) constraint_dict[name] = dates return constraint_dict def parse_practice_talks(self, practice, half=False): practice_dict = {} for talk in practice: name = talk[0] day = str_to_date(talk[1]) if day in practice_dict: assert half,\ 'conflicting talks: %s and %s on %s' %\ (practice_dict[day][0], name, day) practice_dict[day].append(name) elif half: practice_dict[day] = [name,] else: practice_dict[day] = [name, ''] return practice_dict def parse_notes(self, notes): notes_dict = {} for note in notes: day = str_to_date(note[1]) message = note[2] assert day not in notes_dict or notes_dict[day] == message,\ 'conflicting notes: "%s" and "%s" on %s' %\ (notes_dict[day], message, note[1]) notes_dict[day] = message return notes_dict def verify_constraints(self): for name in self.constraints: for day in self.constraints[name]: assert day in self.dates,\ 'constraint added for %s on %s, which is not a meeting date' %\ (name, day) for day in self.practice_talks: assert day in self.dates,\ 'talk by %s designated on %s, which is not a meeting date' %\ (self.practice_talks[day][0], day) for day in self.notes: assert day in self.dates,\ 'note "%s" designated on %s, which is not a meeting date' %\ (self.notes[day], day) def pop_name(self, day, names, i): """Pops the name of the next available on day from the names stack, where 'i' is which speaker they would be for that day (0 or 1). Returns None if no speakers are left in the given names stack.""" name = None if day in self.practice_talks: if i < len(self.practice_talks[day]): name = self.practice_talks[day][i] if name is None: constrained_names = (name for name in names \ if name not in self.constraints \ or day not in self.constraints[name]) name = next(constrained_names, None) if name and name in names: names.remove(name) return name def next_speaker(self, day, names, i): """Wrapper for pop_name() that repopulates names stack if needed.""" name = self.pop_name(day, names, i) if name or self.no_repeat: return name names.extend(self.names) return self.pop_name(day, names, i) def date_to_speakers(self, day, names): speaker1 = self.next_speaker(day, names, 0) speaker2 = self.next_speaker(day, names, 1) note = self.notes.get(day, '') return speaker1, speaker2, note def table(self): """Returns a string containing a twiki table.""" table = '| *Date* | *Speakers* | *Notes* |\n' names = self.names.copy() for day in self.dates: table += day.strftime('| %b %e |') speaker1, speaker2, note = self.date_to_speakers(day, names) if speaker1: table += ' ' + speaker1 if speaker2: table += ', ' + speaker2 table += ' | ' + note + ' |\n' return table def email(self, weeks=2, today=date.today()): """Returns a string for emailing to the lab who is speaking next""" emailstr = '' names = self.names.copy() i = 0 for day in self.dates: if day <= today: self.date_to_speakers(day, names) continue i += 1 if i > weeks: break speaker1, speaker2, note = self.date_to_speakers(day, names) emailstr += day.strftime('%b %e: ') if speaker1: emailstr += speaker1 if speaker2: emailstr += ', ' + speaker2 if note != '': emailstr += ' (' + note + ')' else: emailstr += note emailstr += '\n' emailstr += '\nhttps://cs.uwaterloo.ca/twiki/view/CrySP/SpeakerSchedule\n' return emailstr def parse_constraintsfile(filename): with open(filename, 'r') as f: tweaks = {'c': [], 'p': [], 'h': [], 'n': []} for line in f: l = [x.strip() for x in line.rstrip().split(',')] tweaks[l[0]].append(l[1:]) return tweaks['c'], tweaks['p'], tweaks['h'], tweaks['n'] def make_parser(): parser = argparse.ArgumentParser(description = \ 'Print a speaker schedule for the CrySP weekly meetings.') parser.add_argument('-s', '--start', metavar = 'DATE', required = True, help = 'date of the first meeting in ISO format ' +\ '(yyyy-mm-dd)') parser.add_argument('-e', '--end', metavar = 'DATE', help = 'last date to schedule a meeting on or before ' +\ '(if -w also specified, uses whatever is shortest)') parser.add_argument('-w', '--weeks', type=int, help = 'number of weeks to schedule for ' +\ '(if -e also specified, uses whatever is shortest)') group = parser.add_mutually_exclusive_group() group.add_argument('-n', '--names', nargs = '+', default = [], metavar = 'NAME', help = 'names to schedule in their speaking order') group.add_argument('-f', '--namesfile', metavar = 'FILE', help = 'path of a file that contains a whitespace ' +\ 'and/or comma seperated list of names to schedule in ' +\ 'their speaking order') parser.add_argument('-c', '--constraints', metavar = ('NAME', 'DATES'), nargs = '+', action = 'append', default = [], help = "specify someone *can't* speak on certain " +\ 'dates (e.g., "-c Justin 2018-01-01 2018-01-08 -c ' +\ 'Ian 2018-01-01")') parser.add_argument('-p', '--practice', metavar = ('NAME', 'DATE', 'NOTE'), nargs = 3, action = 'append', default = [], help = 'designate given DATE as a talk given by NAME, '+\ 'and remove the next instance of NAME in the list of '+\ 'names, if present (NOTE should usually be "practice '+\ 'talk")') parser.add_argument('-C', '--constraintsfile', metavar = 'FILE', help = 'provide constraints, practice talks, and '+\ 'notes via supplied csv file') parser.add_argument('-r', '--no-repeat', action = 'store_true', help = 'disables repeating the list of names') parser.add_argument('-E', '--email', nargs = '?', const = 2, default = False, metavar = 'WEEKS', help = 'print an email for notifying who is speaking' +\ ' the next two (or specified) weeks') return parser def main(argv=None): if argv is None: argv = sys.argv parser = make_parser() args = parser.parse_args() if not args.end and not args.weeks: parser.error('requires -w/--weeks or -e/--end') if args.namesfile: with open(args.namesfile, 'r') as f: names = [x.strip() for x in f.read().split(',')] else: names = args.names if args.constraintsfile: constraints, practice, halves, notes = \ parse_constraintsfile(args.constraintsfile) else: constraints = args.constraints practice = args.practice cal = CryspCalendar(args.start, args.end, args.weeks, names,\ constraints, practice, halves, notes, args.no_repeat) print(cal.table() if not args.email else cal.email(int(args.email))) return 0; if __name__ == '__main__': sys.exit(main())