api: filter by subscriptions in multiple agendas datetimes (#58446)

This commit is contained in:
Valentin Deniaud 2021-11-25 16:35:55 +01:00
parent 0e4689a139
commit 97b0b899af
6 changed files with 272 additions and 19 deletions

View File

@ -33,7 +33,7 @@ from django.contrib.postgres.fields import ArrayField, JSONField
from django.core.exceptions import FieldDoesNotExist, ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import IntegrityError, connection, models, transaction
from django.db.models import Count, Max, Prefetch, Q
from django.db.models import Count, F, Max, Prefetch, Q
from django.template import Context, Template, TemplateSyntaxError, VariableDoesNotExist, engines
from django.urls import reverse
from django.utils import functional
@ -901,7 +901,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,
show_only_subscribed=False,
min_start=None,
max_start=None,
):
event_queryset = Event.objects.filter(
Q(publication_datetime__isnull=True) | Q(publication_datetime__lte=now()),
@ -917,6 +922,12 @@ class Agenda(models.Model):
event_queryset = event_queryset.filter(start_datetime__gte=min_start)
if max_start:
event_queryset = event_queryset.filter(start_datetime__lt=max_start)
if show_only_subscribed:
event_queryset = event_queryset.filter(
agenda__subscriptions__user_external_id=user_external_id,
agenda__subscriptions__date_start__lt=F('start_datetime'),
agenda__subscriptions__date_end__gt=F('start_datetime'),
)
exceptions_desk = Desk.objects.filter(slug='_exceptions_holder').prefetch_related(
'unavailability_calendars'
@ -934,6 +945,7 @@ class Agenda(models.Model):
),
)
qs = Agenda.prefetch_recurring_events(qs)
qs = qs.select_related('category')
agendas_exceptions = TimePeriodException.objects.filter(
Q(desk__slug='_exceptions_holder', desk__agenda__in=qs)
| Q(

View File

@ -8,6 +8,16 @@ from rest_framework.exceptions import ValidationError
from chrono.agendas.models import AbsenceReason, Agenda, Booking, Category, Event, Subscription
def get_objects_from_slugs(slugs, qs):
slugs = set(slugs)
objects = qs.filter(slug__in=slugs)
if len(objects) != len(slugs):
unknown_slugs = sorted(slugs - {obj.slug for obj in objects})
unknown_slugs = ', '.join(unknown_slugs)
raise ValidationError(('invalid slugs: %s') % unknown_slugs)
return objects
class StringOrListField(serializers.ListField):
def to_internal_value(self, data):
if isinstance(data, str):
@ -190,10 +200,41 @@ class DatetimesSerializer(DateRangeSerializer):
return attrs
class MultipleAgendasDatetimesSerializer(DatetimesSerializer):
class AgendaOrSubscribedSlugsMixin(metaclass=serializers.SerializerMetaclass):
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)
)
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.'))
user_external_id = attrs.get('user_external_id')
if 'subscribed' in attrs and not user_external_id:
raise ValidationError(
{'user_external_id': _('This field is required when using "subscribed" parameter.')}
)
if 'subscribed' in attrs:
agendas = Agenda.objects.filter(subscriptions__user_external_id=user_external_id).distinct()
if attrs['subscribed'] != ['all']:
agendas = agendas.filter(category__slug__in=attrs['subscribed'])
attrs['agendas'] = agendas
else:
attrs['agenda_slugs'] = self.agenda_slugs
return attrs
def validate_agendas(self, value):
self.agenda_slugs = value
return get_objects_from_slugs(value, qs=Agenda.objects.filter(kind='events'))
class MultipleAgendasDatetimesSerializer(AgendaOrSubscribedSlugsMixin, DatetimesSerializer):
show_past_events = serializers.BooleanField(default=False)

View File

