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/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 | |
... | ... | |
926 | 928 |
show_out_of_minimal_delay=show_past_events, |
927 | 929 |
) |
928 | 930 |
entries = Event.annotate_queryset_for_user(entries, user_external_id, with_status=with_status) |
931 |
entries = Event.annotate_queryset_with_overlaps(entries) |
|
929 | 932 |
if show_only_subscribed: |
930 | 933 |
entries = entries.filter( |
931 | 934 |
agenda__subscriptions__user_external_id=user_external_id, |
... | ... | |
1846 | 1849 | |
1847 | 1850 |
events = self.get_events(request, payload) |
1848 | 1851 | |
1852 |
overlapping_events = Event.annotate_queryset_with_overlaps(events).filter(has_overlap=True) |
|
1853 |
if overlapping_events: |
|
1854 |
raise APIError( |
|
1855 |
N_('Some events occur at the same time: %s'), |
|
1856 |
', '.join(sorted(str(x) for x in overlapping_events)), |
|
1857 |
) |
|
1858 | ||
1849 | 1859 |
already_booked_events = self.get_already_booked_events(user_external_id) |
1850 | 1860 |
already_booked_events = already_booked_events.filter(start_datetime__gt=now()) |
1851 | 1861 |
if start_datetime: |
... | ... | |
1877 | 1887 |
booking__user_external_id=user_external_id, |
1878 | 1888 |
booking__cancellation_datetime__isnull=False, |
1879 | 1889 |
) |
1890 | ||
1880 | 1891 |
# book only events without active booking for the user |
1881 | 1892 |
events = events.exclude( |
1882 | 1893 |
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 | ||
---|---|---|
1352 | 1352 |
status=400, |
1353 | 1353 |
) |
1354 | 1354 |
assert 'required' in resp.json['errors']['user_external_id'][0] |
1355 | ||
1356 | ||
1357 |
@pytest.mark.freeze_time('2021-09-06 12:00') |
|
1358 |
def test_datetimes_multiple_agendas_overlapping_events(app): |
|
1359 |
agenda = Agenda.objects.create(label='Foo bar', kind='events') |
|
1360 |
Event.objects.create( |
|
1361 |
label='Event', |
|
1362 |
start_datetime=now() + datetime.timedelta(days=5), |
|
1363 |
duration=120, |
|
1364 |
places=5, |
|
1365 |
agenda=agenda, |
|
1366 |
) |
|
1367 |
Event.objects.create( |
|
1368 |
label='Event containing all events', |
|
1369 |
start_datetime=now() + datetime.timedelta(days=4, hours=23), |
|
1370 |
duration=440, |
|
1371 |
places=5, |
|
1372 |
agenda=agenda, |
|
1373 |
) |
|
1374 |
second_agenda = Agenda.objects.create(label='Foo bar 2', kind='events') |
|
1375 |
Event.objects.create( |
|
1376 |
label='Event', |
|
1377 |
start_datetime=now() + datetime.timedelta(days=5, hours=1), |
|
1378 |
duration=120, |
|
1379 |
places=5, |
|
1380 |
agenda=second_agenda, |
|
1381 |
) |
|
1382 |
Event.objects.create( |
|
1383 |
label='Event', |
|
1384 |
start_datetime=now() + datetime.timedelta(days=5, hours=2), |
|
1385 |
duration=120, |
|
1386 |
places=5, |
|
1387 |
agenda=second_agenda, |
|
1388 |
) |
|
1389 |
Event.objects.create( |
|
1390 |
label='Event no duration', |
|
1391 |
start_datetime=now() + datetime.timedelta(days=5, hours=1), |
|
1392 |
places=5, |
|
1393 |
agenda=second_agenda, |
|
1394 |
) |
|
1395 | ||
1396 |
resp = app.get('/api/agendas/datetimes/', params={'agendas': 'foo-bar,foo-bar-2'}) |
|
1397 |
assert [(x['id'], x['overlaps']) for x in resp.json['data']] == [ |
|
1398 |
('foo-bar@event-containing-all-events', ['foo-bar@event', 'foo-bar-2@event', 'foo-bar-2@event-1']), |
|
1399 |
('foo-bar@event', ['foo-bar@event-containing-all-events', 'foo-bar-2@event']), |
|
1400 |
('foo-bar-2@event', ['foo-bar@event-containing-all-events', 'foo-bar@event', 'foo-bar-2@event-1']), |
|
1401 |
('foo-bar-2@event-no-duration', []), |
|
1402 |
('foo-bar-2@event-1', ['foo-bar@event-containing-all-events', 'foo-bar-2@event']), |
|
1403 |
] |
tests/api/fillslot/test_events.py | ||
---|---|---|
33 | 33 |
params = {'user_external_id': 'user_id', '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', |
|
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 2', |
|
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 no duration', |
|
426 |
start_datetime=now() + datetime.timedelta(days=5, hours=1), |
|
427 |
places=5, |
|
428 |
agenda=agenda, |
|
429 |
) |
|
430 | ||
431 |
app.authorization = ('Basic', ('john.doe', 'password')) |
|
432 |
fillslots_url = '/api/agenda/foo-bar/events/fillslots/' |
|
433 |
resp = app.post_json(fillslots_url, params={'user_external_id': 'user_id', 'slots': 'event'}) |
|
434 |
assert resp.json['booking_count'] == 1 |
|
435 | ||
436 |
# booking the same event is still allowed |
|
437 |
resp = app.post_json(fillslots_url, params={'user_external_id': 'user_id', 'slots': 'event'}) |
|
438 |
assert resp.json['err'] == 0 |
|
439 |
assert resp.json['booking_count'] == 0 |
|
440 | ||
441 |
# changing booking to second event is allowed |
|
442 |
resp = app.post_json(fillslots_url, params={'user_external_id': 'user_id', 'slots': 'event-2'}) |
|
443 |
assert resp.json['err'] == 0 |
|
444 |
assert resp.json['booking_count'] == 1 |
|
445 |
assert resp.json['cancelled_booking_count'] == 1 |
|
446 | ||
447 |
# booking overlapping events is allowed if one has no duration |
|
448 |
resp = app.post_json( |
|
449 |
fillslots_url, params={'user_external_id': 'user_id', 'slots': 'event,event-no-duration'} |
|
450 |
) |
|
451 |
assert resp.json['err'] == 0 |
|
452 |
assert resp.json['booking_count'] == 2 |
|
453 |
assert resp.json['cancelled_booking_count'] == 1 |
|
454 | ||
455 |
# booking overlapping events with durations is forbidden |
|
456 |
resp = app.post_json(fillslots_url, params={'user_external_id': 'user_id', 'slots': 'event,event-2'}) |
|
457 |
assert resp.json['err'] == 1 |
|
458 |
assert resp.json['err_desc'] == 'Some events occur at the same time: Event, Event 2' |
|
459 | ||
460 |
# still overlaps but start before |
|
461 |
second_event.start_datetime -= datetime.timedelta(hours=2) |
|
462 |
second_event.save() |
|
463 | ||
464 |
resp = app.post_json(fillslots_url, params={'user_external_id': 'user_id', 'slots': 'event,event-2'}) |
|
465 |
assert resp.json['err'] == 1 |
|
466 |
assert resp.json['err_desc'] == 'Some events occur at the same time: Event, Event 2' |
|
467 | ||
468 |
# still overlaps but contains first event |
|
469 |
second_event.start_datetime = first_event.start_datetime - datetime.timedelta(minutes=10) |
|
470 |
second_event.save() |
|
471 |
second_event.duration = first_event.duration + 10 |
|
472 |
second_event.save() |
|
473 | ||
474 |
resp = app.post_json(fillslots_url, params={'user_external_id': 'user_id', 'slots': 'event,event-2'}) |
|
475 |
assert resp.json['err'] == 1 |
|
476 |
assert resp.json['err_desc'] == 'Some events occur at the same time: Event, Event 2' |
|
477 | ||
478 |
# still overlaps but contained by first event |
|
479 |
second_event.start_datetime = first_event.start_datetime + datetime.timedelta(minutes=10) |
|
480 |
second_event.save() |
|
481 |
second_event.duration = first_event.duration - 10 |
|
482 |
second_event.save() |
|
483 | ||
484 |
resp = app.post_json(fillslots_url, params={'user_external_id': 'user_id', 'slots': 'event,event-2'}) |
|
485 |
assert resp.json['err'] == 1 |
|
486 |
assert resp.json['err_desc'] == 'Some events occur at the same time: Event, Event 2' |
|
487 | ||
488 |
# no more overlap |
|
489 |
second_event.start_datetime -= datetime.timedelta(hours=5) |
|
490 |
second_event.save() |
|
491 | ||
492 |
resp = app.post_json(fillslots_url, params={'user_external_id': 'user_id', 'slots': 'event,event-2'}) |
|
493 |
assert resp.json['booking_count'] == 1 |
tests/api/fillslot/test_events_multiple_agendas.py | ||
---|---|---|
163 | 163 |
params = {'user_external_id': 'user_id', '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={'user_external_id': 'user_id', 'slots': 'foo-bar@event,foo-bar-2@event-2'}, |
|
677 |
) |
|
678 |
assert resp.json['err'] == 1 |
|
679 |
assert resp.json['err_desc'] == 'Some events occur at the same time: Event, Event 2' |
|
680 | ||
681 |
# events can be booked separately |
|
682 |
resp = app.post_json( |
|
683 |
fillslots_url % agenda.slug, params={'user_external_id': 'user_id', 'slots': 'foo-bar@event'} |
|
684 |
) |
|
685 |
assert resp.json['booking_count'] == 1 |
|
686 | ||
687 |
resp = app.post_json( |
|
688 |
fillslots_url % second_agenda.slug, |
|
689 |
params={'user_external_id': 'user_id', 'slots': 'foo-bar-2@event-2'}, |
|
690 |
) |
|
691 |
assert resp.json['booking_count'] == 1 |
|
651 |
- |