Projet

Général

Profil

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

Valentin Deniaud, 12 mai 2022 13:59

Télécharger (16,6 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                    |   2 +
 chrono/api/views.py                          |  42 ++++++-
 tests/api/datetimes/test_recurring_events.py |  73 +++++++++++
 tests/api/fillslot/test_recurring_events.py  | 125 ++++++++++++++++++-
 5 files changed, 279 insertions(+), 3 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
127 127

  
128 128
    def validate_slots(self, value):
129 129
        super().validate_slots(value)
130
        self.initial_slots = value
130 131
        open_event_slugs = collections.defaultdict(set)
131 132
        for agenda in self.context['agendas']:
132 133
            for event in agenda.get_open_recurring_events():
......
348 349

  
349 350
class RecurringEventsListSerializer(AgendaOrSubscribedSlugsSerializer):
350 351
    sort = serializers.ChoiceField(required=False, choices=['day'])
352
    check_overlaps = serializers.BooleanField(default=False)
351 353

  
352 354

  
353 355
class AgendaSlugsSerializer(serializers.Serializer):
chrono/api/views.py
1119 1119
        if not serializer.is_valid():
1120 1120
            raise APIErrorBadRequest(N_('invalid payload'), errors=serializer.errors)
1121 1121
        data = serializer.validated_data
1122
        check_overlaps = bool(data.get('check_overlaps'))
1122 1123

  
1123 1124
        guardian_external_id = data.get('guardian_external_id')
1124 1125
        if guardian_external_id:
......
1136 1137
            recurring_events = Event.objects.filter(pk__in=days_by_event).select_related(
1137 1138
                'agenda', 'agenda__category'
1138 1139
            )
1140
            if check_overlaps:
1141
                recurring_events = Event.annotate_recurring_events_with_overlaps(recurring_events)
1139 1142
            events = []
1140 1143
            for event in recurring_events:
1141 1144
                for day in days_by_event[event.pk]:
......
1143 1146
                    event.day = day
1144 1147
                    events.append(event)
1145 1148
        else:
1146
            agendas = Agenda.prefetch_recurring_events(data['agendas'])
1149
            agendas = Agenda.prefetch_recurring_events(data['agendas'], with_overlaps=check_overlaps)
1147 1150
            events = []
1148 1151
            for agenda in agendas:
1149 1152
                for event in agenda.get_open_recurring_events():
......
1152 1155
                        event.day = day
1153 1156
                        events.append(event)
1154 1157

  
1158
        if check_overlaps:
1159
            for event in events:
1160
                event.overlaps = [
1161
                    '%s:%s' % (x['slug'], day)
1162
                    for x in event.overlaps
1163
                    for day in x['days']
1164
                    if day == event.day
1165
                ]
1166

  
1155 1167
        if 'agendas' in request.query_params:
1156 1168
            agenda_querystring_indexes = {
1157 1169
                agenda_slug: i for i, agenda_slug in enumerate(data['agenda_slugs'])
......
1191 1203
                        'description': event.description,
1192 1204
                        'pricing': event.pricing,
1193 1205
                        'url': event.url,
1206
                        'overlaps': event.overlaps if check_overlaps else None,
1194 1207
                    }
1195 1208
                    for event in events
1196 1209
                ]
......
1704 1717
                guardian_external_id,
1705 1718
            ).values_list('pk', flat=True)
1706 1719

  
1720
        if payload.get('check_overlaps'):
1721
            self.check_for_overlaps(events_to_book, serializer.initial_slots)
1722

  
1707 1723
        # outdated bookings to remove (cancelled bookings to replace by an active booking)
1708 1724
        events_cancelled_to_delete = events_to_book.filter(
1709 1725
            booking__user_external_id=user_external_id,
......
1821 1837
        ]
1822 1838
        return events_to_unbook
1823 1839

  
1840
    @staticmethod
1841
    def check_for_overlaps(events, slots):
1842
        def get_slug(event, day):
1843
            slug = event['slug'] if isinstance(event, dict) else '%s@%s' % (event.agenda.slug, event.slug)
1844
            return '%s:%s' % (slug, day)
1845

  
1846
        recurring_events = Event.objects.filter(pk__in=events.values('primary_event_id'))
1847
        recurring_events = Event.annotate_recurring_events_with_overlaps(recurring_events)
1848

  
1849
        overlaps = set()
1850
        for event in recurring_events.select_related('agenda'):
1851
            overlaps.update(
1852
                tuple(sorted((get_slug(event, d), get_slug(x, d))))
1853
                for x in event.overlaps
1854
                for d in x['days']
1855
                if get_slug(x, d) in slots and get_slug(event, d) in slots
1856
            )
1857

  
1858
        if overlaps:
1859
            raise APIError(
1860
                N_('Some events occur at the same time: %s')
1861
                % ', '.join(sorted('%s / %s' % (x, y) for x, y in overlaps))
1862
            )
