From 6bd595fa9828ba1bb80ca4e1980f21369d8b9dbd Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Mon, 9 Aug 2021 11:54:18 +0200 Subject: [PATCH 1/2] api: add datetimes for multiple events agendas (#55370) --- chrono/api/serializers.py | 15 +++++ chrono/api/urls.py | 1 + chrono/api/views.py | 86 ++++++++++++++++++++++++++-- tests/api/test_datetimes.py | 108 ++++++++++++++++++++++++++++++++++++ 4 files changed, 206 insertions(+), 4 deletions(-) diff --git a/chrono/api/serializers.py b/chrono/api/serializers.py index d9ffe983..485de7b0 100644 --- a/chrono/api/serializers.py +++ b/chrono/api/serializers.py @@ -12,6 +12,15 @@ class StringOrListField(serializers.ListField): return super().to_internal_value(data) +class CommaSeparatedStringField(serializers.ListField): + def get_value(self, dictionary): + return super(serializers.ListField, self).get_value(dictionary) + + def to_internal_value(self, data): + data = [s.strip() for s in data.split(',') if s.strip()] + return super().to_internal_value(data) + + class SlotSerializer(serializers.Serializer): label = serializers.CharField(max_length=250, allow_blank=True) user_external_id = serializers.CharField(max_length=250, allow_blank=True) @@ -118,3 +127,9 @@ class DatetimesSerializer(DateRangeSerializer): {'user_external_id': _('user_external_id and exclude_user_external_id have different values')} ) return attrs + + +class MultipleAgendasDatetimesSerializer(DatetimesSerializer): + agendas = CommaSeparatedStringField( + required=True, child=serializers.SlugField(max_length=160, allow_blank=False) + ) diff --git a/chrono/api/urls.py b/chrono/api/urls.py index 7c5a528c..15eeddfc 100644 --- a/chrono/api/urls.py +++ b/chrono/api/urls.py @@ -20,6 +20,7 @@ from . import views urlpatterns = [ url(r'^agenda/$', views.agendas), + url(r'^agenda/datetimes/$', views.agendas_datetimes, name='api-agendas-datetimes'), url(r'^agenda/(?P[\w-]+)/$', views.agenda_detail), url(r'^agenda/(?P[\w-]+)/datetimes/$', views.datetimes, name='api-agenda-datetimes'), url( diff --git a/chrono/api/views.py b/chrono/api/views.py index 0265d769..7077cd3c 100644 --- a/chrono/api/views.py +++ b/chrono/api/views.py @@ -449,11 +449,17 @@ def get_event_text(event, agenda, day=None): def get_event_detail( - request, event, agenda=None, min_places=1, booked_user_external_id=None, show_events=None + request, + event, + agenda=None, + min_places=1, + booked_user_external_id=None, + show_events=None, + multiple_agendas=False, ): agenda = agenda or event.agenda details = { - 'id': event.slug, + 'id': '%s@%s' % (agenda.slug, event.slug) if multiple_agendas else event.slug, 'slug': event.slug, # kept for compatibility 'text': get_event_text(event, agenda), 'label': event.label or '', @@ -502,7 +508,9 @@ def get_event_detail( return details -def get_events_meta_detail(request, events, agenda=None, min_places=1, show_events=None): +def get_events_meta_detail( + request, events, agenda=None, min_places=1, show_events=None, multiple_agendas=False +): bookable_datetimes_number_total = 0 bookable_datetimes_number_available = 0 first_bookable_slot = None @@ -512,7 +520,12 @@ def get_events_meta_detail(request, events, agenda=None, min_places=1, show_even bookable_datetimes_number_available += 1 if not first_bookable_slot: first_bookable_slot = get_event_detail( - request, event, agenda=agenda, min_places=min_places, show_events=show_events + request, + event, + agenda=agenda, + min_places=min_places, + show_events=show_events, + multiple_agendas=multiple_agendas, ) return { 'no_bookable_datetimes': bool(bookable_datetimes_number_available == 0), @@ -815,6 +828,71 @@ class Datetimes(APIView): datetimes = Datetimes.as_view() +class MultipleAgendasDatetimes(APIView): + permission_classes = () + serializer_class = serializers.MultipleAgendasDatetimesSerializer + + def get(self, request): + serializer = self.serializer_class(data=request.query_params) + 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 + + if 'events' in payload: + raise APIError( + _('events parameter is not supported'), + err_class='events parameter is not supported', + http_status=status.HTTP_400_BAD_REQUEST, + ) + + agenda_slugs = payload['agendas'] + agendas = Agenda.objects.filter(slug__in=agenda_slugs, kind='events') + if not len(agendas) == len(agenda_slugs): + not_found_slugs = sorted(set(agenda_slugs) - {agenda.slug for agenda in agendas}) + raise APIError( + _('events agendas do not exist: %s') % ', '.join(not_found_slugs), + err_class='events agendas do not exist', + http_status=status.HTTP_404_NOT_FOUND, + ) + + user_external_id = payload.get('user_external_id') or payload.get('exclude_user_external_id') + entries = [] + for agenda in agendas: + entries.extend( + agenda.get_open_events( + annotate_queryset=True, + min_start=payload.get('date_start'), + max_start=payload.get('date_end'), + user_external_id=user_external_id, + ) + ) + + response = { + 'data': [ + get_event_detail( + request, + x, + min_places=payload['min_places'], + booked_user_external_id=payload.get('user_external_id'), + multiple_agendas=True, + ) + for x in entries + ], + 'meta': get_events_meta_detail( + request, entries, min_places=payload['min_places'], multiple_agendas=True + ), + } + return Response(response) + + +agendas_datetimes = MultipleAgendasDatetimes.as_view() + + class MeetingDatetimes(APIView): permission_classes = () diff --git a/tests/api/test_datetimes.py b/tests/api/test_datetimes.py index cf1643eb..5a07941d 100644 --- a/tests/api/test_datetimes.py +++ b/tests/api/test_datetimes.py @@ -2,7 +2,9 @@ import datetime import urllib.parse as urlparse import pytest +from django.db import connection from django.test import override_settings +from django.test.utils import CaptureQueriesContext from django.utils.timezone import localtime, make_aware, now from chrono.agendas.models import Agenda, Booking, Desk, Event, TimePeriodException @@ -1344,3 +1346,109 @@ def test_recurring_events_api_list(app, freezer): freezer.move_to(event.recurrence_end_date) resp = app.get('/api/agenda/%s/recurring-events/' % agenda.slug) assert len(resp.json['data']) == 1 + + +@pytest.mark.freeze_time('2021-05-06 14:00') +def test_datetimes_multiple_agendas(app): + first_agenda = Agenda.objects.create(label='First agenda', kind='events') + Desk.objects.create(agenda=first_agenda, slug='_exceptions_holder') + event = Event.objects.create( + slug='event', + start_datetime=now() + datetime.timedelta(days=5), + places=5, + agenda=first_agenda, + ) + second_agenda = Agenda.objects.create(label='Second agenda', kind='events') + Desk.objects.create(agenda=second_agenda, slug='_exceptions_holder') + event = Event.objects.create( + slug='event', + start_datetime=now() + datetime.timedelta(days=6), + places=5, + agenda=second_agenda, + ) + Booking.objects.create(event=event) + + agenda_slugs = '%s,%s' % (first_agenda.slug, second_agenda.slug) + resp = app.get('/api/agenda/datetimes/', params={'agendas': agenda_slugs}) + assert len(resp.json['data']) == 2 + assert resp.json['data'][0]['id'] == 'first-agenda@event' + assert resp.json['data'][0]['text'] == 'May 11, 2021, 4 p.m.' + assert resp.json['data'][0]['places']['available'] == 5 + + assert resp.json['data'][1]['id'] == 'second-agenda@event' + assert resp.json['data'][1]['text'] == 'May 12, 2021, 4 p.m.' + assert resp.json['data'][1]['places']['available'] == 4 + + # check user_external_id + Booking.objects.create(event=event, user_external_id='user') + resp = app.get('/api/agenda/datetimes/', params={'agendas': agenda_slugs, 'user_external_id': 'user'}) + assert resp.json['data'][0]['places']['available'] == 5 + assert 'booked_for_external_user' not in resp.json['data'][0] + assert resp.json['data'][0]['disabled'] is False + + assert resp.json['data'][1]['places']['available'] == 3 + assert resp.json['data'][1]['booked_for_external_user'] == 'main-list' + assert resp.json['data'][1]['disabled'] is True + + # check exclude_user_external_id + resp = app.get( + '/api/agenda/datetimes/', params={'agendas': agenda_slugs, 'exclude_user_external_id': 'user'} + ) + assert 'booked_for_external_user' not in resp.json['data'][0] + assert resp.json['data'][0]['disabled'] is False + + assert 'booked_for_external_user' not in resp.json['data'][1] + assert resp.json['data'][1]['disabled'] is True + + # check min_places + resp = app.get('/api/agenda/datetimes/', params={'agendas': agenda_slugs, 'min_places': 4}) + assert resp.json['data'][0]['disabled'] is False + assert resp.json['data'][1]['disabled'] is True + + # check meta + resp = app.get('/api/agenda/datetimes/', params={'agendas': agenda_slugs, 'min_places': 4}) + assert resp.json['meta']['bookable_datetimes_number_total'] == 2 + assert resp.json['meta']['bookable_datetimes_number_available'] == 1 + assert resp.json['meta']['first_bookable_slot'] == resp.json['data'][0] + + # check date_start + date_start = localtime() + datetime.timedelta(days=5, hours=1) + resp = app.get('/api/agenda/datetimes/', params={'agendas': agenda_slugs, 'date_start': date_start}) + assert len(resp.json['data']) == 1 + assert resp.json['data'][0]['id'] == 'second-agenda@event' + + # check date_end + date_end = localtime() + datetime.timedelta(days=5, hours=1) + resp = app.get('/api/agenda/datetimes/', params={'agendas': agenda_slugs, 'date_end': date_end}) + assert len(resp.json['data']) == 1 + assert resp.json['data'][0]['id'] == 'first-agenda@event' + + resp = app.get( + '/api/agenda/datetimes/', + params={'agendas': agenda_slugs, 'date_start': date_start, 'date_end': date_end}, + ) + assert len(resp.json['data']) == 0 + + # invalid slugs + resp = app.get('/api/agenda/datetimes/', params={'agendas': 'xxx'}, status=404) + assert resp.json['err_desc'] == 'events agendas do not exist: xxx' + + resp = app.get('/api/agenda/datetimes/', params={'agendas': 'first-agenda,xxx,yyy'}, status=404) + assert resp.json['err_desc'] == 'events agendas do not exist: xxx, yyy' + + # no support for past events + resp = app.get('/api/agenda/datetimes/', params={'agendas': agenda_slugs, 'events': 'past'}, status=400) + + +@pytest.mark.freeze_time('2021-05-06 14:00') +def test_datetimes_multiple_agendas_queries(app): + for i in range(10): + agenda = Agenda.objects.create(label=str(i), kind='events') + Desk.objects.create(agenda=agenda, slug='_exceptions_holder') + Event.objects.create(start_datetime=now() + datetime.timedelta(days=5), places=5, agenda=agenda) + Event.objects.create(start_datetime=now() + datetime.timedelta(days=5), places=5, agenda=agenda) + + with CaptureQueriesContext(connection) as ctx: + resp = app.get('/api/agenda/datetimes/', params={'agendas': ','.join(str(i) for i in range(10))}) + assert len(resp.json['data']) == 20 + assert len(ctx.captured_queries) == 21 -- 2.20.1