0002-api-forbid-overlapping-recurring-events-booking-6438.patch
chrono/agendas/models.py | ||
---|---|---|
924 | 924 |
) |
925 | 925 | |
926 | 926 |
@staticmethod |
927 |
def prefetch_recurring_events(qs): |
|
927 |
def prefetch_recurring_events(qs, with_overlaps=False):
|
|
928 | 928 |
recurring_event_queryset = Event.objects.filter( |
929 | 929 |
Q(publication_datetime__isnull=True) | Q(publication_datetime__lte=now()), |
930 | 930 |
recurrence_days__isnull=False, |
931 | 931 |
) |
932 | ||
933 |
if with_overlaps: |
|
934 |
recurring_event_queryset = Event.annotate_recurring_events_with_overlaps(recurring_event_queryset) |
|
935 | ||
932 | 936 |
qs = qs.prefetch_related( |
933 | 937 |
Prefetch( |
934 | 938 |
'event_set', |
... | ... | |
1585 | 1589 |
has_overlap=Exists(overlapping_events), |
1586 | 1590 |
) |
1587 | 1591 | |
1592 |
@staticmethod |
|
1593 |
def annotate_recurring_events_with_overlaps(qs): |
|
1594 |
qs = qs.annotate( |
|
1595 |
start_hour=Cast('start_datetime', models.TimeField()), |
|
1596 |
computed_end_datetime=ExpressionWrapper( |
|
1597 |
F('start_datetime') + datetime.timedelta(minutes=1) * F('duration'), |
|
1598 |
output_field=models.DateTimeField(), |
|
1599 |
), |
|
1600 |
end_hour=Cast('computed_end_datetime', models.TimeField()), |
|
1601 |
computed_slug=Concat('agenda__slug', Value('@'), 'slug'), |
|
1602 |
) |
|
1603 | ||
1604 |
overlapping_events = qs.filter( |
|
1605 |
start_hour__lt=OuterRef('end_hour'), |
|
1606 |
end_hour__gt=OuterRef('start_hour'), |
|
1607 |
recurrence_days__overlap=F('recurrence_days'), |
|
1608 |
).exclude(pk=OuterRef('pk')) |
|
1609 | ||
1610 |
json_object = Func( |
|
1611 |
Value('slug'), |
|
1612 |
'computed_slug', |
|
1613 |
Value('days'), |
|
1614 |
'recurrence_days', |
|
1615 |
function='jsonb_build_object', |
|
1616 |
output_field=JSONField(), |
|
1617 |
) # use django.db.models.functions.JSONObject in Django>=3.2 |
|
1618 | ||
1619 |
return qs.annotate( |
|
1620 |
overlaps=ArraySubquery( |
|
1621 |
overlapping_events.values(json=json_object), |
|
1622 |
output_field=ArrayField(models.CharField()), |
|
1623 |
) |
|
1624 |
) |
|
1625 | ||
1588 | 1626 |
@property |
1589 | 1627 |
def remaining_places(self): |
1590 | 1628 |
return max(0, self.places - self.booked_places) |
chrono/api/serializers.py | ||
---|---|---|
127 | 127 | |
128 | 128 |
def validate_slots(self, value): |
129 | 129 |
super().validate_slots(value) |
130 |
self.initial_slots = value |
|
130 | 131 |
open_event_slugs = collections.defaultdict(set) |
131 | 132 |
for agenda in self.context['agendas']: |
132 | 133 |
for event in agenda.get_open_recurring_events(): |
... | ... | |
348 | 349 | |
349 | 350 |
class RecurringEventsListSerializer(AgendaOrSubscribedSlugsSerializer): |
350 | 351 |
sort = serializers.ChoiceField(required=False, choices=['day']) |
352 |
check_overlaps = serializers.BooleanField(default=False) |
|
351 | 353 | |
352 | 354 | |
353 | 355 |
class AgendaSlugsSerializer(serializers.Serializer): |
chrono/api/views.py | ||
---|---|---|
1119 | 1119 |
if not serializer.is_valid(): |
1120 | 1120 |
raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors) |
1121 | 1121 |
data = serializer.validated_data |
1122 |
check_overlaps = bool(data.get('check_overlaps')) |
|
1122 | 1123 | |
1123 | 1124 |
guardian_external_id = data.get('guardian_external_id') |
1124 | 1125 |
if guardian_external_id: |
... | ... | |
1136 | 1137 |
recurring_events = Event.objects.filter(pk__in=days_by_event).select_related( |
1137 | 1138 |
'agenda', 'agenda__category' |
1138 | 1139 |
) |
1140 |
if check_overlaps: |
|
1141 |
recurring_events = Event.annotate_recurring_events_with_overlaps(recurring_events) |
|
1139 | 1142 |
events = [] |
1140 | 1143 |
for event in recurring_events: |
1141 | 1144 |
for day in days_by_event[event.pk]: |
... | ... | |
1143 | 1146 |
event.day = day |
1144 | 1147 |
events.append(event) |
1145 | 1148 |
else: |
1146 |
agendas = Agenda.prefetch_recurring_events(data['agendas']) |
|
1149 |
agendas = Agenda.prefetch_recurring_events(data['agendas'], with_overlaps=check_overlaps)
|
|
1147 | 1150 |
events = [] |
1148 | 1151 |
for agenda in agendas: |
1149 | 1152 |
for event in agenda.get_open_recurring_events(): |
... | ... | |
1152 | 1155 |
event.day = day |
1153 | 1156 |
events.append(event) |
1154 | 1157 | |
1158 |
if check_overlaps: |
|
1159 |
for event in events: |
|
1160 |
event.overlaps = [ |
|
1161 |
'%s:%s' % (x['slug'], day) |
|
1162 |
for x in event.overlaps |
|
1163 |
for day in x['days'] |
|
1164 |
if day == event.day |
|
1165 |
] |
|
1166 | ||
1155 | 1167 |
if 'agendas' in request.query_params: |
1156 | 1168 |
agenda_querystring_indexes = { |
1157 | 1169 |
agenda_slug: i for i, agenda_slug in enumerate(data['agenda_slugs']) |
... | ... | |
1191 | 1203 |
'description': event.description, |
1192 | 1204 |
'pricing': event.pricing, |
1193 | 1205 |
'url': event.url, |
1206 |
'overlaps': event.overlaps if check_overlaps else None, |
|
1194 | 1207 |
} |
1195 | 1208 |
for event in events |
1196 | 1209 |
] |
... | ... | |
1704 | 1717 |
guardian_external_id, |
1705 | 1718 |
).values_list('pk', flat=True) |
1706 | 1719 | |
1720 |
if payload.get('check_overlaps'): |
|
1721 |
self.check_for_overlaps(events_to_book, serializer.initial_slots) |
|
1722 | ||
1707 | 1723 |
# outdated bookings to remove (cancelled bookings to replace by an active booking) |
1708 | 1724 |
events_cancelled_to_delete = events_to_book.filter( |
1709 | 1725 |
booking__user_external_id=user_external_id, |
... | ... | |
1821 | 1837 |
] |
1822 | 1838 |
return events_to_unbook |
1823 | 1839 | |
1840 |
@staticmethod |
|
1841 |
def check_for_overlaps(events, slots): |
|
1842 |
def get_slug(event, day): |
|
1843 |
slug = event['slug'] if isinstance(event, dict) else '%s@%s' % (event.agenda.slug, event.slug) |
|
1844 |
return '%s:%s' % (slug, day) |
|
1845 | ||
1846 |
recurring_events = Event.objects.filter(pk__in=events.values('primary_event_id')) |
|
1847 |
recurring_events = Event.annotate_recurring_events_with_overlaps(recurring_events) |
|
1848 | ||
1849 |
overlaps = set() |
|
1850 |
for event in recurring_events.select_related('agenda'): |
|
1851 |
overlaps.update( |
|
1852 |
tuple(sorted((get_slug(event, d), get_slug(x, d)))) |
|
1853 |
for x in event.overlaps |
|
1854 |
for d in x['days'] |
|
1855 |
if get_slug(x, d) in slots and get_slug(event, d) in slots |
|
1856 |
) |
|
1857 | ||
1858 |
if overlaps: |
|
1859 |
raise APIError( |
|
1860 |
N_('Some events occur at the same time: %s') |
|
1861 |
% ', '.join(sorted('%s / %s' % (x, y) for x, y in overlaps)) |
|
1862 |
) |
|
1863 | ||
1824 | 1864 | |
1825 | 1865 |
recurring_fillslots = RecurringFillslots.as_view() |
1826 | 1866 |
tests/api/datetimes/test_recurring_events.py | ||
---|---|---|
434 | 434 |
resp = app.get('/api/agendas/recurring-events/?subscribed=category-b,category-a&user_external_id=xxx') |
435 | 435 |
event_ids = [x['id'] for x in resp.json['data']] |
436 | 436 |
assert event_ids.index('first-agenda@event:0') > event_ids.index('second-agenda@event:0') |
437 | ||
438 | ||
439 |
@pytest.mark.freeze_time('2021-09-06 12:00') |
|
440 |
def test_recurring_events_api_list_overlapping_events(app): |
|
441 |
agenda = Agenda.objects.create(label='First Agenda', kind='events') |
|
442 |
Desk.objects.create(agenda=agenda, slug='_exceptions_holder') |
|
443 |
start, end = now(), now() + datetime.timedelta(days=30) |
|
444 |
Event.objects.create( |
|
445 |
label='Event 12-14', |
|
446 |
start_datetime=start, |
|
447 |
duration=120, |
|
448 |
places=2, |
|
449 |
recurrence_end_date=end, |
|
450 |
recurrence_days=[1], |
|
451 |
agenda=agenda, |
|
452 |
) |
|
453 |
Event.objects.create( |
|
454 |
label='Event 14-15', |
|
455 |
start_datetime=start + datetime.timedelta(hours=2), |
|
456 |
duration=60, |
|
457 |
places=2, |
|
458 |
recurrence_end_date=end, |
|
459 |
recurrence_days=[1], |
|
460 |
agenda=agenda, |
|
461 |
) |
|
462 |
Event.objects.create( |
|
463 |
label='Event 15-17', |
|
464 |
start_datetime=start + datetime.timedelta(hours=3), |
|
465 |
duration=120, |
|
466 |
places=2, |
|
467 |
recurrence_end_date=end, |
|
468 |
recurrence_days=[1, 3, 5], |
|
469 |
agenda=agenda, |
|
470 |
) |
|
471 |
agenda2 = Agenda.objects.create(label='Second Agenda', kind='events') |
|
472 |
Desk.objects.create(agenda=agenda2, slug='_exceptions_holder') |
|
473 |
Event.objects.create( |
|
474 |
label='Event 12-18', |
|
475 |
start_datetime=start, |
|
476 |
duration=360, |
|
477 |
places=2, |
|
478 |
recurrence_end_date=end, |
|
479 |
recurrence_days=[1, 5], |
|
480 |
agenda=agenda2, |
|
481 |
) |
|
482 |
Event.objects.create( |
|
483 |
label='No duration', |
|
484 |
start_datetime=start, |
|
485 |
places=2, |
|
486 |
recurrence_end_date=end, |
|
487 |
recurrence_days=[5], |
|
488 |
agenda=agenda2, |
|
489 |
) |
|
490 | ||
491 |
resp = app.get( |
|
492 |
'/api/agendas/recurring-events/?agendas=first-agenda,second-agenda&sort=day&check_overlaps=true' |
|
493 |
) |
|
494 |
assert [(x['id'], x['overlaps']) for x in resp.json['data']] == [ |
|
495 |
('first-agenda@event-12-14:1', ['second-agenda@event-12-18:1']), |
|
496 |
( |
|
497 |
'second-agenda@event-12-18:1', |
|
498 |
['first-agenda@event-12-14:1', 'first-agenda@event-14-15:1', 'first-agenda@event-15-17:1'], |
|
499 |
), |
|
500 |
('first-agenda@event-14-15:1', ['second-agenda@event-12-18:1']), |
|
501 |
('first-agenda@event-15-17:1', ['second-agenda@event-12-18:1']), |
|
502 |
('first-agenda@event-15-17:3', []), |
|
503 |
('second-agenda@event-12-18:5', ['first-agenda@event-15-17:5']), |
|
504 |
('second-agenda@no-duration:5', []), |
|
505 |
('first-agenda@event-15-17:5', ['second-agenda@event-12-18:5']), |
|
506 |
] |
|
507 | ||
508 |
resp = app.get('/api/agendas/recurring-events/?agendas=first-agenda,second-agenda&sort=day') |
|
509 |
assert ['overlaps' not in x for x in resp.json['data']] |
tests/api/fillslot/test_recurring_events.py | ||
---|---|---|
1288 | 1288 |
'slots': events_to_book, |
1289 | 1289 |
'user_external_id': 'user', |
1290 | 1290 |
'include_booked_events_detail': True, |
1291 |
'check_overlaps': True, |
|
1291 | 1292 |
}, |
1292 | 1293 |
) |
1293 | 1294 |
assert resp.json['booking_count'] == 180 |
1294 |
assert len(ctx.captured_queries) == 13
|
|
1295 |
assert len(ctx.captured_queries) == 14
|
|
1295 | 1296 | |
1296 | 1297 |
father = Person.objects.create(user_external_id='father_id', first_name='John', last_name='Doe') |
1297 | 1298 |
mother = Person.objects.create(user_external_id='mother_id', first_name='Jane', last_name='Doe') |
... | ... | |
1371 | 1372 |
'2022-03-19', |
1372 | 1373 |
'2022-03-20', |
1373 | 1374 |
] |
1375 | ||
1376 | ||
1377 |
@pytest.mark.freeze_time('2021-09-06 12:00') |
|
1378 |
def test_recurring_events_api_fillslots_overlapping_events(app, user): |
|
1379 |
agenda = Agenda.objects.create(label='First Agenda', kind='events') |
|
1380 |
Desk.objects.create(agenda=agenda, slug='_exceptions_holder') |
|
1381 |
start, end = now(), now() + datetime.timedelta(days=30) |
|
1382 |
Event.objects.create( |
|
1383 |
label='Event 12-14', |
|
1384 |
start_datetime=start, |
|
1385 |
duration=120, |
|
1386 |
places=2, |
|
1387 |
recurrence_end_date=end, |
|
1388 |
recurrence_days=[1], |
|
1389 |
agenda=agenda, |
|
1390 |
).create_all_recurrences() |
|
1391 |
Event.objects.create( |
|
1392 |
label='Event 14-15', |
|
1393 |
start_datetime=start + datetime.timedelta(hours=2), |
|
1394 |
duration=60, |
|
1395 |
places=2, |
|
1396 |
recurrence_end_date=end, |
|
1397 |
recurrence_days=[1], |
|
1398 |
agenda=agenda, |
|
1399 |
).create_all_recurrences() |
|
1400 |
Event.objects.create( |
|
1401 |
label='Event 15-17', |
|
1402 |
start_datetime=start + datetime.timedelta(hours=3), |
|
1403 |
duration=120, |
|
1404 |
places=2, |
|
1405 |
recurrence_end_date=end, |
|
1406 |
recurrence_days=[1, 3, 5], |
|
1407 |
agenda=agenda, |
|
1408 |
).create_all_recurrences() |
|
1409 |
agenda2 = Agenda.objects.create(label='Second Agenda', kind='events') |
|
1410 |
Desk.objects.create(agenda=agenda2, slug='_exceptions_holder') |
|
1411 |
Event.objects.create( |
|
1412 |
label='Event 12-18', |
|
1413 |
start_datetime=start, |
|
1414 |
duration=360, |
|
1415 |
places=2, |
|
1416 |
recurrence_end_date=end, |
|
1417 |
recurrence_days=[1, 5], |
|
1418 |
agenda=agenda2, |
|
1419 |
).create_all_recurrences() |
|
1420 |
Event.objects.create( |
|
1421 |
label='No duration', |
|
1422 |
start_datetime=start, |
|
1423 |
places=2, |
|
1424 |
recurrence_end_date=end, |
|
1425 |
recurrence_days=[5], |
|
1426 |
agenda=agenda2, |
|
1427 |
).create_all_recurrences() |
|
1428 | ||
1429 |
app.authorization = ('Basic', ('john.doe', 'password')) |
|
1430 |
fillslots_url = '/api/agendas/recurring-events/fillslots/?action=%s&agendas=%s' |
|
1431 | ||
1432 |
# booking without overlap |
|
1433 |
params = { |
|
1434 |
'user_external_id': 'user_id', |
|
1435 |
'check_overlaps': True, |
|
1436 |
'slots': 'first-agenda@event-12-14:1,first-agenda@event-14-15:1,second-agenda@event-12-18:5', |
|
1437 |
} |
|
1438 |
resp = app.post_json(fillslots_url % ('update', 'first-agenda,second-agenda'), params=params) |
|
1439 |
assert resp.json['booking_count'] == 14 |
|
1440 | ||
1441 |
# book again |
|
1442 |
resp = app.post_json(fillslots_url % ('update', 'first-agenda,second-agenda'), params=params) |
|
1443 |
assert resp.json['booking_count'] == 0 |
|
1444 | ||
1445 |
# change bookings |
|
1446 |
params = {'user_external_id': 'user_id', 'check_overlaps': True, 'slots': 'second-agenda@event-12-18:1'} |
|
1447 |
resp = app.post_json(fillslots_url % ('update', 'first-agenda,second-agenda'), params=params) |
|
1448 |
assert resp.json['booking_count'] == 5 |
|
1449 |
assert resp.json['cancelled_booking_count'] == 14 |
|
1450 | ||
1451 |
# booking overlapping events is allowed if one has no duration |
|
1452 |
params = { |
|
1453 |
'user_external_id': 'user_id', |
|
1454 |
'check_overlaps': True, |
|
1455 |
'slots': 'second-agenda@event-12-18:5,second-agenda@no-duration:5', |
|
1456 |
} |
|
1457 |
resp = app.post_json(fillslots_url % ('update', 'first-agenda,second-agenda'), params=params) |
|
1458 |
assert resp.json['booking_count'] == 8 |
|
1459 |
assert resp.json['cancelled_booking_count'] == 5 |
|
1460 | ||
1461 |
# booking overlapping events with durations is forbidden |
|
1462 |
params = { |
|
1463 |
'user_external_id': 'user_id', |
|
1464 |
'check_overlaps': True, |
|
1465 |
'slots': 'first-agenda@event-12-14:1,second-agenda@event-12-18:1', |
|
1466 |
} |
|
1467 |
resp = app.post_json(fillslots_url % ('update', 'first-agenda,second-agenda'), params=params) |
|
1468 |
assert resp.json['err'] == 1 |
|
1469 |
assert ( |
|
1470 |
resp.json['err_desc'] |
|
1471 |
== 'Some events occur at the same time: first-agenda@event-12-14:1 / second-agenda@event-12-18:1' |
|
1472 |
) |
|
1473 | ||
1474 |
params = { |
|
1475 |
'user_external_id': 'user_id', |
|
1476 |
'check_overlaps': True, |
|
1477 |
'slots': ( |
|
1478 |
'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,' |
|
1479 |
'second-agenda@event-12-18:5,second-agenda@no-duration:5' |
|
1480 |
), |
|
1481 |
} |
|
1482 |
resp = app.post_json(fillslots_url % ('update', 'first-agenda,second-agenda'), params=params) |
|
1483 |
assert resp.json['err'] == 1 |
|
1484 |
assert resp.json['err_desc'] == ( |
|
1485 |
'Some events occur at the same time: first-agenda@event-12-14:1 / second-agenda@event-12-18:1, ' |
|
1486 |
'first-agenda@event-15-17:1 / second-agenda@event-12-18:1, first-agenda@event-15-17:5 / second-agenda@event-12-18:5' |
|
1487 |
) |
|
1488 | ||
1489 |
# overlaps check is disabled by default |
|
1490 |
params = { |
|
1491 |
'user_external_id': 'user_id', |
|
1492 |
'slots': 'first-agenda@event-12-14:1,second-agenda@event-12-18:1', |
|
1493 |
} |
|
1494 |
resp = app.post_json(fillslots_url % ('update', 'first-agenda,second-agenda'), params=params) |
|
1495 |
assert resp.json['err'] == 0 |
|
1496 |
assert resp.json['booking_count'] == 10 |
|
1374 |
- |