agendas: replace vobject by icalendar & recurring_ical_events (#88806) #251

Merged
yweber merged 1 commits from wip/88806-change-ics-lib into main 2024-04-30 14:47:46 +02:00
8 changed files with 299 additions and 83 deletions

View File

@ -27,8 +27,9 @@ import sys
import uuid
from contextlib import contextmanager
import icalendar
import recurring_ical_events
import requests
import vobject
from dateutil.relativedelta import SU, relativedelta
from dateutil.rrule import DAILY, WEEKLY, rrule, rruleset
from django.conf import settings
@ -64,7 +65,7 @@ from django.template import (
)
from django.template.defaultfilters import yesno
from django.urls import reverse
from django.utils import functional
from django.utils import functional, timezone
from django.utils.dates import WEEKDAYS
from django.utils.encoding import force_str
from django.utils.formats import date_format
@ -3350,41 +3351,44 @@ class Booking(models.Model):
)
def get_vevent_ics(self, request=None):
vevent = vobject.newFromBehavior('vevent')
vevent.add('uid').value = '%s-%s-%s' % (
self.event.start_datetime.isoformat(),
self.event.agenda.pk,
self.pk,
event = icalendar.Event()
event.add(
'uid',
'%s-%s-%s'
% (
self.event.start_datetime.isoformat(),
self.event.agenda.pk,
self.pk,
),
)
vevent.add('summary').value = self.user_display_label or self.label
vevent.add('dtstart').value = self.event.start_datetime
event.add('summary', self.user_display_label or self.label)
event.add('dtstart', self.event.start_datetime)
if self.user_name:
vevent.add('attendee').value = self.user_name
event.add('attendee', self.user_name)
if request is None or request.GET.get('organizer') != 'no':
organizer_name = getattr(settings, 'TEMPLATE_VARS', {}).get('global_title', 'chrono')
organizer_email = getattr(settings, 'TEMPLATE_VARS', {}).get(
'default_from_email', 'chrono@example.net'
)
organizer = vevent.add('organizer')
organizer.value = f'mailto:{organizer_email}'
organizer.cn_param = organizer_name
organizer = icalendar.vCalAddress(f'mailto:{organizer_email}')
organizer.params['cn'] = organizer_name
event.add('organizer', organizer)
if self.event.end_datetime:
vevent.add('dtend').value = self.event.end_datetime
event.add('dtend', self.event.end_datetime)
for field in ('description', 'location', 'comment', 'url'):
field_value = request and request.GET.get(field) or (self.extra_data or {}).get(field)
if field_value:
vevent.add(field).value = field_value
return vevent
event.add(field, field_value)
return event
def get_ics(self, request=None):
ics = vobject.iCalendar()
ics.add('prodid').value = '-//Entr\'ouvert//NON SGML Publik'
vevent = self.get_vevent_ics(request)
ics.add(vevent)
return ics.serialize()
cal = icalendar.Calendar()
cal.add('propid', '-//Entr\'ouvert//NON SGML Publik')
cal.add_component(self.get_vevent_ics(request))
return cal.to_ical().decode('utf-8')
def clone(self, primary_booking=None, save=True):
new_booking = copy.deepcopy(self)
@ -3933,25 +3937,30 @@ class TimePeriodExceptionSource(WithInspectMixin, models.Model):
data = force_str(self.ics_file.read())
try:
parsed = vobject.readOne(data)
except vobject.base.ParseError:
cal = icalendar.Calendar.from_ical(data)
except ValueError:
raise ICSError(_('File format is invalid.'))
if not parsed.contents.get('vevent'):
vevents = list(cal.walk('vevent'))
if len(vevents) == 0:
raise ICSError(_('The file doesn\'t contain any events.'))
for vevent in parsed.contents.get('vevent', []):
for vevent in vevents:

Si les TimePeriodException fonctionnent comme ics (avec le "moment" de DTEND exclue de l’événement) il faudrait peut être se caler sur ce qui est fait dans ics :

DTEND:20240101T235959

Est un évènement qui fini 1 seconde avant la fin de la journée du 2024-01-01

On voudrait plutôt peut être plutôt :

DTEND:20240102T000000

un évènement qui finie avec la fin de la journée du 2024-01-01

Si les `TimePeriodException` fonctionnent comme ics (avec le "moment" de DTEND exclue de l’événement) il faudrait peut être se caler sur ce qui est fait dans ics : ``` DTEND:20240101T235959 ``` Est un évènement qui fini 1 seconde avant la fin de la journée du 2024-01-01 On voudrait plutôt peut être plutôt : ``` DTEND:20240102T000000 ``` un évènement qui finie avec la fin de la journée du 2024-01-01

Ce n'est pas lié à l'objet de ce ticket mais oui je pense qu'on pourrait faire ce changement, même si ça m'a l'air surtout cosmétique/anecdotique ou bien tu vois un souci possible avec un évènement s'arrêtant à 23:59 plutôt que 00:00 le lendemain ? Sachant qu'on ne s'intéresse qu'a des créneaux calé sur des multiples de 5 minutes en général, ça ne changera pas grand chose il me semble.

Ce n'est pas lié à l'objet de ce ticket mais oui je pense qu'on pourrait faire ce changement, même si ça m'a l'air surtout cosmétique/anecdotique ou bien tu vois un souci possible avec un évènement s'arrêtant à 23:59 plutôt que 00:00 le lendemain ? Sachant qu'on ne s'intéresse qu'a des créneaux calé sur des multiples de 5 minutes en général, ça ne changera pas grand chose il me semble.

ça m'a l'air surtout cosmétique/anecdotique

Ok, cool, c'est aussi ce qu'il me semblait

ou bien tu vois un souci possible avec un évènement s'arrêtant à 23:59 plutôt que 00:00 le lendemain ? Sachant qu'on ne s'intéresse qu'a des créneaux calé sur des multiples de 5 minutes en général, ça ne changera pas grand chose il me semble.

A vrai dire je m'inquiétais presque plutôt de l'inverse : avec le comportement par défaut de "finir le même jour", est-ce qu'il n'y aurait pas un problème avec une exception qui dure 1 jour (par exemple avec DURATION:P1D ) et qui se retrouve avec une date de fin différente de la date de début : s'il existe un endroit dans le code qui vérifie que le début et la fin sont au même jour/même date, ça pourrait ne pas produire le résultat attendu.

> ça m'a l'air surtout cosmétique/anecdotique Ok, cool, c'est aussi ce qu'il me semblait > ou bien tu vois un souci possible avec un évènement s'arrêtant à 23:59 plutôt que 00:00 le lendemain ? Sachant qu'on ne s'intéresse qu'a des créneaux calé sur des multiples de 5 minutes en général, ça ne changera pas grand chose il me semble. A vrai dire je m'inquiétais presque plutôt de l'inverse : avec le comportement par défaut de "finir le même jour", est-ce qu'il n'y aurait pas un problème avec une exception qui dure 1 jour (par exemple avec `DURATION:P1D` ) et qui se retrouve avec une date de fin différente de la date de début : s'il existe un endroit dans le code qui vérifie que le début et la fin sont au même jour/même date, ça pourrait ne pas produire le résultat attendu.
summary = self._get_summary_from_vevent(vevent)
try:
vevent.dtstart.value
except AttributeError:
if 'dtstart' not in vevent:
raise ICSError(_('Event "%s" has no start date.') % summary)
# with icalendar date parse error lead to None properties
# and then raises an Attribute error when trying to decode it
if vevent['dtstart'] is None:
raise ICSError(_('File format is invalid.'))
if 'dtend' in vevent and vevent['dtend'] is None:
raise ICSError(_('File format is invalid.'))
return parsed
return cal
def _get_summary_from_vevent(self, vevent):
if 'summary' in vevent.contents:
return force_str(vevent.contents['summary'][0].value)
if 'summary' in vevent:
return vevent.decoded('summary').decode('utf-8')
return _('Exception')
def refresh_timeperiod_exceptions(self, data=None):
@ -3980,31 +3989,36 @@ class TimePeriodExceptionSource(WithInspectMixin, models.Model):
self.timeperiodexception_set.all().delete()
# create new exceptions
update_datetime = now()
for vevent in parsed.contents.get('vevent', []):
for vevent in parsed.walk('vevent'):
summary = self._get_summary_from_vevent(vevent)
try:
start_dt = vevent.dtstart.value
if 'dtstart' in vevent:
start_dt = vevent.decoded('dtstart')
if not isinstance(start_dt, datetime.datetime):
start_dt = datetime.datetime.combine(start_dt, datetime.datetime.min.time())
if not is_aware(start_dt):
start_dt = make_aware(start_dt)
except AttributeError:
else:
# Enforce local timezone to calculate the end of the day
# when no DTEND and no duration in the local tz
start_dt = start_dt.astimezone(timezone.get_current_timezone())
else:
raise ICSError(_('Event "%s" has no start date.') % summary)
try:
end_dt = vevent.dtend.value
if 'dtend' in vevent:
end_dt = vevent.decoded('dtend')
if not isinstance(end_dt, datetime.datetime):
end_dt = datetime.datetime.combine(end_dt, datetime.datetime.min.time())
if not is_aware(end_dt):
end_dt = make_aware(end_dt)
duration = end_dt - start_dt
except AttributeError:
else:
try:
duration = vevent.duration.value
duration = vevent.decoded('duration')
end_dt = start_dt + duration
except AttributeError:
# events without end date are considered as ending the same day
except (KeyError, AttributeError):
# events without end date and with no/invalid duration are
# considered as ending the same day leading in "strange"
# ics files with a DTEND set at 23:59:59.999999 meaning
# that the event ends at 23:59:59.999998 (DTEND is excluded)
end_dt = make_aware(datetime.datetime.combine(start_dt, datetime.datetime.max.time()))
duration = end_dt - start_dt
event = {
'start_datetime': start_dt,
@ -4016,29 +4030,34 @@ class TimePeriodExceptionSource(WithInspectMixin, models.Model):
'recurrence_id': 0,
}
if 'categories' in vevent.contents and len(vevent.categories.value) > 0:
category = vevent.categories.value[0]
if 'categories' in vevent and len(vevent['categories'].cats) > 0:
category = str(vevent['categories'].cats[0])
else:
category = None
if not vevent.rruleset:
# Updating vevent to match calculated start & end so the
# recurrence matches what we calculated
vevent.pop('dtstart')
vevent.add('dtstart', start_dt)
if 'duration' in vevent:
vevent.pop('duration')
if 'dtend' in vevent:
vevent.pop('dtend')
vevent.add('dtend', end_dt)
rrule = recurring_ical_events.of(vevent)
if 'rrule' not in vevent:
# classical event
exception = TimePeriodException.objects.create(**event)
if category:
categories[category].append(exception)
elif vevent.rruleset.count():
elif len(rrule.repetitions) > 0:
# recurring event until recurring_days in the future
from_dt = start_dt
until_dt = update_datetime + datetime.timedelta(days=recurring_days)
if not is_aware(vevent.rruleset[0]):
from_dt = make_naive(from_dt)
until_dt = make_naive(until_dt)
i = -1
for i, start_dt in enumerate(vevent.rruleset.between(from_dt, until_dt, inc=True)):
# recompute start_dt and end_dt from occurrences and duration
if not is_aware(start_dt):
start_dt = make_aware(start_dt)
end_dt = start_dt + duration
for i, revent in enumerate(rrule.between(from_dt, until_dt)):
start_dt = revent.decoded('dtstart')
end_dt = revent.decoded('dtend')
event['recurrence_id'] = i
event['start_datetime'] = start_dt
event['end_datetime'] = end_dt

View File

@ -20,7 +20,7 @@ import datetime
import json
import uuid
import vobject
import icalendar
from django.conf import settings
from django.db import IntegrityError, transaction
from django.db.models import BooleanField, Case, Count, ExpressionWrapper, F, Func, Prefetch, Q, When
@ -2836,14 +2836,14 @@ class BookingsICS(BookingsAPI):
except ValidationError as e:
raise APIErrorBadRequest(N_('invalid payload'), errors=e.detail)
ics = vobject.iCalendar()
ics.add('prodid').value = '-//Entr\'ouvert//NON SGML Publik'
cal = icalendar.Calendar()
cal['propid'] = '-//Entr\'ouvert//NON SGML Publik'
for booking in bookings:
vevent = booking.get_vevent_ics()
ics.add(vevent)
cal.add_component(vevent)
return HttpResponse(ics.serialize(), content_type='text/calendar')
return HttpResponse(cal.to_ical(), content_type='text/calendar')
bookings_ics = BookingsICS.as_view()

2
debian/control vendored
View File

@ -35,9 +35,9 @@ Depends: libcairo-gobject2,
python3-django-mellon,
python3-django-tenant-schemas,
python3-hobo (>= 1.34),
python3-icalendar,
python3-psycopg2,
python3-sorl-thumbnail,
python3-vobject,
uwsgi,
uwsgi-plugin-python3,
weasyprint,

View File

@ -163,8 +163,9 @@ setup(
'gadjo',
'djangorestframework>=3.4,<3.15',
'django-filter<23.2',
'vobject',
'python-dateutil',
'icalendar<=4.0.3',
'recurring-ical-events<=2.0.1',
'pyquery',
'requests',
'workalendar',

View File

@ -1678,24 +1678,24 @@ def test_duration_on_booking_api_fillslot_response(app, user):
assert resp.json['end_datetime'] is None
assert 'ics_url' in resp.json['api']
ics = app.get(resp.json['api']['ics_url']).text
assert 'DTSTART:20170519T231200Z' in ics
assert 'DTEND:' not in ics
assert 'DTSTART;VALUE=DATE-TIME:20170519T231200Z' in ics
bdauvergne marked this conversation as resolved Outdated

Ce changement est-il nécessaire ? Est-ce que ça indique que icalendar serait par ailleurs moins tolérant à des .ics non conforme comparé à vobject ? Parce que ça pourrait éventuellement poser un souci (ou alors ce sont juste les tests qui sont non conformes et ça n'est pas un souci).

Ce changement est-il nécessaire ? Est-ce que ça indique que icalendar serait par ailleurs moins tolérant à des .ics non conforme comparé à vobject ? Parce que ça pourrait éventuellement poser un souci (ou alors ce sont juste les tests qui sont non conformes et ça n'est pas un souci).

Oui ce changement était nécessaire. Il me semble qu'ici on test plutôt les ics produits par icalendar : il a l'air aussi permissif à la lecture, mais plus précis à la production.

Oui ce changement était nécessaire. Il me semble qu'ici on test plutôt les ics produits par icalendar : il a l'air aussi permissif à la lecture, mais plus précis à la production.
assert 'DTEND' not in ics
resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda.slug, evt[1].id))
assert resp.json['datetime'] == '2017-05-21 01:12:00'
assert resp.json['end_datetime'] == resp.json['datetime']
assert 'ics_url' in resp.json['api']
ics = app.get(resp.json['api']['ics_url']).text
assert 'DTSTART:20170520T231200Z' in ics
assert 'DTEND:20170520T231200Z' in ics
assert 'DTSTART;VALUE=DATE-TIME:20170520T231200Z' in ics
assert 'DTEND;VALUE=DATE-TIME:20170520T231200Z' in ics
resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda.slug, evt[2].id))
assert resp.json['datetime'] == '2017-05-22 01:12:00'
assert resp.json['end_datetime'] == '2017-05-22 01:57:00'
assert 'ics_url' in resp.json['api']
ics = app.get(resp.json['api']['ics_url']).text
assert 'DTSTART:20170521T231200Z' in ics
assert 'DTEND:20170521T235700Z' in ics
assert 'DTSTART;VALUE=DATE-TIME:20170521T231200Z' in ics
assert 'DTEND;VALUE=DATE-TIME:20170521T235700Z' in ics
def test_fillslot_past_event(app, user):

