agendas: replace vobject by icalendar & recurring_ical_events (#88806) #251
|
@ -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:
|
||||
|
||||
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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
|
|
3
setup.py
|
@ -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',
|
||||
|
|
|
@ -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
bdauvergne
commented
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).
yweber
commented
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):
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
bdauvergne
commented
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.
yweber
commented
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
bdauvergne
commented
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.
yweber
commented
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')
|
||||
|
|
|
@ -63,6 +63,64 @@ SEQUENCE:2
|
|||
END:VEVENT
|
||||
END:VCALENDAR"""
|
||||
|
||||
ICS_SAMPLE_WITH_TIMEZONES = """BEGIN:VCALENDAR
|
||||
yweber marked this conversation as resolved
Outdated
bdauvergne
commented
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.
yweber
commented
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
|
||||
|
|
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 :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 :
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.
Ok, cool, c'est aussi ce qu'il me semblait
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.