Projet

Général

Profil

0002-api-allow-booking-all-recurrences-of-recurring-event.patch

Valentin Deniaud, 09 juin 2021 18:08

Télécharger (21 ko)

Voir les différences:

Subject: [PATCH 2/2] api: allow booking all recurrences of recurring events
 (#54332)

 chrono/agendas/models.py    |   7 ++
 chrono/api/urls.py          |  10 ++
 chrono/api/views.py         | 208 ++++++++++++++++++++++++++++++++----
 chrono/settings.py          |   2 +
 tests/api/test_datetimes.py |  51 +++++++++
 tests/api/test_fillslot.py  |  93 ++++++++++++++++
 tests/settings.py           |   2 +
 7 files changed, 352 insertions(+), 21 deletions(-)
chrono/agendas/models.py
664 664

  
665 665
        return entries
666 666

  
667
    def get_open_recurring_events(self):
668
        return self.event_set.filter(
669
            Q(publication_date__isnull=True) | Q(publication_date__lte=localtime(now()).date()),
670
            recurrence_days__isnull=False,
671
            recurrence_end_date__gt=localtime(now()).date(),
672
        )
673

  
667 674
    def add_event_recurrences(
668 675
        self,
669 676
        events,
chrono/api/urls.py
22 22
    url(r'^agenda/$', views.agendas),
23 23
    url(r'^agenda/(?P<agenda_identifier>[\w-]+)/$', views.agenda_detail),
24 24
    url(r'^agenda/(?P<agenda_identifier>[\w-]+)/datetimes/$', views.datetimes, name='api-agenda-datetimes'),
25
    url(
26
        r'^agenda/(?P<agenda_identifier>[\w-]+)/recurring_events/$',
27
        views.recurring_events_list,
28
        name='api-agenda-recurring-events',
29
    ),
25 30
    url(
26 31
        r'^agenda/(?P<agenda_identifier>[\w-]+)/fillslot/(?P<event_identifier>[\w:-]+)/$',
27 32
        views.fillslot,
28 33
        name='api-fillslot',
29 34
    ),
30 35
    url(r'^agenda/(?P<agenda_identifier>[\w-]+)/fillslots/$', views.fillslots, name='api-agenda-fillslots'),
36
    url(
37
        r'^agenda/(?P<agenda_identifier>[\w-]+)/recurring_fillslots/$',
38
        views.recurring_fillslots,
39
        name='api-recurring-fillslots',
40
    ),
31 41
    url(
32 42
        r'^agenda/(?P<agenda_identifier>[\w-]+)/status/(?P<event_identifier>[\w:-]+)/$',
33 43
        views.slot_status,
chrono/api/views.py
19 19
import itertools
20 20
import uuid
21 21

  
22
import django
23
from django.conf import settings
22 24
from django.db import transaction
23
from django.db.models import Count, Prefetch, Q
25
from django.db.models import BooleanField, Count, ExpressionWrapper, F, Max, Prefetch, Q, Value
24 26
from django.db.models.functions import TruncDay
25 27
from django.http import Http404, HttpResponse
26 28
from django.shortcuts import get_object_or_404
27 29
from django.template import Context, Template
28 30
from django.urls import reverse
29 31
from django.utils.dateparse import parse_date, parse_datetime
32
from django.utils.dates import WEEKDAYS
30 33
from django.utils.encoding import force_text
31 34
from django.utils.formats import date_format
32 35
from django.utils.timezone import is_naive, localtime, make_aware, now
......
413 416
    return False
414 417

  
415 418

  
416
def get_event_detail(request, event, agenda=None, min_places=1):
417
    agenda = agenda or event.agenda
419
def get_event_text(event, agenda, day=None):
418 420
    event_text = force_text(event)
419 421
    if agenda.event_display_template:
420 422
        event_text = Template(agenda.event_display_template).render(Context({'event': event}))
......
423 425
            event.label,
424 426
            date_format(localtime(event.start_datetime), 'DATETIME_FORMAT'),
425 427
        )
428
    elif event.recurrence_days:
429
        event_text = _('%s: %s') % (WEEKDAYS[day].capitalize(), event_text)
430
    return event_text
431

  
432

  
433
def get_event_detail(request, event, agenda=None, min_places=1):
434
    agenda = agenda or event.agenda
426 435
    return {
427 436
        'id': event.slug,
428 437
        'slug': event.slug,  # kept for compatibility
429
        'text': event_text,
438
        'text': get_event_text(event, agenda),
430 439
        'datetime': format_response_datetime(event.start_datetime),
431 440
        'description': event.description,
432 441
        'pricing': event.pricing,
......
556 565
    return start_datetime, end_datetime
557 566

  
558 567

  
568
def make_booking(event, payload, extra_data, primary_booking=None, in_waiting_list=False, color=None):
569
    return Booking(
570
        event_id=event.pk,
571
        in_waiting_list=getattr(event, 'in_waiting_list', in_waiting_list),
572
        primary_booking=primary_booking,
573
        label=payload.get('label', ''),
574
        user_external_id=payload.get('user_external_id', ''),
575
        user_first_name=payload.get('user_first_name', ''),
576
        user_last_name=payload.get('user_last_name') or payload.get('user_name') or '',
577
        user_email=payload.get('user_email', ''),
578
        user_phone_number=payload.get('user_phone_number', ''),
579
        form_url=payload.get('form_url', ''),
580
        backoffice_url=payload.get('backoffice_url', ''),
581
        cancel_callback_url=payload.get('cancel_callback_url', ''),
582
        user_display_label=payload.get('user_display_label', ''),
583
        extra_data=extra_data,
584
        color=color,
585
    )
586

  
587

  
559 588
class Agendas(APIView):
560 589
    permission_classes = ()
561 590

  
......
814 843
meeting_datetimes = MeetingDatetimes.as_view()
815 844

  
816 845

  
846
class RecurringEventsList(APIView):
847
    permission_classes = ()
848

  
849
    def get(self, request, agenda_identifier=None, format=None):
850
        if not settings.ENABLE_RECURRING_EVENT_BOOKING:
851
            raise Http404()
852

  
853
        agenda = get_object_or_404(Agenda, slug=agenda_identifier, kind='events')
854
        entries = agenda.get_open_recurring_events()
855

  
856
        events = []
857
        for event in entries:
858
            for day in event.recurrence_days:
859
                slug = '%s:%s' % (event.slug, day)
860
                events.append(
861
                    {
862
                        'id': slug,
863
                        'text': get_event_text(event, agenda, day),
864
                        'datetime': format_response_datetime(event.start_datetime),
865
                        'description': event.description,
866
                        'pricing': event.pricing,
867
                        'url': event.url,
868
                    }
869
                )
870

  
871
        return Response({'data': events})
872

  
873

  
874
recurring_events_list = RecurringEventsList.as_view()
875

  
876

  
817 877
class MeetingList(APIView):
818 878
    permission_classes = ()
819 879

  
......
1282 1342
            primary_booking = None
1283 1343
            for event in events:
1284 1344
                for i in range(places_count):
1285
                    new_booking = Booking(
1286
                        event_id=event.id,
1287
                        in_waiting_list=in_waiting_list,
1288
                        label=payload.get('label', ''),
1289
                        user_external_id=payload.get('user_external_id', ''),
1290
                        user_first_name=payload.get('user_first_name', ''),
1291
                        user_last_name=payload.get('user_last_name') or payload.get('user_name') or '',
1292
                        user_email=payload.get('user_email', ''),
1293
                        user_phone_number=payload.get('user_phone_number', ''),
1294
                        form_url=payload.get('form_url', ''),
1295
                        backoffice_url=payload.get('backoffice_url', ''),
1296
                        cancel_callback_url=payload.get('cancel_callback_url', ''),
1297
                        user_display_label=payload.get('user_display_label', ''),
1298
                        extra_data=extra_data,
1299
                        color=color,
1345
                    new_booking = make_booking(
1346
                        event, payload, extra_data, primary_booking, in_waiting_list, color
1300 1347
                    )
1301
                    if primary_booking is not None:
1302
                        new_booking.primary_booking = primary_booking
1303 1348
                    new_booking.save()
1304 1349
                    if primary_booking is None:
1305 1350
                        primary_booking = new_booking
......
1386 1431
fillslot = Fillslot.as_view()
1387 1432

  
1388 1433

  
1434
class RecurringFillslots(APIView):
1435
    permission_classes = (permissions.IsAuthenticated,)
1436
    serializer_class = SlotsSerializer
1437

  
1438
    def post(self, request, agenda_identifier=None, format=None):
1439
        if not settings.ENABLE_RECURRING_EVENT_BOOKING:
1440
            raise Http404()
1441

  
1442
        agenda = get_object_or_404(Agenda, slug=agenda_identifier, kind='events')
1443
        start_datetime, end_datetime = get_start_and_end_datetime_from_request(request)
1444
        if not start_datetime or start_datetime < now():
1445
            start_datetime = now()
1446

  
1447
        serializer = self.serializer_class(data=request.data, partial=True)
1448
        if not serializer.is_valid():
1449
            raise APIError(
1450
                _('invalid payload'),
1451
                err_class='invalid payload',
1452
                errors=serializer.errors,
1453
                http_status=status.HTTP_400_BAD_REQUEST,
1454
            )
1455
        payload = serializer.validated_data
1456

  
1457
        user_external_id = payload.get('user_external_id')
1458
        if not user_external_id:
1459
            raise APIError(
1460
                _('user_external_id is required'),
1461
                err_class='user_external_id is required',
1462
                errors=serializer.errors,
1463
                http_status=status.HTTP_400_BAD_REQUEST,
1464
            )
1465

  
1466
        open_event_slugs = set(agenda.get_open_recurring_events().values_list('slug', flat=True))
1467
        slots = collections.defaultdict(list)
1468
        for slot in payload['slots']:
1469
            try:
1470
                slug, day = slot.split(':')
1471
                day = int(day)
1472
            except ValueError:
1473
                raise APIError(
1474
                    _('invalid slot: %s') % slot,
1475
                    err_class='invalid slot: %s' % slot,
1476
                    http_status=status.HTTP_400_BAD_REQUEST,
1477
                )
1478
            if slug not in open_event_slugs:
1479
                raise APIError(
1480
                    _('event %s is not bookable') % slug,
1481
                    err_class='event %s is not bookable' % slug,
1482
                    http_status=status.HTTP_400_BAD_REQUEST,
1483
                )
1484
            # convert ISO day number to db lookup day number
1485
            day = (day + 1) % 7 + 1
1486
            slots[slug].append(day)
1487

  
1488
        event_filter = Q()
1489
        for slug, days in slots.items():
1490
            event_filter |= Q(agenda=agenda, primary_event__slug=slug, start_datetime__week_day__in=days)
1491

  
1492
        events_to_book = Event.objects.filter(event_filter)
1493
        events_to_book = events_to_book.filter(start_datetime__gte=start_datetime, cancelled=False)
1494

  
1495
        full_events = list(events_to_book.filter(full=True))
1496
        events_to_book = events_to_book.filter(full=False)
1497
        if end_datetime:
1498
            events_to_book = events_to_book.filter(start_datetime__lte=end_datetime)
1499
        if not events_to_book.exists():
1500
            if full_events:
1501
                raise APIError(_('all events are all full'), err_class='all events are all full')
1502
            else:
1503
                raise APIError(_('no event recurrences to book'), err_class='no event recurrences to book')
1504

  
1505
        events_to_book = Event.annotate_queryset(events_to_book)
1506
        events_to_book = events_to_book.annotate(
1507
            in_waiting_list=ExpressionWrapper(
1508
                Q(booked_places_count__gte=F('places')), output_field=BooleanField()
1509
            )
1510
        )
1511

  
1512
        extra_data = {k: v for k, v in request.data.items() if k not in payload}
1513
        bookings = [make_booking(event, payload, extra_data) for event in events_to_book]
1514

  
1515
        with transaction.atomic():
1516
            Booking.objects.bulk_create(bookings)
1517
            if django.VERSION < (2, 0):
1518
                from django.db.models import Case, When
1519

  
1520
                events_to_book.update(
1521
                    full=Case(
1522
                        When(
1523
                            Q(booked_places_count__gte=F('places'), waiting_list_places=0)
1524
                            | Q(
1525
                                waiting_list_places__gt=0,
1526
                                waiting_list_count__gte=F('waiting_list_places'),
1527
                            ),
1528
                            then=Value(True),
1529
                        ),
1530
                        default=Value(False),
1531
                    ),
1532
                    almost_full=Case(
1533
                        When(Q(booked_places_count__gte=0.9 * F('places')), then=Value(True)),
1534
                        default=Value(False),
1535
                    ),
1536
                )
1537
            else:
1538
                events_to_book.update(
1539
                    full=Q(booked_places_count__gte=F('places'), waiting_list_places=0)
1540
                    | Q(waiting_list_places__gt=0, waiting_list_count__gte=F('waiting_list_places')),
1541
                    almost_full=Q(booked_places_count__gte=0.9 * F('places')),
1542
                )
1543

  
1544
        response = {
1545
            'err': 0,
1546
            'booking_count': len(bookings),
1547
            'full_events': [get_event_detail(request, x, agenda=agenda) for x in full_events],
1548
        }
1549
        return Response(response)
1550

  
1551

  
1552
recurring_fillslots = RecurringFillslots.as_view()
1553

  
1554

  
1389 1555
class BookingSerializer(serializers.ModelSerializer):
1390 1556
    user_absence_reason = serializers.CharField(required=False, allow_blank=True, allow_null=True)
1391 1557

  
chrono/settings.py
188 188

  
189 189
REST_FRAMEWORK = {'EXCEPTION_HANDLER': 'chrono.api.utils.exception_handler'}
190 190

  
191
ENABLE_RECURRING_EVENT_BOOKING = False
192

  
191 193
local_settings_file = os.environ.get(
192 194
    'CHRONO_SETTINGS_FILE', os.path.join(os.path.dirname(__file__), 'local_settings.py')
193 195
)
tests/api/test_datetimes.py
648 648
    app.authorization = ('Basic', ('john.doe', 'password'))
649 649
    resp = app.post(fillslot_url, status=400)
650 650
    assert resp.json['err'] == 1
651

  
652

  
653
def test_recurring_events_api_list(app, freezer):
654
    freezer.move_to('2021-09-06 12:00')
655
    agenda = Agenda.objects.create(label='Foo bar', kind='events')
656
    Event.objects.create(label='Normal event', start_datetime=now(), places=5, agenda=agenda)
657
    event = Event.objects.create(
658
        label='Example Event',
659
        start_datetime=now(),
660
        recurrence_days=[0, 3, 4],  # Monday, Thursday, Friday
661
        places=2,
662
        agenda=agenda,
663
    )
664

  
665
    resp = app.get('/api/agenda/xxx/recurring_events/', status=404)
666

  
667
    # recurring events without recurrence_end_date are not bookable
668
    resp = app.get('/api/agenda/%s/recurring_events/' % agenda.slug)
669
    assert len(resp.json['data']) == 0
670

  
671
    event.recurrence_end_date = now() + datetime.timedelta(days=30)
672
    event.save()
673
    start_datetime = now() + datetime.timedelta(days=15)
674
    Event.objects.create(
675
        label='Other',
676
        start_datetime=start_datetime,
677
        recurrence_days=[start_datetime.weekday()],
678
        places=2,
679
        agenda=agenda,
680
        recurrence_end_date=now() + datetime.timedelta(days=45),
681
    )
682

  
683
    resp = app.get('/api/agenda/%s/recurring_events/' % agenda.slug)
684
    assert len(resp.json['data']) == 4
685
    assert resp.json['data'][0]['id'] == 'example-event:0'
686
    assert resp.json['data'][0]['text'] == 'Monday: Example Event'
687
    assert resp.json['data'][1]['id'] == 'example-event:3'
688
    assert resp.json['data'][1]['text'] == 'Thursday: Example Event'
689
    assert resp.json['data'][2]['id'] == 'example-event:4'
690
    assert resp.json['data'][2]['text'] == 'Friday: Example Event'
691
    assert resp.json['data'][3]['id'] == 'other:1'
692
    assert resp.json['data'][3]['text'] == 'Tuesday: Other'
693

  
694
    event.publication_date = now() + datetime.timedelta(days=2)
695
    event.save()
696
    resp = app.get('/api/agenda/%s/recurring_events/' % agenda.slug)
697
    assert len(resp.json['data']) == 1
698

  
699
    freezer.move_to(event.recurrence_end_date)
700
    resp = app.get('/api/agenda/%s/recurring_events/' % agenda.slug)
701
    assert len(resp.json['data']) == 1
tests/api/test_fillslot.py
1891 1891
    ics = app.get(resp.json['api']['ics_url']).text
1892 1892
    assert 'DTSTART:20170519T231200Z' in ics
1893 1893
    assert 'DTEND:20170520T004200Z' in ics
1894

  
1895

  
1896
def test_recurring_events_api_fillslots(app, user, freezer):
1897
    freezer.move_to('2021-09-06 12:00')
1898
    agenda = Agenda.objects.create(label='Foo bar', kind='events')
1899
    event = Event.objects.create(
1900
        label='Event',
1901
        start_datetime=now(),
1902
        recurrence_days=[0, 1, 3, 4],  # Monday, Tuesday, Thursday, Friday
1903
        places=2,
1904
        waiting_list_places=1,
1905
        agenda=agenda,
1906
        recurrence_end_date=now() + datetime.timedelta(days=364),
1907
    )
1908
    event.create_all_recurrences()
1909
    sunday_event = Event.objects.create(
1910
        label='Sunday Event',
1911
        start_datetime=now(),
1912
        recurrence_days=[6],
1913
        places=2,
1914
        waiting_list_places=1,
1915
        agenda=agenda,
1916
        recurrence_end_date=now() + datetime.timedelta(days=364),
1917
    )
1918
    sunday_event.create_all_recurrences()
1919

  
1920
    resp = app.get('/api/agenda/%s/recurring_events/' % agenda.slug)
1921
    assert len(resp.json['data']) == 5
1922

  
1923
    app.authorization = ('Basic', ('john.doe', 'password'))
1924
    fillslots_url = '/api/agenda/%s/recurring_fillslots/' % agenda.slug
1925
    params = {'user_external_id': 'user_id'}
1926
    # Book Monday and Thursday of first event and Sunday of second event
1927
    params['slots'] = 'event:0,event:3,sunday-event:6'
1928
    resp = app.post_json(fillslots_url, params=params)
1929
    assert resp.json['booking_count'] == 156
1930

  
1931
    assert Booking.objects.count() == 156
1932
    assert Booking.objects.filter(event__primary_event=event).count() == 104
1933
    assert Booking.objects.filter(event__primary_event=sunday_event).count() == 52
1934

  
1935
    events = Event.annotate_queryset(Event.objects.filter(primary_event__isnull=False))
1936
    assert events.filter(booked_places_count=1).count() == 156
1937

  
1938
    # one recurrence is booked separately
1939
    event = Event.objects.filter(primary_event__isnull=False).first()
1940
    Booking.objects.create(event=event)
1941

  
1942
    params['user_external_id'] = 'user_id_2'
1943
    resp = app.post_json(fillslots_url, params=params)
1944
    assert resp.json['booking_count'] == 156
1945
    assert not resp.json['full_events']
1946
    assert Booking.objects.count() == 313
1947
    events = Event.annotate_queryset(Event.objects.filter(primary_event__isnull=False))
1948
    assert events.filter(booked_places_count=2).count() == 156
1949
    # one booking has been put in waiting list
1950
    assert events.filter(waiting_list_count=1).count() == 1
1951

  
1952
    params['user_external_id'] = 'user_id_3'
1953
    resp = app.post_json(fillslots_url, params=params)
1954
    # everything goes in waiting list
1955
    assert events.filter(waiting_list_count=1).count() == 156
1956
    # but an event was full
1957
    assert resp.json['booking_count'] == 155
1958
    assert len(resp.json['full_events']) == 1
1959
    assert resp.json['full_events'][0]['slug'] == event.slug
1960

  
1961
    params['user_external_id'] = 'user_id_4'
1962
    resp = app.post_json(fillslots_url, params=params)
1963
    assert resp.json['err'] == 1
1964
    assert resp.json['err_desc'] == 'all events are all full'
1965

  
1966
    params['slots'] = 'event:1'
1967
    resp = app.post_json(fillslots_url + '?date_start=2021-10-06&date_end=2021-11-06', params=params)
1968
    assert resp.json['booking_count'] == 4
1969
    assert Booking.objects.filter(user_external_id='user_id_4').count() == 4
1970

  
1971
    resp = app.post_json(fillslots_url + '?date_start=2020-10-06&date_end=2020-11-06', params=params)
1972
    assert resp.json['err'] == 1
1973
    assert resp.json['err_desc'] == 'no event recurrences to book'
1974

  
1975
    del params['user_external_id']
1976
    resp = app.post_json(fillslots_url, params=params, status=400)
1977
    assert resp.json['err'] == 1
1978
    assert resp.json['err_desc'] == 'user_external_id is required'
1979

  
1980
    resp = app.post_json(fillslots_url, params={'user_external_id': 'a', 'slots': 'a:a'}, status=400)
1981
    assert resp.json['err'] == 1
1982
    assert resp.json['err_desc'] == 'invalid slot: a:a'
1983

  
1984
    resp = app.post_json(fillslots_url, params={'user_external_id': 'a', 'slots': 'a:1'}, status=400)
1985
    assert resp.json['err'] == 1
1986
    assert resp.json['err_desc'] == 'event a is not bookable'
tests/settings.py
32 32
EXCEPTIONS_SOURCES = {}
33 33

  
34 34
SITE_BASE_URL = 'https://example.com'
35

  
36
ENABLE_RECURRING_EVENT_BOOKING = True
35
-