talks.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  1. '''
  2. Manage talks scheduling in a semantic way
  3. '''
  4. from __future__ import print_function
  5. import os
  6. import io
  7. from functools import wraps
  8. import logging
  9. import re
  10. import datetime
  11. import shutil
  12. import time
  13. from copy import copy
  14. import locale
  15. from contextlib import contextmanager
  16. from babel.dates import format_date, format_datetime, format_time
  17. from markdown import markdown
  18. from docutils import nodes
  19. from docutils.parsers.rst import directives, Directive
  20. from pelican import signals, generators
  21. import jinja2
  22. pelican = None # This will be set during register()
  23. def memoize(function):
  24. '''decorators to cache'''
  25. memo = {}
  26. @wraps(function)
  27. def wrapper(*args):
  28. if args in memo:
  29. return memo[args]
  30. else:
  31. rv = function(*args)
  32. memo[args] = rv
  33. return rv
  34. return wrapper
  35. @contextmanager
  36. def setlocale(name):
  37. saved = locale.setlocale(locale.LC_ALL)
  38. try:
  39. yield locale.setlocale(locale.LC_ALL, name)
  40. finally:
  41. locale.setlocale(locale.LC_ALL, saved)
  42. @memoize
  43. def get_talk_names():
  44. return [name for name in os.listdir(pelican.settings['TALKS_PATH'])
  45. if not name.startswith('_') and
  46. get_talk_data(name) is not None
  47. ]
  48. def all_talks():
  49. return [get_talk_data(tn) for tn in get_talk_names()]
  50. def unique_attr(iterable, attr):
  51. return {x[attr] for x in iterable
  52. if attr in x}
  53. @memoize
  54. def get_global_data():
  55. fname = os.path.join(pelican.settings['TALKS_PATH'], 'meta.yaml')
  56. if not os.path.isfile(fname):
  57. return None
  58. with io.open(fname, encoding='utf8') as buf:
  59. try:
  60. data = yaml.load(buf)
  61. except Exception:
  62. logging.exception("Syntax error reading %s; skipping", fname)
  63. return None
  64. if data is None:
  65. return None
  66. if 'startdate' not in data:
  67. logging.error("Missing startdate in global data")
  68. data['startdate'] = datetime.datetime.now()
  69. return data
  70. @memoize
  71. def get_talk_data(talkname):
  72. fname = os.path.join(pelican.settings['TALKS_PATH'], talkname, 'meta.yaml')
  73. if not os.path.isfile(fname):
  74. return None
  75. with io.open(fname, encoding='utf8') as buf:
  76. try:
  77. data = yaml.load(buf)
  78. except:
  79. logging.exception("Syntax error reading %s; skipping", fname)
  80. return None
  81. if data is None:
  82. return None
  83. try:
  84. gridstep = pelican.settings['TALKS_GRID_STEP']
  85. if 'title' not in data:
  86. logging.warn("Talk <{}> has no `title` field".format(talkname))
  87. data['title'] = talkname
  88. if 'text' not in data:
  89. logging.warn("Talk <{}> has no `text` field".format(talkname))
  90. data['text'] = ''
  91. if 'duration' not in data:
  92. logging.info("Talk <{}> has no `duration` field (50min used)"
  93. .format(talkname))
  94. data['duration'] = 50
  95. data['duration'] = int(data['duration'])
  96. if data['duration'] < gridstep:
  97. logging.info("Talk <{}> lasts only {} minutes; changing to {}"
  98. .format(talkname, data['duration'], gridstep))
  99. data['duration'] = gridstep
  100. if 'links' not in data or not data['links']:
  101. data['links'] = []
  102. if 'contacts' not in data or not data['contacts']:
  103. data['contacts'] = []
  104. if 'needs' not in data or not data['needs']:
  105. data['needs'] = []
  106. if 'room' not in data:
  107. logging.warn("Talk <{}> has no `room` field".format(talkname))
  108. if 'time' not in data or 'day' not in data:
  109. logging.warn("Talk <{}> has no `time` or `day`".format(talkname))
  110. if 'time' in data:
  111. del data['time']
  112. if 'day' in data:
  113. del data['day']
  114. if 'day' in data:
  115. data['day'] = get_global_data()['startdate'] + \
  116. datetime.timedelta(days=data['day'])
  117. if 'time' in data and 'day' in data:
  118. timeparts = re.findall(r'\d+', str(data['time']))
  119. if 4 > len(timeparts) > 0:
  120. timeparts = [int(p) for p in timeparts]
  121. data['time'] = datetime.datetime.combine(
  122. data['day'], datetime.time(*timeparts))
  123. else:
  124. logging.error("Talk <%s> has malformed `time`", talkname)
  125. data['id'] = talkname
  126. resdir = os.path.join(pelican.settings['TALKS_PATH'], talkname,
  127. pelican.settings['TALKS_ATTACHMENT_PATH'])
  128. if os.path.isdir(resdir) and os.listdir(resdir):
  129. data['resources'] = resdir
  130. return data
  131. except:
  132. logging.exception("Error on talk %s", talkname)
  133. raise
  134. @memoize
  135. def jinja_env():
  136. env = jinja2.Environment(
  137. loader=jinja2.FileSystemLoader(os.path.join(pelican.settings['TALKS_PATH'], '_templates')),
  138. autoescape=True,
  139. )
  140. env.filters['markdown'] = lambda text: jinja2.Markup(markdown(text))
  141. env.filters['dateformat'] = format_date
  142. env.filters['datetimeformat'] = format_datetime
  143. env.filters['timeformat'] = format_time
  144. return env
  145. class TalkListDirective(Directive):
  146. final_argument_whitespace = True
  147. has_content = True
  148. option_spec = {
  149. 'lang': directives.unchanged
  150. }
  151. def run(self):
  152. lang = self.options.get('lang', 'C')
  153. tmpl = jinja_env().get_template('talk.html')
  154. def _sort_date(name):
  155. '''
  156. This function is a helper to sort talks by start date
  157. When no date is available, put at the beginning
  158. '''
  159. d = get_talk_data(name)
  160. room = d.get('room', '')
  161. time = d.get('time', datetime.datetime(1, 1, 1))
  162. title = d.get('title', '')
  163. return (time, room, title)
  164. return [
  165. nodes.raw('', tmpl.render(lang=lang, **get_talk_data(n)),
  166. format='html')
  167. for n in sorted(get_talk_names(),
  168. key=_sort_date)
  169. ]
  170. class TalkDirective(Directive):
  171. required_arguments = 1
  172. final_argument_whitespace = True
  173. has_content = True
  174. option_spec = {
  175. 'lang': directives.unchanged
  176. }
  177. def run(self):
  178. lang = self.options.get('lang', 'C')
  179. tmpl = jinja_env().get_template('talk.html')
  180. data = get_talk_data(self.arguments[0])
  181. if data is None:
  182. return []
  183. return [
  184. nodes.raw('', tmpl.render(lang=lang, **data),
  185. format='html')
  186. ]
  187. class TalkGridDirective(Directive):
  188. '''A complete grid'''
  189. final_argument_whitespace = True
  190. has_content = True
  191. option_spec = {
  192. 'lang': directives.unchanged
  193. }
  194. def run(self):
  195. lang = self.options.get('lang', 'C')
  196. tmpl = jinja_env().get_template('grid.html')
  197. output = []
  198. days = unique_attr(all_talks(), 'day')
  199. gridstep = pelican.settings['TALKS_GRID_STEP']
  200. for day in sorted(days):
  201. talks = {talk['id'] for talk in all_talks()
  202. if talk.get('day', None) == day
  203. and 'time' in talk
  204. and 'room' in talk}
  205. if not talks:
  206. continue
  207. talks = [get_talk_data(t) for t in talks]
  208. rooms = set()
  209. for t in talks:
  210. if type(t['room']) is list:
  211. for r in t['room']:
  212. rooms.add(r)
  213. else:
  214. rooms.add(t['room'])
  215. rooms = list(sorted(rooms))
  216. # room=* is not a real room.
  217. # Remove it unless that day only has special rooms
  218. if '*' in rooms and len(rooms) > 1:
  219. del rooms[rooms.index('*')]
  220. mintime = min({talk['time'].hour * 60 +
  221. talk['time'].minute
  222. for talk in talks}) // gridstep * gridstep
  223. maxtime = max({talk['time'].hour * 60 +
  224. talk['time'].minute +
  225. talk['duration']
  226. for talk in talks})
  227. times = {}
  228. for t in range(mintime, maxtime, gridstep):
  229. times[t] = [None] * len(rooms)
  230. for talk in sorted(talks, key=lambda x: x['time']):
  231. talktime = talk['time'].hour * 60 + talk['time'].minute
  232. position = talktime // gridstep * gridstep # round
  233. assert position in times
  234. if talk['room'] == '*':
  235. roomnums = range(len(rooms))
  236. elif type(talk['room']) is list:
  237. roomnums = [rooms.index(r) for r in talk['room']]
  238. else:
  239. roomnums = [rooms.index(talk['room'])]
  240. for roomnum in roomnums:
  241. if times[position][roomnum] is not None:
  242. logging.error("Talk %s and %s overlap! (room %s)",
  243. times[position][roomnum]['id'],
  244. talk['id'],
  245. rooms[roomnum]
  246. )
  247. continue
  248. times[position][roomnum] = copy(talk)
  249. times[position][roomnum]['skip'] = False
  250. for i in range(1, talk['duration'] // gridstep):
  251. times[position + i*gridstep][roomnum] = copy(talk)
  252. times[position + i*gridstep][roomnum]['skip'] = True
  253. render = tmpl.render(times=times,
  254. rooms=rooms,
  255. mintime=mintime, maxtime=maxtime,
  256. timestep=gridstep,
  257. lang=lang,
  258. )
  259. output.append(nodes.raw(
  260. '', u'<h4>%s</h4>' % format_date(day, format='full',
  261. locale=lang),
  262. format='html'))
  263. output.append(nodes.raw('', render, format='html'))
  264. return output
  265. def talks_to_ics():
  266. content = u'BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:pelican\n'
  267. for t in all_talks():
  268. try:
  269. content += talk_to_ics(t)
  270. except:
  271. logging.exception("Error producing calendar for talk %s", t['id'])
  272. content += 'END:VCALENDAR\n'
  273. return content
  274. def talk_to_ics(talk):
  275. if 'time' not in talk or 'duration' not in talk:
  276. return ''
  277. start = talk['time']
  278. end = start + datetime.timedelta(minutes=talk['duration'])
  279. content = 'BEGIN:VEVENT\n'
  280. content += "UID:%s@%d.hackmeeting.org\n" % (talk['id'], talk['day'].year)
  281. content += "SUMMARY:%s\n" % talk['title']
  282. content += "DTSTAMP:%s\n" % time.strftime('%Y%m%dT%H%M%SZ',
  283. time.gmtime(float(
  284. start.strftime('%s'))))
  285. content += "DTSTART:%s\n" % time.strftime('%Y%m%dT%H%M%SZ',
  286. time.gmtime(float(
  287. start.strftime('%s'))))
  288. content += "DTEND:%s\n" % time.strftime('%Y%m%dT%H%M%SZ',
  289. time.gmtime(float(
  290. end.strftime('%s'))))
  291. content += "LOCATION:%s\n" % (talk['room'] if 'room' in talk else 'todo')
  292. content += 'END:VEVENT\n'
  293. return content
  294. class TalksGenerator(generators.Generator):
  295. def __init__(self, *args, **kwargs):
  296. self.talks = []
  297. super(TalksGenerator, self).__init__(*args, **kwargs)
  298. def generate_context(self):
  299. self.talks = {n: get_talk_data(n) for n in get_talk_names()}
  300. self._update_context(('talks',))
  301. def generate_output(self, writer=None):
  302. for talkname in self.talks:
  303. if 'resources' in self.talks[talkname]:
  304. outdir = os.path.join(self.output_path,
  305. pelican.settings['TALKS_PATH'], talkname,
  306. pelican.settings['TALKS_ATTACHMENT_PATH'])
  307. if os.path.isdir(outdir):
  308. shutil.rmtree(outdir)
  309. shutil.copytree(self.talks[talkname]['resources'], outdir)
  310. with io.open(os.path.join(self.output_path, pelican.settings.get('TALKS_ICS')),
  311. 'w',
  312. encoding='utf8') as buf:
  313. buf.write(talks_to_ics())
  314. def add_talks_option_defaults(pelican):
  315. pelican.settings.setdefault('TALKS_PATH', 'talks')
  316. pelican.settings.setdefault('TALKS_ATTACHMENT_PATH', 'res')
  317. pelican.settings.setdefault('TALKS_ICS', 'schedule.ics')
  318. pelican.settings.setdefault('TALKS_GRID_STEP', 30)
  319. def get_generators(gen):
  320. return TalksGenerator
  321. def pelican_init(pelicanobj):
  322. global pelican
  323. pelican = pelicanobj
  324. try:
  325. import yaml
  326. except ImportError:
  327. print('ERROR: yaml not found. Talks plugins will be disabled')
  328. def register():
  329. pass
  330. else:
  331. def register():
  332. signals.initialized.connect(pelican_init)
  333. signals.get_generators.connect(get_generators)
  334. signals.initialized.connect(add_talks_option_defaults)
  335. directives.register_directive('talklist', TalkListDirective)
  336. directives.register_directive('talk', TalkDirective)
  337. directives.register_directive('talkgrid', TalkGridDirective)