talks.py 15 KB

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