talks.py 17 KB

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