From 16732641b478cda9147bc81e1341907bb5efbc6b Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Thu, 25 Nov 2021 16:35:55 +0100 Subject: [PATCH] api: filter by subscriptions in multiple agendas datetimes (#58444) --- chrono/agendas/models.py | 12 +++- chrono/api/serializers.py | 17 ++++- chrono/api/views.py | 36 +++++++--- tests/api/test_datetimes.py | 128 +++++++++++++++++++++++++++++++++++- 4 files changed, 182 insertions(+), 11 deletions(-) diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py index 184b600d..24aeb9bb 100644 --- a/chrono/agendas/models.py +++ b/chrono/agendas/models.py @@ -898,7 +898,12 @@ class Agenda(models.Model): @staticmethod def prefetch_events_and_exceptions( - qs, user_external_id=None, show_past_events=False, min_start=None, max_start=None + qs, + user_external_id=None, + show_past_events=False, + prefetch_subscriptions=False, + min_start=None, + max_start=None, ): event_queryset = Event.objects.filter( Q(publication_datetime__isnull=True) | Q(publication_datetime__lte=now()), @@ -931,6 +936,11 @@ class Agenda(models.Model): ), ) qs = Agenda.prefetch_recurring_events(qs) + if prefetch_subscriptions: + subscriptions = Subscription.objects.filter(user_external_id=user_external_id) + qs = qs.prefetch_related( + Prefetch('subscriptions', queryset=subscriptions, to_attr='user_subscriptions') + ) agendas_exceptions = TimePeriodException.objects.filter( Q(desk__slug='_exceptions_holder', desk__agenda__in=qs) | Q( diff --git a/chrono/api/serializers.py b/chrono/api/serializers.py index 294d7352..5ae3f020 100644 --- a/chrono/api/serializers.py +++ b/chrono/api/serializers.py @@ -192,10 +192,25 @@ class DatetimesSerializer(DateRangeSerializer): class MultipleAgendasDatetimesSerializer(DatetimesSerializer): agendas = CommaSeparatedStringField( - required=True, child=serializers.SlugField(max_length=160, allow_blank=False) + required=False, child=serializers.SlugField(max_length=160, allow_blank=False) + ) + subscribed = CommaSeparatedStringField( + required=False, child=serializers.SlugField(max_length=160, allow_blank=False) ) show_past_events = serializers.BooleanField(default=False) + def validate(self, attrs): + super().validate(attrs) + if 'agendas' not in attrs and 'subscribed' not in attrs: + raise ValidationError(_('Either "agendas" or "subscribed" parameter is required.')) + if 'agendas' in attrs and 'subscribed' in attrs: + raise ValidationError(_('"agendas" and "subscribed" parameters are mutually exclusive.')) + if 'subscribed' in attrs and 'user_external_id' not in attrs: + raise ValidationError( + {'user_external_id': _('This field is required when using "subscribed" parameter.')} + ) + return attrs + class AgendaSlugsSerializer(serializers.Serializer): agendas = CommaSeparatedStringField( diff --git a/chrono/api/views.py b/chrono/api/views.py index 65d29b46..ea12fd95 100644 --- a/chrono/api/views.py +++ b/chrono/api/views.py @@ -866,31 +866,39 @@ class MultipleAgendasDatetimes(APIView): raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors) payload = serializer.validated_data - agenda_slugs = payload['agendas'] - agendas = get_objects_from_slugs(agenda_slugs, qs=Agenda.objects.filter(kind='events')) - user_external_id = payload.get('user_external_id') or payload.get('exclude_user_external_id') + if 'agendas' in payload: + agenda_slugs = payload['agendas'] + agendas = get_objects_from_slugs(agenda_slugs, qs=Agenda.objects.filter(kind='events')) + else: + subscribed = payload['subscribed'] + agendas = Agenda.objects.filter(subscriptions__user_external_id=user_external_id).distinct() + if subscribed != ['all']: + agendas = agendas.filter(category__slug__in=subscribed) + disable_booked = bool(payload.get('exclude_user_external_id')) show_past_events = bool(payload.get('show_past_events')) agendas = Agenda.prefetch_events_and_exceptions( agendas, user_external_id=user_external_id, show_past_events=show_past_events, + prefetch_subscriptions=bool('subscribed' in payload), min_start=payload.get('date_start'), max_start=payload.get('date_end'), ) entries = [] for agenda in agendas: + agenda_entries = [] if show_past_events: - entries.extend( + agenda_entries.extend( agenda.get_past_events( prefetched_queryset=True, min_start=payload.get('date_start'), max_start=payload.get('date_end'), ) ) - entries.extend( + agenda_entries.extend( agenda.get_open_events( prefetched_queryset=True, min_start=payload.get('date_start'), @@ -899,9 +907,21 @@ class MultipleAgendasDatetimes(APIView): show_out_of_minimal_delay=show_past_events, ) ) - - agenda_querystring_indexes = {agenda_slug: i for i, agenda_slug in enumerate(agenda_slugs)} - entries.sort(key=lambda event: (event.start_datetime, agenda_querystring_indexes[event.agenda.slug])) + if 'subscribed' in payload: + filtered_entries = [] + for entry in agenda_entries: + for subscription in agenda.user_subscriptions: + if subscription.date_start <= entry.start_datetime.date() <= subscription.date_end: + filtered_entries.append(entry) + break + agenda_entries = filtered_entries + entries.extend(agenda_entries) + + if 'agendas' in payload: + agenda_querystring_indexes = {agenda_slug: i for i, agenda_slug in enumerate(agenda_slugs)} + entries.sort( + key=lambda event: (event.start_datetime, agenda_querystring_indexes[event.agenda.slug]) + ) response = { 'data': [ diff --git a/tests/api/test_datetimes.py b/tests/api/test_datetimes.py index 1bbb6195..e3f551a7 100644 --- a/tests/api/test_datetimes.py +++ b/tests/api/test_datetimes.py @@ -7,7 +7,7 @@ from django.test import override_settings from django.test.utils import CaptureQueriesContext from django.utils.timezone import localtime, make_aware, make_naive, now -from chrono.agendas.models import Agenda, Booking, Desk, Event, TimePeriodException +from chrono.agendas.models import Agenda, Booking, Category, Desk, Event, Subscription, TimePeriodException pytestmark = pytest.mark.django_db @@ -1589,6 +1589,10 @@ def test_datetimes_multiple_agendas(app): resp = app.get('/api/agendas/datetimes/', params={'agendas': 'first-agenda,xxx,yyy'}, status=400) assert resp.json['err_desc'] == 'invalid slugs: xxx, yyy' + # missing agendas parameter + resp = app.get('/api/agendas/datetimes/', params={}, status=400) + assert resp.json['err_desc'] == 'invalid payload' + # it's possible to show past events resp = app.get('/api/agendas/datetimes/', params={'agendas': agenda_slugs, 'show_past_events': True}) assert len(resp.json['data']) == 5 @@ -1753,6 +1757,12 @@ def test_datetimes_multiple_agendas_sort(app): def test_datetimes_multiple_agendas_queries(app): for i in range(10): agenda = Agenda.objects.create(label=str(i), kind='events') + Subscription.objects.create( + agenda=agenda, + user_external_id='xxx', + date_start=now() - datetime.timedelta(days=10), + date_end=now() + datetime.timedelta(days=10), + ) 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) @@ -1765,3 +1775,119 @@ def test_datetimes_multiple_agendas_queries(app): ) assert len(resp.json['data']) == 30 assert len(ctx.captured_queries) == 7 + + with CaptureQueriesContext(connection) as ctx: + resp = app.get( + '/api/agendas/datetimes/', + params={'subscribed': 'all', 'user_external_id': 'xxx', 'show_past_events': True}, + ) + assert len(resp.json['data']) == 30 + assert len(ctx.captured_queries) == 7 + + +@pytest.mark.freeze_time('2021-05-06 14:00') +def test_datetimes_multiple_agendas_subscribed(app): + first_agenda = Agenda.objects.create(label='First agenda', kind='events') + Desk.objects.create(agenda=first_agenda, slug='_exceptions_holder') + Event.objects.create( + slug='event', + start_datetime=now() + datetime.timedelta(days=5), + places=5, + agenda=first_agenda, + ) + Event.objects.create( + slug='event-2', + start_datetime=now() + datetime.timedelta(days=20), + places=5, + agenda=first_agenda, + ) + category = Category.objects.create(label='Category A') + second_agenda = Agenda.objects.create( + label='Second agenda', kind='events', category=category, maximal_booking_delay=400 + ) + Desk.objects.create(agenda=second_agenda, slug='_exceptions_holder') + Event.objects.create( + slug='event', + start_datetime=now() + datetime.timedelta(days=20), + places=5, + agenda=second_agenda, + ) + Event.objects.create( + slug='next-year-event', + start_datetime=now() + datetime.timedelta(days=365), + places=5, + agenda=second_agenda, + ) + + # no subscription + resp = app.get('/api/agendas/datetimes/', params={'subscribed': 'all', 'user_external_id': 'xxx'}) + assert len(resp.json['data']) == 0 + + # add subscription to first agenda + Subscription.objects.create( + agenda=first_agenda, + user_external_id='xxx', + date_start=now(), + date_end=now() + datetime.timedelta(days=10), + ) + + resp = app.get('/api/agendas/datetimes/', params={'subscribed': 'all', 'user_external_id': 'xxx'}) + assert len(resp.json['data']) == 1 + assert resp.json['data'][0]['id'] == 'first-agenda@event' + + # no subscription to second agenda + resp = app.get('/api/agendas/datetimes/', params={'subscribed': 'category-a', 'user_external_id': 'xxx'}) + assert len(resp.json['data']) == 0 + + # add subscription to second agenda + Subscription.objects.create( + agenda=second_agenda, + user_external_id='xxx', + date_start=now() + datetime.timedelta(days=15), + date_end=now() + datetime.timedelta(days=25), + ) + + resp = app.get('/api/agendas/datetimes/', params={'subscribed': 'category-a', 'user_external_id': 'xxx'}) + assert len(resp.json['data']) == 1 + assert resp.json['data'][0]['id'] == 'second-agenda@event' + + # add new subscription to second agenda + Subscription.objects.create( + agenda=second_agenda, + user_external_id='xxx', + date_start=now() + datetime.timedelta(days=355), + date_end=now() + datetime.timedelta(days=375), + ) + + resp = app.get('/api/agendas/datetimes/', params={'subscribed': 'category-a', 'user_external_id': 'xxx'}) + assert len(resp.json['data']) == 2 + assert resp.json['data'][0]['id'] == 'second-agenda@event' + assert resp.json['data'][1]['id'] == 'second-agenda@next-year-event' + + # view events from all subscriptions + resp = app.get('/api/agendas/datetimes/', params={'subscribed': 'all', 'user_external_id': 'xxx'}) + assert len(resp.json['data']) == 3 + assert resp.json['data'][0]['id'] == 'first-agenda@event' + assert resp.json['data'][1]['id'] == 'second-agenda@event' + assert resp.json['data'][2]['id'] == 'second-agenda@next-year-event' + + # overlapping subscription changes nothing + Subscription.objects.create( + agenda=first_agenda, + user_external_id='xxx', + date_start=now() + datetime.timedelta(days=1), + date_end=now() + datetime.timedelta(days=11), + ) + resp = app.get('/api/agendas/datetimes/', params={'subscribed': 'all', 'user_external_id': 'xxx'}) + assert len(resp.json['data']) == 3 + + # check errors + resp = app.get('/api/agendas/datetimes/', params={'subscribed': 'all'}, status=400) + assert 'required' in resp.json['errors']['user_external_id'][0] + + resp = app.get( + '/api/agendas/datetimes/', + params={'subscribed': 'all', 'agendas': 'xxx', 'user_external_id': 'xxx'}, + status=400, + ) + assert 'mutually exclusive' in resp.json['errors']['non_field_errors'][0] -- 2.30.2