diff --git a/cfp/planning.py b/cfp/planning.py
new file mode 100644
index 0000000..be9e4ae
--- /dev/null
+++ b/cfp/planning.py
@@ -0,0 +1,326 @@
+from django.db.models import Q
+from django.utils.safestring import mark_safe
+from django.utils.html import escape
+from django.utils.timezone import localtime
+from django.core.cache import cache
+from django.core.urlresolvers import reverse
+
+from datetime import datetime, timedelta
+from copy import deepcopy
+from collections import OrderedDict, namedtuple
+from itertools import islice
+
+from .models import Conference, Talk, Room
+
+
+Event = namedtuple('Event', ['talk', 'row', 'rowcount'])
+
+
+class Program:
+ def __init__(self, site, pending=False, cache=True):
+ self.site = site
+ self.pending = pending
+ self.cache = cache
+ self.initialized = False
+
+ def _lazy_init(self):
+ self.conference = Conference.objects.get(site=self.site)
+ self.talks = Talk.objects.\
+ exclude(category__label__exact='').\
+ filter(site=self.site, room__isnull=False, start_date__isnull=False).\
+ filter(Q(duration__gt=0) | Q(category__duration__gt=0))
+
+ if self.pending:
+ self.talks = self.talks.exclude(accepted=False)
+ else:
+ self.talks = self.talks.filter(accepted=True)
+
+ self.talks = self.talks.order_by('start_date')
+
+ self.rooms = Room.objects.filter(talk__in=self.talks.all()).order_by('name').distinct()
+
+ self.days = {}
+ for talk in self.talks.all():
+ duration = talk.estimated_duration
+ assert(duration)
+ dt1 = talk.start_date
+ d1 = localtime(dt1).date()
+ if d1 not in self.days.keys():
+ self.days[d1] = {'timeslots': []}
+ dt2 = dt1 + timedelta(minutes=duration)
+ d2 = localtime(dt2).date()
+ if d2 not in self.days.keys():
+ self.days[d2] = {'timeslots': []}
+ if dt1 not in self.days[d1]['timeslots']:
+ self.days[d1]['timeslots'].append(dt1)
+ if dt2 not in self.days[d2]['timeslots']:
+ self.days[d2]['timeslots'].append(dt2)
+
+ self.cols = OrderedDict([(room, 1) for room in self.rooms])
+ for day in self.days.keys():
+ self.days[day]['timeslots'] = sorted(self.days[day]['timeslots'])
+ self.days[day]['rows'] = OrderedDict([(timeslot, OrderedDict([(room, []) for room in self.rooms])) for timeslot in self.days[day]['timeslots'][:-1]])
+
+ for talk in self.talks.exclude(plenary=True).all():
+ self._add_talk(talk)
+
+ for talk in self.talks.filter(plenary=True).all():
+ self._add_talk(talk)
+
+ self.initialized = True
+
+ def _add_talk(self, talk):
+ room = talk.room
+ dt1 = talk.start_date
+ d1 = localtime(dt1).date()
+ dt2 = talk.start_date + timedelta(minutes=talk.estimated_duration)
+ d2 = localtime(dt2).date()
+ assert(d1 == d2) # this is a current limitation
+ dt1 = self.days[d1]['timeslots'].index(dt1)
+ dt2 = self.days[d1]['timeslots'].index(dt2)
+ col = None
+ for row, timeslot in enumerate(islice(self.days[d1]['timeslots'], dt1, dt2)):
+ if col is None:
+ col = 0
+ while col < len(self.days[d1]['rows'][timeslot][room]) and self.days[d1]['rows'][timeslot][room][col]:
+ col += 1
+ self.cols[room] = max(self.cols[room], col+1)
+ event = Event(talk=talk, row=row, rowcount=dt2-dt1)
+ while len(self.days[d1]['rows'][timeslot][room]) <= col:
+ self.days[d1]['rows'][timeslot][room].append(None)
+ self.days[d1]['rows'][timeslot][room][col] = event
+
+ def _html_header(self):
+ output = '
Room | '
+ room_cell = '%(name)s %(label)s | '
+ for room, colspan in self.cols.items():
+ options = ' style="min-width: 100px;" colspan="%d"' % colspan
+ output += room_cell % {'name': escape(room.name), 'label': escape(room.label), 'options': options}
+ return '%s
' % output
+
+ def _html_body(self):
+ output = ''
+ for day in sorted(self.days.keys()):
+ output += self._html_day_header(day)
+ output += self._html_day(day)
+ return output
+
+ def _html_day_header(self, day):
+ row = '%(day)s |
'
+ colcount = 1
+ for room, col in self.cols.items():
+ colcount += col
+ return row % {
+ 'colcount': colcount,
+ 'day': datetime.strftime(day, '%A %d %B'),
+ }
+
+ def _html_day(self, day):
+ output = []
+ rows = self.days[day]['rows']
+ for ts, rooms in rows.items():
+ output.append(self._html_row(day, ts, rooms))
+ return '\n'.join(output)
+
+ def _html_row(self, day, ts, rooms):
+ row = '%(timeslot)s%(content)s
'
+ cell = '%(content)s | '
+ content = ''
+ for room, events in rooms.items():
+ colspan = 1
+ for i in range(self.cols[room]):
+ options = ' colspan="%d"' % colspan
+ cellcontent = ''
+ if i < len(events) and events[i]:
+ event = events[i]
+ if event.row != 0:
+ continue
+ options = ' rowspan="%d" bgcolor="%s"' % (event.rowcount, event.talk.category.color)
+ cellcontent = escape(str(event.talk)) + '
' + escape(event.talk.get_speakers_str()) + ''
+ elif (i+1 > len(events) or not events[i+1]) and i+1 < self.cols[room]:
+ colspan += 1
+ continue
+ colspan = 1
+ content += cell % {'options': options, 'content': mark_safe(cellcontent)}
+ style, timeslot = self._html_timeslot(day, ts)
+ return row % {
+ 'style': style,
+ 'timeslot': timeslot,
+ 'content': content,
+ }
+
+ def _html_timeslot(self, day, ts):
+ template = '%(content)s | '
+ start = ts
+ end = self.days[day]['timeslots'][self.days[day]['timeslots'].index(ts)+1]
+ duration = (end - start).seconds / 60
+ date_to_string = lambda date: datetime.strftime(localtime(date), '%H:%M')
+ style = 'height: %dpx;' % int(duration * 1.2)
+ timeslot = '%s – %s | ' % tuple(map(date_to_string, [start, end]))
+ return style, timeslot
+
+ def _as_html(self):
+ template = """"""
+ if not self.initialized:
+ self._lazy_init()
+ return template % {
+ 'header': self._html_header(),
+ 'body': self._html_body(),
+ }
+
+ def _as_xml(self):
+ if not self.initialized:
+ self._lazy_init()
+ result = """
+
+%(conference)s
+%(days)s
+
+"""
+
+ if not len(self.days):
+ return result % {'conference': '', 'days': ''}
+
+ conference_xml = """
+ %(title)s
+
+ %(venue)s
+ %(city)s
+ %(start_date)s
+ %(end_date)s
+ %(days_count)s
+ 09:00:00
+ 00:05:00
+
+""" % {
+ 'title': self.site.name,
+ 'venue': ', '.join(map(lambda x: x.strip(), self.conference.venue.split('\n'))),
+ 'city': self.conference.city,
+ 'start_date': sorted(self.days.keys())[0].strftime('%Y-%m-%d'),
+ 'end_date': sorted(self.days.keys(), reverse=True)[0].strftime('%Y-%m-%d'),
+ 'days_count': len(self.days),
+ }
+
+ days_xml = ''
+ for index, day in enumerate(sorted(self.days.keys())):
+ days_xml += '\n' % {
+ 'index': index + 1,
+ 'date': day.strftime('%Y-%m-%d'),
+ }
+ for room in self.rooms.all():
+ days_xml += ' \n' % room.name
+ for talk in self.talks.filter(room=room).order_by('start_date'):
+ if localtime(talk.start_date).date() != day:
+ continue
+ duration = talk.estimated_duration
+ persons = ''
+ for speaker in talk.speakers.all():
+ persons += ' %(person)s\n' % {
+ 'person_id': speaker.id,
+ 'person': str(speaker.profile),
+ }
+ links = ''
+ registration = ''
+ if talk.registration_required and self.conference.subscriptions_open:
+ links += mark_safe("""
+ %(link)s""" % {
+ 'link': reverse('register-for-a-talk', args=[talk.slug]),
+ })
+ registration = """
+ %(max)s
+ %(remain)s""" % {
+ 'max': talk.attendees_limit,
+ 'remain': talk.remaining_attendees or 0,
+ }
+ if talk.materials:
+ links += mark_safe("""
+ %(link)s""" % {
+ 'link': talk.materials.url,
+ })
+ if talk.video:
+ links += mark_safe("""
+ %(link)s""" % {
+ 'link': talk.video,
+ })
+ days_xml += """
+ %(start)s
+ %(duration)s
+ %(room)s
+ %(slug)s
+ %(title)s
+
+
+ %(type)s
+
+ %(abstract)s
+ %(description)s
+
+%(persons)s
+ %(links)s
+ %(registration)s
+ \n""" % {
+ 'id': talk.id,
+ 'start': localtime(talk.start_date).strftime('%H:%M'),
+ 'duration': '%02d:%02d' % (talk.estimated_duration / 60, talk.estimated_duration % 60),
+ 'room': escape(room.name),
+ 'slug': escape(talk.slug),
+ 'title': escape(talk.title),
+ 'track': escape(talk.track or ''),
+ 'type': escape(talk.category.label),
+ 'abstract': escape(talk.abstract),
+ 'description': escape(talk.description),
+ 'persons': persons,
+ 'links': links,
+ 'registration': registration,
+ }
+ days_xml += ' \n'
+ days_xml += '\n'
+
+ return result % {
+ 'conference': '\n'.join(map(lambda x: ' ' + x, conference_xml.split('\n'))),
+ 'days': '\n'.join(map(lambda x: ' ' + x, days_xml.split('\n'))),
+ }
+
+ def _as_ics(self):
+ if not self.initialized:
+ self._lazy_init()
+ talks = [ICS_TALK.format(site=self.site, talk=talk) for talk in self.talks]
+ return ICS_MAIN.format(site=self.site, talks='\n'.join(talks))
+
+ def render(self, output='html'):
+ if self.cache:
+ cache_entry = 'program.%s.%s' % ('pending' if self.pending else 'final', output)
+ result = cache.get(cache_entry)
+ if not result:
+ result = getattr(self, '_as_%s' % output)()
+ cache.set(cache_entry, result, 3 * 60 * 60) # 3H
+ return mark_safe(result)
+ else:
+ return mark_safe(getattr(self, '_as_%s' % output)())
+
+ def __str__(self):
+ return self.render()
+
+
+# FIXME definitely the wrong place for this, but hey, other templates are already here :P
+
+ICS_MAIN = """BEGIN:VCALENDAR
+PRODID:-//{site.domain}//{site.name}//FR
+X-WR-CALNAME:PonyConf
+X-WR-TIMEZONE:Europe/Paris
+VERSION:2.0
+CALSCALE:GREGORIAN
+METHOD:PUBLISH
+{talks}
+END:VCALENDAR"""
+
+ICS_TALK = """BEGIN:VEVENT
+DTSTART:{talk.dtstart}
+DTEND:{talk.dtend}
+SUMMARY:{talk.title}
+LOCATION:{talk.room}
+STATUS: CONFIRMED
+DESCRIPTION:{talk.abstract}\n---\n\n{talk.description}
+UID:{site.domain}/{talk.id}
+END:VEVENT
+"""
diff --git a/cfp/templates/cfp/staff/base.html b/cfp/templates/cfp/staff/base.html
index fd50435..65ad24a 100644
--- a/cfp/templates/cfp/staff/base.html
+++ b/cfp/templates/cfp/staff/base.html
@@ -9,7 +9,6 @@
{% comment %}
{% trans "Topics" %}
{% trans "Volunteers" %}
- {% trans "Schedule" %}
{% trans "Correspondents" %}
{% trans "Conference" %}
{% endcomment %}
@@ -17,6 +16,7 @@
{% trans "Speakers" %}
{% trans "Tracks" %}
{% trans "Rooms" %}
+ {% trans "Schedule" %}
{% trans "Conference" %}
{% if request.user.is_staff %}
Django-Admin
diff --git a/cfp/templates/cfp/staff/schedule.html b/cfp/templates/cfp/staff/schedule.html
new file mode 100644
index 0000000..dd3b5b9
--- /dev/null
+++ b/cfp/templates/cfp/staff/schedule.html
@@ -0,0 +1,13 @@
+{% extends 'cfp/staff/base.html' %}
+
+{% load i18n %}
+
+{% block scheduletab %} class="active"{% endblock %}
+
+{% block content %}
+
+{% trans "Schedule" %}
+
+{{ program }}
+
+{% endblock %}
diff --git a/cfp/urls.py b/cfp/urls.py
index 7db0878..0d13a7b 100644
--- a/cfp/urls.py
+++ b/cfp/urls.py
@@ -27,6 +27,7 @@ urlpatterns = [
url(r'^staff/rooms/(?P[-\w]+)/$', views.RoomDetail.as_view(), name='room-details'),
url(r'^staff/rooms/(?P[-\w]+)/edit/$', views.RoomUpdate.as_view(), name='room-edit'),
url(r'^staff/add-user/$', views.create_user, name='create-user'),
+ url(r'^staff/schedule/$', views.schedule, name='schedule'),
url(r'^staff/select2/$', views.Select2View.as_view(), name='django_select2-json'),
#url(r'^markdown/$', views.markdown_preview, name='markdown'),
]
diff --git a/cfp/views.py b/cfp/views.py
index 972509a..0c18946 100644
--- a/cfp/views.py
+++ b/cfp/views.py
@@ -14,6 +14,7 @@ from functools import reduce
from mailing.models import Message
from mailing.forms import MessageForm
+from .planning import Program
from .decorators import staff_required
from .mixins import StaffRequiredMixin, OnSiteMixin, OnSiteFormMixin
from .utils import is_staff
@@ -467,5 +468,19 @@ def create_user(request):
})
+@staff_required
+def schedule(request):
+ program = Program(site=request.conference.site, pending=True, cache=False)
+ output = request.GET.get('format', 'html')
+ if output == 'html':
+ return render(request, 'cfp/staff/schedule.html', {'program': program.render('html')})
+ elif output == 'xml':
+ return HttpResponse(program.render('xml'), content_type="application/xml")
+ elif output == 'ics':
+ return HttpResponse(program.render('ics'), content_type="text/calendar")
+ else:
+ raise Http404("Format not available")
+
+
class Select2View(StaffRequiredMixin, AutoResponseView):
pass