|
@@ -0,0 +1,247 @@
|
|
|
|
+#!/usr/bin/env python
|
|
|
|
+from datetime import date,timedelta
|
|
|
|
+import sys
|
|
|
|
+import 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])
|
|
|
|
+ assert half or day not in practice_dict,\
|
|
|
|
+ "conflicting talks: %s and %s on %s" %\
|
|
|
|
+ (practice_dict[day][0], name, day)
|
|
|
|
+ if 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,\
|
|
|
|
+ '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_practice_talk(self, day, i):
|
|
|
|
+ if i < len(self.practice_talks[day]):
|
|
|
|
+ return self.practice_talks[day][i]
|
|
|
|
+ return None
|
|
|
|
+
|
|
|
|
+ def pop_name(self, day, names, i):
|
|
|
|
+ name = None
|
|
|
|
+ if day in self.practice_talks:
|
|
|
|
+ #name = self.practice_talks[day][i]
|
|
|
|
+ name = self.pop_practice_talk(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):
|
|
|
|
+ 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
|
|
|
|
+
|
|
|
|
+ # returns a string containing a twiki table
|
|
|
|
+ def table(self):
|
|
|
|
+ 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
|
|
|
|
+
|
|
|
|
+ # returns a string containing the currently used email layout, filled in
|
|
|
|
+ def email(self, weeks=2, today=date.today()):
|
|
|
|
+ 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):
|
|
|
|
+ f = open(filename, 'r')
|
|
|
|
+ tweaks = {'c': [], 'p': [], 'h': [], 'n': []}
|
|
|
|
+ for line in f:
|
|
|
|
+ l = 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:
|
|
|
|
+ f = open(args.namesfile, 'r')
|
|
|
|
+ names = f.read().replace(",", " ").split()
|
|
|
|
+ f.close()
|
|
|
|
+ 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())
|