Projet

Général

Profil

0002-api-forbid-overlapping-recurring-events-booking-6438.patch

Valentin Deniaud, 09 mai 2022 16:19

Télécharger (15,7 ko)

Voir les différences:

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

 chrono/agendas/models.py                     |  40 ++++++-
 chrono/api/serializers.py                    |   1 +
 chrono/api/views.py                          |  35 +++++-
 tests/api/datetimes/test_recurring_events.py |  68 +++++++++++
 tests/api/fillslot/test_recurring_events.py  | 115 ++++++++++++++++++-
 5 files changed, 254 insertions(+), 5 deletions(-)
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='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('/api/agendas/recurring-events/?agendas=first-agenda,second-agenda&sort=day')
492
    assert [(x['id'], x['overlaps']) for x in resp.json['data']] == [
493
        ('first-agenda@event-12-14:1', ['second-agenda@event-12-18:1']),
494
        (
495
            'second-agenda@event-12-18:1',
496
            ['first-agenda@event-12-14:1', 'first-agenda@event-14-15:1', 'first-agenda@event-15-17:1'],
497
        ),
498
        ('first-agenda@event-14-15:1', ['second-agenda@event-12-18:1']),
499
        ('first-agenda@event-15-17:1', ['second-agenda@event-12-18:1']),
500
        ('first-agenda@event-15-17:3', []),
501
        ('second-agenda@event-12-18:5', ['first-agenda@event-15-17:5']),
502
        ('second-agenda@no-duration:5', []),
503
        ('first-agenda@event-15-17:5', ['second-agenda@event-12-18:5']),
504
    ]
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='Event 12-14',
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='Event 14-15',
1392
        start_datetime=start + datetime.timedelta(hours=2),
1393
        duration=60,
1394
        places=2,
1395
        recurrence_end_date=end,
1396
        recurrence_days=[1],
1397
        agenda=agenda,
1398
    ).create_all_recurrences()
1399
    Event.objects.create(
1400
        label='Event 15-17',
1401
        start_datetime=start + datetime.timedelta(hours=3),
1402
        duration=120,
1403
        places=2,
1404
        recurrence_end_date=end,
1405
        recurrence_days=[1, 3, 5],
1406
        agenda=agenda,
1407
    ).create_all_recurrences()
1408
    agenda2 = Agenda.objects.create(label='Second Agenda', kind='events')
1409
    Desk.objects.create(agenda=agenda2, slug='_exceptions_holder')
1410
    Event.objects.create(
1411
        label='Event 12-18',
1412
        start_datetime=start,
1413
        duration=360,
1414
        places=2,
1415
        recurrence_end_date=end,
1416
        recurrence_days=[1, 5],
1417
        agenda=agenda2,
1418
    ).create_all_recurrences()
1419
    Event.objects.create(
1420
        label='No duration',
1421
        start_datetime=start,
1422
        places=2,
1423
        recurrence_end_date=end,
1424
        recurrence_days=[5],
1425
        agenda=agenda2,
1426
    ).create_all_recurrences()
1427

  
1428
    app.authorization = ('Basic', ('john.doe', 'password'))
1429
    fillslots_url = '/api/agendas/recurring-events/fillslots/?action=%s&agendas=%s'
1430

  
1431
    # booking without overlap
1432
    params = {
1433
        'user_external_id': 'user_id',
1434
        'slots': 'first-agenda@event-12-14:1,first-agenda@event-14-15:1,second-agenda@event-12-18:5',
1435
    }
1436
    resp = app.post_json(fillslots_url % ('update', 'first-agenda,second-agenda'), params=params)
1437
    assert resp.json['booking_count'] == 14
1438

  
1439
    # book again
1440
    resp = app.post_json(fillslots_url % ('update', 'first-agenda,second-agenda'), params=params)
1441
    assert resp.json['booking_count'] == 0
1442

  
1443
    # change bookings
1444
    params = {'user_external_id': 'user_id', 'slots': 'second-agenda@event-12-18:1'}
1445
    resp = app.post_json(fillslots_url % ('update', 'first-agenda,second-agenda'), params=params)
1446
    assert resp.json['booking_count'] == 5
1447
    assert resp.json['cancelled_booking_count'] == 14
1448

  
1449
    # booking overlapping events is allowed if one has no duration
1450
    params = {
1451
        'user_external_id': 'user_id',
1452
        'slots': 'second-agenda@event-12-18:5,second-agenda@no-duration:5',
1453
    }
1454
    resp = app.post_json(fillslots_url % ('update', 'first-agenda,second-agenda'), params=params)
1455
    assert resp.json['booking_count'] == 8
1456
    assert resp.json['cancelled_booking_count'] == 5
1457

  
1458
    # booking overlapping events with durations is forbidden
1459
    params = {
1460
        'user_external_id': 'user_id',
1461
        'slots': 'first-agenda@event-12-14:1,second-agenda@event-12-18:1',
1462
    }
1463
    resp = app.post_json(fillslots_url % ('update', 'first-agenda,second-agenda'), params=params)
1464
    assert resp.json['err'] == 1
1465
    assert (
1466
        resp.json['err_desc']
1467
        == 'Some events occur at the same time: first-agenda@event-12-14:1 / second-agenda@event-12-18:1'
1468
    )
1469

  
1470
    params = {
1471
        'user_external_id': 'user_id',
1472
        'slots': (
1473
            '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,'
1474
            'second-agenda@event-12-18:5,second-agenda@no-duration:5'
1475
        ),
1476
    }
1477
    resp = app.post_json(fillslots_url % ('update', 'first-agenda,second-agenda'), params=params)
1478
    assert resp.json['err'] == 1
1479
    assert resp.json['err_desc'] == (
1480
        'Some events occur at the same time: first-agenda@event-12-14:1 / second-agenda@event-12-18:1, '
1481
        'first-agenda@event-15-17:1 / second-agenda@event-12-18:1, first-agenda@event-15-17:5 / second-agenda@event-12-18:5'
1482
    )
1374
-