0001-api-forbid-overlapping-events-booking-64383.patch
chrono/agendas/models.py | ||
---|---|---|
36 | 36 |
from django.core.exceptions import FieldDoesNotExist, ValidationError |
37 | 37 |
from django.core.validators import MaxValueValidator, MinValueValidator |
38 | 38 |
from django.db import connection, models, transaction |
39 |
from django.db.models import Count, Exists, F, Func, Max, OuterRef, Prefetch, Q
|
|
40 |
from django.db.models.functions import Cast, Coalesce, ExtractWeek |
|
39 |
from django.db.models import Count, Exists, ExpressionWrapper, F, Func, Max, OuterRef, Prefetch, Q, Value
|
|
40 |
from django.db.models.functions import Cast, Coalesce, Concat, ExtractWeek
|
|
41 | 41 |
from django.template import Context, Template, TemplateSyntaxError, VariableDoesNotExist, engines |
42 | 42 |
from django.urls import reverse |
43 | 43 |
from django.utils import functional |
... | ... | |
55 | 55 | |
56 | 56 |
from chrono.interval import Interval, IntervalSet |
57 | 57 |
from chrono.utils.date import get_weekday_index |
58 |
from chrono.utils.db import SumCardinality |
|
58 |
from chrono.utils.db import ArraySubquery, SumCardinality
|
|
59 | 59 |
from chrono.utils.publik_urls import translate_from_publik_url |
60 | 60 |
from chrono.utils.requests_wrapper import requests as requests_wrapper |
61 | 61 | |
... | ... | |
1562 | 1562 |
) |
1563 | 1563 |
return qs |
1564 | 1564 | |
1565 |
@staticmethod |
|
1566 |
def annotate_queryset_with_overlaps(qs): |
|
1567 |
qs = qs.annotate( |
|
1568 |
computed_end_datetime=ExpressionWrapper( |
|
1569 |
F('start_datetime') + datetime.timedelta(minutes=1) * F('duration'), |
|
1570 |
output_field=models.DateTimeField(), |
|
1571 |
), |
|
1572 |
computed_slug=Concat('agenda__slug', Value('@'), 'slug'), |
|
1573 |
) |
|
1574 | ||
1575 |
overlapping_events = qs.filter( |
|
1576 |
start_datetime__lt=OuterRef('computed_end_datetime'), |
|
1577 |
computed_end_datetime__gt=OuterRef('start_datetime'), |
|
1578 |
).exclude(pk=OuterRef('pk')) |
|
1579 | ||
1580 |
return qs.annotate( |
|
1581 |
overlaps=ArraySubquery( |
|
1582 |
overlapping_events.values('computed_slug'), |
|
1583 |
output_field=ArrayField(models.CharField()), |
|
1584 |
), |
|
1585 |
has_overlap=Exists(overlapping_events), |
|
1586 |
) |
|
1587 | ||
1565 | 1588 |
@property |
1566 | 1589 |
def remaining_places(self): |
1567 | 1590 |
return max(0, self.places - self.booked_places) |
chrono/api/serializers.py | ||
---|---|---|
75 | 75 |
extra_phone_numbers = StringOrListField( |
76 | 76 |
required=False, child=serializers.CharField(max_length=16, allow_blank=False) |
77 | 77 |
) |
78 |
check_overlaps = serializers.BooleanField(default=False) |
|
78 | 79 | |
79 | 80 | |
80 | 81 |
class SlotsSerializer(SlotSerializer): |
... | ... | |
325 | 326 |
guardian_external_id = serializers.CharField(max_length=250, required=False) |
326 | 327 |
with_status = serializers.BooleanField(default=False) |
327 | 328 |
guardian_external_id = serializers.CharField(max_length=250, required=False) |
329 |
check_overlaps = serializers.BooleanField(default=False) |
|
328 | 330 | |
329 | 331 |
def validate(self, attrs): |
330 | 332 |
super().validate(attrs) |
chrono/api/views.py | ||
---|---|---|
596 | 596 |
details['status'] = 'cancelled' |
597 | 597 |
else: |
598 | 598 |
details['status'] = 'free' |
599 |
if hasattr(event, 'overlaps'): |
|
600 |
details['overlaps'] = event.overlaps |
|
599 | 601 | |
600 | 602 |
return details |
601 | 603 | |
... | ... | |
911 | 913 |
show_past_events = bool(payload.get('show_past_events')) |
912 | 914 |
show_only_subscribed = bool('subscribed' in payload) |
913 | 915 |
with_status = bool(payload.get('with_status')) |
916 |
check_overlaps = bool(payload.get('check_overlaps')) |
|
914 | 917 | |
915 | 918 |
entries = Event.objects.none() |
916 | 919 |
for agenda in agendas: |
... | ... | |
926 | 929 |
show_out_of_minimal_delay=show_past_events, |
927 | 930 |
) |
928 | 931 |
entries = Event.annotate_queryset_for_user(entries, user_external_id, with_status=with_status) |
932 |
if check_overlaps: |
|
933 |
entries = Event.annotate_queryset_with_overlaps(entries) |
|
929 | 934 |
if show_only_subscribed: |
930 | 935 |
entries = entries.filter( |
931 | 936 |
agenda__subscriptions__user_external_id=user_external_id, |
... | ... | |
1843 | 1848 |
payload = serializer.validated_data |
1844 | 1849 |
user_external_id = payload['user_external_id'] |
1845 | 1850 |
bypass_delays = payload.get('bypass_delays') |
1851 |
check_overlaps = payload.get('check_overlaps') |
|
1846 | 1852 | |
1847 | 1853 |
events = self.get_events(request, payload) |
1848 | 1854 | |
1855 |
if check_overlaps: |
|
1856 |
overlapping_events = Event.annotate_queryset_with_overlaps(events).filter(has_overlap=True) |
|
1857 |
if overlapping_events: |
|
1858 |
raise APIError( |
|
1859 |
N_('Some events occur at the same time: %s'), |
|
1860 |
', '.join(sorted(str(x) for x in overlapping_events)), |
|
1861 |
) |
|
1862 | ||
1849 | 1863 |
already_booked_events = self.get_already_booked_events(user_external_id) |
1850 | 1864 |
already_booked_events = already_booked_events.filter(start_datetime__gt=now()) |
1851 | 1865 |
if start_datetime: |
... | ... | |
1877 | 1891 |
booking__user_external_id=user_external_id, |
1878 | 1892 |
booking__cancellation_datetime__isnull=False, |
1879 | 1893 |
) |
1894 | ||
1880 | 1895 |
# book only events without active booking for the user |
1881 | 1896 |
events = events.exclude( |
1882 | 1897 |
pk__in=Booking.objects.filter( |
chrono/utils/db.py | ||
---|---|---|
15 | 15 |
# along with this program. If not, see <http://www.gnu.org/licenses/>. |
16 | 16 | |
17 | 17 |
from django.db.migrations.operations.base import Operation |
18 |
from django.db.models import Aggregate |
|
18 |
from django.db.models import Aggregate, Subquery
|
|
19 | 19 | |
20 | 20 | |
21 | 21 |
class SumCardinality(Aggregate): |
22 | 22 |
template = 'SUM(CARDINALITY(%(expressions)s))' |
23 | 23 | |
24 | 24 | |
25 |
class ArraySubquery(Subquery): |
|
26 |
''' |
|
27 |
Available in Django 4.0 |
|
28 |
https://docs.djangoproject.com/en/4.0/ref/contrib/postgres/expressions/#arraysubquery-expressions |
|
29 |
''' |
|
30 | ||
31 |
template = 'ARRAY(%(subquery)s)' |
|
32 | ||
33 | ||
25 | 34 |
class EnsureJsonbType(Operation): |
26 | 35 | |
27 | 36 |
reversible = True |
tests/api/datetimes/test_events_multiple_agendas.py | ||
---|---|---|
382 | 382 |
with CaptureQueriesContext(connection) as ctx: |
383 | 383 |
resp = app.get( |
384 | 384 |
'/api/agendas/datetimes/', |
385 |
params={'agendas': ','.join(str(i) for i in range(10)), 'show_past_events': True}, |
|
385 |
params={ |
|
386 |
'agendas': ','.join(str(i) for i in range(10)), |
|
387 |
'show_past_events': True, |
|
388 |
'check_overlaps': True, |
|
389 |
}, |
|
386 | 390 |
) |
387 | 391 |
assert len(resp.json['data']) == 30 |
388 | 392 |
assert len(ctx.captured_queries) == 2 |
... | ... | |
390 | 394 |
with CaptureQueriesContext(connection) as ctx: |
391 | 395 |
resp = app.get( |
392 | 396 |
'/api/agendas/datetimes/', |
393 |
params={'subscribed': 'all', 'user_external_id': 'xxx', 'show_past_events': True}, |
|
397 |
params={ |
|
398 |
'subscribed': 'all', |
|
399 |
'user_external_id': 'xxx', |
|
400 |
'show_past_events': True, |
|
401 |
'check_overlaps': True, |
|
402 |
}, |
|
394 | 403 |
) |
395 | 404 |
assert len(resp.json['data']) == 30 |
396 | 405 |
assert len(ctx.captured_queries) == 2 |
... | ... | |
398 | 407 |
with CaptureQueriesContext(connection) as ctx: |
399 | 408 |
resp = app.get( |
400 | 409 |
'/api/agendas/datetimes/', |
401 |
params={'subscribed': 'category-a', 'user_external_id': 'xxx', 'show_past_events': True}, |
|
410 |
params={ |
|
411 |
'subscribed': 'category-a', |
|
412 |
'user_external_id': 'xxx', |
|
413 |
'show_past_events': True, |
|
414 |
'check_overlaps': True, |
|
415 |
}, |
|
402 | 416 |
) |
403 | 417 |
assert len(resp.json['data']) == 30 |
404 | 418 |
assert len(ctx.captured_queries) == 2 |
... | ... | |
420 | 434 |
'user_external_id': 'xxx', |
421 | 435 |
'guardian_external_id': 'mother_id', |
422 | 436 |
'show_past_events': True, |
437 |
'check_overlaps': True, |
|
423 | 438 |
}, |
424 | 439 |
) |
425 | 440 |
assert len(resp.json['data']) == 30 |
... | ... | |
1352 | 1367 |
status=400, |
1353 | 1368 |
) |
1354 | 1369 |
assert 'required' in resp.json['errors']['user_external_id'][0] |
1370 | ||
1371 | ||
1372 |
@pytest.mark.freeze_time('2021-09-06 12:00') |
|
1373 |
def test_datetimes_multiple_agendas_overlapping_events(app): |
|
1374 |
agenda = Agenda.objects.create(label='Foo bar', kind='events') |
|
1375 |
Event.objects.create( |
|
1376 |
label='Event 12-14', |
|
1377 |
start_datetime=now() + datetime.timedelta(days=5), |
|
1378 |
duration=120, |
|
1379 |
places=5, |
|
1380 |
agenda=agenda, |
|
1381 |
) |
|
1382 |
Event.objects.create( |
|
1383 |
label='Event containing all events', |
|
1384 |
start_datetime=now() + datetime.timedelta(days=4, hours=23), |
|
1385 |
duration=440, |
|
1386 |
places=5, |
|
1387 |
agenda=agenda, |
|
1388 |
) |
|
1389 |
second_agenda = Agenda.objects.create(label='Foo bar 2', kind='events') |
|
1390 |
Event.objects.create( |
|
1391 |
label='Event 13-15', |
|
1392 |
start_datetime=now() + datetime.timedelta(days=5, hours=1), |
|
1393 |
duration=120, |
|
1394 |
places=5, |
|
1395 |
agenda=second_agenda, |
|
1396 |
) |
|
1397 |
Event.objects.create( |
|
1398 |
label='Event 14-16', |
|
1399 |
start_datetime=now() + datetime.timedelta(days=5, hours=2), |
|
1400 |
duration=120, |
|
1401 |
places=5, |
|
1402 |
agenda=second_agenda, |
|
1403 |
) |
|
1404 |
Event.objects.create( |
|
1405 |
label='Event no duration', |
|
1406 |
start_datetime=now() + datetime.timedelta(days=5, hours=1), |
|
1407 |
places=5, |
|
1408 |
agenda=second_agenda, |
|
1409 |
) |
|
1410 |
Event.objects.create( |
|
1411 |
label='Event other day', |
|
1412 |
start_datetime=now() + datetime.timedelta(days=6), |
|
1413 |
places=5, |
|
1414 |
agenda=second_agenda, |
|
1415 |
) |
|
1416 | ||
1417 |
resp = app.get('/api/agendas/datetimes/', params={'agendas': 'foo-bar,foo-bar-2', 'check_overlaps': True}) |
|
1418 |
assert [(x['id'], x['overlaps']) for x in resp.json['data']] == [ |
|
1419 |
( |
|
1420 |
'foo-bar@event-containing-all-events', |
|
1421 |
['foo-bar@event-12-14', 'foo-bar-2@event-13-15', 'foo-bar-2@event-14-16'], |
|
1422 |
), |
|
1423 |
('foo-bar@event-12-14', ['foo-bar@event-containing-all-events', 'foo-bar-2@event-13-15']), |
|
1424 |
( |
|
1425 |
'foo-bar-2@event-13-15', |
|
1426 |
['foo-bar@event-containing-all-events', 'foo-bar@event-12-14', 'foo-bar-2@event-14-16'], |
|
1427 |
), |
|
1428 |
('foo-bar-2@event-no-duration', []), |
|
1429 |
('foo-bar-2@event-14-16', ['foo-bar@event-containing-all-events', 'foo-bar-2@event-13-15']), |
|
1430 |
('foo-bar-2@event-other-day', []), |
|
1431 |
] |
|
1432 | ||
1433 |
resp = app.get('/api/agendas/datetimes/', params={'agendas': 'foo-bar,foo-bar-2'}) |
|
1434 |
assert ['overlaps' not in x for x in resp.json['data']] |
tests/api/fillslot/test_events.py | ||
---|---|---|
30 | 30 | |
31 | 31 |
app.authorization = ('Basic', ('john.doe', 'password')) |
32 | 32 |
fillslots_url = '/api/agenda/%s/events/fillslots/' % agenda.slug |
33 |
params = {'user_external_id': 'user_id', 'slots': 'event,event-2'} |
|
33 |
params = {'user_external_id': 'user_id', 'check_overlaps': True, 'slots': 'event,event-2'}
|
|
34 | 34 |
with CaptureQueriesContext(connection) as ctx: |
35 | 35 |
resp = app.post_json(fillslots_url, params=params) |
36 |
assert len(ctx.captured_queries) == 11
|
|
36 |
assert len(ctx.captured_queries) == 12
|
|
37 | 37 |
assert resp.json['booking_count'] == 2 |
38 | 38 |
assert len(resp.json['booked_events']) == 2 |
39 | 39 |
assert resp.json['booked_events'][0]['id'] == 'event' |
... | ... | |
402 | 402 |
assert resp.json['cancelled_booking_count'] == 0 |
403 | 403 |
assert event.booking_set.filter(cancellation_datetime__isnull=True).count() == 1 |
404 | 404 |
assert second_event.booking_set.filter(cancellation_datetime__isnull=True).count() == 0 |
405 | ||
406 | ||
407 |
@pytest.mark.freeze_time('2021-09-06 12:00') |
|
408 |
def test_api_events_fillslots_overlapping_events(app, user, freezer): |
|
409 |
agenda = Agenda.objects.create(label='Foo bar', kind='events') |
|
410 |
first_event = Event.objects.create( |
|
411 |
label='Event 12-14', |
|
412 |
start_datetime=now() + datetime.timedelta(days=5), |
|
413 |
duration=120, |
|
414 |
places=5, |
|
415 |
agenda=agenda, |
|
416 |
) |
|
417 |
second_event = Event.objects.create( |
|
418 |
label='Event 13-15', |
|
419 |
start_datetime=now() + datetime.timedelta(days=5, hours=1), |
|
420 |
duration=120, |
|
421 |
places=5, |
|
422 |
agenda=agenda, |
|
423 |
) |
|
424 |
Event.objects.create( |
|
425 |
label='Event 14-16', |
|
426 |
start_datetime=now() + datetime.timedelta(days=5, hours=2), |
|
427 |
duration=120, |
|
428 |
places=5, |
|
429 |
agenda=agenda, |
|
430 |
) |
|
431 |
Event.objects.create( |
|
432 |
label='Event no duration', |
|
433 |
start_datetime=now() + datetime.timedelta(days=5, hours=1), |
|
434 |
places=5, |
|
435 |
agenda=agenda, |
|
436 |
) |
|
437 | ||
438 |
app.authorization = ('Basic', ('john.doe', 'password')) |
|
439 |
fillslots_url = '/api/agenda/foo-bar/events/fillslots/' |
|
440 |
params = {'user_external_id': 'user_id', 'check_overlaps': True} |
|
441 |
resp = app.post_json(fillslots_url, params={**params, 'slots': 'event-12-14'}) |
|
442 |
assert resp.json['booking_count'] == 1 |
|
443 | ||
444 |
# booking the same event is still allowed |
|
445 |
resp = app.post_json(fillslots_url, params={**params, 'slots': 'event-12-14'}) |
|
446 |
assert resp.json['err'] == 0 |
|
447 |
assert resp.json['booking_count'] == 0 |
|
448 | ||
449 |
# changing booking to second event is allowed |
|
450 |
resp = app.post_json(fillslots_url, params={**params, 'slots': 'event-13-15'}) |
|
451 |
assert resp.json['err'] == 0 |
|
452 |
assert resp.json['booking_count'] == 1 |
|
453 |
assert resp.json['cancelled_booking_count'] == 1 |
|
454 | ||
455 |
# events are not overlapping if one ends when the other starts |
|
456 |
resp = app.post_json(fillslots_url, params={**params, 'slots': 'event-12-14,event-14-16'}) |
|
457 |
assert resp.json['booking_count'] == 2 |
|
458 |
assert resp.json['cancelled_booking_count'] == 1 |
|
459 | ||
460 |
# booking overlapping events is allowed if one has no duration |
|
461 |
resp = app.post_json(fillslots_url, params={**params, 'slots': 'event-12-14,event-no-duration'}) |
|
462 |
assert resp.json['err'] == 0 |
|
463 |
assert resp.json['booking_count'] == 1 |
|
464 |
assert resp.json['cancelled_booking_count'] == 1 |
|
465 | ||
466 |
# default behavior does not check for overlaps |
|
467 |
resp = app.post_json( |
|
468 |
fillslots_url, params={'user_external_id': 'user_id', 'slots': 'event-12-14,event-13-15'} |
|
469 |
) |
|
470 |
assert resp.json['err'] == 0 |
|
471 |
assert resp.json['booking_count'] == 1 |
|
472 |
assert resp.json['cancelled_booking_count'] == 1 |
|
473 | ||
474 |
# clearing overlapping bookings is allowed |
|
475 |
resp = app.post_json(fillslots_url, params={**params, 'slots': ''}) |
|
476 |
assert resp.json['err'] == 0 |
|
477 |
assert resp.json['booking_count'] == 0 |
|
478 |
assert resp.json['cancelled_booking_count'] == 2 |
|
479 | ||
480 |
# booking overlapping events with durations is forbidden |
|
481 |
resp = app.post_json(fillslots_url, params={**params, 'slots': 'event-12-14,event-13-15'}) |
|
482 |
assert resp.json['err'] == 1 |
|
483 |
assert resp.json['err_desc'] == 'Some events occur at the same time: Event 12-14, Event 13-15' |
|
484 | ||
485 |
# still overlaps but start before |
|
486 |
second_event.start_datetime -= datetime.timedelta(hours=2) |
|
487 |
second_event.save() |
|
488 | ||
489 |
resp = app.post_json(fillslots_url, params={**params, 'slots': 'event-12-14,event-13-15'}) |
|
490 |
assert resp.json['err'] == 1 |
|
491 |
assert resp.json['err_desc'] == 'Some events occur at the same time: Event 12-14, Event 13-15' |
|
492 | ||
493 |
# still overlaps but contains first event |
|
494 |
second_event.start_datetime = first_event.start_datetime - datetime.timedelta(minutes=10) |
|
495 |
second_event.save() |
|
496 |
second_event.duration = first_event.duration + 10 |
|
497 |
second_event.save() |
|
498 | ||
499 |
resp = app.post_json(fillslots_url, params={**params, 'slots': 'event-12-14,event-13-15'}) |
|
500 |
assert resp.json['err'] == 1 |
|
501 |
assert resp.json['err_desc'] == 'Some events occur at the same time: Event 12-14, Event 13-15' |
|
502 | ||
503 |
# still overlaps but contained by first event |
|
504 |
second_event.start_datetime = first_event.start_datetime + datetime.timedelta(minutes=10) |
|
505 |
second_event.save() |
|
506 |
second_event.duration = first_event.duration - 10 |
|
507 |
second_event.save() |
|
508 | ||
509 |
resp = app.post_json(fillslots_url, params={**params, 'slots': 'event-12-14,event-13-15'}) |
|
510 |
assert resp.json['err'] == 1 |
|
511 |
assert resp.json['err_desc'] == 'Some events occur at the same time: Event 12-14, Event 13-15' |
|
512 | ||
513 |
# no more overlap |
|
514 |
second_event.start_datetime -= datetime.timedelta(hours=5) |
|
515 |
second_event.save() |
|
516 | ||
517 |
resp = app.post_json(fillslots_url, params={**params, 'slots': 'event-12-14,event-13-15'}) |
|
518 |
assert resp.json['booking_count'] == 2 |
tests/api/fillslot/test_events_multiple_agendas.py | ||
---|---|---|
160 | 160 |
assert event_slugs == 'first-agenda@event,second-agenda@event' |
161 | 161 | |
162 | 162 |
app.authorization = ('Basic', ('john.doe', 'password')) |
163 |
params = {'user_external_id': 'user_id', 'slots': event_slugs} |
|
163 |
params = {'user_external_id': 'user_id', 'check_overlaps': True, 'slots': event_slugs}
|
|
164 | 164 |
with CaptureQueriesContext(connection) as ctx: |
165 | 165 |
resp = app.post_json('/api/agendas/events/fillslots/?agendas=%s' % agenda_slugs, params=params) |
166 |
assert len(ctx.captured_queries) == 16
|
|
166 |
assert len(ctx.captured_queries) == 17
|
|
167 | 167 |
assert resp.json['booking_count'] == 2 |
168 | 168 |
assert len(resp.json['booked_events']) == 2 |
169 | 169 |
assert resp.json['booked_events'][0]['id'] == 'first-agenda@event' |
... | ... | |
648 | 648 |
) |
649 | 649 |
assert resp.json['err'] == 1 |
650 | 650 |
assert resp.json['err_desc'] == 'Some events are outside guardian custody: first-agenda@event-thursday' |
651 | ||
652 | ||
653 |
@pytest.mark.freeze_time('2021-09-06 12:00') |
|
654 |
def test_api_events_fillslots_multiple_agendas_overlapping_events(app, user, freezer): |
|
655 |
agenda = Agenda.objects.create(label='Foo bar', kind='events') |
|
656 |
Event.objects.create( |
|
657 |
label='Event', |
|
658 |
start_datetime=now() + datetime.timedelta(days=5), |
|
659 |
duration=120, |
|
660 |
places=5, |
|
661 |
agenda=agenda, |
|
662 |
) |
|
663 |
second_agenda = Agenda.objects.create(label='Foo bar 2', kind='events') |
|
664 |
Event.objects.create( |
|
665 |
label='Event 2', |
|
666 |
start_datetime=now() + datetime.timedelta(days=5, hours=1), |
|
667 |
duration=120, |
|
668 |
places=5, |
|
669 |
agenda=second_agenda, |
|
670 |
) |
|
671 | ||
672 |
app.authorization = ('Basic', ('john.doe', 'password')) |
|
673 |
fillslots_url = '/api/agendas/events/fillslots/?agendas=%s' |
|
674 |
resp = app.post_json( |
|
675 |
fillslots_url % ','.join((agenda.slug, second_agenda.slug)), |
|
676 |
params={ |
|
677 |
'user_external_id': 'user_id', |
|
678 |
'check_overlaps': True, |
|
679 |
'slots': 'foo-bar@event,foo-bar-2@event-2', |
|
680 |
}, |
|
681 |
) |
|
682 |
assert resp.json['err'] == 1 |
|
683 |
assert resp.json['err_desc'] == 'Some events occur at the same time: Event, Event 2' |
|
684 | ||
685 |
# events can be booked separately |
|
686 |
resp = app.post_json( |
|
687 |
fillslots_url % agenda.slug, |
|
688 |
params={'user_external_id': 'user_id', 'check_overlaps': True, 'slots': 'foo-bar@event'}, |
|
689 |
) |
|
690 |
assert resp.json['booking_count'] == 1 |
|
691 | ||
692 |
resp = app.post_json( |
|
693 |
fillslots_url % second_agenda.slug, |
|
694 |
params={'user_external_id': 'user_id', 'check_overlaps': True, 'slots': 'foo-bar-2@event-2'}, |
|
695 |
) |
|
696 |
assert resp.json['booking_count'] == 1 |
|
651 |
- |