View File

@ -32,8 +32,8 @@ def test_booking_ics(app, some_data, meetings_agenda, user, settings, rf):
booking_ics = booking.get_ics()
assert 'UID:%s-%s-%s\r\n' % (event.start_datetime.isoformat(), agenda.pk, booking.pk) in booking_ics
assert 'SUMMARY:\r\n' in booking_ics
assert 'DTSTART:%sZ\r\n' % formatted_start_date in booking_ics
assert 'DTEND:' not in booking_ics
assert 'DTSTART;VALUE=DATE-TIME:%sZ\r\n' % formatted_start_date in booking_ics
assert 'DTEND' not in booking_ics
assert 'ORGANIZER;CN=chrono:mailto:chrono@example.net\r\n' in booking_ics
booking_ics = booking.get_ics(rf.get('/?organizer=no'))
@ -53,7 +53,7 @@ def test_booking_ics(app, some_data, meetings_agenda, user, settings, rf):
assert 'SUMMARY:foo\r\n' in booking_ics
assert 'ATTENDEE:bar\r\n' in booking_ics
assert 'URL:http://example.com/booking\r\n' in booking_ics
assert 'ORGANIZER;CN=meeting server:mailto:donotanswer@meeting-server.com\r\n' in booking_ics
assert 'ORGANIZER;CN="meeting server":mailto:donotanswer@meeting-server.com\r\n' in booking_ics
# test with user_label in additionnal data
booking.user_first_name = 'foo'
@ -110,8 +110,8 @@ def test_booking_ics(app, some_data, meetings_agenda, user, settings, rf):
end = (
booking.event.start_datetime + datetime.timedelta(minutes=booking.event.meeting_type.duration)
).strftime('%Y%m%dT%H%M%S')
assert 'DTSTART:%sZ\r\n' % start in booking_ics
assert 'DTEND:%sZ\r\n' % end in booking_ics
assert 'DTSTART;VALUE=DATE-TIME:%sZ\r\n' % start in booking_ics
assert 'DTEND;VALUE=DATE-TIME:%sZ\r\n' % end in booking_ics
@pytest.mark.freeze_time('2023-09-18 14:00')
@ -135,13 +135,13 @@ def test_bookings_ics(app, user):
resp = app.get('/api/bookings/ics/', params={'user_external_id': 'enfant-1234'})
assert 'BEGIN:VCALENDAR' in resp.text
assert resp.text.count('UID') == 2
assert 'DTSTART:20230921' in resp.text
assert 'DTSTART:20230922' in resp.text
assert 'DTSTART;VALUE=DATE-TIME:20230921T140000Z\r\n' in resp.text
assert 'DTSTART;VALUE=DATE-TIME:20230922T140000Z\r\n' in resp.text
resp = app.get('/api/bookings/ics/', params={'user_external_id': 'enfant-1234', 'agenda': 'foo-bar'})
assert resp.text.count('UID') == 1
assert 'DTSTART:20230921' in resp.text
assert 'DTSTART:20230922' not in resp.text
assert 'DTSTART;VALUE=DATE-TIME:20230921T140000Z\r\n' in resp.text
assert 'DTSTART;VALUE=DATE-TIME:20230922T140000Z\r\n' not in resp.text
resp = app.get('/api/bookings/ics/', params={'user_external_id': 'enfant-1234', 'agenda': 'xxx'})
assert 'BEGIN:VCALENDAR' in resp.text

