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.exceptions import FieldDoesNotExist, ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import IntegrityError, connection, models, transaction 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.template import Context, Template, TemplateSyntaxError, VariableDoesNotExist, engines
from django.urls import reverse from django.urls import reverse
from django.utils import functional from django.utils import functional
@ -901,7 +901,12 @@ class Agenda(models.Model):
@staticmethod @staticmethod
def prefetch_events_and_exceptions( 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( event_queryset = Event.objects.filter(
Q(publication_datetime__isnull=True) | Q(publication_datetime__lte=now()), 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) event_queryset = event_queryset.filter(start_datetime__gte=min_start)
if max_start: if max_start:
event_queryset = event_queryset.filter(start_datetime__lt=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( exceptions_desk = Desk.objects.filter(slug='_exceptions_holder').prefetch_related(
'unavailability_calendars' 'unavailability_calendars'
@ -934,6 +945,7 @@ class Agenda(models.Model):
), ),
) )
qs = Agenda.prefetch_recurring_events(qs) qs = Agenda.prefetch_recurring_events(qs)
qs = qs.select_related('category')
agendas_exceptions = TimePeriodException.objects.filter( agendas_exceptions = TimePeriodException.objects.filter(
Q(desk__slug='_exceptions_holder', desk__agenda__in=qs) Q(desk__slug='_exceptions_holder', desk__agenda__in=qs)
| Q( | Q(

View File

@ -8,6 +8,16 @@ from rest_framework.exceptions import ValidationError
from chrono.agendas.models import AbsenceReason, Agenda, Booking, Category, Event, Subscription 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): class StringOrListField(serializers.ListField):
def to_internal_value(self, data): def to_internal_value(self, data):
if isinstance(data, str): if isinstance(data, str):
@ -190,10 +200,41 @@ class DatetimesSerializer(DateRangeSerializer):
return attrs return attrs
class MultipleAgendasDatetimesSerializer(DatetimesSerializer): class AgendaOrSubscribedSlugsMixin(metaclass=serializers.SerializerMetaclass):
agendas = CommaSeparatedStringField( 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) show_past_events = serializers.BooleanField(default=False)

View File

@ -875,9 +875,7 @@ class MultipleAgendasDatetimes(APIView):
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors) raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors)
payload = serializer.validated_data payload = serializer.validated_data
agenda_slugs = payload['agendas'] agendas = 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') user_external_id = payload.get('user_external_id') or payload.get('exclude_user_external_id')
disable_booked = bool(payload.get('exclude_user_external_id')) disable_booked = bool(payload.get('exclude_user_external_id'))
show_past_events = bool(payload.get('show_past_events')) show_past_events = bool(payload.get('show_past_events'))
@ -885,6 +883,7 @@ class MultipleAgendasDatetimes(APIView):
agendas, agendas,
user_external_id=user_external_id, user_external_id=user_external_id,
show_past_events=show_past_events, show_past_events=show_past_events,
show_only_subscribed=bool('subscribed' in payload),
min_start=payload.get('date_start'), min_start=payload.get('date_start'),
max_start=payload.get('date_end'), 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)} if 'agendas' in request.query_params:
entries.sort(key=lambda event: (event.start_datetime, agenda_querystring_indexes[event.agenda.slug])) 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 = { response = {
'data': [ 'data': [

View File

@ -7,7 +7,7 @@ from django.test import override_settings
from django.test.utils import CaptureQueriesContext from django.test.utils import CaptureQueriesContext
from django.utils.timezone import localtime, make_aware, make_naive, now 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 pytestmark = pytest.mark.django_db
@ -1584,10 +1584,14 @@ def test_datetimes_multiple_agendas(app):
# invalid slugs # invalid slugs
resp = app.get('/api/agendas/datetimes/', params={'agendas': 'xxx'}, status=400) 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) 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 # it's possible to show past events
resp = app.get('/api/agendas/datetimes/', params={'agendas': agenda_slugs, 'show_past_events': True}) 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') @pytest.mark.freeze_time('2021-05-06 14:00')
def test_datetimes_multiple_agendas_sort(app): 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') 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) 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') 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='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) 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') 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='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) 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 # 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)) 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'][3]['id'] == 'second-agenda@09-05'
assert resp.json['data'][4]['id'] == 'first-agenda@10-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') @pytest.mark.freeze_time('2021-05-06 14:00')
def test_datetimes_multiple_agendas_queries(app): def test_datetimes_multiple_agendas_queries(app):
category = Category.objects.create(label='Category A')
for i in range(10): 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') 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)
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(resp.json['data']) == 30
assert len(ctx.captured_queries) == 7 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( resp = app.post_json(
'/api/agendas/events/fillslots/?agendas=first-agenda,xxx,yyy', params=params, status=400 '/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 # invalid agenda slugs in payload
params = {'user_external_id': 'user_id_3', 'slots': 'first-agenda@event,xxx@event,yyy@event'} params = {'user_external_id': 'user_id_3', 'slots': 'first-agenda@event,xxx@event,yyy@event'}

View File

@ -1,6 +1,6 @@
import pytest import pytest
from chrono.agendas.models import Agenda from chrono.agendas.models import Agenda, MeetingType
from chrono.api.utils import Response 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_desc'] == 'contenu de requête invalide'
assert resp.json['err_class'] == 'invalid payload' 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_desc'] == 'slugs invalides : hop'
assert resp.json['err_class'] == 'invalid slugs: hop' assert resp.json['err_class'] == 'invalid slugs: hop'