From 55953d5b4e2f33ea0df37f692e1b68fd94d02c84 Mon Sep 17 00:00:00 2001 From: jlwt90 Date: Fri, 10 Mar 2017 05:13:08 +0900 Subject: [PATCH] Fix #389 -- Add event ical download feature (#413) * added event ical download feature * handle event settings and timezone * add test cases for ical download * fix failed test case for timezone settings * using vobject lib to generate ical * customised UID & add vobject dependency --- .../templates/pretixpresale/event/index.html | 17 ++- src/pretix/presale/urls.py | 3 + src/pretix/presale/views/event.py | 48 +++++++ src/requirements/production.txt | 1 + src/setup.py | 3 +- src/tests/presale/test_event.py | 122 ++++++++++++++++++ 6 files changed, 190 insertions(+), 4 deletions(-) diff --git a/src/pretix/presale/templates/pretixpresale/event/index.html b/src/pretix/presale/templates/pretixpresale/event/index.html index e661741c1..febcefb73 100644 --- a/src/pretix/presale/templates/pretixpresale/event/index.html +++ b/src/pretix/presale/templates/pretixpresale/event/index.html @@ -68,9 +68,20 @@ {% endif %} {% endif %} - {% if frontpage_text %} - {{ frontpage_text|rich_text }} - {% endif %} +
+ {% if frontpage_text %} +
+ {{ frontpage_text|rich_text }} +
+ {% endif %} + +
+
+ {% eventsignal event "pretix.presale.signals.front_page_top" %} {% if event.presale_is_running or event.settings.show_items_outside_presale_period %}
[^/]+)/(?P[A-Za-z0-9]+)/invoice/(?P[0-9]+)$', pretix.presale.views.order.InvoiceDownload.as_view(), name='event.invoice.download'), + url(r'^ical$', + pretix.presale.views.event.EventIcalDownload.as_view(), + name='event.ical.download'), url(r'^auth/$', pretix.presale.views.event.EventAuth.as_view(), name='event.auth'), url(r'^$', pretix.presale.views.event.EventIndex.as_view(), name='event.index'), ] diff --git a/src/pretix/presale/views/event.py b/src/pretix/presale/views/event.py index 5a55bc82a..4abb7bc65 100644 --- a/src/pretix/presale/views/event.py +++ b/src/pretix/presale/views/event.py @@ -1,16 +1,22 @@ import sys +from datetime import datetime from importlib import import_module +import pytz +import vobject from django.conf import settings from django.core.exceptions import PermissionDenied from django.db.models import Count, Prefetch, Q +from django.http import Http404, HttpResponse from django.shortcuts import redirect from django.utils.decorators import method_decorator +from django.utils.functional import cached_property from django.utils.timezone import now from django.utils.translation import ugettext_lazy as _ from django.views import View from django.views.decorators.csrf import csrf_exempt from django.views.generic import TemplateView +from pytz import timezone from pretix.base.models import ItemVariation from pretix.multidomain.urlreverse import eventreverse @@ -105,6 +111,48 @@ class EventIndex(EventViewMixin, CartMixin, TemplateView): return context +class EventIcalDownload(EventViewMixin, View): + + @cached_property + def event_timezone(self): + return timezone(self.request.event.settings.timezone) + + def get(self, request, *args, **kwargs): + if not self.request.event: + raise Http404(_('Unknown event code or not authorized to access this event.')) + + event = self.request.event + creation_time = datetime.now(pytz.utc) + cal = vobject.iCalendar() + cal.add('prodid').value = '-//pretix//{}//'.format(settings.PRETIX_INSTANCE_NAME) + + vevent = cal.add('vevent') + vevent.add('summary').value = str(event.name) + vevent.add('dtstamp').value = creation_time + vevent.add('location').value = str(event.location) + vevent.add('organizer').value = event.organizer.name + vevent.add('uid').value = '{}-{}-{}'.format( + event.organizer.slug, event.slug, creation_time.strftime('%Y%m%d%H%M%S%f') + ) + + if event.settings.show_times: + vevent.add('dtstart').value = event.date_from.astimezone(self.event_timezone) + else: + vevent.add('dtstart').value = event.date_from.astimezone(self.event_timezone).date() + + if event.settings.show_date_to: + if event.settings.show_times: + vevent.add('dtend').value = event.date_to.astimezone(self.event_timezone) + else: + vevent.add('dtend').value = event.date_to.astimezone(self.event_timezone).date() + + resp = HttpResponse(cal.serialize(), content_type='text/calendar') + resp['Content-Disposition'] = 'attachment; filename="{}-{}.ics"'.format( + event.organizer.slug, event.slug + ) + return resp + + class EventAuth(View): @method_decorator(csrf_exempt) def dispatch(self, request, *args, **kwargs): diff --git a/src/requirements/production.txt b/src/requirements/production.txt index bb38a105b..0b70e7f02 100644 --- a/src/requirements/production.txt +++ b/src/requirements/production.txt @@ -36,3 +36,4 @@ pycparser==2.13 # https://github.com/eliben/pycparser/issues/147 # Banktransfer chardet>=2.3,<3 mt-940==3.2 +vobject==0.9.* diff --git a/src/setup.py b/src/setup.py index 924680716..5ddb07984 100644 --- a/src/setup.py +++ b/src/setup.py @@ -97,7 +97,8 @@ setup( 'stripe==1.22.*', 'chardet>=2.3,<3', 'mt-940==3.2', - 'django-i18nfield' + 'django-i18nfield', + 'vobject==0.9.*' ], extras_require={ 'dev': [ diff --git a/src/tests/presale/test_event.py b/src/tests/presale/test_event.py index b1a7c0d8e..18158e2b2 100644 --- a/src/tests/presale/test_event.py +++ b/src/tests/presale/test_event.py @@ -1,10 +1,13 @@ import datetime +import re from decimal import Decimal +from django.conf import settings from django.core import mail from django.core.exceptions import ValidationError from django.test import TestCase from django.utils.timezone import now +from pytz import timezone from tests.base import SoupTest from pretix.base.models import ( @@ -49,6 +52,16 @@ class EventMiddlewareTest(EventTestMixin, SoupTest): resp = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug)) self.assertEqual(resp.status_code, 200) + def test_not_found_event(self): + resp = self.client.get('/%s/%s/ical' % ('foo', 'bar')) + self.assertEqual(resp.status_code, 404) + + def test_mandatory_field(self): + self.event.date_to = self.event.date_from + datetime.timedelta(days=2) + self.event.save() + resp = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug)) + self.assertEqual(resp.status_code, 200) + class ItemDisplayTest(EventTestMixin, SoupTest): def test_not_active(self): @@ -548,6 +561,115 @@ class TestResendLink(EventTestMixin, SoupTest): self.assertIn('DUMMY2', mail.outbox[0].body) +class EventIcalDownloadTest(EventTestMixin, SoupTest): + def setUp(self): + super().setUp() + self.event.settings.show_date_to = True + self.event.settings.show_times = True + self.event.location = 'DUMMY ARENA' + self.event.date_from = datetime.datetime(2013, 12, 26, 21, 57, 58, tzinfo=datetime.timezone.utc) + self.event.date_to = self.event.date_from + datetime.timedelta(days=2) + self.event.settings.timezone = 'UTC' + self.event.save() + + def test_response_type(self): + ical = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug)) + self.assertEqual(ical['Content-Type'], 'text/calendar') + self.assertEqual(ical['Content-Disposition'], 'attachment; filename="{}-{}.ics"'.format( + self.orga.slug, self.event.slug + )) + + def test_header_footer(self): + ical = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug)).content.decode() + self.assertTrue(ical.startswith('BEGIN:VCALENDAR'), 'missing VCALENDAR header') + self.assertTrue(ical.strip().endswith('END:VCALENDAR'), 'missing VCALENDAR footer') + self.assertIn('BEGIN:VEVENT', ical, 'missing VEVENT header') + self.assertIn('END:VEVENT', ical, 'missing VEVENT footer') + + def test_timezone_header_footer(self): + self.event.settings.timezone = 'Asia/Tokyo' + self.event.save() + ical = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug)).content.decode() + self.assertTrue(ical.startswith('BEGIN:VCALENDAR'), 'missing VCALENDAR header') + self.assertTrue(ical.strip().endswith('END:VCALENDAR'), 'missing VCALENDAR footer') + self.assertIn('BEGIN:VEVENT', ical, 'missing VEVENT header') + self.assertIn('END:VEVENT', ical, 'missing VEVENT footer') + self.assertIn('BEGIN:VTIMEZONE', ical, 'missing VTIMEZONE header') + self.assertIn('END:VTIMEZONE', ical, 'missing VTIMEZONE footer') + + def test_metadata(self): + ical = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug)).content.decode() + self.assertIn('VERSION:2.0', ical, 'incorrect version tag - 2.0') + self.assertIn('-//pretix//%s//' % settings.PRETIX_INSTANCE_NAME, ical, 'incorrect PRODID') + + def test_event_info(self): + ical = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug)).content.decode() + self.assertIn('SUMMARY:%s' % self.event.name, ical, 'incorrect correct summary') + self.assertIn('LOCATION:DUMMY ARENA', ical, 'incorrect location') + self.assertIn('ORGANIZER:%s' % self.event.organizer.name, ical, 'incorrect organizer') + self.assertTrue(re.search(r'DTSTAMP:\d{8}T\d{6}Z', ical), 'incorrect timestamp') + self.assertTrue(re.search(r'UID:\w*-\w*-\d{20}', ical), 'missing UID key') + + def test_utc_timezone(self): + ical = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug)).content.decode() + # according to icalendar spec, timezone must NOT be shown if it is UTC + self.assertIn('DTSTART:%s' % self.event.date_from.strftime('%Y%m%dT%H%M%SZ'), ical, 'incorrect start time') + self.assertIn('DTEND:%s' % self.event.date_to.strftime('%Y%m%dT%H%M%SZ'), ical, 'incorrect end time') + + def test_include_timezone(self): + self.event.settings.timezone = 'Asia/Tokyo' + self.event.save() + ical = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug)).content.decode() + # according to icalendar spec, timezone must be shown if it is not UTC + fmt = '%Y%m%dT%H%M%S' + self.assertIn('DTSTART;TZID=%s:%s' % + (self.event.settings.timezone, + self.event.date_from.astimezone(timezone(self.event.settings.timezone)).strftime(fmt)), + ical, 'incorrect start time') + self.assertIn('DTEND;TZID=%s:%s' % + (self.event.settings.timezone, + self.event.date_to.astimezone(timezone(self.event.settings.timezone)).strftime(fmt)), + ical, 'incorrect end time') + self.assertIn('TZID:%s' % self.event.settings.timezone, ical, 'missing VCALENDAR') + + def test_no_time(self): + self.event.settings.show_times = False + self.event.save() + ical = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug)).content.decode() + self.assertIn('DTSTART;VALUE=DATE:%s' % self.event.date_from.strftime('%Y%m%d'), ical, 'incorrect start date') + self.assertIn('DTEND;VALUE=DATE:%s' % self.event.date_to.strftime('%Y%m%d'), ical, 'incorrect end date') + + def test_no_date_to(self): + self.event.settings.timezone = 'Asia/Tokyo' + self.event.settings.show_date_to = False + self.event.save() + ical = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug)).content.decode() + fmt = '%Y%m%dT%H%M%S' + self.assertIn('DTSTART;TZID=%s:%s' % + (self.event.settings.timezone, + self.event.date_from.astimezone(timezone(self.event.settings.timezone)).strftime(fmt)), + ical, 'incorrect start time') + self.assertNotIn('DTEND', ical, 'unexpected end time attribute') + + def test_no_date_to_and_time(self): + self.event.settings.show_date_to = False + self.event.settings.show_times = False + self.event.save() + ical = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug)).content.decode() + self.assertIn('DTSTART;VALUE=DATE:%s' % self.event.date_from.strftime('%Y%m%d'), ical, 'incorrect start date') + self.assertNotIn('DTEND', ical, 'unexpected end time attribute') + + def test_local_date_diff_from_utc(self): + self.event.date_from = datetime.datetime(2013, 12, 26, 21, 57, 58, tzinfo=datetime.timezone.utc) + self.event.date_to = self.event.date_from + datetime.timedelta(days=2) + self.event.settings.timezone = 'Asia/Tokyo' + self.event.settings.show_times = False + self.event.save() + ical = self.client.get('/%s/%s/ical' % (self.orga.slug, self.event.slug)).content.decode() + self.assertIn('DTSTART;VALUE=DATE:20131227', ical, 'incorrect start date') + self.assertIn('DTEND;VALUE=DATE:20131229', ical, 'incorrect end date') + + class EventSlugBlacklistValidatorTest(EventTestMixin, SoupTest): def test_slug_validation(self): event = Event(