View File

@ -652,8 +652,34 @@ END:VCALENDAR"""
assert AgendaSnapshot.objects.count() == 1
# Testing with a DTEND and with a DURATION
@pytest.mark.parametrize(
'recurrent_ics',
(
b"""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//foo.bar//EN
BEGIN:VEVENT
DTSTART:20180101
DTEND:20180102
SUMMARY:New Year's Eve
RRULE:FREQ=YEARLY
END:VEVENT
END:VCALENDAR""",
b"""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//foo.bar//EN
BEGIN:VEVENT
DTSTART:20180101
yweber marked this conversation as resolved Outdated

Ajouter un commentaire pour expliquer que la différence c'est DTEND / DURATION, pas évident au premier coup d’œil.

Ajouter un commentaire pour expliquer que la différence c'est DTEND / DURATION, pas évident au premier coup d’œil.

ok ! Merci :)

ok ! Merci :)
DURATION:P1D
SUMMARY:New Year's Eve
RRULE:FREQ=YEARLY
END:VEVENT
END:VCALENDAR""",
),
)
@pytest.mark.freeze_time('2017-12-01')
def test_agenda_import_time_period_exception_from_ics_recurrent(app, admin_user):
def test_agenda_import_time_period_exception_from_ics_recurrent(app, admin_user, recurrent_ics):
agenda = Agenda.objects.create(label='Example', kind='meetings')
desk = Desk.objects.create(agenda=agenda, label='Test Desk')
MeetingType(agenda=agenda, label='Foo').save()
@ -663,19 +689,54 @@ def test_agenda_import_time_period_exception_from_ics_recurrent(app, admin_user)
login(app)
resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
resp = resp.click('manage exceptions')
ics_with_recurrent_exceptions = b"""BEGIN:VCALENDAR
resp.form['ics_file'] = Upload('exceptions.ics', recurrent_ics, 'text/calendar')
resp = resp.form.submit(status=302).follow()
assert TimePeriodException.objects.filter(desk=desk).count() == 2
expt_start = '2018-01-01T00:00:00+0100', '2019-01-01T00:00:00T+0100'
expt_end = '2018-01-02T00:00:00+0100', '2019-01-02T00:00:00T+0100'
assert set(TimePeriodException.objects.values_list('start_datetime', flat=True)) == {
datetime.datetime.fromisoformat(dt) for dt in expt_start
}
assert set(TimePeriodException.objects.values_list('end_datetime', flat=True)) == {
datetime.datetime.fromisoformat(dt) for dt in expt_end
}
@pytest.mark.freeze_time('2017-12-01')
def test_agenda_import_time_period_exception_from_ics_recurrent_invalid_duration(app, admin_user):
# Specific test for invalid/missing duration : in this case
# we set the DTEND to 23:59:59.999999 the same day.
agenda = Agenda.objects.create(label='Example', kind='meetings')
desk = Desk.objects.create(agenda=agenda, label='Test Desk')
MeetingType(agenda=agenda, label='Foo').save()
TimePeriod.objects.create(
weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)
)
login(app)
resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
resp = resp.click('manage exceptions')
recurrent_ics = b"""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//foo.bar//EN
BEGIN:VEVENT
DTSTART:20180101
DTEND:20180101
DURATION:invalid duration as 1 day - 1us
SUMMARY:New Year's Eve
RRULE:FREQ=YEARLY
END:VEVENT
END:VCALENDAR"""
resp.form['ics_file'] = Upload('exceptions.ics', ics_with_recurrent_exceptions, 'text/calendar')
resp.form['ics_file'] = Upload('exceptions.ics', recurrent_ics, 'text/calendar')
resp = resp.form.submit(status=302).follow()
yweber marked this conversation as resolved Outdated

