talks.py 18 KB

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