api: forbid overlapping recurring events booking (#64383)

This commit is contained in:
Valentin Deniaud 2022-05-02 11:29:39 +02:00
parent b359c3f1ff
commit e629fccaec
5 changed files with 279 additions and 3 deletions

View File

@ -924,11 +924,15 @@ class Agenda(models.Model):
)
@staticmethod
def prefetch_recurring_events(qs):
def prefetch_recurring_events(qs, with_overlaps=False):
recurring_event_queryset = Event.objects.filter(
Q(publication_datetime__isnull=True) | Q(publication_datetime__lte=now()),
recurrence_days__isnull=False,
)
if with_overlaps:
recurring_event_queryset = Event.annotate_recurring_events_with_overlaps(recurring_event_queryset)
qs = qs.prefetch_related(
Prefetch(
'event_set',
@ -1585,6 +1589,40 @@ class Event(models.Model):
has_overlap=Exists(overlapping_events),
)
@staticmethod
def annotate_recurring_events_with_overlaps(qs):
qs = qs.annotate(
start_hour=Cast('start_datetime', models.TimeField()),
computed_end_datetime=ExpressionWrapper(
F('start_datetime') + datetime.timedelta(minutes=1) * F('duration'),
output_field=models.DateTimeField(),
),
end_hour=Cast('computed_end_datetime', models.TimeField()),
computed_slug=Concat('agenda__slug', Value('@'), 'slug'),
)
overlapping_events = qs.filter(
start_hour__lt=OuterRef('end_hour'),
end_hour__gt=OuterRef('start_hour'),
recurrence_days__overlap=F('recurrence_days'),
).exclude(pk=OuterRef('pk'))
json_object = Func(
Value('slug'),
'computed_slug',
Value('days'),
'recurrence_days',
function='jsonb_build_object',
output_field=JSONField(),
) # use django.db.models.functions.JSONObject in Django>=3.2
return qs.annotate(
overlaps=ArraySubquery(
overlapping_events.values(json=json_object),
output_field=ArrayField(models.CharField()),
)
)
@property
def remaining_places(self):
return max(0, self.places - self.booked_places)

View File

@ -127,6 +127,7 @@ class RecurringFillslotsSerializer(MultipleAgendasEventsSlotsSerializer):
def validate_slots(self, value):
super().validate_slots(value)
self.initial_slots = value
open_event_slugs = collections.defaultdict(set)
for agenda in self.context['agendas']:
for event in agenda.get_open_recurring_events():
@ -348,6 +349,7 @@ class RecurringFillslotsQueryStringSerializer(AgendaOrSubscribedSlugsSerializer)
class RecurringEventsListSerializer(AgendaOrSubscribedSlugsSerializer):
sort = serializers.ChoiceField(required=False, choices=['day'])
check_overlaps = serializers.BooleanField(default=False)
class AgendaSlugsSerializer(serializers.Serializer):

View File

@ -1119,6 +1119,7 @@ class RecurringEventsList(APIView):
if not serializer.is_valid():
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors)
data = serializer.validated_data
check_overlaps = bool(data.get('check_overlaps'))
guardian_external_id = data.get('guardian_external_id')
if guardian_external_id:
@ -1136,6 +1137,8 @@ class RecurringEventsList(APIView):
recurring_events = Event.objects.filter(pk__in=days_by_event).select_related(
'agenda', 'agenda__category'
)
if check_overlaps:
recurring_events = Event.annotate_recurring_events_with_overlaps(recurring_events)
events = []
for event in recurring_events:
for day in days_by_event[event.pk]:
@ -1143,7 +1146,7 @@ class RecurringEventsList(APIView):
event.day = day
events.append(event)
else:
agendas = Agenda.prefetch_recurring_events(data['agendas'])
agendas = Agenda.prefetch_recurring_events(data['agendas'], with_overlaps=check_overlaps)
events = []
for agenda in agendas:
for event in agenda.get_open_recurring_events():
@ -1152,6 +1155,15 @@ class RecurringEventsList(APIView):
event.day = day
events.append(event)
if check_overlaps:
for event in events:
event.overlaps = [
'%s:%s' % (x['slug'], day)
for x in event.overlaps
for day in x['days']
if day == event.day
]
if 'agendas' in request.query_params:
agenda_querystring_indexes = {
agenda_slug: i for i, agenda_slug in enumerate(data['agenda_slugs'])
@ -1191,6 +1203,7 @@ class RecurringEventsList(APIView):
'description': event.description,
'pricing': event.pricing,
'url': event.url,
'overlaps': event.overlaps if check_overlaps else None,
}
for event in events
]
@ -1704,6 +1717,9 @@ class RecurringFillslots(APIView):
guardian_external_id,
).values_list('pk', flat=True)
if payload.get('check_overlaps'):
self.check_for_overlaps(events_to_book, serializer.initial_slots)
# outdated bookings to remove (cancelled bookings to replace by an active booking)
events_cancelled_to_delete = events_to_book.filter(
booking__user_external_id=user_external_id,
@ -1821,6 +1837,30 @@ class RecurringFillslots(APIView):
]
return events_to_unbook
@staticmethod
def check_for_overlaps(events, slots):
def get_slug(event, day):
slug = event['slug'] if isinstance(event, dict) else '%s@%s' % (event.agenda.slug, event.slug)
return '%s:%s' % (slug, day)
recurring_events = Event.objects.filter(pk__in=events.values('primary_event_id'))
recurring_events = Event.annotate_recurring_events_with_overlaps(recurring_events)
overlaps = set()
for event in recurring_events.select_related('agenda'):
overlaps.update(
tuple(sorted((get_slug(event, d), get_slug(x, d))))
for x in event.overlaps
for d in x['days']
if get_slug(x, d) in slots and get_slug(event, d) in slots
)
if overlaps:
raise APIError(
N_('Some events occur at the same time: %s')
% ', '.join(sorted('%s / %s' % (x, y) for x, y in overlaps))
)
recurring_fillslots = RecurringFillslots.as_view()