Remettre le commentaire de plus haut sur le fonctionnement quand duration / dtend est absent / invalide.

Remettre le commentaire de plus haut sur le fonctionnement quand duration / dtend est absent / invalide.

Ok, merci :)

Ok, merci :)
assert TimePeriodException.objects.filter(desk=desk).count() == 2
expt_start = '2018-01-01T00:00:00+0100', '2019-01-01T00:00:00T+0100'
expt_end = '2018-01-01T23:59:59.999999+0100', '2019-01-01T23:59:59.999999T+0100'
assert set(TimePeriodException.objects.values_list('start_datetime', flat=True)) == {
datetime.datetime.fromisoformat(dt) for dt in expt_start
}
assert set(TimePeriodException.objects.values_list('end_datetime', flat=True)) == {
datetime.datetime.fromisoformat(dt) for dt in expt_end
}
@mock.patch('chrono.agendas.models.requests.get')

View File

@ -63,6 +63,64 @@ SEQUENCE:2
END:VEVENT
END:VCALENDAR"""
ICS_SAMPLE_WITH_TIMEZONES = """BEGIN:VCALENDAR
yweber marked this conversation as resolved Outdated

Utiliser https://docs.pytest.org/en/6.2.x/reference.html?highlight=pytest%20param#pytest.param pour documenter le souci avec chaque exemple.

