diff --git a/chrono/agendas/migrations/0080_create_exceptions_desks.py b/chrono/agendas/migrations/0080_create_exceptions_desks.py new file mode 100644 index 00000000..fb377a73 --- /dev/null +++ b/chrono/agendas/migrations/0080_create_exceptions_desks.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', '0079_auto_20210428_1533'), + ] + + operations = [ + migrations.RunPython(create_exceptions_desk, migrations.RunPython.noop), + ] diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py index 419c58aa..ee16fa4a 100644 --- a/chrono/agendas/models.py +++ b/chrono/agendas/models.py @@ -213,6 +213,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': @@ -223,6 +224,9 @@ class Agenda(models.Model): if self.kind != 'events' and self.pk is None: self.default_view = 'day' 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): @@ -342,6 +346,7 @@ class Agenda(models.Model): agenda['absence_reasons_group'] = ( self.absence_reasons_group.slug if self.absence_reasons_group else None ) + agenda['exceptions_desk'] = self.desk_set.get().export_json() elif self.kind == 'meetings': agenda['meetingtypes'] = [x.export_json() for x in self.meetingtype_set.filter(deleted=False)] agenda['desks'] = [desk.export_json() for desk in self.desk_set.all()] @@ -359,6 +364,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') @@ -408,6 +414,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() @@ -653,10 +662,12 @@ 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.get(event.pk), slug_separator=':' + min_start, max_start, excluded_datetimes.get(event.pk), exceptions, slug_separator=':' ) ) @@ -674,6 +685,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() @@ -1382,12 +1404,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 aff91b42..da862b96 100644 --- a/chrono/manager/templates/chrono/manager_events_agenda_settings.html +++ b/chrono/manager/templates/chrono/manager_events_agenda_settings.html @@ -68,4 +68,28 @@ {% endfor %} + +{% if has_recurring_events %} +
+

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

+
+ +
+
+{% endif %} + {% endblock %} diff --git a/chrono/manager/views.py b/chrono/manager/views.py index 40ad81ed..df7c6987 100644 --- a/chrono/manager/views.py +++ b/chrono/manager/views.py @@ -1573,6 +1573,15 @@ class AgendaSettings(ManagedAgendaMixin, DetailView): ) if self.agenda.kind == 'events': context['has_absence_reasons'] = AbsenceReasonGroup.objects.exists() + 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/manager/test_all.py b/tests/manager/test_all.py index 7aa17723..3bfe55e5 100644 --- a/tests/manager/test_all.py +++ b/tests/manager/test_all.py @@ -2617,7 +2617,7 @@ def test_agenda_events_day_view(app, admin_user): with CaptureQueriesContext(connection) as ctx: resp = app.get('/manage/agendas/%s/2020/11/11/' % agenda.pk) - assert len(ctx.captured_queries) == 5 + assert len(ctx.captured_queries) == 6 assert len(resp.pyquery.find('.event-info')) == 2 assert 'abc' in resp.pyquery.find('.event-info')[0].text @@ -2680,7 +2680,7 @@ def test_agenda_events_month_view(app, admin_user): with CaptureQueriesContext(connection) as ctx: resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2020, 11)) - assert len(ctx.captured_queries) == 7 + assert len(ctx.captured_queries) == 8 assert len(resp.pyquery.find('.event-info')) == 5 assert 'abc' in resp.pyquery.find('.event-info')[0].text assert 'abc' in resp.pyquery.find('.event-info')[1].text @@ -4413,3 +4413,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 diff --git a/tests/test_agendas.py b/tests/test_agendas.py index 8c520792..5b58ea5a 100644 --- a/tests/test_agendas.py +++ b/tests/test_agendas.py @@ -2006,3 +2006,81 @@ def test_recurring_events_sort(freezer): events = agenda.get_open_events()[:8] assert [e.primary_event.slug for e in events] == ['c', 'b', 'a', 'd', 'c', 'b', 'a', 'd'] + + +@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 bce5021f..17edca2d 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -306,9 +306,18 @@ def test_agendas_api(app): resp = app.get('/api/agenda/', params={'with_open_events': '1'}) assert len(resp.json['data']) == 1 + for i in range(10): + event_agenda = Agenda.objects.create(label='Foo bar', category=category_a) + event = Event.objects.create(start_datetime=now(), places=10, agenda=event_agenda, repeat='daily') + TimePeriodException.objects.create( + desk=event_agenda.desk_set.get(), + start_datetime=now(), + end_datetime=now() + datetime.timedelta(hours=1), + ) + 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) == 15 def test_agendas_meetingtypes_api(app, some_data, meetings_agenda): @@ -6322,3 +6331,33 @@ def test_date_filter_overlapping_events(app): resp = app.get('/api/agenda/foo/meetings/mt5/datetimes/', params=make_date_filters(10, 0, 10, 30)) assert len(resp.json['data']) == 3 + + +def test_recurring_events_api_exceptions(app, user, freezer): + freezer.move_to('2021-01-12 12:05') # Tuesday + agenda = Agenda.objects.create( + label='Foo bar', kind='events', minimal_booking_delay=1, maximal_booking_delay=30 + ) + event = Event.objects.create( + slug='abc', start_datetime=localtime(), repeat='weekly', places=5, agenda=agenda + ) + + resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug) + data = resp.json['data'] + assert len(data) == 4 + assert data[0]['datetime'] == '2021-01-19 13:05:00' + + time_period_exception = TimePeriodException.objects.create( + desk=agenda.desk_set.get(), + start_datetime=datetime.date(year=2021, month=1, day=18), + end_datetime=datetime.date(year=2021, month=1, day=20), + ) + resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug) + assert len(resp.json['data']) == 3 + assert resp.json['data'][0]['datetime'] == '2021-01-26 13:05:00' + + # try to book excluded event + fillslot_url = data[0]['api']['fillslot_url'] + app.authorization = ('Basic', ('john.doe', 'password')) + resp = app.post(fillslot_url, status=400) + assert resp.json['err'] == 1 diff --git a/tests/test_import_export.py b/tests/test_import_export.py index 235fce8e..c0fd57ee 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')