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
This commit is contained in:
jlwt90
2017-03-10 05:13:08 +09:00
committed by Raphael Michel
parent c63e69db5f
commit 55953d5b4e
6 changed files with 190 additions and 4 deletions

View File

@@ -68,9 +68,20 @@
{% endif %}
</div>
{% endif %}
{% if frontpage_text %}
{{ frontpage_text|rich_text }}
{% endif %}
<div>
{% if frontpage_text %}
<div class="pull-left">
{{ frontpage_text|rich_text }}
</div>
{% endif %}
<div class="pull-right">
<a href="{% eventurl event "presale:event.ical.download" %}" class="btn btn-link">
<i class="fa fa-calendar"></i> {% trans "Add to Calendar" %}
</a>
</div>
<div class="clearfix"></div>
</div>
{% eventsignal event "pretix.presale.signals.front_page_top" %}
{% if event.presale_is_running or event.settings.show_items_outside_presale_period %}
<form method="post" data-asynctask

View File

@@ -56,6 +56,9 @@ event_patterns = [
url(r'^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/invoice/(?P<invoice>[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'),
]

View File

@@ -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):

View File

@@ -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.*

View File

@@ -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': [

View File

@@ -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(