From 4637e3e2410b8e6e516510407afcb30319b63d09 Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Thu, 28 Jan 2021 15:40:57 +0100 Subject: [PATCH 2/2] agendas: allow exceptions to recurring events (#50561) --- .../migrations/0076_auto_20210127_1746.py | 26 +++++++ chrono/agendas/models.py | 31 +++++++- .../manager_events_agenda_settings.html | 23 ++++++ chrono/manager/views.py | 13 ++++ tests/test_agendas.py | 77 +++++++++++++++++++ tests/test_manager.py | 61 +++++++++++++++ 6 files changed, 229 insertions(+), 2 deletions(-) create mode 100644 chrono/agendas/migrations/0076_auto_20210127_1746.py diff --git a/chrono/agendas/migrations/0076_auto_20210127_1746.py b/chrono/agendas/migrations/0076_auto_20210127_1746.py new file mode 100644 index 0000000..589b4a2 --- /dev/null +++ b/chrono/agendas/migrations/0076_auto_20210127_1746.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.18 on 2021-01-27 16:46 +from __future__ import unicode_literals + +from django.db import migrations + + +def create_exceptions_desk(apps, schema_editor): + Agenda = apps.get_model('agendas', 'Agenda') + Desk = apps.get_model('agendas', 'Desk') + + desks = [] + for agenda in Agenda.objects.filter(kind='events'): + desk = Desk.objects.create(agenda=agenda, slug='_exceptions_holder') + desk.import_timeperiod_exceptions_from_settings() + + +class Migration(migrations.Migration): + + dependencies = [ + ('agendas', '0075_event_recurrence_end_date'), + ] + + operations = [ + migrations.RunPython(create_exceptions_desk, migrations.RunPython.noop), + ] diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py index b90f811..56c98c1 100644 --- a/chrono/agendas/models.py +++ b/chrono/agendas/models.py @@ -581,8 +581,10 @@ class Agenda(models.Model): recurring_events = self.prefetched_recurring_events else: recurring_events = self.event_set.filter(recurrence_rule__isnull=False) + + exceptions = self.get_recurrence_exceptions(min_start, max_start) for event in recurring_events: - events.extend(event.get_recurrences(min_start, max_start, excluded_datetimes)) + events.extend(event.get_recurrences(min_start, max_start, excluded_datetimes, exceptions)) events.sort(key=lambda x: [getattr(x, field) for field in Event._meta.ordering]) return events @@ -596,6 +598,14 @@ class Agenda(models.Model): except (VariableDoesNotExist, TemplateSyntaxError): return + def get_recurrence_exceptions(self, min_start, max_start): + desk, _ = Desk.objects.get_or_create(agenda=self, slug='_exceptions_holder') + return TimePeriodException.objects.filter( + Q(desk=desk) | Q(unavailability_calendar__desks=desk), + start_datetime__lt=max_start, + end_datetime__gt=min_start, + ) + def prefetch_desks_and_exceptions(self): if self.kind == 'meetings': desks = self.desk_set.all() @@ -1155,12 +1165,29 @@ class Event(models.Model): event.save() return event - def get_recurrences(self, min_datetime, max_datetime, excluded_datetimes=None): + def get_recurrences(self, min_datetime, max_datetime, excluded_datetimes=None, exceptions=None): recurrences = [] rrule_set = rruleset() # do not generate recurrences for existing events rrule_set._exdate = excluded_datetimes or [] + exceptions = exceptions or self.agenda.get_recurrence_exceptions(min_datetime, max_datetime) + for exception in exceptions: + dtstart = localtime(exception.start_datetime) + start_datetime = localtime(self.start_datetime) + if start_datetime.time() < dtstart.time(): + dtstart += datetime.timedelta(days=1) + dtstart = dtstart.replace( + hour=start_datetime.hour, minute=start_datetime.minute, second=0, microsecond=0 + ) + rrule_set.exrule( + rrule( + freq=DAILY, + dtstart=make_naive(dtstart), + until=make_naive(exception.end_datetime), + ) + ) + event_base = Event( agenda=self.agenda, primary_event=self, diff --git a/chrono/manager/templates/chrono/manager_events_agenda_settings.html b/chrono/manager/templates/chrono/manager_events_agenda_settings.html index b509b46..d5f9037 100644 --- a/chrono/manager/templates/chrono/manager_events_agenda_settings.html +++ b/chrono/manager/templates/chrono/manager_events_agenda_settings.html @@ -49,4 +49,27 @@ +{% if has_recurring_events %} +
+

{% trans "Recurrence exceptions" %} +{% trans 'Configure' %} +

+
+ +
+
+{% endif %} + {% endblock %} diff --git a/chrono/manager/views.py b/chrono/manager/views.py index 1bc0026..5b8e378 100644 --- a/chrono/manager/views.py +++ b/chrono/manager/views.py @@ -623,6 +623,9 @@ class AgendaAddView(CreateView): default_desk = Desk(agenda=self.object, label=_('Desk 1')) default_desk.save() default_desk.import_timeperiod_exceptions_from_settings(enable=True) + elif self.object.kind == 'events': + desk = Desk.objects.create(agenda=self.object, slug='_exceptions_holder') + desk.import_timeperiod_exceptions_from_settings() return model_form def get_success_url(self): @@ -1395,6 +1398,16 @@ class AgendaSettings(ManagedAgendaMixin, DetailView): if self.agenda.kind == 'meetings': context['has_resources'] = Resource.objects.exists() context['has_unavailability_calendars'] = UnavailabilityCalendar.objects.exists() + if self.agenda.kind == 'events': + context['has_recurring_events'] = self.agenda.event_set.filter( + recurrence_rule__isnull=False + ).exists() + desk, _ = Desk.objects.get_or_create(agenda=self.agenda, slug='_exceptions_holder') + context['exceptions'] = TimePeriodException.objects.filter( + Q(desk=desk) | Q(unavailability_calendar__desks=desk), + end_datetime__gt=now(), + ).select_related('source') + context['desk'] = desk return context def get_events(self): diff --git a/tests/test_agendas.py b/tests/test_agendas.py index 3277871..7431506 100644 --- a/tests/test_agendas.py +++ b/tests/test_agendas.py @@ -1793,3 +1793,80 @@ def test_recurring_events_with_end_date(): assert len(recurrences) == 5 assert recurrences[0].start_datetime == start_datetime assert recurrences[-1].start_datetime == start_datetime + datetime.timedelta(days=4) + + +@override_settings( + EXCEPTIONS_SOURCES={ + 'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'}, + } +) +def test_recurring_events_exceptions(freezer): + freezer.move_to('2021-05-01 12:00') + agenda = Agenda.objects.create(label='Agenda', kind='events') + desk = Desk.objects.create(slug='_exceptions_holder', agenda=agenda) + event = Event.objects.create( + agenda=agenda, + start_datetime=now(), + repeat='daily', + places=5, + ) + event.refresh_from_db() + start_datetime = localtime(event.start_datetime) + + recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7)) + assert recurrences[0].start_datetime.strftime('%m-%d') == '05-01' + first_of_may = recurrences[0] + + recurrence = event.get_or_create_event_recurrence(first_of_may.start_datetime) + recurrence.delete() + + desk.import_timeperiod_exceptions_from_settings(enable=True) + recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7)) + # 05-01 is a holiday + assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02' + with pytest.raises(ValueError): + recurrence = event.get_or_create_event_recurrence(first_of_may.start_datetime) + first_event = recurrences[0] + + # exception before first_event start_datetime + time_period_exception = TimePeriodException.objects.create( + desk=desk, + start_datetime=first_event.start_datetime - datetime.timedelta(hours=1), + end_datetime=first_event.start_datetime - datetime.timedelta(minutes=30), + ) + recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7)) + assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02' + + # exception wraps around first_event start_datetime + time_period_exception.end_datetime = first_event.start_datetime + datetime.timedelta(minutes=30) + time_period_exception.save() + recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7)) + assert recurrences[0].start_datetime.strftime('%m-%d') == '05-03' + + # exception starts after first_event start_datetime + time_period_exception.start_datetime = first_event.start_datetime + datetime.timedelta(minutes=15) + time_period_exception.save() + recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7)) + assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02' + assert recurrences[1].start_datetime.strftime('%m-%d') == '05-03' + + # exception spans multiple days + time_period_exception.end_datetime = first_event.start_datetime + datetime.timedelta(days=3) + time_period_exception.save() + recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7)) + assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02' + assert recurrences[1].start_datetime.strftime('%m-%d') == '05-06' + + # move exception to unavailability calendar + unavailability_calendar = UnavailabilityCalendar.objects.create(label='Calendar') + time_period_exception.desk = None + time_period_exception.unavailability_calendar = unavailability_calendar + time_period_exception.save() + recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7)) + assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02' + assert recurrences[1].start_datetime.strftime('%m-%d') == '05-03' + + unavailability_calendar.desks.add(desk) + recurrences = event.get_recurrences(now(), now() + datetime.timedelta(days=7)) + assert recurrences[0].start_datetime.strftime('%m-%d') == '05-02' + assert recurrences[1].start_datetime.strftime('%m-%d') == '05-06' diff --git a/tests/test_manager.py b/tests/test_manager.py index dd3ddc6..63f5dd7 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -5606,3 +5606,64 @@ def test_agenda_booking_colors(app, admin_user, api_user, view): assert resp.text.count('Swimming') == 2 # 1 booking + legend assert 'Booking colors:' in resp.text assert len(resp.pyquery.find('div.booking-colors span.booking-color-label')) == 2 + + +@override_settings( + EXCEPTIONS_SOURCES={ + 'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'}, + } +) +def test_recurring_events_manage_exceptions(settings, app, admin_user, freezer): + freezer.move_to('2021-07-01 12:10') + + app = login(app) + resp = app.get('/manage/') + resp = resp.click('New') + resp.form['label'] = 'Foo bar' + resp.form['kind'] = 'events' + resp = resp.form.submit().follow() + + agenda = Agenda.objects.get(label='Foo bar') + assert agenda.desk_set.count() == 1 + desk = agenda.desk_set.get(slug='_exceptions_holder') + + event = Event.objects.create(start_datetime=now(), places=10, agenda=agenda) + resp = app.get('/manage/agendas/%s/settings' % agenda.id) + assert not 'Recurrence exceptions' in resp.text + + event.repeat = 'daily' + event.save() + + resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2021, 7)) + assert len(resp.pyquery.find('.event-info')) == 31 + + resp = app.get('/manage/agendas/%s/settings' % agenda.id) + assert 'Recurrence exceptions' in resp.text + + resp = resp.click('Add a time period exception') + resp.form['start_datetime_0'] = now().strftime('%Y-%m-%d') + resp.form['start_datetime_1'] = now().strftime('%H:%M') + resp.form['end_datetime_0'] = (now() + datetime.timedelta(days=7)).strftime('%Y-%m-%d') + resp.form['end_datetime_1'] = (now() + datetime.timedelta(days=7)).strftime('%H:%M') + resp = resp.form.submit().follow() + assert desk.timeperiodexception_set.count() == 1 + + resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2021, 7)) + assert len(resp.pyquery.find('.event-info')) == 24 + + resp = app.get('/manage/agendas/%s/settings' % agenda.id) + resp = resp.click('Configure', href='exceptions') + resp = resp.click('enable').follow() + assert TimePeriodException.objects.count() > 1 + assert 'Bastille Day' in resp.text + + resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2021, 7)) + assert len(resp.pyquery.find('.event-info')) == 23 + + # add recurrence end date, which lead to recurrences creation + resp = app.get('/manage/agendas/%s/events/%s/edit' % (agenda.id, event.id)) + resp.form['recurrence_end_date'] = (now() + datetime.timedelta(days=31)).strftime('%Y-%m-%d') + resp = resp.form.submit() + + # recurrences corresponding to exceptions have not been created + assert Event.objects.count() == 24 -- 2.20.1