vice-speaker-rotator.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283
  1. #!/usr/bin/env python3
  2. from datetime import date,timedelta
  3. import sys, argparse
  4. from argparse import RawTextHelpFormatter
  5. # text appended to the end of generated emails
  6. email_body = """
  7. Full speaker schedule:
  8. https://cs.uwaterloo.ca/twiki/view/CrySP/SpeakerSchedule
  9. Other upcoming events:
  10. https://cs.uwaterloo.ca/twiki/view/CrySP/UpcomingEvents
  11. """
  12. def str_to_date(string_date):
  13. try:
  14. return date(*[int(s) for s in string_date.split('-')])
  15. except:
  16. print("invalid date: " + string_date)
  17. sys.exit()
  18. class CryspCalendar:
  19. """Representation of a CrySP weekly meeting schedule for a term"""
  20. def __init__(self, start, end=None, weeks=None, names=[],
  21. constraints=[], practice_talks=[], half_practices=[], notes=[],
  22. no_repeat=False):
  23. self.dates = self.meeting_dates(start, end, weeks)
  24. self.names = names
  25. self.constraints = self.parse_constraints(constraints)
  26. self.practice_talks = self.parse_practice_talks(practice_talks)
  27. self.practice_talks.update(self.parse_practice_talks(half_practices,
  28. half=True))
  29. self.notes = self.parse_notes(notes)
  30. self.notes.update(self.parse_notes(map(lambda l:l[1:], practice_talks)))
  31. self.notes.update(self.parse_notes(map(lambda l:l[1:], half_practices)))
  32. self.verify_constraints()
  33. self.no_repeat = no_repeat
  34. def meeting_dates(self, start, end=None, weeks=None):
  35. first_meeting = str_to_date(start)
  36. last_meeting = None
  37. if end:
  38. last_meeting = str_to_date(end)
  39. if weeks:
  40. last_meeting = min(first_meeting + timedelta(weeks=weeks),
  41. last_meeting)
  42. ret = []
  43. next_meeting = first_meeting
  44. one_week = timedelta(weeks=1)
  45. while next_meeting <= last_meeting:
  46. ret.append(next_meeting)
  47. next_meeting += one_week
  48. return ret
  49. def parse_constraints(self, constraints):
  50. constraint_dict = {}
  51. for constraint in constraints:
  52. name = constraint[0]
  53. date_strs = constraint[1:]
  54. dates = []
  55. for date_str in date_strs:
  56. if '--' in date_str:
  57. #date_range = list(map(str_to_date, date_str.split('--')))
  58. date_range = date_str.split('--')
  59. date_range = [str_to_date(date_range[0]),
  60. str_to_date(date_range[1])]
  61. [dates.append(d) for d in self.dates \
  62. if date_range[0] <= d <= date_range[1]]
  63. else:
  64. dates.append(str_to_date(date_str))
  65. constraint_dict[name] = dates
  66. return constraint_dict
  67. def parse_practice_talks(self, practice, half=False):
  68. practice_dict = {}
  69. for talk in practice:
  70. name = talk[0]
  71. day = str_to_date(talk[1])
  72. if day in practice_dict:
  73. assert half,\
  74. 'conflicting talks: %s and %s on %s' %\
  75. (practice_dict[day][0], name, day)
  76. practice_dict[day].append(name)
  77. elif half:
  78. practice_dict[day] = [name,]
  79. else:
  80. practice_dict[day] = [name, '']
  81. return practice_dict
  82. def parse_notes(self, notes):
  83. notes_dict = {}
  84. for note in notes:
  85. day = str_to_date(note[0])
  86. message = note[1]
  87. assert day not in notes_dict or notes_dict[day] == message,\
  88. 'conflicting notes: "%s" and "%s" on %s' %\
  89. (notes_dict[day], message, note[0])
  90. notes_dict[day] = message
  91. return notes_dict
  92. def verify_constraints(self):
  93. for name in self.constraints:
  94. for day in self.constraints[name]:
  95. assert day in self.dates,\
  96. 'constraint added for %s on %s, which is not a meeting date' %\
  97. (name, day)
  98. for day in self.practice_talks:
  99. assert day in self.dates,\
  100. 'talk by %s designated on %s, which is not a meeting date' %\
  101. (self.practice_talks[day][0], day)
  102. for day in self.notes:
  103. assert day in self.dates,\
  104. 'note "%s" designated on %s, which is not a meeting date' %\
  105. (self.notes[day], day)
  106. def pop_name(self, day, names, i):
  107. """Pops the name of the next available on day from the names stack,
  108. where 'i' is which speaker they would be for that day (0 or 1).
  109. Returns None if no speakers are left in the given names stack."""
  110. name = None
  111. if day in self.practice_talks:
  112. if i < len(self.practice_talks[day]):
  113. name = self.practice_talks[day][i]
  114. if name is None:
  115. constrained_names = (name for name in names \
  116. if name not in self.constraints \
  117. or day not in self.constraints[name])
  118. name = next(constrained_names, None)
  119. if name and name in names:
  120. names.remove(name)
  121. return name
  122. def next_speaker(self, day, names, i):
  123. """Wrapper for pop_name() that repopulates names stack if needed."""
  124. name = self.pop_name(day, names, i)
  125. if name or self.no_repeat:
  126. return name
  127. names.extend(self.names)
  128. return self.pop_name(day, names, i)
  129. def date_to_speakers(self, day, names):
  130. speaker1 = self.next_speaker(day, names, 0)
  131. speaker2 = self.next_speaker(day, names, 1)
  132. note = self.notes.get(day, '')
  133. return speaker1, speaker2, note
  134. def table(self):
  135. """Returns a string containing a twiki table."""
  136. table = '| *Date* | *Speakers* | *Notes* |\n'
  137. names = self.names.copy()
  138. for day in self.dates:
  139. table += day.strftime('| %b %e |')
  140. speaker1, speaker2, note = self.date_to_speakers(day, names)
  141. if speaker1:
  142. table += ' ' + speaker1
  143. if speaker2:
  144. table += ', ' + speaker2
  145. table += ' | ' + note + ' |\n'
  146. return table
  147. def email(self, weeks=2, today=date.today()):
  148. """Returns a string for emailing to the lab who is speaking next"""
  149. emailstr = ''
  150. names = self.names.copy()
  151. i = 0
  152. for day in self.dates:
  153. if day <= today:
  154. self.date_to_speakers(day, names)
  155. continue
  156. i += 1
  157. if i > weeks:
  158. break
  159. speaker1, speaker2, note = self.date_to_speakers(day, names)
  160. emailstr += day.strftime('%b %e: ')
  161. if speaker1:
  162. emailstr += speaker1
  163. if speaker2:
  164. emailstr += ', ' + speaker2
  165. if note != '':
  166. emailstr += ' (' + note + ')'
  167. else:
  168. emailstr += note
  169. emailstr += '\n'
  170. emailstr += email_body
  171. return emailstr
  172. def parse_constraintsfile(filename):
  173. with open(filename, 'r') as f:
  174. tweaks = {'c': [], 'p': [], 'h': [], 'n': []}
  175. for line in f:
  176. l = [x.strip() for x in line.rstrip().split(',')]
  177. tweaks[l[0]].append(l[1:])
  178. return tweaks['c'], tweaks['p'], tweaks['h'], tweaks['n']
  179. def make_parser():
  180. parser = argparse.ArgumentParser(description ='''\
  181. Print a speaker schedule for the CrySP weekly meetings.
  182. All dates are ISO formatted (yyyy-mm-dd). Constraints in the constraints file
  183. can also be ranges of ISO formatted dates (yyyy-mm-dd--yyyy-mm-dd).''',
  184. formatter_class=RawTextHelpFormatter)
  185. parser.add_argument('-s', '--start',
  186. metavar = 'DATE', required = True,
  187. help =
  188. 'date of the first meeting')
  189. parser.add_argument('-e', '--end', metavar = 'DATE',
  190. help =
  191. 'last date to schedule a meeting on or before\n' \
  192. '(if -w also specified, uses whatever is shortest)')
  193. parser.add_argument('-w', '--weeks', type=int,
  194. help =
  195. 'number of weeks to schedule for\n' \
  196. '(if -e also specified, uses whatever is shortest)')
  197. group = parser.add_mutually_exclusive_group()
  198. group.add_argument('-n', '--names',
  199. nargs = '+', default = [], metavar = 'NAME',
  200. help =
  201. 'names to schedule in their speaking order')
  202. group.add_argument('-f', '--namesfile',
  203. metavar = 'FILE',
  204. help =
  205. 'path of a file that contains a comma-seperated\n' \
  206. ' list of names to schedule in their speaking order')
  207. parser.add_argument('-c', '--constraintsfile',
  208. metavar = 'FILE',
  209. help =
  210. 'Provide constraints, practice talks, and notes\n' \
  211. 'via supplied CSV file. The CSV can contain the\n' \
  212. 'following lines:\n' \
  213. 'constraints - dates where someone cannot speak:\n' \
  214. '"c,[name],[date/range1],[date/range2],[...]"\n' \
  215. 'notes - goes in the notes column for that date:\n' \
  216. '"n,[date],[note]"\n' \
  217. 'practice talks - dates where something other than\n' \
  218. 'the usual CrySP meeting talks is happening:\n' \
  219. '"p,[name],[date],[note (e.g., "practice talk")]"\n' \
  220. 'half-slot - a rare case where someone/thing needs\n' \
  221. 'only one of the speaker slots on this date:\n' \
  222. '"h,[name],[date],[note]"\n')
  223. parser.add_argument('-r', '--no-repeat',
  224. action = 'store_true',
  225. help =
  226. 'disables repeating the list of names')
  227. parser.add_argument('-E', '--email',
  228. nargs = '?', const = 2, default = False,
  229. metavar = 'WEEKS',
  230. help =
  231. 'print an email for notifying who is speaking\n' \
  232. 'the next two (or specified) weeks')
  233. return parser
  234. def main(argv=None):
  235. if argv is None:
  236. argv = sys.argv
  237. parser = make_parser()
  238. args = parser.parse_args()
  239. if not args.end and not args.weeks:
  240. parser.error('requires -w/--weeks or -e/--end')
  241. if args.namesfile:
  242. with open(args.namesfile, 'r') as f:
  243. names = [x.strip() for x in f.read().split(',')]
  244. else:
  245. names = args.names
  246. if args.constraintsfile:
  247. constraints, practice, halves, notes = \
  248. parse_constraintsfile(args.constraintsfile)
  249. cal = CryspCalendar(args.start, args.end, args.weeks, names,
  250. constraints, practice, halves, notes,
  251. args.no_repeat)
  252. else:
  253. cal = CryspCalendar(args.start, args.end, args.weeks, names,
  254. no_repeat=args.no_repeat)
  255. print(cal.table() if not args.email else cal.email(int(args.email)))
  256. return 0;
  257. if __name__ == '__main__':
  258. sys.exit(main())