From 96afe5c7732506c703138197f40dd4ae64a7a195 Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Thu, 28 Jan 2021 15:40:57 +0100 Subject: [PATCH 2/4] agendas: allow exceptions to recurring events (#50561) --- .../migrations/0077_auto_20210218_1533.py | 27 +++++++ chrono/agendas/models.py | 50 +++++++++++- .../manager_events_agenda_settings.html | 23 ++++++ chrono/manager/views.py | 10 +++ tests/test_agendas.py | 78 +++++++++++++++++++ tests/test_api.py | 2 +- tests/test_import_export.py | 10 ++- tests/test_manager.py | 61 +++++++++++++++ 8 files changed, 255 insertions(+), 6 deletions(-) create mode 100644 chrono/agendas/migrations/0077_auto_20210218_1533.py diff --git a/chrono/agendas/migrations/0077_auto_20210218_1533.py b/chrono/agendas/migrations/0077_auto_20210218_1533.py new file mode 100644 index 0000000..8c89105 --- /dev/null +++ b/chrono/agendas/migrations/0077_auto_20210218_1533.py @@ -0,0 +1,27 @@ +# -*- 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'): + desks.append(Desk(agenda=agenda, slug='_exceptions_holder')) + Desk.objects.bulk_create(desks) + + +class Migration(migrations.Migration): + + dependencies = [ + ('agendas', '0076_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 f682a48..4c447b2 100644 --- a/chrono/agendas/models.py +++ b/chrono/agendas/models.py @@ -207,6 +207,7 @@ class Agenda(models.Model): return self.label def save(self, *args, **kwargs): + created = bool(not self.pk) if not self.slug: self.slug = generate_slug(self) if self.kind != 'virtual': @@ -215,6 +216,9 @@ class Agenda(models.Model): if self.maximal_booking_delay is None: self.maximal_booking_delay = 8 * 7 super(Agenda, self).save(*args, **kwargs) + if created and self.kind == 'events': + desk = Desk.objects.create(agenda=self, slug='_exceptions_holder') + desk.import_timeperiod_exceptions_from_settings() @property def base_slug(self): @@ -324,6 +328,7 @@ class Agenda(models.Model): agenda['events'] = [x.export_json() for x in self.event_set.filter(primary_event__isnull=True)] if hasattr(self, 'notifications_settings'): agenda['notifications_settings'] = self.notifications_settings.export_json() + agenda['exceptions_desk'] = self.desk_set.get().export_json() elif self.kind == 'meetings': agenda['meetingtypes'] = [x.export_json() for x in self.meetingtype_set.all()] agenda['desks'] = [desk.export_json() for desk in self.desk_set.all()] @@ -341,6 +346,7 @@ class Agenda(models.Model): if data['kind'] == 'events': events = data.pop('events') notifications_settings = data.pop('notifications_settings', None) + exceptions_desk = data.pop('exceptions_desk', None) elif data['kind'] == 'meetings': meetingtypes = data.pop('meetingtypes') desks = data.pop('desks') @@ -381,6 +387,9 @@ class Agenda(models.Model): if notifications_settings: notifications_settings['agenda'] = agenda AgendaNotificationsSettings.import_json(notifications_settings) + if exceptions_desk: + exceptions_desk['agenda'] = agenda + Desk.import_json(exceptions_desk) elif data['kind'] == 'meetings': if overwrite: MeetingType.objects.filter(agenda=agenda).delete() @@ -589,8 +598,14 @@ 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, slug_separator=':')) + events.extend( + event.get_recurrences( + min_start, max_start, excluded_datetimes, exceptions, slug_separator=':' + ) + ) events.sort(key=lambda x: [getattr(x, field) for field in Event._meta.ordering]) return events @@ -604,6 +619,17 @@ class Agenda(models.Model): except (VariableDoesNotExist, TemplateSyntaxError): return + def get_recurrence_exceptions(self, min_start, max_start): + return TimePeriodException.objects.filter( + Q(desk__slug='_exceptions_holder', desk__agenda=self) + | Q( + unavailability_calendar__desks__slug='_exceptions_holder', + unavailability_calendar__desks__agenda=self, + ), + start_datetime__lt=max_start, + end_datetime__gt=min_start, + ) + def prefetch_desks_and_exceptions(self, with_sources=False): if self.kind == 'meetings': desks = self.desk_set.all() @@ -1263,12 +1289,32 @@ class Event(models.Model): event.save() return event - def get_recurrences(self, min_datetime, max_datetime, excluded_datetimes=None, slug_separator='--'): + def get_recurrences( + self, min_datetime, max_datetime, excluded_datetimes=None, exceptions=None, slug_separator='--' + ): recurrences = [] rrule_set = rruleset() # do not generate recurrences for existing events rrule_set._exdate = excluded_datetimes or [] + if exceptions is None: + exceptions = self.agenda.get_recurrence_exceptions(min_datetime, max_datetime) + for exception in exceptions: + exception_start = localtime(exception.start_datetime) + event_start = localtime(self.start_datetime) + if event_start.time() < exception_start.time(): + exception_start += datetime.timedelta(days=1) + exception_start = exception_start.replace( + hour=event_start.hour, minute=event_start.minute, second=0, microsecond=0 + ) + rrule_set.exrule( + rrule( + freq=DAILY, + dtstart=make_naive(exception_start), + 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 70bcda6..d5c017e 100644 --- a/chrono/manager/views.py +++ b/chrono/manager/views.py @@ -1394,6 +1394,16 @@ class AgendaSettings(ManagedAgendaMixin, DetailView): if not self.object.desk_simple_management else False ) + if self.agenda.kind == 'events': + context['has_recurring_events'] = self.agenda.event_set.filter( + recurrence_rule__isnull=False + ).exists() + desk = Desk.objects.get(agenda=self.agenda, slug='_exceptions_holder') + context['exceptions'] = TimePeriodException.objects.filter( + Q(desk=desk) | Q(unavailability_calendar__desks=desk), + end_datetime__gt=now(), + ) + context['desk'] = desk return context def get_events(self): diff --git a/tests/test_agendas.py b/tests/test_agendas.py index b850fd3..f58f082 100644 --- a/tests/test_agendas.py +++ b/tests/test_agendas.py @@ -1977,3 +1977,81 @@ 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.get(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_api.py b/tests/test_api.py index 3c31d07..3913549 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -309,7 +309,7 @@ def test_agendas_api(app): with CaptureQueriesContext(connection) as ctx: resp = app.get('/api/agenda/', params={'with_open_events': '1'}) - assert len(ctx.captured_queries) == 4 + assert len(ctx.captured_queries) == 6 def test_agendas_meetingtypes_api(app, some_data, meetings_agenda): diff --git a/tests/test_import_export.py b/tests/test_import_export.py index 6a3223e..5b1be82 100644 --- a/tests/test_import_export.py +++ b/tests/test_import_export.py @@ -58,11 +58,13 @@ def test_import_export(app): agenda_meetings = Agenda.objects.create(label='Meetings Agenda', kind='meetings') MeetingType.objects.create(agenda=agenda_meetings, label='Meeting Type', duration=30) desk = Desk.objects.create(agenda=agenda_meetings, label='Desk') + exceptions_desk = Desk.objects.get(agenda=agenda_events, slug='_exceptions_holder') - # add exception to meeting agenda tpx_start = make_aware(datetime.datetime(2017, 5, 22, 8, 0)) tpx_end = make_aware(datetime.datetime(2017, 5, 22, 12, 30)) TimePeriodException.objects.create(desk=desk, start_datetime=tpx_start, end_datetime=tpx_end) + TimePeriodException.objects.create(desk=exceptions_desk, start_datetime=tpx_start, end_datetime=tpx_end) + output = get_output_of_command('export_site') assert len(json.loads(output)['agendas']) == 2 import_site(data={}, clean=True) @@ -87,8 +89,10 @@ def test_import_export(app): assert Agenda.objects.count() == 2 first_imported_event = Agenda.objects.get(label='Events Agenda').event_set.first() assert first_imported_event.start_datetime == first_event.start_datetime - assert TimePeriodException.objects.get().start_datetime == tpx_start - assert TimePeriodException.objects.get().end_datetime == tpx_end + assert TimePeriodException.objects.get(desk__agenda__kind='meetings').start_datetime == tpx_start + assert TimePeriodException.objects.get(desk__agenda__kind='meetings').end_datetime == tpx_end + assert TimePeriodException.objects.get(desk__agenda__kind='events').start_datetime == tpx_start + assert TimePeriodException.objects.get(desk__agenda__kind='events').end_datetime == tpx_end agenda1 = Agenda.objects.get(label='Events Agenda') agenda2 = Agenda.objects.get(label='Meetings Agenda') diff --git a/tests/test_manager.py b/tests/test_manager.py index b7a4a92..4ec45fe 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -6333,3 +6333,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