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 = """\n%(header)s\n%(body)s\n
""" + 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 + + %(track)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