Utiliser https://docs.pytest.org/en/6.2.x/reference.html?highlight=pytest%20param#pytest.param pour documenter le souci avec chaque exemple.

Ah oui cool ! Bonne idée :)

Ah oui cool ! Bonne idée :)
VERSION:2.0
PRODID:-//foo.bar//EN
BEGIN:VTIMEZONE
TZID:(UTC+01:00) Bruxelles, Copenhague, Madrid, Paris
BEGIN:DAYLIGHT
TZOFFSETFROM:+0100
TZOFFSETTO:+0200
TZNAME:CEST
DTSTART:19700329T020000
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0200
TZOFFSETTO:+0100
TZNAME:CET
DTSTART:19701025T030000
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
END:STANDARD
END:VTIMEZONE
BEGIN:VTIMEZONE
TZID:CUSTOM TZ
BEGIN:DAYLIGHT
TZOFFSETFROM:+0300
TZOFFSETTO:+0400
TZNAME:WTF0
DTSTART:19700101T020000
RRULE:FREQ=MONTHLY;BYMONTH=1,3,5,7,9,11
END:DAYLIGHT
BEGIN:STANDARD
TZOFFSETFROM:+0400
TZOFFSETTO:+0300
TZNAME:WTF1
DTSTART:19700201T030000
RRULE:FREQ=MONTHLY;BYMONTH=2,4,6,8,10,12
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTSTART;TZID="(UTC+01:00) Bruxelles, Copenhague, Madrid, Paris":20171213T120100
DTEND;TZID="(UTC+01:00) Bruxelles, Copenhague, Madrid, Paris":20171213T120200
END:VEVENT
BEGIN:VEVENT
DTSTART;TZID="CUSTOM TZ":20180101T112233
DTEND;TZID="CUSTOM TZ":20180202T112233
END:VEVENT
BEGIN:VEVENT
DTSTART:20190102T030405Z
DTEND:20190504T030201Z
END:VEVENT
END:VCALENDAR
"""
ICS_SAMPLE_WITH_DURATION = """BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//foo.bar//EN
@ -76,7 +134,7 @@ END:VEVENT
BEGIN:VEVENT
DTSTAMP:20170824T092855Z
DTSTART:20170830T180800Z
DURATION:P1D4H26M
DURATION:P1DT4H26M
SEQUENCE:2
SUMMARY:Event 2
END:VEVENT
@ -1078,6 +1136,83 @@ def test_timeperiodexception_creation_from_ics_with_duration():
}
def test_timeperiodexception_creation_from_ics_with_timezone():
agenda = Agenda.objects.create(label='Test 1 agenda')
desk = Desk.objects.create(label='Test 1 desk', agenda=agenda)
source = desk.timeperiodexceptionsource_set.create(
ics_filename='sample.ics', ics_file=ContentFile(ICS_SAMPLE_WITH_TIMEZONES, name='sample.ics')
)
source.refresh_timeperiod_exceptions_from_ics()
assert TimePeriodException.objects.filter(desk=desk).count() == 3
expt_start = '2017-12-13T12:01:00+0100', '2018-01-01T11:22:33+0400', '2019-01-02T03:04:05Z'
expt_end = '2017-12-13T12:02:00+0100', '2018-02-02T11:22:33+0300', '2019-05-04T03:02:01Z'
assert set(TimePeriodException.objects.values_list('start_datetime', flat=True)) == {
datetime.datetime.fromisoformat(dt) for dt in expt_start
}
assert set(TimePeriodException.objects.values_list('end_datetime', flat=True)) == {
datetime.datetime.fromisoformat(dt) for dt in expt_end
}
@pytest.mark.parametrize(
'bad_ics_content',
[
pytest.param(
"""BBEGIN:nothing
VERSION:2.0
PRODID:-//foo.bar//EN
BEGIN:VEVENT
DTSTAMP:20170824T082855Z
DTSTART:20170831T170800Z
DTEND:20170831T203400Z
SEQUENCE:1
SUMMARY:Évènement 1
END:VEVENT
END:VCALENDAR""",
marks=pytest.mark.comment('Missing BEGIN:VCALENDAR'),
),
pytest.param(
"""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//foo.bar//EN
BEGIN:VEVENT
DTSTAMP:20170824T082855Z
DTSTART:2017-08-24T13:37:00
DTEND:20170831T203400Z
SEQUENCE:1
SUMMARY:Évènement 1
END:VEVENT
END:VCALENDAR""",
marks=pytest.mark.comment('Bad DTSTART format'),
),
pytest.param(
"""BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//foo.bar//EN
BEGIN:VEVENT
DTSTAMP:20170824T082855Z
DTSTART:20170830T203400Z
DTEND:something
SEQUENCE:1
SUMMARY:Évènement 1
END:VEVENT
END:VCALENDAR""",
marks=pytest.mark.comment('Bad DTEND format'),
),
],
)
def test_timeperiodexception_creation_from_bad_ics(bad_ics_content):
agenda = Agenda.objects.create(label='Test 1 agenda')
desk = Desk.objects.create(label='Test 1 desk', agenda=agenda)
source = desk.timeperiodexceptionsource_set.create(
ics_filename='sample.ics', ics_file=ContentFile(bad_ics_content, name='sample.ics')
)
with pytest.raises(ICSError):
source.refresh_timeperiod_exceptions_from_ics()
assert TimePeriodException.objects.filter(desk=desk).count() == 0
@pytest.mark.freeze_time('2017-12-01')
def test_timeperiodexception_creation_from_ics_with_recurrences_in_the_past():
# test that recurrent events before today are not created