@ -875,9 +875,7 @@ 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'))
agendas = payload['agendas']
user_external_id = payload.get('user_external_id') or payload.get('exclude_user_external_id')
disable_booked = bool(payload.get('exclude_user_external_id'))
show_past_events = bool(payload.get('show_past_events'))
@ -885,6 +883,7 @@ class MultipleAgendasDatetimes(APIView):
agendas,
user_external_id=user_external_id,
show_past_events=show_past_events,
show_only_subscribed=bool('subscribed' in payload),
min_start=payload.get('date_start'),
max_start=payload.get('date_end'),
)
@ -909,8 +908,28 @@ class MultipleAgendasDatetimes(APIView):
)
)
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 'agendas' in request.query_params:
agenda_querystring_indexes = {
agenda_slug: i for i, agenda_slug in enumerate(payload['agenda_slugs'])
}
entries.sort(
key=lambda event: (
event.start_datetime,
agenda_querystring_indexes[event.agenda.slug],
event.slug,
)
)
elif 'subscribed' in request.query_params:
category_querystring_indexes = {category: i for i, category in enumerate(payload['subscribed'])}
sort_by_category = bool(payload['subscribed'] != ['all'])
entries.sort(
key=lambda event: (
event.start_datetime,
category_querystring_indexes[event.agenda.category.slug] if sort_by_category else None,
event.agenda.slug,
event.slug,
)
)
response = {
'data': [

View File

@ -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
@ -1584,10 +1584,14 @@ def test_datetimes_multiple_agendas(app):
# invalid slugs
resp = app.get('/api/agendas/datetimes/', params={'agendas': 'xxx'}, status=400)
assert resp.json['err_desc'] == 'invalid slugs: xxx'
assert resp.json['errors']['agendas'][0] == 'invalid slugs: xxx'
resp = app.get('/api/agendas/datetimes/', params={'agendas': 'first-agenda,xxx,yyy'}, status=400)
assert resp.json['err_desc'] == 'invalid slugs: xxx, yyy'
assert resp.json['errors']['agendas'][0] == '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})
@ -1720,17 +1724,26 @@ def test_datetimes_multiple_agendas(app):
@pytest.mark.freeze_time('2021-05-06 14:00')
def test_datetimes_multiple_agendas_sort(app):
first_agenda = Agenda.objects.create(label='First agenda', kind='events')
category = Category.objects.create(label='Category A')
first_agenda = Agenda.objects.create(label='First agenda', kind='events', category=category)
Desk.objects.create(agenda=first_agenda, slug='_exceptions_holder')
Event.objects.create(label='10-05', start_datetime=now().replace(day=10), places=5, agenda=first_agenda)
second_agenda = Agenda.objects.create(label='Second agenda', kind='events')
second_agenda = Agenda.objects.create(label='Second agenda', kind='events', category=category)
Desk.objects.create(agenda=second_agenda, slug='_exceptions_holder')
Event.objects.create(label='09-05', start_datetime=now().replace(day=9), places=5, agenda=second_agenda)
Event.objects.create(label='04-05', start_datetime=now().replace(day=4), places=5, agenda=second_agenda)
third_agenda = Agenda.objects.create(label='Third agenda', kind='events')
category = Category.objects.create(label='Category B')
third_agenda = Agenda.objects.create(label='Third agenda', kind='events', category=category)
Desk.objects.create(agenda=third_agenda, slug='_exceptions_holder')
Event.objects.create(label='09-05', start_datetime=now().replace(day=9), places=5, agenda=third_agenda)
Event.objects.create(label='04-05', start_datetime=now().replace(day=4), places=5, agenda=third_agenda)
for agenda in Agenda.objects.all():
Subscription.objects.create(
agenda=agenda,
user_external_id='xxx',
date_start=now(),
date_end=now() + datetime.timedelta(days=30),
)
# check events are ordered by start_datetime and then by agenda order in querystring
agenda_slugs = ','.join((first_agenda.slug, third_agenda.slug, second_agenda.slug))
@ -1748,11 +1761,45 @@ def test_datetimes_multiple_agendas_sort(app):
assert resp.json['data'][3]['id'] == 'second-agenda@09-05'
assert resp.json['data'][4]['id'] == 'first-agenda@10-05'
# check subscribed events are ordered by start_datetime
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'] == 'second-agenda@09-05'
assert resp.json['data'][1]['id'] == 'third-agenda@09-05'
assert resp.json['data'][2]['id'] == 'first-agenda@10-05'
# and by category slug if specified in querystring
resp = app.get(
'/api/agendas/datetimes/', params={'subscribed': 'category-b,category-a', 'user_external_id': 'xxx'}
)
assert len(resp.json['data']) == 3
assert resp.json['data'][0]['id'] == 'third-agenda@09-05'
assert resp.json['data'][1]['id'] == 'second-agenda@09-05'
assert resp.json['data'][2]['id'] == 'first-agenda@10-05'
# order is stable if same date events are in the same category
Event.objects.create(label='09-05', start_datetime=now().replace(day=9), places=5, agenda=first_agenda)
resp = app.get(
'/api/agendas/datetimes/', params={'subscribed': 'category-b,category-a', 'user_external_id': 'xxx'}
)
assert len(resp.json['data']) == 4
assert resp.json['data'][0]['id'] == 'third-agenda@09-05'
assert resp.json['data'][1]['id'] == 'first-agenda@09-05'
assert resp.json['data'][2]['id'] == 'second-agenda@09-05'
assert resp.json['data'][3]['id'] == 'first-agenda@10-05'
@pytest.mark.freeze_time('2021-05-06 14:00')
def test_datetimes_multiple_agendas_queries(app):
category = Category.objects.create(label='Category A')
for i in range(10):
agenda = Agenda.objects.create(label=str(i), kind='events')
agenda = Agenda.objects.create(label=str(i), kind='events', category=category)
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 +1812,133 @@ 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
with CaptureQueriesContext(connection) as ctx:
resp = app.get(
'/api/agendas/datetimes/',
params={'subscribed': 'category-a', '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,
)
Subscription.objects.create(
agenda=first_agenda,
user_external_id='yyy',
date_start=now(),
date_end=now() + datetime.timedelta(days=10),
)
# no subscription for user xxx
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 (disjoint) 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': 'first-agenda', 'user_external_id': 'xxx'},
status=400,
)
assert 'mutually exclusive' in resp.json['errors']['non_field_errors'][0]

View File

@ -2664,7 +2664,7 @@ def test_api_events_fillslots_multiple_agendas(app, user):
resp = app.post_json(
'/api/agendas/events/fillslots/?agendas=first-agenda,xxx,yyy', params=params, status=400
)
assert resp.json['err_desc'] == 'invalid slugs: xxx, yyy'
assert resp.json['errors']['agendas'][0] == 'invalid slugs: xxx, yyy'
# invalid agenda slugs in payload
params = {'user_external_id': 'user_id_3', 'slots': 'first-agenda@event,xxx@event,yyy@event'}

View File

@ -1,6 +1,6 @@
import pytest
from chrono.agendas.models import Agenda
from chrono.agendas.models import Agenda, MeetingType
from chrono.api.utils import Response
@ -31,6 +31,10 @@ def test_err_desc_translation(db, app, settings):
assert resp.json['err_desc'] == 'contenu de requête invalide'
assert resp.json['err_class'] == 'invalid payload'
resp = app.get('/api/agendas/datetimes/?agendas=hop', status=400)
agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
meeting_type = MeetingType.objects.create(agenda=agenda, slug='foo', duration=60)
resp = app.get(
'/api/agenda/%s/meetings/%s/datetimes/?resources=hop' % (agenda.slug, meeting_type.slug), status=400
)
assert resp.json['err_desc'] == 'slugs invalides : hop'
assert resp.json['err_class'] == 'invalid slugs: hop'