Projet

Général

Profil

0001-api-forbid-overlapping-events-booking-64383.patch

Valentin Deniaud, 12 mai 2022 13:59

Télécharger (20,7 ko)

Voir les différences:

Subject: [PATCH 1/2] api: forbid overlapping events booking (#64383)

 chrono/agendas/models.py                      |  29 ++++-
 chrono/api/serializers.py                     |   2 +
 chrono/api/views.py                           |  14 +++
 chrono/utils/db.py                            |  11 +-
 .../datetimes/test_events_multiple_agendas.py |  86 ++++++++++++-
 tests/api/fillslot/test_events.py             | 118 +++++++++++++++++-
 .../fillslot/test_events_multiple_agendas.py  |  50 +++++++-
 7 files changed, 299 insertions(+), 11 deletions(-)
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
        overlapping_events = Event.annotate_queryset_with_overlaps(events).filter(has_overlap=True)
1856
        if check_overlaps and overlapping_events:
1857
            raise APIError(
1858
                N_('Some events occur at the same time: %s'),
1859
                ', '.join(sorted(str(x) for x in overlapping_events)),
1860
            )
1861

  
1849 1862
        already_booked_events = self.get_already_booked_events(user_external_id)
1850 1863
        already_booked_events = already_booked_events.filter(start_datetime__gt=now())
1851 1864
        if start_datetime:
......
1877 1890
            booking__user_external_id=user_external_id,
1878 1891
            booking__cancellation_datetime__isnull=False,
1879 1892
        )
1893

  
1880 1894
        # book only events without active booking for the user
1881 1895
        events = events.exclude(
1882 1896
            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
-