From bb78fd88652a2752c6a80d71ccb85253ad9531a6 Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Thu, 21 Oct 2021 15:57:02 +0200 Subject: [PATCH 3/5] api: make recurring events fillslots work with multiple agendas (#57957) --- chrono/api/serializers.py | 29 ++++++++ chrono/api/urls.py | 6 +- chrono/api/views.py | 44 +++++-------- tests/api/test_fillslot.py | 132 +++++++++++++++++++++++++++++++------ 4 files changed, 158 insertions(+), 53 deletions(-) diff --git a/chrono/api/serializers.py b/chrono/api/serializers.py index 07b5aaf3..c19ab957 100644 --- a/chrono/api/serializers.py +++ b/chrono/api/serializers.py @@ -1,3 +1,5 @@ +import collections + from django.contrib.auth.models import Group from django.utils.translation import ugettext_lazy as _ from rest_framework import serializers @@ -88,6 +90,33 @@ class MultipleAgendasEventsSlotsSerializer(EventsSlotsSerializer): return value +class RecurringFillslotsSerializer(MultipleAgendasEventsSlotsSerializer): + def validate_slots(self, value): + super().validate_slots(value) + open_event_slugs = collections.defaultdict(set) + for agenda in self.context['agendas']: + for event in agenda.get_open_recurring_events(): + open_event_slugs[agenda.slug].add(event.slug) + + slots = collections.defaultdict(lambda: collections.defaultdict(list)) + for slot in value: + try: + slugs, day = slot.split(':') + day = int(day) + except ValueError: + raise ValidationError(_('invalid slot: %s') % slot) + + agenda_slug, event_slug = slugs.split('@') + if event_slug not in open_event_slugs[agenda_slug]: + raise ValidationError(_('event %s of agenda %s is not bookable') % (event_slug, agenda_slug)) + + # convert ISO day number to db lookup day number + day = (day + 1) % 7 + 1 + slots[agenda_slug][event_slug].append(day) + + return slots + + class BookingSerializer(serializers.ModelSerializer): user_absence_reason = serializers.CharField(required=False, allow_blank=True, allow_null=True) diff --git a/chrono/api/urls.py b/chrono/api/urls.py index a3cd495b..82b87c3d 100644 --- a/chrono/api/urls.py +++ b/chrono/api/urls.py @@ -22,6 +22,7 @@ urlpatterns = [ url(r'^agenda/$', views.agendas), url(r'^agendas/datetimes/$', views.agendas_datetimes, name='api-agendas-datetimes'), url(r'^agendas/recurring-events/$', views.recurring_events_list, name='api-agenda-recurring-events'), + url(r'^agendas/recurring-events/fillslots/$', views.recurring_fillslots, name='api-recurring-fillslots'), url( r'^agendas/events/fillslots/$', views.agendas_events_fillslots, @@ -40,11 +41,6 @@ urlpatterns = [ views.events_fillslots, name='api-agenda-events-fillslots', ), - url( - r'^agenda/(?P[\w-]+)/recurring-events/fillslots/$', - views.recurring_fillslots, - name='api-recurring-fillslots', - ), url( r'^agenda/(?P[\w-]+)/event/$', views.events, diff --git a/chrono/api/views.py b/chrono/api/views.py index 27fa9d0d..7e9f58ee 100644 --- a/chrono/api/views.py +++ b/chrono/api/views.py @@ -1583,18 +1583,21 @@ fillslot = Fillslot.as_view() class RecurringFillslots(APIView): permission_classes = (permissions.IsAuthenticated,) - serializer_class = serializers.EventsSlotsSerializer + serializer_class = serializers.RecurringFillslotsSerializer - def post(self, request, agenda_identifier): + def post(self, request): if not settings.ENABLE_RECURRING_EVENT_BOOKING: raise Http404() - 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) + agenda_slugs = get_agendas_from_request(request) + agendas = get_objects_from_slugs(agenda_slugs, qs=Agenda.objects.filter(kind='events')) + + context = {'allowed_agenda_slugs': agenda_slugs, 'agendas': agendas} + serializer = self.serializer_class(data=request.data, partial=True, context=context) if not serializer.is_valid(): raise APIError( _('invalid payload'), @@ -1605,31 +1608,14 @@ class RecurringFillslots(APIView): payload = serializer.validated_data user_external_id = payload['user_external_id'] - open_event_slugs = set(agenda.get_open_recurring_events().values_list('slug', flat=True)) - slots = collections.defaultdict(list) - for slot in payload['slots']: - try: - slug, day = slot.split(':') - day = int(day) - except ValueError: - raise APIError( - _('invalid slot: %s') % slot, - err_class='invalid slot: %s' % slot, - http_status=status.HTTP_400_BAD_REQUEST, - ) - if slug not in open_event_slugs: - raise APIError( - _('event %s is not bookable') % slug, - err_class='event %s is not bookable' % slug, - http_status=status.HTTP_400_BAD_REQUEST, - ) - # convert ISO day number to db lookup day number - day = (day + 1) % 7 + 1 - slots[slug].append(day) - event_filter = Q() - for slug, days in slots.items(): - event_filter |= Q(agenda=agenda, primary_event__slug=slug, start_datetime__week_day__in=days) + for agenda_slug, days_by_event in payload['slots'].items(): + for event_slug, days in days_by_event.items(): + event_filter |= Q( + agenda__slug=agenda_slug, + primary_event__slug=event_slug, + start_datetime__week_day__in=days, + ) events_to_book = Event.objects.filter(event_filter) if event_filter else Event.objects.none() events_to_book = events_to_book.filter(start_datetime__gte=start_datetime, cancelled=False) @@ -1666,7 +1652,7 @@ class RecurringFillslots(APIView): 'err': 0, 'booking_count': len(bookings), 'cancelled_booking_count': deleted_count, - 'full_events': [get_event_detail(request, x, agenda=agenda) for x in full_events], + 'full_events': [get_event_detail(request, x, multiple_agendas=True) for x in full_events], } return Response(response) diff --git a/tests/api/test_fillslot.py b/tests/api/test_fillslot.py index afc5a857..9c43cb34 100644 --- a/tests/api/test_fillslot.py +++ b/tests/api/test_fillslot.py @@ -2110,6 +2110,7 @@ def test_fillslot_past_events_recurring_event(app, user): 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') + Desk.objects.create(agenda=agenda, slug='_exceptions_holder') event = Event.objects.create( label='Event', start_datetime=now(), @@ -2131,14 +2132,14 @@ def test_recurring_events_api_fillslots(app, user, freezer): ) sunday_event.create_all_recurrences() - resp = app.get('/api/agenda/%s/recurring-events/' % agenda.slug) + resp = app.get('/api/agendas/recurring-events/?agendas=%s' % agenda.slug) assert len(resp.json['data']) == 5 app.authorization = ('Basic', ('john.doe', 'password')) - fillslots_url = '/api/agenda/%s/recurring-events/fillslots/' % agenda.slug + fillslots_url = '/api/agendas/recurring-events/fillslots/?agendas=%s' % agenda.slug params = {'user_external_id': 'user_id'} # Book Monday and Thursday of first event and Sunday of second event - params['slots'] = 'event:0,event:3,sunday-event:6' + params['slots'] = 'foo-bar@event:0,foo-bar@event:3,foo-bar@sunday-event:6' resp = app.post_json(fillslots_url, params=params) assert resp.json['booking_count'] == 156 @@ -2179,15 +2180,15 @@ def test_recurring_events_api_fillslots(app, user, freezer): assert resp.json['booking_count'] == 0 # no event in range - resp = app.post_json(fillslots_url + '?date_start=2020-10-06&date_end=2020-11-06', params=params) + resp = app.post_json(fillslots_url + '&date_start=2020-10-06&date_end=2020-11-06', params=params) assert resp.json['booking_count'] == 0 - params['slots'] = 'event:1' - resp = app.post_json(fillslots_url + '?date_start=2021-10-06&date_end=2021-11-06', params=params) + params['slots'] = 'foo-bar@event:1' + resp = app.post_json(fillslots_url + '&date_start=2021-10-06&date_end=2021-11-06', params=params) assert resp.json['booking_count'] == 4 assert Booking.objects.filter(user_external_id='user_id_4').count() == 4 - resp = app.post_json(fillslots_url, params={'slots': 'event:0'}, status=400) + resp = app.post_json(fillslots_url, params={'slots': 'foo-bar@event:0'}, status=400) assert resp.json['err'] == 1 assert resp.json['err_desc'] == 'invalid payload' assert resp.json['errors']['user_external_id'] == ['This field is required.'] @@ -2197,18 +2198,23 @@ def test_recurring_events_api_fillslots(app, user, freezer): assert resp.json['err_desc'] == 'invalid payload' assert resp.json['errors']['slots'] == ['This field is required.'] - resp = app.post_json(fillslots_url, params={'user_external_id': 'a', 'slots': 'a:a'}, status=400) + resp = app.post_json(fillslots_url, params={'user_external_id': 'a', 'slots': 'foo-bar@a:a'}, status=400) assert resp.json['err'] == 1 - assert resp.json['err_desc'] == 'invalid slot: a:a' + assert resp.json['errors']['slots'] == ['invalid slot: foo-bar@a:a'] - resp = app.post_json(fillslots_url, params={'user_external_id': 'a', 'slots': 'a:1'}, status=400) + resp = app.post_json(fillslots_url, params={'user_external_id': 'a', 'slots': 'foo-bar@a:1'}, status=400) assert resp.json['err'] == 1 - assert resp.json['err_desc'] == 'event a is not bookable' + assert resp.json['errors']['slots'] == ['event a of agenda foo-bar is not bookable'] + + resp = app.post_json(fillslots_url, params={'user_external_id': 'a', 'slots': 'foo-bar'}, status=400) + assert resp.json['err'] == 1 + assert resp.json['errors']['slots'] == ['Invalid format for slot foo-bar'] def test_recurring_events_api_fillslots_waiting_list(app, user, freezer): freezer.move_to('2021-09-06 12:00') agenda = Agenda.objects.create(label='Foo bar', kind='events') + Desk.objects.create(agenda=agenda, slug='_exceptions_holder') event = Event.objects.create( label='Event', start_datetime=now(), @@ -2228,8 +2234,8 @@ def test_recurring_events_api_fillslots_waiting_list(app, user, freezer): assert events.filter(booked_waiting_list_places=1).count() == 5 # check that new bookings are put in waiting list despite free slots on main list - params = {'user_external_id': 'user_id', 'slots': 'event:0'} - resp = app.post_json('/api/agenda/%s/recurring-events/fillslots/' % agenda.slug, params=params) + params = {'user_external_id': 'user_id', 'slots': 'foo-bar@event:0'} + resp = app.post_json('/api/agendas/recurring-events/fillslots/?agendas=%s' % agenda.slug, params=params) assert resp.json['booking_count'] == 5 assert events.filter(booked_waiting_list_places=2).count() == 5 @@ -2237,6 +2243,7 @@ def test_recurring_events_api_fillslots_waiting_list(app, user, freezer): def test_recurring_events_api_fillslots_change_bookings(app, user, freezer): freezer.move_to('2021-09-06 12:00') agenda = Agenda.objects.create(label='Foo bar', kind='events') + Desk.objects.create(agenda=agenda, slug='_exceptions_holder') event = Event.objects.create( label='Event', start_datetime=now(), @@ -2249,10 +2256,10 @@ def test_recurring_events_api_fillslots_change_bookings(app, user, freezer): event.create_all_recurrences() app.authorization = ('Basic', ('john.doe', 'password')) - fillslots_url = '/api/agenda/%s/recurring-events/fillslots/' % agenda.slug + fillslots_url = '/api/agendas/recurring-events/fillslots/?agendas=%s' % agenda.slug params = {'user_external_id': 'user_id'} # Book Monday and Thursday - params['slots'] = 'event:0,event:3' + params['slots'] = 'foo-bar@event:0,foo-bar@event:3' resp = app.post_json(fillslots_url, params=params) assert resp.json['booking_count'] == 104 assert resp.json['cancelled_booking_count'] == 0 @@ -2261,7 +2268,7 @@ def test_recurring_events_api_fillslots_change_bookings(app, user, freezer): assert Booking.objects.filter(event__start_datetime__week_day=5).count() == 52 # Change booking to Monday and Tuesday - params['slots'] = 'event:0,event:1' + params['slots'] = 'foo-bar@event:0,foo-bar@event:1' resp = app.post_json(fillslots_url, params=params) assert resp.json['booking_count'] == 52 assert resp.json['cancelled_booking_count'] == 52 @@ -2276,7 +2283,7 @@ def test_recurring_events_api_fillslots_change_bookings(app, user, freezer): assert Booking.objects.count() == 104 params = {'user_external_id': 'user_id_2'} - params['slots'] = 'event:0,event:3' + params['slots'] = 'foo-bar@event:0,foo-bar@event:3' resp = app.post_json(fillslots_url, params=params) assert resp.json['booking_count'] == 104 assert resp.json['cancelled_booking_count'] == 0 @@ -2287,7 +2294,7 @@ def test_recurring_events_api_fillslots_change_bookings(app, user, freezer): assert events.filter(booked_places=1).count() == 156 assert events.filter(booked_waiting_list_places=1).count() == 52 - params['slots'] = 'event:1,event:4' + params['slots'] = 'foo-bar@event:1,foo-bar@event:4' resp = app.post_json(fillslots_url, params=params) assert resp.json['booking_count'] == 104 assert resp.json['cancelled_booking_count'] == 104 @@ -2309,11 +2316,98 @@ def test_recurring_events_api_fillslots_change_bookings(app, user, freezer): start_datetime=now() + datetime.timedelta(days=1), places=2, agenda=agenda ) Booking.objects.create(event=normal_event, user_external_id='user_id') - resp = app.post_json(fillslots_url, params={'user_external_id': 'user_id', 'slots': 'event:0'}) + resp = app.post_json(fillslots_url, params={'user_external_id': 'user_id', 'slots': 'foo-bar@event:0'}) assert resp.json['cancelled_booking_count'] == 52 assert Booking.objects.filter(user_external_id='user_id', event=normal_event).count() == 1 +@pytest.mark.freeze_time('2021-09-06 12:00') +def test_recurring_events_api_fillslots_multiple_agendas(app, user): + agenda = Agenda.objects.create(label='First Agenda', kind='events') + Desk.objects.create(agenda=agenda, slug='_exceptions_holder') + start, end = now(), now() + datetime.timedelta(days=30) + event_a = Event.objects.create( + label='A', + start_datetime=start, + places=2, + recurrence_end_date=end, + recurrence_days=[0, 2, 5], + agenda=agenda, + ) + event_a.create_all_recurrences() + event_b = Event.objects.create( + label='B', start_datetime=start, places=2, recurrence_end_date=end, recurrence_days=[1], agenda=agenda + ) + event_b.create_all_recurrences() + agenda2 = Agenda.objects.create(label='Second Agenda', kind='events') + Desk.objects.create(agenda=agenda2, slug='_exceptions_holder') + event_c = Event.objects.create( + label='C', + start_datetime=start, + places=2, + recurrence_end_date=end, + recurrence_days=[2, 3], + agenda=agenda2, + ) + event_c.create_all_recurrences() + + resp = app.get('/api/agendas/recurring-events/?agendas=first-agenda,second-agenda') + assert len(resp.json['data']) == 6 + + app.authorization = ('Basic', ('john.doe', 'password')) + fillslots_url = '/api/agendas/recurring-events/fillslots/?agendas=%s' + params = {'user_external_id': 'user_id', 'slots': 'first-agenda@a:0,first-agenda@a:5,second-agenda@c:3'} + resp = app.post_json(fillslots_url % 'first-agenda,second-agenda', params=params) + assert resp.json['booking_count'] == 13 + + assert Booking.objects.count() == 13 + assert Booking.objects.filter(event__primary_event=event_a).count() == 9 + assert Booking.objects.filter(event__primary_event=event_b).count() == 0 + assert Booking.objects.filter(event__primary_event=event_c).count() == 4 + + # update bookings + params = {'user_external_id': 'user_id', 'slots': 'first-agenda@b:1'} + resp = app.post_json(fillslots_url % 'first-agenda,second-agenda', params=params) + + assert resp.json['booking_count'] == 5 + assert resp.json['cancelled_booking_count'] == 13 + assert Booking.objects.filter(event__primary_event=event_a).count() == 0 + assert Booking.objects.filter(event__primary_event=event_b).count() == 5 + assert Booking.objects.filter(event__primary_event=event_c).count() == 0 + + # error if slot's agenda is not in querystring + resp = app.post_json(fillslots_url % 'second-agenda', params=params, status=400) + assert resp.json['err'] == 1 + assert resp.json['errors']['slots'] == [ + 'Some events belong to agendas that are not present in querystring: first-agenda' + ] + + +@pytest.mark.freeze_time('2021-09-06 12:00') +def test_recurring_events_api_fillslots_multiple_agendas_queries(app, user): + for i in range(20): + agenda = Agenda.objects.create(slug=f'{i}', kind='events') + Desk.objects.create(agenda=agenda, slug='_exceptions_holder') + start, end = now(), now() + datetime.timedelta(days=30) + event = Event.objects.create( + start_datetime=start, places=2, recurrence_end_date=end, recurrence_days=[1, 2], agenda=agenda + ) + event.create_all_recurrences() + + agenda_slugs = ','.join(str(i) for i in range(20)) + resp = app.get('/api/agendas/recurring-events/?agendas=%s' % agenda_slugs) + events_to_book = [x['id'] for x in resp.json['data']] + + app.authorization = ('Basic', ('john.doe', 'password')) + with CaptureQueriesContext(connection) as ctx: + resp = app.post_json( + '/api/agendas/recurring-events/fillslots/?agendas=%s' % agenda_slugs, + params={'slots': events_to_book, 'user_external_id': 'user'}, + ) + assert resp.json['booking_count'] == 180 + assert len(ctx.captured_queries) == 36 + + @pytest.mark.freeze_time('2021-09-06 12:00') def test_api_events_fillslots(app, user): agenda = Agenda.objects.create(label='Foo bar', kind='events') -- 2.30.2