vice-speaker-rotator.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413
  1. #!/usr/bin/env python3
  2. from datetime import date, timedelta
  3. import sys
  4. import argparse
  5. from argparse import RawTextHelpFormatter
  6. import yaml
  7. # text appended to the end of generated emails
  8. email_body = """
  9. BBB room:
  10. [REDACTED]
  11. Full speaker schedule:
  12. https://cs.uwaterloo.ca/twiki/view/CrySP/SpeakerSchedule
  13. Other upcoming events:
  14. https://cs.uwaterloo.ca/twiki/view/CrySP/UpcomingEvents
  15. """
  16. def str_to_date(string_date):
  17. if type(string_date) is date:
  18. return string_date
  19. try:
  20. return date(*[int(s) for s in string_date.split('-')])
  21. except:
  22. print("invalid date: " + string_date)
  23. sys.exit(1)
  24. class CryspCalendar:
  25. """Representation of a CrySP weekly meeting schedule for a term"""
  26. def __init__(self, start, end=None, weeks=None, names=[],
  27. constraints=[], practice_talks=[], half_practices=[],
  28. notes=[], irc_handles=dict(), no_repeat=False):
  29. self.dates = self.meeting_dates(start, end, weeks)
  30. self.names = names
  31. self.constraints = self.parse_constraints(constraints)
  32. self.practice_talks = self.parse_practice_talks(practice_talks)
  33. self.practice_talks.update(self.parse_practice_talks(half_practices,
  34. half=True))
  35. self.notes = self.parse_notes(notes)
  36. self.notes.update(self.parse_notes(map(lambda l: l[1:],
  37. practice_talks)))
  38. self.notes.update(self.parse_notes(map(lambda l: l[1:],
  39. half_practices)))
  40. self.verify_constraints()
  41. self.irc_handles = irc_handles
  42. self.no_repeat = no_repeat
  43. def meeting_dates(self, start, end=None, weeks=None):
  44. first_meeting = str_to_date(start)
  45. last_meeting = None
  46. if end:
  47. last_meeting = str_to_date(end)
  48. if weeks:
  49. last_meeting = min(first_meeting + timedelta(weeks=weeks),
  50. last_meeting)
  51. ret = []
  52. next_meeting = first_meeting
  53. one_week = timedelta(weeks=1)
  54. while next_meeting <= last_meeting:
  55. ret.append(next_meeting)
  56. next_meeting += one_week
  57. return ret
  58. def parse_constraints(self, constraints):
  59. """takes an iterable of (name,datestr,...) tuples"""
  60. constraint_dict = {}
  61. for constraint in constraints:
  62. name = constraint[0]
  63. date_strs = constraint[1:]
  64. dates = []
  65. for date_str in date_strs:
  66. if type(date_str) is not date and '--' in date_str:
  67. # date_range = list(map(str_to_date, date_str.split('--')))
  68. date_range = date_str.split('--')
  69. date_range = [str_to_date(date_range[0]),
  70. str_to_date(date_range[1])]
  71. [dates.append(d) for d in self.dates
  72. if date_range[0] <= d <= date_range[1]]
  73. else:
  74. dates.append(str_to_date(date_str))
  75. constraint_dict[name] = dates
  76. return constraint_dict
  77. def parse_practice_talks(self, practice, half=False):
  78. """'practice' is an iterable of (name, day, note) tuples"""
  79. practice_dict = {}
  80. for talk in practice:
  81. name = talk[0]
  82. day = str_to_date(talk[1])
  83. if day in practice_dict:
  84. assert half,\
  85. 'conflicting talks: %s and %s on %s' %\
  86. (practice_dict[day][0], name, day)
  87. practice_dict[day].append(name)
  88. elif half:
  89. practice_dict[day] = [name, ]
  90. else:
  91. practice_dict[day] = [name, '']
  92. return practice_dict
  93. def parse_notes(self, notes):
  94. """takes an iterable of (day, note) tuples"""
  95. notes_dict = {}
  96. for note in notes:
  97. day = str_to_date(note[0])
  98. message = note[1]
  99. assert day not in notes_dict or notes_dict[day] == message,\
  100. 'conflicting notes: "%s" and "%s" on %s' %\
  101. (notes_dict[day], message, note[0])
  102. notes_dict[day] = message
  103. return notes_dict
  104. def verify_constraints(self):
  105. for name in self.constraints:
  106. for day in self.constraints[name]:
  107. assert day in self.dates,\
  108. "constraint %s on %s isnt a meeting date" %\
  109. (name, day)
  110. for day in self.practice_talks:
  111. assert day in self.dates,\
  112. 'talk by %s designated on %s, which is not a meeting date' %\
  113. (self.practice_talks[day][0], day)
  114. for day in self.notes:
  115. assert day in self.dates,\
  116. 'note "%s" designated on %s, which is not a meeting date' %\
  117. (self.notes[day], day)
  118. def pop_name(self, day, names, i):
  119. """Pops the name of the next available on day from the names stack,
  120. where 'i' is which speaker they would be for that day (0 or 1).
  121. Returns None if no speakers are left in the given names stack."""
  122. name = None
  123. if day in self.practice_talks:
  124. if i < len(self.practice_talks[day]):
  125. name = self.practice_talks[day][i]
  126. if name is None:
  127. constrained_names = (name for name in names
  128. if name not in self.constraints
  129. or day not in self.constraints[name])
  130. name = next(constrained_names, None)
  131. if name and name in names:
  132. names.remove(name)
  133. return name
  134. def next_speaker(self, day, names, i):
  135. """Wrapper for pop_name() that repopulates names stack if needed."""
  136. name = self.pop_name(day, names, i)
  137. if name or self.no_repeat:
  138. return name
  139. names.extend(self.names)
  140. return self.pop_name(day, names, i)
  141. def date_to_speakers(self, day, names):
  142. speaker1 = self.next_speaker(day, names, 0)
  143. speaker2 = self.next_speaker(day, names, 1)
  144. note = self.notes.get(day, '')
  145. return speaker1, speaker2, note
  146. def table(self, today=date.today()):
  147. """Returns a string containing a twiki table."""
  148. table = '| *Date* | *Speakers* | *Notes* |\n'
  149. names = self.names.copy()
  150. for day in self.dates:
  151. color = "%SILVER% " if day <= today else ""
  152. endcolor = " %ENDCOLOR%" if day <= today else ""
  153. speaker1, speaker2, note = self.date_to_speakers(day, names)
  154. speaker2 = ', ' + speaker2 if speaker2 else ""
  155. row = "| {}{}{} | {}{}{}{} | {}{}{} |\n".format(
  156. color, day.strftime('%b %e'), endcolor,
  157. color, speaker1, speaker2, endcolor,
  158. color, note, endcolor
  159. )
  160. table += row
  161. return table
  162. def email(self, weeks=2, today=date.today()):
  163. """Returns a string for emailing to the lab who is speaking next"""
  164. emailstr = ''
  165. names = self.names.copy()
  166. i = 0
  167. for day in self.dates:
  168. if day <= today:
  169. self.date_to_speakers(day, names)
  170. continue
  171. i += 1
  172. if i > weeks:
  173. break
  174. speaker1, speaker2, note = self.date_to_speakers(day, names)
  175. emailstr += day.strftime('%b %e: ')
  176. if speaker1:
  177. emailstr += speaker1
  178. if speaker2:
  179. emailstr += ', ' + speaker2
  180. if note != '':
  181. emailstr += ' (' + note + ')'
  182. else:
  183. emailstr += note
  184. emailstr += '\n'
  185. emailstr += email_body
  186. return emailstr
  187. def irc(self, today=date.today()):
  188. """Returns a string for putting in the IRC channel topic"""
  189. names = self.names.copy()
  190. i = 0
  191. for day in self.dates:
  192. if day <= today:
  193. self.date_to_speakers(day, names)
  194. continue
  195. i += 1
  196. speaker1, speaker2, note = self.date_to_speakers(day, names)
  197. if speaker1:
  198. speaker1 = self.irc_handles.get(speaker1, speaker1)
  199. if speaker2:
  200. speaker2 = ', ' + self.irc_handles.get(speaker2, speaker2)
  201. if note != '':
  202. note = ' (' + note + ')'
  203. return "{}{}{}".format(speaker1, speaker2, note)
  204. def parse_constraintsfile(filename):
  205. with open(filename, 'r') as f:
  206. tweaks = {'c': [], 'p': [], 'h': [], 'n': []}
  207. for line in f:
  208. line = [x.strip() for x in line.rstrip().split(',')]
  209. tweaks[line[0]].append(line[1:])
  210. return tweaks['c'], tweaks['p'], tweaks['h'], tweaks['n']
  211. def parse_yaml(filename, today=date.today()):
  212. with open(filename, 'r') as f:
  213. struct = yaml.safe_load(f)
  214. currentTerm = None
  215. for term in struct['terms'].values():
  216. if 'start' in term and 'end' in term:
  217. term['end'] = str_to_date(term['end'])
  218. term['start'] = str_to_date(term['start'])
  219. if currentTerm is None or (term['end'] >= today and
  220. term['start'] > currentTerm['start']):
  221. currentTerm = term
  222. names = currentTerm['speakers']
  223. for name in names:
  224. assert name not in currentTerm['out']
  225. def triple_check(field_name, location):
  226. return field_name in location and \
  227. location[field_name] is not None and \
  228. len(location[field_name]) > 0
  229. constraints = [[name] + struct['speakers'][name]['constraints']
  230. for name in names
  231. if triple_check('constraints', struct['speakers'][name])]
  232. practice_talks = [(talk['name'], talk['date'], talk['note'])
  233. for talk in currentTerm['practice_talks']]
  234. half_practices = [(talk['name'], talk['date'], talk['note'])
  235. for talk in currentTerm['half_practices']]
  236. for speaker in struct['speakers']:
  237. location = struct['speakers'][speaker]
  238. if triple_check('practice_talks', location):
  239. practice_talks += {(speaker, talk['date'], talk['note'])
  240. for talk in location['practice_talks']}
  241. if triple_check('half_practices', location):
  242. half_practices += {(speaker, talk['date'], talk['note'])
  243. for talk in location['half_practices']}
  244. notes = [(note['date'], note['note']) for note in currentTerm['notes']]
  245. irc_handles = {name: struct['speakers'][name]['irc']
  246. for name in struct['speakers']
  247. if 'irc' in struct['speakers'][name]}
  248. return CryspCalendar(currentTerm['start'], currentTerm['end'],
  249. names=names, constraints=constraints,
  250. practice_talks=practice_talks,
  251. half_practices=half_practices,
  252. notes=notes, irc_handles=irc_handles)
  253. def make_parser():
  254. parser = argparse.ArgumentParser(description='''\
  255. Print a speaker schedule for the CrySP weekly meetings.
  256. All dates are ISO formatted (yyyy-mm-dd). Constraints in files can also be
  257. ranges of ISO formatted dates (yyyy-mm-dd--yyyy-mm-dd).''',
  258. epilog='''\
  259. Now that the lab is so large, you likely want to use one of the file options,
  260. probably YAML. The command line options still function if you want to play
  261. with how this script works to learn, though.
  262. The YAML file has the following format:
  263. There are two main objects, a "terms" object that stores data about particular
  264. terms, and a "speakers" ojbect that stores data about the speakers.
  265. The "terms" object contains any number of term objects with any name. Each
  266. term has the following objects:
  267. start: the date of the first meeting
  268. end: a date on and beyond which there are no more meetings
  269. speakers: an ordered list of names for each speaker this term
  270. out: a list to store lab members who won't speak this term (ignored)
  271. practice_talks: a list of practice talks, of the form
  272. {date: _, name: _, note: _}
  273. half_practices: a list of half practice talks (format is the same)
  274. notes: notes, of the form
  275. {date: _, note: _}
  276. The "speakers" object contains any number of speaker objects. The name of each
  277. speaker object should be the same as the name in the speakers list, if they
  278. are speaking this term. The names can have spaces. Each speaker object has
  279. the following objects:
  280. irc: the IRC handle of the speaker, if they're in CrySP IRC
  281. email: the email address of the speaker (check the UW whitepages if need be)
  282. constraints: a list of dates/date ranges the speaker cannot speak on
  283. practice_talks: a list of practice talks, each of the form:
  284. {date: _, note: _}
  285. half_practices: a list of half practice talks (format is the same)
  286. You cannot have multiple notes on the same day. If you have multiple objects
  287. with notes on the same day (likely two half_practices), their notes must match.
  288. ''',
  289. formatter_class=RawTextHelpFormatter)
  290. parser.add_argument('-y', '--yaml',
  291. metavar='FILE',
  292. help='full description of the term via YAML file.')
  293. parser.add_argument('-s', '--start',
  294. metavar='DATE',
  295. help='date of the first meeting')
  296. parser.add_argument('-e', '--end', metavar='DATE',
  297. help='last date to schedule a meeting on or before\n'
  298. '(if -w also specified, uses whatever is shortest)')
  299. parser.add_argument('-w', '--weeks', type=int,
  300. help='number of weeks to schedule for\n'
  301. '(if -e also specified, uses whatever is shortest)')
  302. group = parser.add_mutually_exclusive_group()
  303. group.add_argument('-n', '--names',
  304. nargs='+', default=[], metavar='NAME',
  305. help='names to schedule in their speaking order')
  306. group.add_argument('-f', '--namesfile',
  307. metavar='FILE',
  308. help='path of a file that contains a comma-seperated\n'
  309. 'list of names to schedule in their speaking order')
  310. parser.add_argument('-c', '--constraintsfile',
  311. metavar='FILE',
  312. help='Provide constraints, practice talks, and notes\n'
  313. 'via supplied CSV file. The CSV can contain the\n'
  314. 'following lines:\n'
  315. 'constraints - dates where someone cannot speak:\n'
  316. '"c,[name],[date/range1],[date/range2],[...]"\n'
  317. 'notes - goes in the notes column for that date:\n'
  318. '"n,[date],[note]"\n'
  319. 'practice talks - dates where something other than\n'
  320. 'the usual CrySP meeting talks is happening:\n'
  321. '"p,[name],[date],[note (e.g., "practice talk")]"\n'
  322. 'half-slot - a rare case where someone/thing needs\n'
  323. 'only one of the speaker slots on this date:\n'
  324. '"h,[name],[date],[note]"\n')
  325. parser.add_argument('-r', '--no-repeat',
  326. action='store_true',
  327. help='disables repeating the list of names')
  328. parser.add_argument('-E', '--email',
  329. nargs='?', const=2, default=False,
  330. metavar='WEEKS',
  331. help='print an email for notifying who is speaking\n'
  332. 'the next two (or specified) weeks')
  333. parser.add_argument('-i', '--irc',
  334. action='store_true',
  335. help='print names formatted for an irc channel topic')
  336. parser.add_argument('-d', '--today',
  337. default=date.today(), metavar='DATE',
  338. help='pretend the script is being run on the day DATE')
  339. return parser
  340. def old_format(args):
  341. if args.namesfile:
  342. with open(args.namesfile, 'r') as f:
  343. names = [x.strip() for x in f.read().split(',')]
  344. else:
  345. names = args.names
  346. if args.constraintsfile:
  347. constraints, practice, halves, notes = \
  348. parse_constraintsfile(args.constraintsfile)
  349. return CryspCalendar(args.start, args.end, args.weeks, names,
  350. constraints, practice, halves, notes,
  351. args.no_repeat)
  352. return CryspCalendar(args.start, args.end, args.weeks, names,
  353. no_repeat=args.no_repeat)
  354. def main(argv=None):
  355. if argv is None:
  356. argv = sys.argv
  357. parser = make_parser()
  358. args = parser.parse_args()
  359. if not args.end and not args.weeks and not args.yaml:
  360. parser.error('requires -w/--weeks or -e/--end')
  361. if not args.yaml:
  362. cal = old_format(args)
  363. else:
  364. cal = parse_yaml(args.yaml)
  365. today = str_to_date(args.today)
  366. if args.email:
  367. print(cal.email(int(args.email), today))
  368. elif args.irc:
  369. print(cal.irc(today))
  370. else:
  371. print(cal.table(today))
  372. return 0
  373. if __name__ == '__main__':
  374. sys.exit(main())