From 32f51bda5b463019b5ec1ec21db7bda972ec6c20 Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Tue, 2 Mar 2021 14:05:13 +0100 Subject: [PATCH 2/2] api: allow booking all recurrences of recurring events (#54332) --- chrono/agendas/models.py | 7 ++ chrono/api/urls.py | 10 ++ chrono/api/views.py | 210 +++++++++++++++++++++++++++++++++++---- tests/test_api.py | 120 ++++++++++++++++++++++ 4 files changed, 326 insertions(+), 21 deletions(-) diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py index a9ceb5b..91fcdf5 100644 --- a/chrono/agendas/models.py +++ b/chrono/agendas/models.py @@ -664,6 +664,13 @@ class Agenda(models.Model): return entries + def get_open_recurring_events(self): + return self.event_set.filter( + Q(publication_date__isnull=True) | Q(publication_date__lte=localtime(now()).date()), + recurrence_rule__isnull=False, + recurrence_end_date__gt=localtime(now()).date(), + ) + def add_event_recurrences( self, events, diff --git a/chrono/api/urls.py b/chrono/api/urls.py index ef15bd1..90a8851 100644 --- a/chrono/api/urls.py +++ b/chrono/api/urls.py @@ -22,12 +22,22 @@ urlpatterns = [ url(r'^agenda/$', views.agendas), url(r'^agenda/(?P[\w-]+)/$', views.agenda_detail), url(r'^agenda/(?P[\w-]+)/datetimes/$', views.datetimes, name='api-agenda-datetimes'), + url( + r'^agenda/(?P[\w-]+)/recurring_events/$', + views.recurring_events_list, + name='api-agenda-recurring-events', + ), url( r'^agenda/(?P[\w-]+)/fillslot/(?P[\w:-]+)/$', views.fillslot, name='api-fillslot', ), url(r'^agenda/(?P[\w-]+)/fillslots/$', views.fillslots, name='api-agenda-fillslots'), + url( + r'^agenda/(?P[\w-]+)/recurring_fillslots/$', + views.recurring_fillslots, + name='api-recurring-fillslots', + ), url( r'^agenda/(?P[\w-]+)/status/(?P[\w:-]+)/$', views.slot_status, diff --git a/chrono/api/views.py b/chrono/api/views.py index 0de03eb..d84f100 100644 --- a/chrono/api/views.py +++ b/chrono/api/views.py @@ -19,8 +19,9 @@ import datetime import itertools import uuid +import django from django.db import transaction -from django.db.models import Count, Prefetch, Q +from django.db.models import BooleanField, Count, ExpressionWrapper, F, Max, Prefetch, Q, Value from django.db.models.functions import TruncDay from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404 @@ -413,8 +414,7 @@ def is_event_disabled(event, min_places=1): return False -def get_event_detail(request, event, agenda=None, min_places=1): - agenda = agenda or event.agenda +def get_event_text(event, agenda): event_text = force_text(event) if agenda.event_display_template: event_text = Template(agenda.event_display_template).render(Context({'event': event})) @@ -423,10 +423,15 @@ def get_event_detail(request, event, agenda=None, min_places=1): event.label, date_format(localtime(event.start_datetime), 'DATETIME_FORMAT'), ) + return event_text + + +def get_event_detail(request, event, agenda=None, min_places=1): + agenda = agenda or event.agenda return { 'id': event.slug, 'slug': event.slug, # kept for compatibility - 'text': event_text, + 'text': get_event_text(event, agenda), 'datetime': format_response_datetime(event.start_datetime), 'description': event.description, 'pricing': event.pricing, @@ -556,6 +561,26 @@ def get_start_and_end_datetime_from_request(request): return start_datetime, end_datetime +def make_booking(event, payload, extra_data, primary_booking, in_waiting_list=False, color=None): + return Booking( + event_id=event.pk, + in_waiting_list=getattr(event, 'in_waiting_list', in_waiting_list), + primary_booking=primary_booking, + label=payload.get('label', ''), + user_external_id=payload.get('user_external_id', ''), + user_first_name=payload.get('user_first_name', ''), + user_last_name=payload.get('user_last_name') or payload.get('user_name') or '', + user_email=payload.get('user_email', ''), + user_phone_number=payload.get('user_phone_number', ''), + form_url=payload.get('form_url', ''), + backoffice_url=payload.get('backoffice_url', ''), + cancel_callback_url=payload.get('cancel_callback_url', ''), + user_display_label=payload.get('user_display_label', ''), + extra_data=extra_data, + color=color, + ) + + class Agendas(APIView): permission_classes = () @@ -814,6 +839,32 @@ class MeetingDatetimes(APIView): meeting_datetimes = MeetingDatetimes.as_view() +class RecurringEventsList(APIView): + permission_classes = () + + def get(self, request, agenda_identifier=None, format=None): + agenda = get_object_or_404(Agenda, slug=agenda_identifier, kind='events') + entries = agenda.get_open_recurring_events() + + events = [] + for event in entries: + events.append( + { + 'id': event.slug, + 'text': get_event_text(event, agenda), + 'datetime': format_response_datetime(event.start_datetime), + 'description': event.description, + 'pricing': event.pricing, + 'url': event.url, + } + ) + + return Response({'data': events}) + + +recurring_events_list = RecurringEventsList.as_view() + + class MeetingList(APIView): permission_classes = () @@ -1282,24 +1333,9 @@ class Fillslots(APIView): primary_booking = None for event in events: for i in range(places_count): - new_booking = Booking( - event_id=event.id, - in_waiting_list=in_waiting_list, - label=payload.get('label', ''), - user_external_id=payload.get('user_external_id', ''), - user_first_name=payload.get('user_first_name', ''), - user_last_name=payload.get('user_last_name') or payload.get('user_name') or '', - user_email=payload.get('user_email', ''), - user_phone_number=payload.get('user_phone_number', ''), - form_url=payload.get('form_url', ''), - backoffice_url=payload.get('backoffice_url', ''), - cancel_callback_url=payload.get('cancel_callback_url', ''), - user_display_label=payload.get('user_display_label', ''), - extra_data=extra_data, - color=color, + new_booking = make_booking( + event, payload, extra_data, primary_booking, in_waiting_list, color ) - if primary_booking is not None: - new_booking.primary_booking = primary_booking new_booking.save() if primary_booking is None: primary_booking = new_booking @@ -1386,6 +1422,138 @@ class Fillslot(Fillslots): fillslot = Fillslot.as_view() +class RecurringFillslots(APIView): + permission_classes = (permissions.IsAuthenticated,) + serializer_class = SlotsSerializer + + def post(self, request, agenda_identifier=None, format=None): + agenda = get_object_or_404(Agenda, slug=agenda_identifier, kind='events') + start_datetime, end_datetime = get_start_and_end_datetime_from_request(request) + if not start_datetime or start_datetime < now(): + start_datetime = now() + + serializer = self.serializer_class(data=request.data, partial=True) + if not serializer.is_valid(): + raise APIError( + _('invalid payload'), + err_class='invalid payload', + errors=serializer.errors, + http_status=status.HTTP_400_BAD_REQUEST, + ) + payload = serializer.validated_data + + user_external_id = payload.get('user_external_id') or None + exclude_user = payload.get('exclude_user') + + recurring_events = agenda.get_open_recurring_events().filter(slug__in=payload['slots']) + if not recurring_events.exists(): + raise APIError( + _('unknown recurring event slugs'), + err_class='unknown recurring event slugs', + http_status=status.HTTP_400_BAD_REQUEST, + ) + + events_to_book = Event.objects.filter( + primary_event__in=recurring_events, + start_datetime__gte=start_datetime, + cancelled=False, + ) + full_events = list(events_to_book.filter(full=True)) + events_to_book = events_to_book.filter(full=False) + if end_datetime: + events_to_book = events_to_book.filter(start_datetime__lte=end_datetime) + if not events_to_book.exists(): + if full_events: + raise APIError(_('all events are all full'), err_class='all events are all full') + else: + raise APIError(_('no event recurrences to book'), err_class='no event recurrences to book') + if exclude_user and user_external_id: + events_to_book = events_to_book.exclude(booking__user_external_id=user_external_id) + if not events_to_book.exists(): + raise APIError( + _('events are already booked by user'), err_class='events are already booked by user' + ) + + events_to_book = Event.annotate_queryset(events_to_book) + events_to_book = events_to_book.annotate( + in_waiting_list=ExpressionWrapper( + Q(booked_places_count__gte=F('places')), output_field=BooleanField() + ) + ) + + extra_data = {k: v for k, v in request.data.items() if k not in payload} + primary_booking = None + bookings = [] + for event in events_to_book: + bookings.append(make_booking(event, payload, extra_data, primary_booking)) + if primary_booking is None: + primary_booking = bookings.pop() + primary_booking.save() + + with transaction.atomic(): + Booking.objects.bulk_create(bookings) + if django.VERSION < (2, 0): + from django.db.models import Case, When + + events_to_book.update( + full=Case( + When( + Q(booked_places_count__gte=F('places'), waiting_list_places=0) + | Q( + waiting_list_places__gt=0, + waiting_list_count__gte=F('waiting_list_places'), + ), + then=Value(True), + ), + default=Value(False), + ), + almost_full=Case( + When(Q(booked_places_count__gte=0.9 * F('places')), then=Value(True)), + default=Value(False), + ), + ) + else: + events_to_book.update( + full=Q(booked_places_count__gte=F('places'), waiting_list_places=0) + | Q(waiting_list_places__gt=0, waiting_list_count__gte=F('waiting_list_places')), + almost_full=Q(booked_places_count__gte=0.9 * F('places')), + ) + + response = { + 'err': 0, + 'booking_id': primary_booking.id, + 'agenda': { + 'label': primary_booking.event.agenda.label, + 'slug': primary_booking.event.agenda.slug, + }, + 'api': { + 'booking_url': request.build_absolute_uri( + reverse('api-booking', kwargs={'booking_pk': primary_booking.id}) + ), + 'cancel_url': request.build_absolute_uri( + reverse('api-cancel-booking', kwargs={'booking_pk': primary_booking.id}) + ), + 'ics_url': request.build_absolute_uri( + reverse('api-booking-ics', kwargs={'booking_pk': primary_booking.id}) + ), + 'anonymize_url': request.build_absolute_uri( + reverse('api-anonymize-booking', kwargs={'booking_pk': primary_booking.id}) + ), + 'accept_url': request.build_absolute_uri( + reverse('api-accept-booking', kwargs={'booking_pk': primary_booking.pk}) + ), + 'suspend_url': request.build_absolute_uri( + reverse('api-suspend-booking', kwargs={'booking_pk': primary_booking.pk}) + ), + }, + 'full_events': [get_event_detail(request, x, agenda=agenda) for x in full_events], + } + return Response(response) + + +recurring_fillslots = RecurringFillslots.as_view() + + class BookingSerializer(serializers.ModelSerializer): user_absence_reason = serializers.CharField(required=False, allow_blank=True, allow_null=True) diff --git a/tests/test_api.py b/tests/test_api.py index 3f0db5a..e96c0f5 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -6563,3 +6563,123 @@ def test_statistics_bookings(app, user, freezer): {'label': 'Absent', 'data': [None, None, 5, None]}, ], } + + +def test_recurring_events_api_list(app, freezer): + freezer.move_to('2021-09-06 12:00') + agenda = Agenda.objects.create(label='Foo bar', kind='events') + Event.objects.create(label='Normal event', start_datetime=now(), places=5, agenda=agenda) + event = Event.objects.create( + label='Monday', + start_datetime=now(), + repeat='weekly', + places=2, + agenda=agenda, + ) + + resp = app.get('/api/agenda/xxx/recurring_events/', status=404) + + # recurring events without recurrence_end_date are not bookable + resp = app.get('/api/agenda/%s/recurring_events/' % agenda.slug) + assert len(resp.json['data']) == 0 + + event.recurrence_end_date = now() + datetime.timedelta(days=30) + event.save() + Event.objects.create( + label='Tuesday', + start_datetime=now() + datetime.timedelta(days=15), + repeat='weekly', + places=2, + agenda=agenda, + recurrence_end_date=now() + datetime.timedelta(days=45), + ) + + resp = app.get('/api/agenda/%s/recurring_events/' % agenda.slug) + assert len(resp.json['data']) == 2 + + event.publication_date = now() + datetime.timedelta(days=2) + event.save() + resp = app.get('/api/agenda/%s/recurring_events/' % agenda.slug) + assert len(resp.json['data']) == 1 + + freezer.move_to(event.recurrence_end_date) + resp = app.get('/api/agenda/%s/recurring_events/' % agenda.slug) + assert len(resp.json['data']) == 1 + + +def test_recurring_events_api_fillslots(app, user, freezer): + freezer.move_to('2021-09-06 12:00') + agenda = Agenda.objects.create(label='Foo bar', kind='events') + end_date = now() + datetime.timedelta(days=364) + for i, day in enumerate(('Monday', 'Tuesday', 'Thursday', 'Friday')): + event = Event.objects.create( + label=day, + start_datetime=now() + datetime.timedelta(days=i), + repeat='weekly', + places=2, + waiting_list_places=1, + agenda=agenda, + recurrence_end_date=end_date, + ) + event.create_all_recurrences() + + resp = app.get('/api/agenda/%s/recurring_events/' % agenda.slug) + assert len(resp.json['data']) == 4 + + app.authorization = ('Basic', ('john.doe', 'password')) + fillslots_url = '/api/agenda/%s/recurring_fillslots/' % agenda.slug + resp = app.post_json(fillslots_url, params={'slots': 'monday,tuesday'}) + assert resp.json['err'] == 0 + + assert Booking.objects.count() == 104 + events = Event.annotate_queryset(Event.objects.filter(primary_event__isnull=False)) + assert events.filter(booked_places_count=1).count() == 104 + + # one recurrence is booked separately + event = Event.objects.filter(primary_event__isnull=False).first() + Booking.objects.create(event=event) + + resp = app.post_json(fillslots_url, params={'slots': 'monday,tuesday'}) + assert resp.json['err'] == 0 + assert not resp.json['full_events'] + assert Booking.objects.count() == 209 + events = Event.annotate_queryset(Event.objects.filter(primary_event__isnull=False)) + assert events.filter(booked_places_count=2).count() == 104 + # one booking has been put in waiting list + assert events.filter(waiting_list_count=1).count() == 1 + + resp = app.post_json(fillslots_url, params={'slots': 'monday,tuesday'}) + assert resp.json['err'] == 0 + # everything goes in waiting list + assert events.filter(waiting_list_count=1).count() == 104 + # but an event was reported full + assert len(resp.json['full_events']) == 1 + assert resp.json['full_events'][0]['slug'] == event.slug + + resp = app.post_json(fillslots_url, params={'slots': 'monday,tuesday'}) + assert resp.json['err'] == 1 + assert resp.json['err_desc'] == 'all events are all full' + + resp = app.post_json( + fillslots_url + '?date_start=2021-10-06&date_end=2021-11-06', + params={'slots': 'friday', 'user_external_id': 'a'}, + ) + assert resp.json['err'] == 0 + assert Booking.objects.filter(event__slug__startswith='friday').count() == 5 + + resp = app.post_json( + fillslots_url + '?date_start=2021-10-06&date_end=2021-11-06', + params={'slots': 'friday', 'user_external_id': 'a', 'exclude_user': True}, + ) + assert resp.json['err'] == 1 + assert resp.json['err_desc'] == 'events are already booked by user' + + resp = app.post_json( + fillslots_url + '?date_start=2020-10-06&date_end=2020-11-06', params={'slots': 'friday'} + ) + assert resp.json['err'] == 1 + assert resp.json['err_desc'] == 'no event recurrences to book' + + resp = app.post_json(fillslots_url, params={'slots': 'unknown'}, status=400) + assert resp.json['err'] == 1 + assert resp.json['err_desc'] == 'unknown recurring event slugs' -- 2.20.1