talks.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  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. 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. TALKS_PATH = 'talks'
  23. TALK_ATTACHMENT_PATH = 'res'
  24. TALK_ICS = 'schedule.ics'
  25. GRID_STEP = 15
  26. def memoize(function):
  27. '''decorators to cache'''
  28. memo = {}
  29. @wraps(function)
  30. def wrapper(*args):
  31. if args in memo:
  32. return memo[args]
  33. else:
  34. rv = function(*args)
  35. memo[args] = rv
  36. return rv
  37. return wrapper
  38. @contextmanager
  39. def setlocale(name):
  40. saved = locale.setlocale(locale.LC_ALL)
  41. try:
  42. yield locale.setlocale(locale.LC_ALL, name)
  43. finally:
  44. locale.setlocale(locale.LC_ALL, saved)
  45. @memoize
  46. def get_talk_names():
  47. return [name for name in os.listdir(TALKS_PATH)
  48. if not name.startswith('_') and
  49. get_talk_data(name) is not None
  50. ]
  51. def all_talks():
  52. return [get_talk_data(tn) for tn in get_talk_names()]
  53. def unique_attr(iterable, attr):
  54. return {x[attr] for x in iterable
  55. if attr in x}
  56. @memoize
  57. def get_global_data():
  58. fname = os.path.join(TALKS_PATH, 'meta.yaml')
  59. if not os.path.isfile(fname):
  60. return None
  61. with io.open(fname, encoding='utf8') as buf:
  62. try:
  63. data = yaml.load(buf)
  64. except Exception:
  65. logging.exception("Syntax error reading %s; skipping", fname)
  66. return None
  67. if data is None:
  68. return None
  69. if 'startdate' not in data:
  70. logging.error("Missing startdate in global data")
  71. data['startdate'] = datetime.datetime.now()
  72. return data
  73. @memoize
  74. def get_talk_data(talkname):
  75. fname = os.path.join(TALKS_PATH, talkname, 'meta.yaml')
  76. if not os.path.isfile(fname):
  77. return None
  78. with io.open(fname, encoding='utf8') as buf:
  79. try:
  80. data = yaml.load(buf)
  81. except Exception as exc:
  82. logging.exception("Syntax error reading %s; skipping", fname)
  83. return None
  84. if data is None:
  85. return None
  86. if 'title' not in data:
  87. logging.warn("Talk <{}> has no `title` field".format(talkname))
  88. data['title'] = talkname
  89. if 'text' not in data:
  90. logging.warn("Talk <{}> has no `text` field".format(talkname))
  91. data['text'] = ''
  92. if 'duration' not in data:
  93. logging.info("Talk <{}> has no `duration` field (50min used)"
  94. .format(talkname))
  95. data['duration'] = 50
  96. data['duration'] = int(data['duration'])
  97. if data['duration'] < GRID_STEP:
  98. logging.info("Talk <{}> lasts only {} minutes; changing to {}"
  99. .format(talkname, data['duration'], GRID_STEP))
  100. data['duration'] = GRID_STEP
  101. if 'links' not in data or not data['links']:
  102. data['links'] = []
  103. if 'contacts' not in data or not data['contacts']:
  104. data['contacts'] = []
  105. if 'needs' not in data or not data['needs']:
  106. data['needs'] = []
  107. if 'room' not in data:
  108. logging.warn("Talk <{}> has no `room` field".format(talkname))
  109. if 'time' not in data or 'day' not in data:
  110. logging.warn("Talk <{}> has no `time` or `day`".format(talkname))
  111. if 'day' in data:
  112. data['day'] = get_global_data()['startdate'] + datetime.timedelta(days=data['day'])
  113. if 'time' in data and 'day' in data:
  114. timeparts = re.findall(r'\d+', str(data['time']))
  115. if 4 > len(timeparts) > 0:
  116. timeparts = [int(p) for p in timeparts]
  117. data['time'] = datetime.datetime.combine(data['day'],
  118. datetime.time(*timeparts))
  119. else:
  120. logging.error("Talk <{}> has malformed `time`".format(talkname))
  121. data['id'] = talkname
  122. resdir = os.path.join(TALKS_PATH, talkname, TALK_ATTACHMENT_PATH)
  123. if os.path.isdir(resdir) and os.listdir(resdir):
  124. data['resources'] = resdir
  125. return data
  126. jinja_env = jinja2.Environment(
  127. loader=jinja2.FileSystemLoader(os.path.join(TALKS_PATH, '_templates')),
  128. autoescape=True,
  129. )
  130. jinja_env.filters['markdown'] = lambda text: \
  131. jinja2.Markup(markdown.Markdown(extensions=['meta']).
  132. convert(text))
  133. jinja_env.filters['dateformat'] = format_date
  134. jinja_env.filters['datetimeformat'] = format_datetime
  135. jinja_env.filters['timeformat'] = format_time
  136. class TalkListDirective(Directive):
  137. final_argument_whitespace = True
  138. has_content = True
  139. option_spec = {
  140. 'lang': directives.unchanged
  141. }
  142. def run(self):
  143. lang = self.options.get('lang', 'C')
  144. tmpl = jinja_env.get_template('talk.html')
  145. return [
  146. nodes.raw('', tmpl.render(lang=lang, **get_talk_data(n)),
  147. format='html')
  148. for n in get_talk_names()
  149. ]
  150. class TalkDirective(Directive):
  151. required_arguments = 1
  152. final_argument_whitespace = True
  153. has_content = True
  154. option_spec = {
  155. 'lang': directives.unchanged
  156. }
  157. def run(self):
  158. lang = self.options.get('lang', 'C')
  159. tmpl = jinja_env.get_template('talk.html')
  160. data = get_talk_data(self.arguments[0])
  161. if data is None:
  162. return []
  163. return [
  164. nodes.raw('', tmpl.render(lang=lang, **data),
  165. format='html')
  166. ]
  167. class TalkGridDirective(Directive):
  168. '''A complete grid'''
  169. final_argument_whitespace = True
  170. has_content = True
  171. option_spec = {
  172. 'lang': directives.unchanged
  173. }
  174. def run(self):
  175. lang = self.options.get('lang', 'C')
  176. tmpl = jinja_env.get_template('grid.html')
  177. output = []
  178. days = unique_attr(all_talks(), 'day')
  179. for day in sorted(days):
  180. talks = {talk['id'] for talk in all_talks()
  181. if talk.get('day', None) == day
  182. and 'time' in talk
  183. and 'room' in talk}
  184. if not talks:
  185. continue
  186. talks = [get_talk_data(t) for t in talks]
  187. rooms = tuple(sorted(unique_attr(talks, 'room')))
  188. mintime = min({talk['time'].hour * 60 +
  189. talk['time'].minute
  190. for talk in talks}) // GRID_STEP * GRID_STEP
  191. maxtime = max({talk['time'].hour * 60 +
  192. talk['time'].minute +
  193. talk['duration']
  194. for talk in talks})
  195. times = {}
  196. for t in range(mintime, maxtime, GRID_STEP):
  197. times[t] = [None] * len(rooms)
  198. for talk in sorted(talks, key=lambda x: x['time']):
  199. talktime = talk['time'].hour * 60 + talk['time'].minute
  200. position = talktime // GRID_STEP * GRID_STEP # round
  201. assert position in times
  202. roomnum = rooms.index(talk['room'])
  203. if times[position][roomnum] is not None:
  204. logging.error("Talk {} and {} overlap! "
  205. .format(times[position][roomnum]['id'],
  206. talk['id']))
  207. continue
  208. times[position][roomnum] = copy(talk)
  209. times[position][roomnum]['skip'] = False
  210. for i in range(1, talk['duration'] // GRID_STEP):
  211. times[position + i*GRID_STEP][roomnum] = copy(talk)
  212. times[position + i*GRID_STEP][roomnum]['skip'] = True
  213. #with setlocale(locale.normalize(lang)):
  214. render = tmpl.render(times=times,
  215. rooms=rooms,
  216. mintime=mintime, maxtime=maxtime,
  217. timestep=GRID_STEP,
  218. lang=lang,
  219. )
  220. output.append(nodes.raw('', u'<h4>%s</h4>' %
  221. format_date(day, format='full', locale=lang),
  222. format='html'))
  223. output.append(nodes.raw('', render, format='html'))
  224. return output
  225. def talks_to_ics():
  226. content = 'BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:pelican\n'
  227. for t in all_talks():
  228. content += talk_to_ics(t)
  229. content += 'END:VCALENDAR\n'
  230. return content
  231. def talk_to_ics(talk):
  232. if 'time' not in talk:
  233. return ''
  234. start = talk['time']
  235. end = start + datetime.timedelta(minutes=talk['duration'])
  236. content = 'BEGIN:VEVENT\n'
  237. content += "UID:%s@%d.hackmeeting.org\n" % (talk['id'], talk['day'].year)
  238. content += "SUMMARY:%s\n" % talk['title']
  239. content += "DTSTAMP:%s\n" % time.strftime('%Y%m%dT%H%M%SZ',
  240. time.gmtime(float(start.strftime('%s'))))
  241. content += "DTSTART:%s\n" % time.strftime('%Y%m%dT%H%M%SZ',
  242. time.gmtime(float(
  243. start.strftime('%s'))))
  244. content += "DTEND:%s\n" % time.strftime('%Y%m%dT%H%M%SZ',
  245. time.gmtime(float(
  246. end.strftime('%s'))))
  247. content += "LOCATION:%s\n" % talk['room']
  248. content += 'END:VEVENT\n'
  249. return content
  250. class TalksGenerator(generators.Generator):
  251. def __init__(self, *args, **kwargs):
  252. self.talks = []
  253. super(TalksGenerator, self).__init__(*args, **kwargs)
  254. def generate_context(self):
  255. self.talks = {n: get_talk_data(n) for n in get_talk_names()}
  256. self._update_context(('talks',))
  257. def generate_output(self, writer=None):
  258. for talkname in self.talks:
  259. if 'resources' in self.talks[talkname]:
  260. outdir = os.path.join(self.output_path, TALKS_PATH, talkname,
  261. TALK_ATTACHMENT_PATH)
  262. if os.path.isdir(outdir):
  263. shutil.rmtree(outdir)
  264. shutil.copytree(self.talks[talkname]['resources'], outdir)
  265. with open(os.path.join(self.output_path, TALK_ICS), 'w') as buf:
  266. buf.write(talks_to_ics())
  267. def add_talks_option_defaults(pelican):
  268. pelican.settings.setdefault('TALKS_PATH', TALKS_PATH)
  269. def get_generators(gen):
  270. return TalksGenerator
  271. try:
  272. import yaml
  273. except ImportError:
  274. print('ERROR: yaml not found. Talks plugins will be disabled')
  275. def register():
  276. pass
  277. else:
  278. def register():
  279. signals.get_generators.connect(get_generators)
  280. signals.initialized.connect(add_talks_option_defaults)
  281. directives.register_directive('talklist', TalkListDirective)
  282. directives.register_directive('talk', TalkDirective)
  283. directives.register_directive('talkgrid', TalkGridDirective)