diff --git a/chrono/agendas/migrations/0024_auto_20180426_1127.py b/chrono/agendas/migrations/0024_auto_20180426_1127.py new file mode 100644 index 00000000..2ac2a19b --- /dev/null +++ b/chrono/agendas/migrations/0024_auto_20180426_1127.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-04-26 11:27 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('agendas', '0023_auto_20171202_1835'), + ] + + operations = [ + migrations.AlterModelOptions( + name='meetingtype', + options={'ordering': ['duration', 'label']}, + ), + migrations.AddField( + model_name='timeperiodexception', + name='recurrence_id', + field=models.PositiveIntegerField(default=0, verbose_name='Recurrence ID'), + ), + ] diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py index ff1cc009..c59df792 100644 --- a/chrono/agendas/models.py +++ b/chrono/agendas/models.py @@ -454,7 +454,7 @@ class Desk(models.Model): def remove_timeperiod_exceptions_from_remote_ics(self): TimePeriodException.objects.filter(desk=self).exclude(external_id='').delete() - def create_timeperiod_exceptions_from_ics(self, data, keep_synced_by_uid=False): + def create_timeperiod_exceptions_from_ics(self, data, keep_synced_by_uid=False, recurring_days=600): try: parsed = vobject.readOne(data) except vobject.base.ParseError: @@ -468,46 +468,75 @@ class Desk(models.Model): with transaction.atomic(): update_datetime = now() for vevent in parsed.contents.get('vevent', []): - event = {} - summary = vevent.contents['summary'][0].value if not isinstance(summary, unicode): summary = unicode(summary, 'utf-8') - if 'rrule' in vevent.contents: - raise ICSError(_('Recurrent events are not handled.')) try: start_dt = vevent.dtstart.value if not isinstance(start_dt, datetime.datetime): - start_dt = datetime.datetime.combine(start_dt, - datetime.datetime.min.time()) + start_dt = datetime.datetime.combine(start_dt, datetime.datetime.min.time()) if not is_aware(start_dt): - event['start_datetime'] = make_aware(start_dt) - else: - event['start_datetime'] = start_dt + start_dt = make_aware(start_dt) except AttributeError: raise ICSError(_('Event "%s" has no start date.') % summary) try: end_dt = vevent.dtend.value if not isinstance(end_dt, datetime.datetime): - end_dt = datetime.datetime.combine(end_dt, - datetime.datetime.min.time()) + 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: - # events without end date are considered as ending the same day - end_dt = datetime.datetime.combine(start_dt, datetime.datetime.max.time()) - if not is_aware(end_dt): - event['end_datetime'] = make_aware(end_dt) - else: - event['end_datetime'] = end_dt + try: + duration = vevent.duration.value + end_dt = start_dt + duration + except AttributeError: + # events without end date are considered as ending the same day + end_dt = make_aware(datetime.datetime.combine(start_dt, datetime.datetime.max.time())) + duration = end_dt - start_dt + + event = {} + event['start_datetime'] = start_dt + event['end_datetime'] = end_dt + event['label'] = summary + + kwargs = {} + kwargs['desk'] = self if keep_synced_by_uid: - external_id = vevent.contents['uid'][0].value - event['label'] = summary - obj, created = TimePeriodException.objects.update_or_create(desk=self, external_id=external_id, - defaults=event) + kwargs['external_id'] = vevent.contents['uid'][0].value else: - obj, created = TimePeriodException.objects.update_or_create(desk=self, label=summary, defaults=event) - # return total_created - if created: - total_created += 1 + kwargs['label'] = summary + + if not vevent.rruleset: + # classical event + obj, created = TimePeriodException.objects.update_or_create(defaults=event, **kwargs) + if created: + total_created += 1 + else: + # 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 + kwargs['recurrence_id'] = i + event['start_datetime'] = start_dt + event['end_datetime'] = end_dt + if end_dt < update_datetime: + TimePeriodException.objects.filter(**kwargs).update(**event) + else: + obj, created = TimePeriodException.objects.update_or_create(defaults=event, **kwargs) + if created: + total_created += 1 + # delete unseen occurrences + kwargs.pop('recurrence_id', None) + TimePeriodException.objects.filter(recurrence_id__gt=i, **kwargs).delete() if keep_synced_by_uid: # delete all outdated exceptions from remote calendar @@ -540,6 +569,7 @@ class TimePeriodException(models.Model): start_datetime = models.DateTimeField(_('Exception start time')) end_datetime = models.DateTimeField(_('Exception end time')) update_datetime = models.DateTimeField(auto_now=True) + recurrence_id = models.PositiveIntegerField(_('Recurrence ID'), default=0) class Meta: ordering = ['start_datetime'] diff --git a/debian/control b/debian/control index 31779d78..f9378954 100644 --- a/debian/control +++ b/debian/control @@ -24,6 +24,7 @@ Depends: ${misc:Depends}, python-django-tenant-schemas, python-psycopg2, python-django-mellon, + python-dateutil, gunicorn Recommends: nginx Suggests: postgresql diff --git a/setup.py b/setup.py index ea9fdc7d..4b8ea53a 100644 --- a/setup.py +++ b/setup.py @@ -140,6 +140,7 @@ setup( 'djangorestframework>=3.1, <3.7', 'django-jsonfield >= 0.9.3', 'vobject', + 'python-dateutil', 'requests' ], zip_safe=False, diff --git a/tests/test_agendas.py b/tests/test_agendas.py index 6ff8820d..f5db8afc 100644 --- a/tests/test_agendas.py +++ b/tests/test_agendas.py @@ -46,7 +46,7 @@ DTEND;VALUE=DATE:20180101 SUMMARY:reccurent event END:VEVENT BEGIN:VEVENT -DTSTAMP:20170824T082855Z +DTSTAMP:20180824T082855Z DTSTART:20180101 DTEND:20180101 SUMMARY:New Year's Eve @@ -54,6 +54,25 @@ RRULE:FREQ=YEARLY END:VEVENT END:VCALENDAR""" +ICS_SAMPLE_WITH_RECURRENT_EVENT_2 = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//foo.bar//EN +BEGIN:VEVENT +DTSTAMP:20170720T145803Z +DESCRIPTION:Vacances d'ete +DTSTART;VALUE=DATE:20180101 +DTEND;VALUE=DATE:20180101 +SUMMARY:reccurent event +END:VEVENT +BEGIN:VEVENT +DTSTAMP:20170824T082855Z +DTSTART:20180102 +DTEND:20180101 +SUMMARY:New Year's Eve +RRULE:FREQ=YEARLY;COUNT=1 +END:VEVENT +END:VCALENDAR""" + ICS_SAMPLE_WITH_NO_EVENTS = """BEGIN:VCALENDAR VERSION:2.0 PRODID:-//foo.bar//EN @@ -181,15 +200,23 @@ def test_timeperiodexception_creation_from_ics_without_enddt(): end_time = localtime(exception.end_datetime).time() assert end_time == datetime.time(23, 59, 59, 999999) + +@pytest.mark.freeze_time('2017-12-01') def test_timeperiodexception_creation_from_ics_with_recurrences(): agenda = Agenda(label=u'Test 4 agenda') agenda.save() desk = Desk(label='Test 4 desk', agenda=agenda) desk.save() - with pytest.raises(ICSError) as e: - exceptions_count = desk.create_timeperiod_exceptions_from_ics(ICS_SAMPLE_WITH_RECURRENT_EVENT) - assert 'Recurrent events are not handled.' == str(e.value) - assert TimePeriodException.objects.filter(desk=desk).count() == 0 + assert desk.create_timeperiod_exceptions_from_ics(ICS_SAMPLE_WITH_RECURRENT_EVENT) == 3 + assert TimePeriodException.objects.filter(desk=desk).count() == 3 + assert set(TimePeriodException.objects.values_list('start_datetime', flat=True)) == set([ + make_aware(datetime.datetime(2018, 1, 1)), make_aware(datetime.datetime(2019, 1, 1))]) + assert desk.create_timeperiod_exceptions_from_ics(ICS_SAMPLE_WITH_RECURRENT_EVENT) == 0 + # verify occurences are cleaned when count changed + assert desk.create_timeperiod_exceptions_from_ics(ICS_SAMPLE_WITH_RECURRENT_EVENT_2) == 0 + assert TimePeriodException.objects.filter(desk=desk).count() == 2 + assert set(TimePeriodException.objects.values_list('start_datetime', flat=True)) == set([ + make_aware(datetime.datetime(2018, 1, 1)), make_aware(datetime.datetime(2018, 1, 2))]) def test_timeexception_creation_from_ics_with_dates(): agenda = Agenda(label=u'Test 5 agenda') diff --git a/tests/test_manager.py b/tests/test_manager.py index 0968fe40..12095847 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -925,19 +925,6 @@ def test_agenda_import_time_period_exception_from_ics(app, admin_user): resp.form['ics_file'] = Upload('exceptions.ics', 'invalid content', 'text/calendar') resp = resp.form.submit(status=200) assert 'File format is invalid' in resp.content - ics_with_recurrent_exceptions = """BEGIN:VCALENDAR -VERSION:2.0 -PRODID:-//foo.bar//EN -BEGIN:VEVENT -DTSTART:20180101 -DTEND:20180101 -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 = resp.form.submit(status=200) - assert 'Recurrent events are not handled.' in resp.content ics_with_no_start_date = """BEGIN:VCALENDAR VERSION:2.0 PRODID:-//foo.bar//EN @@ -975,6 +962,34 @@ END:VCALENDAR""" resp = resp.follow() assert 'An exception has been imported.' in resp.content + +@pytest.mark.freeze_time('2017-12-01') +def test_agenda_import_time_period_exception_from_ics_recurrent(app, admin_user): + 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/%d/' % agenda.pk).follow() + resp = resp.click('Settings') + resp = resp.click('upload') + ics_with_recurrent_exceptions = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//foo.bar//EN +BEGIN:VEVENT +DTSTART:20180101 +DTEND:20180101 +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 = resp.form.submit(status=302).follow() + assert TimePeriodException.objects.filter(desk=desk).count() == 2 + + @mock.patch('chrono.agendas.models.requests.get') def test_agenda_import_time_period_exception_with_remote_ics(mocked_get, app, admin_user): agenda = Agenda.objects.create(label='New Example', kind='meetings') diff --git a/tox.ini b/tox.ini index 96cfe913..22fa3fd1 100644 --- a/tox.ini +++ b/tox.ini @@ -23,6 +23,7 @@ deps = pylint-django<0.9 django-webtest<1.9.3 django-mellon + pytest-freezegun commands = ./getlasso.sh py.test {env:COVERAGE:} {posargs:tests/}