talks.py 13 KB

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