View File

@ -434,3 +434,76 @@ def test_recurring_events_api_list_subscribed(app, user):
resp = app.get('/api/agendas/recurring-events/?subscribed=category-b,category-a&user_external_id=xxx')
event_ids = [x['id'] for x in resp.json['data']]
assert event_ids.index('first-agenda@event:0') > event_ids.index('second-agenda@event:0')
@pytest.mark.freeze_time('2021-09-06 12:00')
def test_recurring_events_api_list_overlapping_events(app):
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.objects.create(
label='Event 12-14',
start_datetime=start,
duration=120,
places=2,
recurrence_end_date=end,
recurrence_days=[1],
agenda=agenda,
)
Event.objects.create(
label='Event 14-15',
start_datetime=start + datetime.timedelta(hours=2),
duration=60,
places=2,
recurrence_end_date=end,
recurrence_days=[1],
agenda=agenda,
)
Event.objects.create(
label='Event 15-17',
start_datetime=start + datetime.timedelta(hours=3),
duration=120,
places=2,
recurrence_end_date=end,
recurrence_days=[1, 3, 5],
agenda=agenda,
)
agenda2 = Agenda.objects.create(label='Second Agenda', kind='events')
Desk.objects.create(agenda=agenda2, slug='_exceptions_holder')
Event.objects.create(
label='Event 12-18',
start_datetime=start,
duration=360,
places=2,
recurrence_end_date=end,
recurrence_days=[1, 5],
agenda=agenda2,
)
Event.objects.create(
label='No duration',
start_datetime=start,
places=2,
recurrence_end_date=end,
recurrence_days=[5],
agenda=agenda2,
)
resp = app.get(
'/api/agendas/recurring-events/?agendas=first-agenda,second-agenda&sort=day&check_overlaps=true'
)
assert [(x['id'], x['overlaps']) for x in resp.json['data']] == [
('first-agenda@event-12-14:1', ['second-agenda@event-12-18:1']),
(
'second-agenda@event-12-18:1',
['first-agenda@event-12-14:1', 'first-agenda@event-14-15:1', 'first-agenda@event-15-17:1'],
),
('first-agenda@event-14-15:1', ['second-agenda@event-12-18:1']),
('first-agenda@event-15-17:1', ['second-agenda@event-12-18:1']),
('first-agenda@event-15-17:3', []),
('second-agenda@event-12-18:5', ['first-agenda@event-15-17:5']),
('second-agenda@no-duration:5', []),
('first-agenda@event-15-17:5', ['second-agenda@event-12-18:5']),
]
resp = app.get('/api/agendas/recurring-events/?agendas=first-agenda,second-agenda&sort=day')
assert ['overlaps' not in x for x in resp.json['data']]

View File

