From bb2d2cab411b3457b125740e2f30969e31706cce Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Tue, 22 Dec 2020 17:26:29 +0100 Subject: [PATCH 4/5] add support for recurring events (#41663) --- .../migrations/0071_auto_20210112_1543.py | 47 +++++++ chrono/agendas/models.py | 128 +++++++++++++++++- chrono/api/urls.py | 4 +- chrono/api/views.py | 53 +++++++- chrono/manager/forms.py | 3 +- .../chrono/manager_agenda_event_fragment.html | 8 +- chrono/manager/urls.py | 5 + chrono/manager/views.py | 35 ++++- tests/test_agendas.py | 91 +++++++++++++ tests/test_api.py | 97 ++++++++++++- tests/test_import_export.py | 32 +++++ tests/test_manager.py | 38 +++++- 12 files changed, 528 insertions(+), 13 deletions(-) create mode 100644 chrono/agendas/migrations/0071_auto_20210112_1543.py diff --git a/chrono/agendas/migrations/0071_auto_20210112_1543.py b/chrono/agendas/migrations/0071_auto_20210112_1543.py new file mode 100644 index 0000000..85d148a --- /dev/null +++ b/chrono/agendas/migrations/0071_auto_20210112_1543.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.29 on 2021-01-12 14:43 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import jsonfield.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('agendas', '0070_auto_20201202_1834'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='primary_event', + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='recurrences', + to='agendas.Event', + ), + ), + migrations.AddField( + model_name='event', + name='recurrence_rule', + field=jsonfield.fields.JSONField(null=True, verbose_name='Recurrence rule'), + ), + migrations.AddField( + model_name='event', + name='repeat', + field=models.CharField( + blank=True, + choices=[ + ('daily', 'Daily'), + ('weekly', 'Weekly'), + ('2-weeks', 'Once every two weeks'), + ('weekdays', 'Every weekdays (Monday to Friday)'), + ], + max_length=16, + verbose_name='Repeat', + ), + ), + ] diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py index 72db21e..e083e32 100644 --- a/chrono/agendas/models.py +++ b/chrono/agendas/models.py @@ -25,6 +25,8 @@ import uuid import requests import vobject +from dateutil.rrule import rrule, DAILY, WEEKLY +from dateutil.relativedelta import relativedelta import django from django.conf import settings @@ -43,7 +45,7 @@ from django.utils.encoding import force_text from django.utils.formats import date_format from django.utils.module_loading import import_string from django.utils.text import slugify -from django.utils.timezone import localtime, now, make_aware, make_naive, is_aware +from django.utils.timezone import localtime, now, make_aware, make_naive, is_aware, utc from django.utils.translation import ugettext_lazy as _, ugettext, ungettext from jsonfield import JSONField @@ -317,7 +319,7 @@ class Agenda(models.Model): if self.kind == 'events': agenda['default_view'] = self.default_view agenda['booking_form_url'] = self.booking_form_url - agenda['events'] = [x.export_json() for x in self.event_set.all()] + 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() elif self.kind == 'meetings': @@ -504,7 +506,10 @@ class Agenda(models.Model): if prefetched_queryset: entries = self.prefetched_events else: - entries = self.event_set.filter(cancelled=False) + # recurring events are never opened + entries = self.event_set.filter(recurrence_rule__isnull=True) + # exclude canceled events except for event recurrences + entries = entries.filter(Q(cancelled=False) | Q(primary_event__isnull=False)) # we never want to allow booking for past events. entries = entries.filter(start_datetime__gte=localtime(now())) # exclude non published events @@ -537,8 +542,45 @@ class Agenda(models.Model): if annotate_queryset: entries = Event.annotate_queryset(entries) + if max_start: + entries = self.add_event_recurrences( + entries, + min_start, + max_start, + include_full=include_full, + prefetched_queryset=prefetched_queryset, + ) + return entries + def add_event_recurrences( + self, + events, + min_start, + max_start, + include_full=True, + include_cancelled=False, + prefetched_queryset=False, + ): + events = list(events) + existing_datetimes = {} + for i, event in enumerate(events.copy()): + if event.primary_event is not None: + existing_datetimes.setdefault(event.primary_event.slug, set()).add(event.start_datetime) + if not include_cancelled and event.cancelled or not include_full and event.full: + del events[i] + + if prefetched_queryset: + recurring_events = self.prefetched_recurring_events + else: + recurring_events = self.event_set.filter(recurrence_rule__isnull=False) + for event in recurring_events: + excluded_datetimes = existing_datetimes.get(event.slug, {}) + events.extend(event.get_recurrences(min_start, max_start, excluded_datetimes)) + + events.sort(key=lambda x: [getattr(x, field) for field in Event._meta.ordering]) + return events + def get_booking_form_url(self): if not self.booking_form_url: return @@ -863,8 +905,18 @@ class MeetingType(models.Model): class Event(models.Model): + REPEAT_CHOICES = [ + ('daily', _('Daily')), + ('weekly', _('Weekly')), + ('2-weeks', _('Once every two weeks')), + ('weekdays', _('Every weekdays (Monday to Friday)')), + ] + agenda = models.ForeignKey(Agenda, on_delete=models.CASCADE) start_datetime = models.DateTimeField(_('Date/time')) + repeat = models.CharField(_('Repeat'), max_length=16, blank=True, choices=REPEAT_CHOICES) + recurrence_rule = JSONField(_('Recurrence rule'), null=True) + primary_event = models.ForeignKey('self', null=True, on_delete=models.CASCADE, related_name='recurrences') duration = models.PositiveIntegerField(_('Duration (in minutes)'), default=None, null=True, blank=True) publication_date = models.DateField(_('Publication date'), blank=True, null=True) places = models.PositiveIntegerField(_('Places')) @@ -917,6 +969,8 @@ class Event(models.Model): self.check_full() if not self.slug: self.slug = generate_slug(self, seen_slugs=seen_slugs, agenda=self.agenda) + if self.repeat: + self.recurrence_rule = self.get_recurrence_rule() return super(Event, self).save(*args, **kwargs) @property @@ -936,6 +990,8 @@ class Event(models.Model): self.almost_full = bool(self.booked_places >= 0.9 * self.places) def in_bookable_period(self): + if self.recurrence_rule is not None: + return True if self.publication_date and localtime(now()).date() < self.publication_date: return False today = localtime(now()).date() @@ -1039,6 +1095,8 @@ class Event(models.Model): return { 'start_datetime': make_naive(self.start_datetime).strftime('%Y-%m-%d %H:%M:%S'), 'publication_date': self.publication_date.strftime('%Y-%m-%d') if self.publication_date else None, + 'repeat': self.repeat, + 'recurrence_rule': self.recurrence_rule, 'places': self.places, 'waiting_list_places': self.waiting_list_places, 'label': self.label, @@ -1072,6 +1130,70 @@ class Event(models.Model): self.cancelled = True self.save() + def get_or_create_event_recurrence(self, start_datetime): + events = self.get_recurrences(start_datetime, start_datetime) + + if len(events) == 0: + raise ValueError('No event recurrence found for specified datetime.') + elif len(events) > 1: # should not happen + raise ValueError('Multiple events found for specified datetime.') + + event = events[0] + event.slug = event.slug.replace(':', '-') + + with transaction.atomic(): + try: + return Event.objects.get(agenda=self.agenda, slug=event.slug) + except Event.DoesNotExist: + event.save() + return event + + def get_recurrences(self, min_datetime, max_datetime, excluded_datetimes=None): + recurrences = [] + excluded_datetimes = excluded_datetimes or {} + + event_base = Event( + agenda=self.agenda, + primary_event=self, + slug=self.slug, + duration=self.duration, + places=self.places, + waiting_list_places=self.waiting_list_places, + label=self.label, + description=self.description, + pricing=self.pricing, + url=self.url, + ) + + datetimes = rrule(dtstart=localtime(self.start_datetime), cache=True, **self.recurrence_rule) + for start_datetime in datetimes.between(min_datetime, max_datetime, inc=True): + if start_datetime in excluded_datetimes: + continue + event = copy.copy(event_base) + event.slug = '%s:%s' % (event.slug, start_datetime.strftime('%Y-%m-%d-%H%M')) + event.start_datetime = start_datetime.astimezone(utc) + recurrences.append(event) + + return recurrences + + def get_recurrence_rule(self): + rrule = {} + if self.repeat == 'daily': + rrule['freq'] = DAILY + elif self.repeat == 'weekly': + rrule['freq'] = WEEKLY + rrule['byweekday'] = [self.start_datetime.weekday()] + elif self.repeat == '2-weeks': + rrule['freq'] = WEEKLY + rrule['byweekday'] = [self.start_datetime.weekday()] + rrule['interval'] = 2 + elif self.repeat == 'weekdays': + rrule['freq'] = WEEKLY + rrule['byweekday'] = [i for i in range(5)] + else: + return None + return rrule + class BookingColor(models.Model): COLOR_COUNT = 8 diff --git a/chrono/api/urls.py b/chrono/api/urls.py index e2f2df6..3fc57de 100644 --- a/chrono/api/urls.py +++ b/chrono/api/urls.py @@ -29,12 +29,12 @@ urlpatterns = [ ), url(r'^agenda/(?P[\w-]+)/fillslots/$', views.fillslots, name='api-agenda-fillslots'), url( - r'^agenda/(?P[\w-]+)/status/(?P[\w-]+)/$', + r'^agenda/(?P[\w-]+)/status/(?P[\w:-]+)/$', views.slot_status, name='api-event-status', ), url( - r'^agenda/(?P[\w-]+)/bookings/(?P[\w-]+)/$', + r'^agenda/(?P[\w-]+)/bookings/(?P[\w:-]+)/$', views.slot_bookings, name='api-event-bookings', ), diff --git a/chrono/api/views.py b/chrono/api/views.py index 1d1e180..3bb2c42 100644 --- a/chrono/api/views.py +++ b/chrono/api/views.py @@ -365,6 +365,11 @@ def get_event_places(event): def get_event_detail(request, event, agenda=None): agenda = agenda or event.agenda + if event.label and event.primary_event is not None: + event.label = '%s (%s)' % ( + event.label, + date_format(localtime(event.start_datetime), 'DATETIME_FORMAT'), + ) return { 'id': event.slug, 'slug': event.slug, # kept for compatibility @@ -398,6 +403,34 @@ def get_event_detail(request, event, agenda=None): } +def get_event_recurrence(agenda, event_identifier): + event_slug, datetime_str = event_identifier.split(':') + try: + start_datetime = make_aware(datetime.datetime.strptime(datetime_str, '%Y-%m-%d-%H%M')) + except ValueError: + raise APIError( + _('bad datetime format: %s') % datetime_str, + err_class='bad datetime format: %s' % datetime_str, + http_status=status.HTTP_400_BAD_REQUEST, + ) + try: + event = agenda.event_set.get(slug=event_slug) + except Event.DoesNotExist: + raise APIError( + _('unknown recurring event slug: %s') % event_slug, + err_class='unknown recurring event slug:' % event_slug, + http_status=status.HTTP_400_BAD_REQUEST, + ) + try: + return event.get_or_create_event_recurrence(start_datetime) + except ValueError: + raise APIError( + _('invalid datetime for event %s') % event_identifier, + err_class='invalid datetime for event %s' % event_identifier, + http_status=status.HTTP_400_BAD_REQUEST, + ) + + def get_resources_from_request(request, agenda): if agenda.kind != 'meetings' or 'resources' not in request.GET: return [] @@ -433,12 +466,18 @@ class Agendas(APIView): cancelled=False, start_datetime__gte=localtime(now()), ).order_by() + recurring_event_queryset = Event.objects.filter(recurrence_rule__isnull=False) agendas_queryset = agendas_queryset.filter(kind='events').prefetch_related( Prefetch( 'event_set', queryset=event_queryset, to_attr='prefetched_events', - ) + ), + Prefetch( + 'event_set', + queryset=recurring_event_queryset, + to_attr='prefetched_recurring_events', + ), ) agendas = [] @@ -997,6 +1036,13 @@ class Fillslots(APIView): event.resources.add(*resources) events.append(event) else: + # convert event recurrence identifiers to real event slugs + for i, slot in enumerate(slots.copy()): + if not ':' in slot: + continue + event = get_event_recurrence(agenda, slot) + slots[i] = event.slug + try: events = agenda.event_set.filter(id__in=[int(s) for s in slots]).order_by('start_datetime') except ValueError: @@ -1440,6 +1486,8 @@ class SlotStatus(APIView): agenda = Agenda.objects.get(pk=agenda_identifier, kind='events') except (ValueError, Agenda.DoesNotExist): raise Http404() + if ':' in event_identifier: + return get_event_recurrence(agenda, event_identifier) try: return agenda.event_set.get(slug=event_identifier) except Event.DoesNotExist: @@ -1465,6 +1513,9 @@ class SlotBookings(APIView): permission_classes = (permissions.IsAuthenticated,) def get_object(self, agenda_identifier, event_identifier): + if ':' in event_identifier: + agenda = get_object_or_404(Agenda, slug=agenda_identifier, kind='events') + return get_event_recurrence(agenda, event_identifier) return get_object_or_404( Event, slug=event_identifier, agenda__slug=agenda_identifier, agenda__kind='events' ) diff --git a/chrono/manager/forms.py b/chrono/manager/forms.py index e7a4b12..98a8733 100644 --- a/chrono/manager/forms.py +++ b/chrono/manager/forms.py @@ -160,6 +160,7 @@ class EventForm(forms.ModelForm): } fields = [ 'start_datetime', + 'repeat', 'duration', 'publication_date', 'places', @@ -462,7 +463,7 @@ class ImportEventsForm(forms.Form): raise ValidationError(_('Invalid file format. (duration, line %d)') % (i + 1)) try: - event.full_clean(exclude=['desk', 'meeting_type']) + event.full_clean(exclude=['desk', 'meeting_type', 'primary_event']) except ValidationError as e: errors = [_('Invalid file format:\n')] for label, field_errors in e.message_dict.items(): diff --git a/chrono/manager/templates/chrono/manager_agenda_event_fragment.html b/chrono/manager/templates/chrono/manager_agenda_event_fragment.html index 5403f23..363ed45 100644 --- a/chrono/manager/templates/chrono/manager_agenda_event_fragment.html +++ b/chrono/manager/templates/chrono/manager_agenda_event_fragment.html @@ -8,7 +8,7 @@ {% elif event.waiting_list_places %} data-total="{{ event.waiting_list_places }}" data-booked="{{ event.waiting_list_count }}" {% endif %} - > + > {% if event.cancellation_status %} {{ event.cancellation_status }} {% elif event.main_list_full %} @@ -20,7 +20,13 @@ {% else %} {% if event.label %}{{ event.label }} / {% endif %} {% endif %} + {% if not event.repeat %} {{ event.start_datetime }} + {% else %} + {% blocktrans with every_x_days=event.get_repeat_display day=event.start_datetime|date:"l" time=event.start_datetime|time %} + {{ every_x_days }} on {{ day }} at {{ time }} + {% endblocktrans %} + {% endif %} {% if not settings_view %} {% if event.places or event.waiting_list_places %}-{% endif %} {% if event.places %} diff --git a/chrono/manager/urls.py b/chrono/manager/urls.py index 80eb888..95956a7 100644 --- a/chrono/manager/urls.py +++ b/chrono/manager/urls.py @@ -170,6 +170,11 @@ urlpatterns = [ views.event_cancellation_report_list, name='chrono-manager-event-cancellation-report-list', ), + url( + r'^agendas/(?P\d+)/create_event_recurrence/(?P[\w:-]+)/$', + views.event_create_recurrence, + name='chrono-manager-event-create-recurrence', + ), url( r'^agendas/(?P\d+)/add-resource/$', views.agenda_add_resource, diff --git a/chrono/manager/views.py b/chrono/manager/views.py index 7754534..9162fb9 100644 --- a/chrono/manager/views.py +++ b/chrono/manager/views.py @@ -49,6 +49,7 @@ from django.views.generic import ( TemplateView, DayArchiveView, MonthArchiveView, + RedirectView, View, ) @@ -893,7 +894,7 @@ class AgendaDateView(DateMixin, ViewableAgendaMixin): def get_queryset(self): if self.agenda.kind == 'events': - queryset = self.agenda.event_set.all() + queryset = self.agenda.event_set.filter(recurrence_rule__isnull=True) else: self.agenda.prefetch_desks_and_exceptions() if self.agenda.kind == 'meetings': @@ -1031,6 +1032,18 @@ class AgendaMonthView(AgendaDateView, MonthArchiveView): return qs return Event.annotate_queryset(qs).order_by('start_datetime', 'label') + def get_dated_items(self): + date_list, object_list, extra_context = super().get_dated_items() + if self.agenda.kind == 'events': + min_start = make_aware(datetime.datetime.combine(extra_context['month'], datetime.time(0, 0))) + max_start = make_aware( + datetime.datetime.combine(extra_context['next_month'], datetime.time(0, 0)) + ) + object_list = self.agenda.add_event_recurrences( + object_list, min_start, max_start, include_cancelled=True + ) + return date_list, object_list, extra_context + def get_template_names(self): if self.agenda.kind == 'virtual': return ['chrono/manager_meetings_agenda_month_view.html'] @@ -1385,7 +1398,7 @@ class AgendaSettings(ManagedAgendaMixin, DetailView): return context def get_events(self): - qs = self.agenda.event_set.all() + qs = self.agenda.event_set.filter(primary_event__isnull=True) return Event.annotate_queryset(qs) def get_template_names(self): @@ -2341,6 +2354,24 @@ class EventCancellationReportListView(ViewableAgendaMixin, ListView): event_cancellation_report_list = EventCancellationReportListView.as_view() +class EventCreateRecurrenceView(ManagedAgendaMixin, RedirectView): + def get_redirect_url(self, *args, **kwargs): + event_slug, datetime_str = kwargs['event_identifier'].split(':') + try: + start_datetime = make_aware(datetime.datetime.strptime(datetime_str, '%Y-%m-%d-%H%M')) + except ValueError: + raise Http404() + event = self.agenda.event_set.get(slug=event_slug) + try: + event_recurrence = event.get_or_create_event_recurrence(start_datetime) + except ValueError: + raise Http404() + return event_recurrence.get_absolute_view_url() + + +event_create_recurrence = EventCreateRecurrenceView.as_view() + + class TimePeriodExceptionSourceToggleView(ManagedDeskSubobjectMixin, DetailView): model = TimePeriodExceptionSource diff --git a/tests/test_agendas.py b/tests/test_agendas.py index e703242..fb306bd 100644 --- a/tests/test_agendas.py +++ b/tests/test_agendas.py @@ -1653,3 +1653,94 @@ def test_anonymize_bookings(freezer): booking.refresh_from_db() assert booking.label == 'hop' assert not booking.anonymization_datetime + + +def test_recurring_events(freezer): + freezer.move_to('2021-01-06 12:00') # Wednesday + agenda = Agenda.objects.create(label='Agenda', kind='events') + event = Event.objects.create( + agenda=agenda, + start_datetime=now(), + repeat='weekly', + label='Event', + places=10, + waiting_list_places=10, + duration=10, + description='Description', + url='https://example.com', + pricing='10€', + ) + event.refresh_from_db() + + recurrences = event.get_recurrences(localtime(), localtime() + datetime.timedelta(days=15)) + assert len(recurrences) == 3 + + first_event = recurrences[0] + assert first_event.slug == event.slug + ':2021-01-06-1300' + + event_json = event.export_json() + first_event_json = first_event.export_json() + different_fields = ['slug', 'repeat', 'recurrence_rule'] + assert all(first_event_json[k] == event_json[k] for k in event_json if k not in different_fields) + + second_event = recurrences[1] + assert second_event.start_datetime == first_event.start_datetime + datetime.timedelta(days=7) + assert second_event.start_datetime.weekday() == first_event.start_datetime.weekday() + assert second_event.slug == 'event:2021-01-13-1300' + + different_fields = ['slug', 'start_datetime'] + second_event_json = second_event.export_json() + assert all(first_event_json[k] == second_event_json[k] for k in event_json if k not in different_fields) + + new_recurrences = event.get_recurrences( + localtime() + datetime.timedelta(days=15), + localtime() + datetime.timedelta(days=30), + ) + assert len(recurrences) == 3 + assert new_recurrences[0].start_datetime == recurrences[-1].start_datetime + datetime.timedelta(days=7) + + +def test_recurring_events_repeat(freezer): + freezer.move_to('2021-01-06 12:00') # Wednesday + agenda = Agenda.objects.create(label='Agenda', kind='events') + event = Event.objects.create( + agenda=agenda, + start_datetime=now(), + repeat='daily', + places=5, + ) + event.refresh_from_db() + start_datetime = localtime(event.start_datetime) + + freezer.move_to('2021-01-06 12:01') # recurrence on same day should not be returned + recurrences = event.get_recurrences( + localtime() + datetime.timedelta(days=1), localtime() + datetime.timedelta(days=7) + ) + assert len(recurrences) == 6 + assert recurrences[0].start_datetime == start_datetime + datetime.timedelta(days=2) + assert recurrences[-1].start_datetime == start_datetime + datetime.timedelta(days=7) + for i in range(len(recurrences) - 1): + assert recurrences[i].start_datetime + datetime.timedelta(days=1) == recurrences[i + 1].start_datetime + + event.repeat = 'weekdays' + event.save() + recurrences = event.get_recurrences( + localtime() + datetime.timedelta(days=1), localtime() + datetime.timedelta(days=7) + ) + assert len(recurrences) == 4 + assert recurrences[0].start_datetime == start_datetime + datetime.timedelta(days=2) + assert recurrences[1].start_datetime == start_datetime + datetime.timedelta(days=5) + assert recurrences[-1].start_datetime == start_datetime + datetime.timedelta(days=7) + + event.repeat = '2-weeks' + event.save() + recurrences = event.get_recurrences( + localtime() + datetime.timedelta(days=3), localtime() + datetime.timedelta(days=45) + ) + assert len(recurrences) == 3 + assert recurrences[0].start_datetime == start_datetime + datetime.timedelta(days=14) + assert recurrences[-1].start_datetime == start_datetime + datetime.timedelta(days=14) * len(recurrences) + for i in range(len(recurrences) - 1): + assert ( + recurrences[i].start_datetime + datetime.timedelta(days=14) == recurrences[i + 1].start_datetime + ) diff --git a/tests/test_api.py b/tests/test_api.py index b55b6c6..bf9dfb7 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -282,9 +282,20 @@ def test_agendas_api(app): resp = app.get('/api/agenda/', params={'with_open_events': '1'}) assert len(resp.json['data']) == 0 + # event recurrences are available + event = Event.objects.create( + start_datetime=now(), + places=10, + agenda=event_agenda, + repeat='daily', + ) + assert len(event_agenda.get_open_events()) == 2 + resp = app.get('/api/agenda/', params={'with_open_events': '1'}) + assert len(resp.json['data']) == 1 + with CaptureQueriesContext(connection) as ctx: resp = app.get('/api/agenda/', params={'with_open_events': '1'}) - assert len(ctx.captured_queries) == 3 + assert len(ctx.captured_queries) == 4 def test_agendas_meetingtypes_api(app, some_data, meetings_agenda): @@ -1671,7 +1682,7 @@ def test_booking_api_available(app, user): with CaptureQueriesContext(connection) as ctx: resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug) - assert len(ctx.captured_queries) == 3 + assert len(ctx.captured_queries) == 4 assert resp.json['data'][-1]['places']['total'] == 20 assert resp.json['data'][-1]['places']['available'] == 20 assert resp.json['data'][-1]['places']['reserved'] == 0 @@ -4817,3 +4828,85 @@ def test_meetings_and_virtual_datetimes_date_filter(app): virtual_agenda.save() resp = app.get(virtual_api_url, params=params) assert len(resp.json['data']) == 16 + + +def test_recurring_events_api(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', label='Test', 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]['id'] == 'abc:2021-01-19-1305' + assert data[0]['datetime'] == '2021-01-19 13:05:00' + assert data[0]['text'] == 'Test (Jan. 19, 2021, 1:05 p.m.)' + assert data[3]['id'] == 'abc:2021-02-09-1305' + assert Event.objects.count() == 1 + + fillslot_url = data[0]['api']['fillslot_url'] + app.authorization = ('Basic', ('john.doe', 'password')) + + # book first event + resp = app.post(fillslot_url) + assert resp.json['err'] == 0 + assert Event.objects.count() == 2 + event = Booking.objects.get(pk=resp.json['booking_id']).event + assert event.slug == 'abc-2021-01-19-1305' + + # first event is now a real event in datetimes + resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug) + data = resp.json['data'] + assert len(data) == 4 + assert data[0]['id'] == event.slug + new_fillslot_url = data[0]['api']['fillslot_url'] + + # booking again with both old and new urls works + resp = app.post(fillslot_url) + assert resp.json['err'] == 0 + resp = app.post(new_fillslot_url) + assert resp.json['err'] == 0 + assert Event.objects.count() == 2 + assert event.booking_set.count() == 3 + + # status and bookings api also create a real event + status_url = data[1]['api']['status_url'] + resp = app.get(status_url) + assert resp.json['places']['total'] == 5 + assert Event.objects.count() == 3 + + bookings_url = data[2]['api']['bookings_url'] + resp = app.get(bookings_url, params={'user_external_id': '42'}) + assert resp.json['data'] == [] + assert Event.objects.count() == 4 + + # cancelled recurrences do not appear + event.cancel() + resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug) + assert len(resp.json['data']) == 3 + assert resp.json['data'][0]['id'] == 'abc-2021-01-26-1305' + + +def test_recurring_events_api_various_times(app, user, mock_now): + agenda = Agenda.objects.create( + label='Foo bar', kind='events', minimal_booking_delay=0, maximal_booking_delay=30 + ) + event = Event.objects.create( + slug='abc', start_datetime=localtime(), repeat='weekly', places=5, agenda=agenda + ) + event.refresh_from_db() + + resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug) + assert len(resp.json['data']) == 5 + fillslot_url = resp.json['data'][0]['api']['fillslot_url'] + + app.authorization = ('Basic', ('john.doe', 'password')) + resp = app.post(fillslot_url) + assert resp.json['err'] == 0 + + new_event = Booking.objects.get(pk=resp.json['booking_id']).event + assert event.start_datetime == new_event.start_datetime diff --git a/tests/test_import_export.py b/tests/test_import_export.py index 0eec30a..fd4d11c 100644 --- a/tests/test_import_export.py +++ b/tests/test_import_export.py @@ -12,6 +12,8 @@ import sys import tempfile import pytest +from dateutil.rrule import DAILY + from django.contrib.auth.models import Group from django.core.management import call_command, CommandError from django.test import override_settings @@ -189,6 +191,36 @@ def test_import_export_event_details(app): assert first_imported_event.publication_date == datetime.date(2020, 5, 11) +def test_import_export_recurring_event(app, freezer): + freezer.move_to('2021-01-12 12:10') + agenda = Agenda.objects.create(label='Foo Bar', kind='events') + event = Event.objects.create( + agenda=agenda, + start_datetime=now(), + repeat='daily', + places=10, + ) + event.refresh_from_db() + event.get_or_create_event_recurrence(event.start_datetime + datetime.timedelta(days=3)) + assert Event.objects.count() == 2 + + output = get_output_of_command('export_site') + assert len(json.loads(output)['agendas']) == 1 + import_site(data={}, clean=True) + + with tempfile.NamedTemporaryFile() as f: + f.write(force_bytes(output)) + f.flush() + call_command('import_site', f.name) + + assert Agenda.objects.count() == 1 + assert Event.objects.count() == 1 + event = Agenda.objects.get(label='Foo Bar').event_set.first() + assert event.primary_event is None + assert event.repeat == 'daily' + assert event.recurrence_rule == {'freq': DAILY} + + def test_import_export_permissions(app): meetings_agenda = Agenda.objects.create(label='Foo Bar', kind='meetings') group1 = Group.objects.create(name=u'gé1') diff --git a/tests/test_manager.py b/tests/test_manager.py index 31db5f9..d248e4d 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -3165,12 +3165,39 @@ def test_agenda_events_month_view(app, admin_user): app.get( '/manage/agendas/%s/%s/%s/' % (agenda.id, event.start_datetime.year, event.start_datetime.month) ) - assert len(ctx.captured_queries) == 6 + assert len(ctx.captured_queries) == 7 # current month still doesn't have events resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, today.year, today.month)) assert "This month doesn't have any event configured." in resp.text + # add recurring event on every Wednesday + start_datetime = localtime().replace(day=6, month=1, year=2021) + event = Event.objects.create( + label='abc', start_datetime=start_datetime, places=10, agenda=agenda, repeat='weekly' + ) + + resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2021, 1)) + assert len(resp.pyquery.find('.event-info')) == 4 + assert 'abc' in resp.pyquery.find('.event-info')[0].text + + # 03/2021 has 5 Wednesday + resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2021, 3)) + assert len(resp.pyquery.find('.event-info')) == 5 + + # trying to access event recurrence creates it + event_count = Event.objects.count() + time = event.start_datetime.strftime('%H%M') + resp = resp.click(href='abc:2021-03-10-%s' % time) + assert Event.objects.count() == event_count + 1 + + Event.objects.get(slug='abc-2021-03-10-%s' % time).cancel() + resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2021, 3)) + assert 'Cancelled' in resp.text + + bad_event_url = '/manage/agendas/%s/create_event_recurrence/abc:2021-03-11-1130/' % agenda.id + resp = app.get(bad_event_url, status=404) + def test_agenda_open_events_view(app, admin_user, manager_user): agenda = Agenda.objects.create( @@ -3219,6 +3246,14 @@ def test_agenda_open_events_view(app, admin_user, manager_user): publication_date=today - datetime.timedelta(days=1), places=42, ) + # weekly recurring event, first recurrence is in the past but second is in range + event = Event.objects.create( + label='event G', + start_datetime=now() - datetime.timedelta(days=3), + places=10, + agenda=agenda, + repeat='weekly', + ) resp = app.get('/manage/agendas/%s/events/open/' % agenda.pk) assert 'event A' not in resp.text assert 'event B' not in resp.text @@ -3226,6 +3261,7 @@ def test_agenda_open_events_view(app, admin_user, manager_user): assert 'event D' in resp.text assert 'event E' not in resp.text assert 'event F' in resp.text + assert resp.text.count('event G') == 1 # not enough permissions app.reset() -- 2.20.1