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 | ||
---|---|---|
126 | 126 | |
127 | 127 |
def validate_slots(self, value): |
128 | 128 |
super().validate_slots(value) |
129 |
self.initial_slots = value |
|
129 | 130 |
open_event_slugs = collections.defaultdict(set) |
130 | 131 |
for agenda in self.context['agendas']: |
131 | 132 |
for event in agenda.get_open_recurring_events(): |
chrono/api/views.py | ||
---|---|---|
1134 | 1134 |
recurring_events = Event.objects.filter(pk__in=days_by_event).select_related( |
1135 | 1135 |
'agenda', 'agenda__category' |
1136 | 1136 |
) |
1137 |
recurring_events = Event.annotate_recurring_events_with_overlaps(recurring_events) |
|
1137 | 1138 |
events = [] |
1138 | 1139 |
for event in recurring_events: |
1139 | 1140 |
for day in days_by_event[event.pk]: |
... | ... | |
1141 | 1142 |
event.day = day |
1142 | 1143 |
events.append(event) |
1143 | 1144 |
else: |
1144 |
agendas = Agenda.prefetch_recurring_events(data['agendas']) |
|
1145 |
agendas = Agenda.prefetch_recurring_events(data['agendas'], with_overlaps=True)
|
|
1145 | 1146 |
events = [] |
1146 | 1147 |
for agenda in agendas: |
1147 | 1148 |
for event in agenda.get_open_recurring_events(): |
... | ... | |
1150 | 1151 |
event.day = day |
1151 | 1152 |
events.append(event) |
1152 | 1153 | |
1154 |
for event in events: |
|
1155 |
event.overlaps = [ |
|
1156 |
'%s:%s' % (x['slug'], day) for x in event.overlaps for day in x['days'] if day == event.day |
|
1157 |
] |
|
1158 | ||
1153 | 1159 |
if 'agendas' in request.query_params: |
1154 | 1160 |
agenda_querystring_indexes = { |
1155 | 1161 |
agenda_slug: i for i, agenda_slug in enumerate(data['agenda_slugs']) |
... | ... | |
1189 | 1195 |
'description': event.description, |
1190 | 1196 |
'pricing': event.pricing, |
1191 | 1197 |
'url': event.url, |
1198 |
'overlaps': event.overlaps, |
|
1192 | 1199 |
} |
1193 | 1200 |
for event in events |
1194 | 1201 |
] |
... | ... | |
1702 | 1709 |
guardian_external_id, |
1703 | 1710 |
).values_list('pk', flat=True) |
1704 | 1711 | |
1712 |
self.check_for_overlaps(events_to_book, serializer.initial_slots) |
|
1713 | ||
1705 | 1714 |
# outdated bookings to remove (cancelled bookings to replace by an active booking) |
1706 | 1715 |
events_cancelled_to_delete = events_to_book.filter( |
1707 | 1716 |
booking__user_external_id=user_external_id, |
... | ... | |
1819 | 1828 |
] |
1820 | 1829 |
return events_to_unbook |
1821 | 1830 | |
1831 |
@staticmethod |
|
1832 |
def check_for_overlaps(events, slots): |
|
1833 |
def get_slug(event, day): |
|
1834 |
slug = event['slug'] if isinstance(event, dict) else '%s@%s' % (event.agenda.slug, event.slug) |
|
1835 |
return '%s:%s' % (slug, day) |
|
1836 | ||
1837 |
recurring_events = Event.objects.filter(pk__in=events.values('primary_event_id')) |
|
1838 |
recurring_events = Event.annotate_recurring_events_with_overlaps(recurring_events) |
|
1839 | ||
1840 |
overlaps = set() |
|
1841 |
for event in recurring_events.select_related('agenda'): |
|
1842 |
overlaps.update( |
|
1843 |
tuple(sorted((get_slug(event, d), get_slug(x, d)))) |
|
1844 |
for x in event.overlaps |
|
1845 |
for d in x['days'] |
|
1846 |
if get_slug(x, d) in slots and get_slug(event, d) in slots |
|
1847 |
) |
|
1848 | ||
1849 |
if overlaps: |
|
1850 |
raise APIError( |
|
1851 |
N_('Some events occur at the same time: %s') |
|
1852 |
% ', '.join(sorted('%s / %s' % (x, y) for x, y in overlaps)) |
|
1853 |
) |
|
1854 | ||
1822 | 1855 | |
1823 | 1856 |
recurring_fillslots = RecurringFillslots.as_view() |
1824 | 1857 |
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='A', |
|
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='B', |
|
455 |
start_datetime=start + datetime.timedelta(hours=3), |
|
456 |
duration=120, |
|
457 |
places=2, |
|
458 |
recurrence_end_date=end, |
|
459 |
recurrence_days=[1, 3, 5], |
|
460 |
agenda=agenda, |
|
461 |
) |
|
462 |
agenda2 = Agenda.objects.create(label='Second Agenda', kind='events') |
|
463 |
Desk.objects.create(agenda=agenda2, slug='_exceptions_holder') |
|
464 |
Event.objects.create( |
|
465 |
label='C', |
|
466 |
start_datetime=start, |
|
467 |
duration=400, |
|
468 |
places=2, |
|
469 |
recurrence_end_date=end, |
|
470 |
recurrence_days=[1, 5], |
|
471 |
agenda=agenda2, |
|
472 |
) |
|
473 |
Event.objects.create( |
|
474 |
label='No duration', |
|
475 |
start_datetime=start, |
|
476 |
places=2, |
|
477 |
recurrence_end_date=end, |
|
478 |
recurrence_days=[5], |
|
479 |
agenda=agenda2, |
|
480 |
) |
|
481 | ||
482 |
resp = app.get('/api/agendas/recurring-events/?agendas=first-agenda,second-agenda&sort=day') |
|
483 |
assert [(x['id'], x['overlaps']) for x in resp.json['data']] == [ |
|
484 |
('first-agenda@a:1', ['second-agenda@c:1']), |
|
485 |
('second-agenda@c:1', ['first-agenda@a:1', 'first-agenda@b:1']), |
|
486 |
('first-agenda@b:1', ['second-agenda@c:1']), |
|
487 |
('first-agenda@b:3', []), |
|
488 |
('second-agenda@c:5', ['first-agenda@b:5']), |
|
489 |
('second-agenda@no-duration:5', []), |
|
490 |
('first-agenda@b:5', ['second-agenda@c:5']), |
|
491 |
] |
tests/api/fillslot/test_recurring_events.py | ||
---|---|---|
106 | 106 |
params['user_external_id'] = 'user_id_3' |
107 | 107 |
with CaptureQueriesContext(connection) as ctx: |
108 | 108 |
resp = app.post_json(fillslots_url, params=params) |
109 |
assert len(ctx.captured_queries) in [12, 13]
|
|
109 |
assert len(ctx.captured_queries) in [13, 14]
|
|
110 | 110 |
# everything goes in waiting list |
111 | 111 |
assert events.filter(booked_waiting_list_places=1).count() == 6 |
112 | 112 |
# but an event was full |
... | ... | |
1291 | 1291 |
}, |
1292 | 1292 |
) |
1293 | 1293 |
assert resp.json['booking_count'] == 180 |
1294 |
assert len(ctx.captured_queries) == 13
|
|
1294 |
assert len(ctx.captured_queries) == 14
|
|
1295 | 1295 | |
1296 | 1296 |
father = Person.objects.create(user_external_id='father_id', first_name='John', last_name='Doe') |
1297 | 1297 |
mother = Person.objects.create(user_external_id='mother_id', first_name='Jane', last_name='Doe') |
... | ... | |
1309 | 1309 |
params={'slots': events_to_book, 'user_external_id': 'xxx'}, |
1310 | 1310 |
) |
1311 | 1311 |
assert resp.json['booking_count'] == 100 |
1312 |
assert len(ctx.captured_queries) == 13
|
|
1312 |
assert len(ctx.captured_queries) == 14
|
|
1313 | 1313 | |
1314 | 1314 | |
1315 | 1315 |
@pytest.mark.freeze_time('2022-03-07 14:00') # Monday of 10th week |
... | ... | |
1371 | 1371 |
'2022-03-19', |
1372 | 1372 |
'2022-03-20', |
1373 | 1373 |
] |
1374 | ||
1375 | ||
1376 |
@pytest.mark.freeze_time('2021-09-06 12:00') |
|
1377 |
def test_recurring_events_api_fillslots_overlapping_events(app, user): |
|
1378 |
agenda = Agenda.objects.create(label='First Agenda', kind='events') |
|
1379 |
Desk.objects.create(agenda=agenda, slug='_exceptions_holder') |
|
1380 |
start, end = now(), now() + datetime.timedelta(days=30) |
|
1381 |
Event.objects.create( |
|
1382 |
label='A', |
|
1383 |
start_datetime=start, |
|
1384 |
duration=120, |
|
1385 |
places=2, |
|
1386 |
recurrence_end_date=end, |
|
1387 |
recurrence_days=[1], |
|
1388 |
agenda=agenda, |
|
1389 |
).create_all_recurrences() |
|
1390 |
Event.objects.create( |
|
1391 |
label='B', |
|
1392 |
start_datetime=start + datetime.timedelta(hours=3), |
|
1393 |
duration=120, |
|
1394 |
places=2, |
|
1395 |
recurrence_end_date=end, |
|
1396 |
recurrence_days=[1, 3, 5], |
|
1397 |
agenda=agenda, |
|
1398 |
).create_all_recurrences() |
|
1399 |
agenda2 = Agenda.objects.create(label='Second Agenda', kind='events') |
|
1400 |
Desk.objects.create(agenda=agenda2, slug='_exceptions_holder') |
|
1401 |
Event.objects.create( |
|
1402 |
label='C', |
|
1403 |
start_datetime=start, |
|
1404 |
duration=400, |
|
1405 |
places=2, |
|
1406 |
recurrence_end_date=end, |
|
1407 |
recurrence_days=[1, 5], |
|
1408 |
agenda=agenda2, |
|
1409 |
).create_all_recurrences() |
|
1410 |
Event.objects.create( |
|
1411 |
label='No duration', |
|
1412 |
start_datetime=start, |
|
1413 |
places=2, |
|
1414 |
recurrence_end_date=end, |
|
1415 |
recurrence_days=[5], |
|
1416 |
agenda=agenda2, |
|
1417 |
).create_all_recurrences() |
|
1418 | ||
1419 |
app.authorization = ('Basic', ('john.doe', 'password')) |
|
1420 |
fillslots_url = '/api/agendas/recurring-events/fillslots/?action=%s&agendas=%s' |
|
1421 | ||
1422 |
# booking without overlap |
|
1423 |
params = {'user_external_id': 'user_id', 'slots': 'first-agenda@a:1,second-agenda@c:5'} |
|
1424 |
resp = app.post_json(fillslots_url % ('update', 'first-agenda,second-agenda'), params=params) |
|
1425 |
assert resp.json['booking_count'] == 9 |
|
1426 | ||
1427 |
# book again |
|
1428 |
resp = app.post_json(fillslots_url % ('update', 'first-agenda,second-agenda'), params=params) |
|
1429 |
assert resp.json['booking_count'] == 0 |
|
1430 | ||
1431 |
# change bookings |
|
1432 |
params = {'user_external_id': 'user_id', 'slots': 'second-agenda@c:1'} |
|
1433 |
resp = app.post_json(fillslots_url % ('update', 'first-agenda,second-agenda'), params=params) |
|
1434 |
assert resp.json['booking_count'] == 5 |
|
1435 |
assert resp.json['cancelled_booking_count'] == 9 |
|
1436 | ||
1437 |
# booking overlapping events is allowed if one has no duration |
|
1438 |
params = {'user_external_id': 'user_id', 'slots': 'second-agenda@c:5,second-agenda@no-duration:5'} |
|
1439 |
resp = app.post_json(fillslots_url % ('update', 'first-agenda,second-agenda'), params=params) |
|
1440 |
assert resp.json['booking_count'] == 8 |
|
1441 |
assert resp.json['cancelled_booking_count'] == 5 |
|
1442 | ||
1443 |
# booking overlapping events with durations is forbidden |
|
1444 |
params = {'user_external_id': 'user_id', 'slots': 'first-agenda@a:1,second-agenda@c:1'} |
|
1445 |
resp = app.post_json(fillslots_url % ('update', 'first-agenda,second-agenda'), params=params) |
|
1446 |
assert resp.json['err'] == 1 |
|
1447 |
assert resp.json['err_desc'] == 'Some events occur at the same time: first-agenda@a:1 / second-agenda@c:1' |
|
1448 | ||
1449 |
params = { |
|
1450 |
'user_external_id': 'user_id', |
|
1451 |
'slots': ( |
|
1452 |
'first-agenda@a:1,first-agenda@b:1,first-agenda@b:3,first-agenda@b:5,second-agenda@c:1,' |
|
1453 |
'second-agenda@c:5,second-agenda@no-duration:5' |
|
1454 |
), |
|
1455 |
} |
|
1456 |
resp = app.post_json(fillslots_url % ('update', 'first-agenda,second-agenda'), params=params) |
|
1457 |
assert resp.json['err'] == 1 |
|
1458 |
assert resp.json['err_desc'] == ( |
|
1459 |
'Some events occur at the same time: first-agenda@a:1 / second-agenda@c:1, ' |
|
1460 |
'first-agenda@b:1 / second-agenda@c:1, first-agenda@b:5 / second-agenda@c:5' |
|
1461 |
) |
|
1374 |
- |