@ -1288,10 +1288,11 @@ def test_recurring_events_api_fillslots_multiple_agendas_queries(app, user):
'slots': events_to_book,
'user_external_id': 'user',
'include_booked_events_detail': True,
'check_overlaps': True,
},
)
assert resp.json['booking_count'] == 180
assert len(ctx.captured_queries) == 13
assert len(ctx.captured_queries) == 14
father = Person.objects.create(user_external_id='father_id', first_name='John', last_name='Doe')
mother = Person.objects.create(user_external_id='mother_id', first_name='Jane', last_name='Doe')
@ -1371,3 +1372,125 @@ def test_recurring_events_api_fillslots_shared_custody(app, user, freezer):
'2022-03-19',
'2022-03-20',
]
@pytest.mark.freeze_time('2021-09-06 12:00')
def test_recurring_events_api_fillslots_overlapping_events(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.objects.create(
label='Event 12-14',
start_datetime=start,
duration=120,
places=2,
recurrence_end_date=end,
recurrence_days=[1],
agenda=agenda,
).create_all_recurrences()
Event.objects.create(
label='Event 14-15',
start_datetime=start + datetime.timedelta(hours=2),
duration=60,
places=2,
recurrence_end_date=end,
recurrence_days=[1],
agenda=agenda,
).create_all_recurrences()
Event.objects.create(
label='Event 15-17',
start_datetime=start + datetime.timedelta(hours=3),
duration=120,
places=2,
recurrence_end_date=end,
recurrence_days=[1, 3, 5],
agenda=agenda,
).create_all_recurrences()
agenda2 = Agenda.objects.create(label='Second Agenda', kind='events')
Desk.objects.create(agenda=agenda2, slug='_exceptions_holder')
Event.objects.create(
label='Event 12-18',
start_datetime=start,
duration=360,
places=2,
recurrence_end_date=end,
recurrence_days=[1, 5],
agenda=agenda2,
).create_all_recurrences()
Event.objects.create(
label='No duration',
start_datetime=start,
places=2,
recurrence_end_date=end,
recurrence_days=[5],
agenda=agenda2,
).create_all_recurrences()
app.authorization = ('Basic', ('john.doe', 'password'))
fillslots_url = '/api/agendas/recurring-events/fillslots/?action=%s&agendas=%s'
# booking without overlap
params = {
'user_external_id': 'user_id',
'check_overlaps': True,
'slots': 'first-agenda@event-12-14:1,first-agenda@event-14-15:1,second-agenda@event-12-18:5',
}
resp = app.post_json(fillslots_url % ('update', 'first-agenda,second-agenda'), params=params)
assert resp.json['booking_count'] == 14
# book again
resp = app.post_json(fillslots_url % ('update', 'first-agenda,second-agenda'), params=params)
assert resp.json['booking_count'] == 0
# change bookings
params = {'user_external_id': 'user_id', 'check_overlaps': True, 'slots': 'second-agenda@event-12-18:1'}
resp = app.post_json(fillslots_url % ('update', 'first-agenda,second-agenda'), params=params)
assert resp.json['booking_count'] == 5
assert resp.json['cancelled_booking_count'] == 14
# booking overlapping events is allowed if one has no duration
params = {
'user_external_id': 'user_id',
'check_overlaps': True,
'slots': 'second-agenda@event-12-18:5,second-agenda@no-duration:5',
}
resp = app.post_json(fillslots_url % ('update', 'first-agenda,second-agenda'), params=params)
assert resp.json['booking_count'] == 8
assert resp.json['cancelled_booking_count'] == 5
# booking overlapping events with durations is forbidden
params = {
'user_external_id': 'user_id',
'check_overlaps': True,
'slots': 'first-agenda@event-12-14:1,second-agenda@event-12-18:1',
}
resp = app.post_json(fillslots_url % ('update', 'first-agenda,second-agenda'), params=params)
assert resp.json['err'] == 1
assert (
resp.json['err_desc']
== 'Some events occur at the same time: first-agenda@event-12-14:1 / second-agenda@event-12-18:1'
)
params = {
'user_external_id': 'user_id',
'check_overlaps': True,
'slots': (
'first-agenda@event-12-14:1,first-agenda@event-15-17:1,first-agenda@event-15-17:3,first-agenda@event-15-17:5,second-agenda@event-12-18:1,'
'second-agenda@event-12-18:5,second-agenda@no-duration:5'
),
}
resp = app.post_json(fillslots_url % ('update', 'first-agenda,second-agenda'), params=params)
assert resp.json['err'] == 1
assert resp.json['err_desc'] == (
'Some events occur at the same time: first-agenda@event-12-14:1 / second-agenda@event-12-18:1, '
'first-agenda@event-15-17:1 / second-agenda@event-12-18:1, first-agenda@event-15-17:5 / second-agenda@event-12-18:5'
)
# overlaps check is disabled by default
params = {
'user_external_id': 'user_id',
'slots': 'first-agenda@event-12-14:1,second-agenda@event-12-18:1',
}
resp = app.post_json(fillslots_url % ('update', 'first-agenda,second-agenda'), params=params)
assert resp.json['err'] == 0
assert resp.json['booking_count'] == 10