1863

  
1824 1864

  
1825 1865
recurring_fillslots = RecurringFillslots.as_view()
1826 1866

  
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(
492
        '/api/agendas/recurring-events/?agendas=first-agenda,second-agenda&sort=day&check_overlaps=true'
493
    )
494
    assert [(x['id'], x['overlaps']) for x in resp.json['data']] == [
495
        ('first-agenda@event-12-14:1', ['second-agenda@event-12-18:1']),
496
        (
497
            'second-agenda@event-12-18:1',
498
            ['first-agenda@event-12-14:1', 'first-agenda@event-14-15:1', 'first-agenda@event-15-17:1'],
499
        ),
500
        ('first-agenda@event-14-15:1', ['second-agenda@event-12-18:1']),
501
        ('first-agenda@event-15-17:1', ['second-agenda@event-12-18:1']),
502
        ('first-agenda@event-15-17:3', []),
503
        ('second-agenda@event-12-18:5', ['first-agenda@event-15-17:5']),
504
        ('second-agenda@no-duration:5', []),
505
        ('first-agenda@event-15-17:5', ['second-agenda@event-12-18:5']),
506
    ]
507

  
508
    resp = app.get('/api/agendas/recurring-events/?agendas=first-agenda,second-agenda&sort=day')
509
    assert ['overlaps' not in x for x in resp.json['data']]
tests/api/fillslot/test_recurring_events.py
1288 1288
                'slots': events_to_book,
1289 1289
                'user_external_id': 'user',
1290 1290
                'include_booked_events_detail': True,
1291
                'check_overlaps': True,
1291 1292
            },
1292 1293
        )
1293 1294
        assert resp.json['booking_count'] == 180
1294
        assert len(ctx.captured_queries) == 13
1295
        assert len(ctx.captured_queries) == 14
1295 1296

  
1296 1297
    father = Person.objects.create(user_external_id='father_id', first_name='John', last_name='Doe')
1297 1298
    mother = Person.objects.create(user_external_id='mother_id', first_name='Jane', last_name='Doe')
......
1371 1372
        '2022-03-19',
1372 1373
        '2022-03-20',
1373 1374
    ]
1375

  
1376

  
1377
@pytest.mark.freeze_time('2021-09-06 12:00')
1378
def test_recurring_events_api_fillslots_overlapping_events(app, user):
1379
    agenda = Agenda.objects.create(label='First Agenda', kind='events')
1380
    Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
1381
    start, end = now(), now() + datetime.timedelta(days=30)
1382
    Event.objects.create(
1383
        label='Event 12-14',
1384
        start_datetime=start,
1385
        duration=120,
1386
        places=2,
1387
        recurrence_end_date=end,
1388
        recurrence_days=[1],
1389
        agenda=agenda,
1390
    ).create_all_recurrences()
1391
    Event.objects.create(
1392
        label='Event 14-15',
1393
        start_datetime=start + datetime.timedelta(hours=2),
1394
        duration=60,
1395
        places=2,
1396
        recurrence_end_date=end,
1397
        recurrence_days=[1],
1398
        agenda=agenda,
1399
    ).create_all_recurrences()
1400
    Event.objects.create(
1401
        label='Event 15-17',
1402
        start_datetime=start + datetime.timedelta(hours=3),
1403
        duration=120,
1404
        places=2,
1405
        recurrence_end_date=end,
1406
        recurrence_days=[1, 3, 5],
1407
        agenda=agenda,
1408
    ).create_all_recurrences()
1409
    agenda2 = Agenda.objects.create(label='Second Agenda', kind='events')
1410
    Desk.objects.create(agenda=agenda2, slug='_exceptions_holder')
1411
    Event.objects.create(
1412
        label='Event 12-18',
1413
        start_datetime=start,
1414
        duration=360,
1415
        places=2,
1416
        recurrence_end_date=end,
1417
        recurrence_days=[1, 5],
1418
        agenda=agenda2,
1419
    ).create_all_recurrences()
1420
    Event.objects.create(
1421
        label='No duration',
1422
        start_datetime=start,
1423
        places=2,
1424
        recurrence_end_date=end,
1425
        recurrence_days=[5],
1426
        agenda=agenda2,
1427
    ).create_all_recurrences()
1428

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

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

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

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

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

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

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

  
1489
    # overlaps check is disabled by default
1490
    params = {
1491
        'user_external_id': 'user_id',
1492
        'slots': 'first-agenda@event-12-14:1,second-agenda@event-12-18:1',
1493
    }
1494
    resp = app.post_json(fillslots_url % ('update', 'first-agenda,second-agenda'), params=params)
1495
    assert resp.json['err'] == 0
1496
    assert resp.json['booking_count'] == 10
1374
-