Projet

Général

Profil

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

Valentin Deniaud, 02 juin 2021 16:39

Télécharger (18,4 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      | 210 +++++++++++++++++++++++++++++++++++----
 tests/test_api.py        | 120 ++++++++++++++++++++++
 4 files changed, 326 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_rule__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
22 23
from django.db import transaction
23
from django.db.models import Count, Prefetch, Q
24
from django.db.models import BooleanField, Count, ExpressionWrapper, F, Max, Prefetch, Q, Value
24 25
from django.db.models.functions import TruncDay
25 26
from django.http import Http404, HttpResponse
26 27
from django.shortcuts import get_object_or_404
......
413 414
    return False
414 415

  
415 416

  
416
def get_event_detail(request, event, agenda=None, min_places=1):
417
    agenda = agenda or event.agenda
417
def get_event_text(event, agenda):
418 418
    event_text = force_text(event)
419 419
    if agenda.event_display_template:
420 420
        event_text = Template(agenda.event_display_template).render(Context({'event': event}))
......
423 423
            event.label,
424 424
            date_format(localtime(event.start_datetime), 'DATETIME_FORMAT'),
425 425
        )
426
    return event_text
427

  
428

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

  
558 563

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

  
583

  
559 584
class Agendas(APIView):
560 585
    permission_classes = ()
561 586

  
......
814 839
meeting_datetimes = MeetingDatetimes.as_view()
815 840

  
816 841

  
842
class RecurringEventsList(APIView):
843
    permission_classes = ()
844

  
845
    def get(self, request, agenda_identifier=None, format=None):
846
        agenda = get_object_or_404(Agenda, slug=agenda_identifier, kind='events')
847
        entries = agenda.get_open_recurring_events()
848

  
849
        events = []
850
        for event in entries:
851
            events.append(
852
                {
853
                    'id': event.slug,
854
                    'text': get_event_text(event, agenda),
855
                    'datetime': format_response_datetime(event.start_datetime),
856
                    'description': event.description,
857
                    'pricing': event.pricing,
858
                    'url': event.url,
859
                }
860
            )
861

  
862
        return Response({'data': events})
863

  
864

  
865
recurring_events_list = RecurringEventsList.as_view()
866

  
867

  
817 868
class MeetingList(APIView):
818 869
    permission_classes = ()
819 870

  
......
1282 1333
            primary_booking = None
1283 1334
            for event in events:
1284 1335
                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,
1336
                    new_booking = make_booking(
1337
                        event, payload, extra_data, primary_booking, in_waiting_list, color
1300 1338
                    )
1301
                    if primary_booking is not None:
1302
                        new_booking.primary_booking = primary_booking
1303 1339
                    new_booking.save()
1304 1340
                    if primary_booking is None:
1305 1341
                        primary_booking = new_booking
......
1386 1422
fillslot = Fillslot.as_view()
1387 1423

  
1388 1424

  
1425
class RecurringFillslots(APIView):
1426
    permission_classes = (permissions.IsAuthenticated,)
1427
    serializer_class = SlotsSerializer
1428

  
1429
    def post(self, request, agenda_identifier=None, format=None):
1430
        agenda = get_object_or_404(Agenda, slug=agenda_identifier, kind='events')
1431
        start_datetime, end_datetime = get_start_and_end_datetime_from_request(request)
1432
        if not start_datetime or start_datetime < now():
1433
            start_datetime = now()
1434

  
1435
        serializer = self.serializer_class(data=request.data, partial=True)
1436
        if not serializer.is_valid():
1437
            raise APIError(
1438
                _('invalid payload'),
1439
                err_class='invalid payload',
1440
                errors=serializer.errors,
1441
                http_status=status.HTTP_400_BAD_REQUEST,
1442
            )
1443
        payload = serializer.validated_data
1444

  
1445
        user_external_id = payload.get('user_external_id') or None
1446
        exclude_user = payload.get('exclude_user')
1447

  
1448
        recurring_events = agenda.get_open_recurring_events().filter(slug__in=payload['slots'])
1449
        if not recurring_events.exists():
1450
            raise APIError(
1451
                _('unknown recurring event slugs'),
1452
                err_class='unknown recurring event slugs',
1453
                http_status=status.HTTP_400_BAD_REQUEST,
1454
            )
1455

  
1456
        events_to_book = Event.objects.filter(
1457
            primary_event__in=recurring_events,
1458
            start_datetime__gte=start_datetime,
1459
            cancelled=False,
1460
        )
1461
        full_events = list(events_to_book.filter(full=True))
1462
        events_to_book = events_to_book.filter(full=False)
1463
        if end_datetime:
1464
            events_to_book = events_to_book.filter(start_datetime__lte=end_datetime)
1465
        if not events_to_book.exists():
1466
            if full_events:
1467
                raise APIError(_('all events are all full'), err_class='all events are all full')
1468
            else:
1469
                raise APIError(_('no event recurrences to book'), err_class='no event recurrences to book')
1470
        if exclude_user and user_external_id:
1471
            events_to_book = events_to_book.exclude(booking__user_external_id=user_external_id)
1472
            if not events_to_book.exists():
1473
                raise APIError(
1474
                    _('events are already booked by user'), err_class='events are already booked by user'
1475
                )
1476

  
1477
        events_to_book = Event.annotate_queryset(events_to_book)
1478
        events_to_book = events_to_book.annotate(
1479
            in_waiting_list=ExpressionWrapper(
1480
                Q(booked_places_count__gte=F('places')), output_field=BooleanField()
1481
            )
1482
        )
1483

  
1484
        extra_data = {k: v for k, v in request.data.items() if k not in payload}
1485
        primary_booking = None
1486
        bookings = []
1487
        for event in events_to_book:
1488
            bookings.append(make_booking(event, payload, extra_data, primary_booking))
1489
            if primary_booking is None:
1490
                primary_booking = bookings.pop()
1491
                primary_booking.save()
1492

  
1493
        with transaction.atomic():
1494
            Booking.objects.bulk_create(bookings)
1495
            if django.VERSION < (2, 0):
1496
                from django.db.models import Case, When
1497

  
1498
                events_to_book.update(
1499
                    full=Case(
1500
                        When(
1501
                            Q(booked_places_count__gte=F('places'), waiting_list_places=0)
1502
                            | Q(
1503
                                waiting_list_places__gt=0,
1504
                                waiting_list_count__gte=F('waiting_list_places'),
1505
                            ),
1506
                            then=Value(True),
1507
                        ),
1508
                        default=Value(False),
1509
                    ),
1510
                    almost_full=Case(
1511
                        When(Q(booked_places_count__gte=0.9 * F('places')), then=Value(True)),
1512
                        default=Value(False),
1513
                    ),
1514
                )
1515
            else:
1516
                events_to_book.update(
1517
                    full=Q(booked_places_count__gte=F('places'), waiting_list_places=0)
1518
                    | Q(waiting_list_places__gt=0, waiting_list_count__gte=F('waiting_list_places')),
1519
                    almost_full=Q(booked_places_count__gte=0.9 * F('places')),
1520
                )
1521

  
1522
        response = {
1523
            'err': 0,
1524
            'booking_id': primary_booking.id,
1525
            'agenda': {
1526
                'label': primary_booking.event.agenda.label,
1527
                'slug': primary_booking.event.agenda.slug,
1528
            },
1529
            'api': {
1530
                'booking_url': request.build_absolute_uri(
1531
                    reverse('api-booking', kwargs={'booking_pk': primary_booking.id})
1532
                ),
1533
                'cancel_url': request.build_absolute_uri(
1534
                    reverse('api-cancel-booking', kwargs={'booking_pk': primary_booking.id})
1535
                ),
1536
                'ics_url': request.build_absolute_uri(
1537
                    reverse('api-booking-ics', kwargs={'booking_pk': primary_booking.id})
1538
                ),
1539
                'anonymize_url': request.build_absolute_uri(
1540
                    reverse('api-anonymize-booking', kwargs={'booking_pk': primary_booking.id})
1541
                ),
1542
                'accept_url': request.build_absolute_uri(
1543
                    reverse('api-accept-booking', kwargs={'booking_pk': primary_booking.pk})
1544
                ),
1545
                'suspend_url': request.build_absolute_uri(
1546
                    reverse('api-suspend-booking', kwargs={'booking_pk': primary_booking.pk})
1547
                ),
1548
            },
1549
            'full_events': [get_event_detail(request, x, agenda=agenda) for x in full_events],
1550
        }
1551
        return Response(response)
1552

  
1553

  
1554
recurring_fillslots = RecurringFillslots.as_view()
1555

  
1556

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

  
tests/test_api.py
6563 6563
            {'label': 'Absent', 'data': [None, None, 5, None]},
6564 6564
        ],
6565 6565
    }
6566

  
6567

  
6568
def test_recurring_events_api_list(app, freezer):
6569
    freezer.move_to('2021-09-06 12:00')
6570
    agenda = Agenda.objects.create(label='Foo bar', kind='events')
6571
    Event.objects.create(label='Normal event', start_datetime=now(), places=5, agenda=agenda)
6572
    event = Event.objects.create(
6573
        label='Monday',
6574
        start_datetime=now(),
6575
        repeat='weekly',
6576
        places=2,
6577
        agenda=agenda,
6578
    )
6579

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

  
6582
    # recurring events without recurrence_end_date are not bookable
6583
    resp = app.get('/api/agenda/%s/recurring_events/' % agenda.slug)
6584
    assert len(resp.json['data']) == 0
6585

  
6586
    event.recurrence_end_date = now() + datetime.timedelta(days=30)
6587
    event.save()
6588
    Event.objects.create(
6589
        label='Tuesday',
6590
        start_datetime=now() + datetime.timedelta(days=15),
6591
        repeat='weekly',
6592
        places=2,
6593
        agenda=agenda,
6594
        recurrence_end_date=now() + datetime.timedelta(days=45),
6595
    )
6596

  
6597
    resp = app.get('/api/agenda/%s/recurring_events/' % agenda.slug)
6598
    assert len(resp.json['data']) == 2
6599

  
6600
    event.publication_date = now() + datetime.timedelta(days=2)
6601
    event.save()
6602
    resp = app.get('/api/agenda/%s/recurring_events/' % agenda.slug)
6603
    assert len(resp.json['data']) == 1
6604

  
6605
    freezer.move_to(event.recurrence_end_date)
6606
    resp = app.get('/api/agenda/%s/recurring_events/' % agenda.slug)
6607
    assert len(resp.json['data']) == 1
6608

  
6609

  
6610
def test_recurring_events_api_fillslots(app, user, freezer):
6611
    freezer.move_to('2021-09-06 12:00')
6612
    agenda = Agenda.objects.create(label='Foo bar', kind='events')
6613
    end_date = now() + datetime.timedelta(days=364)
6614
    for i, day in enumerate(('Monday', 'Tuesday', 'Thursday', 'Friday')):
6615
        event = Event.objects.create(
6616
            label=day,
6617
            start_datetime=now() + datetime.timedelta(days=i),
6618
            repeat='weekly',
6619
            places=2,
6620
            waiting_list_places=1,
6621
            agenda=agenda,
6622
            recurrence_end_date=end_date,
6623
        )
6624
        event.create_all_recurrences()
6625

  
6626
    resp = app.get('/api/agenda/%s/recurring_events/' % agenda.slug)
6627
    assert len(resp.json['data']) == 4
6628

  
6629
    app.authorization = ('Basic', ('john.doe', 'password'))
6630
    fillslots_url = '/api/agenda/%s/recurring_fillslots/' % agenda.slug
6631
    resp = app.post_json(fillslots_url, params={'slots': 'monday,tuesday'})
6632
    assert resp.json['err'] == 0
6633

  
6634
    assert Booking.objects.count() == 104
6635
    events = Event.annotate_queryset(Event.objects.filter(primary_event__isnull=False))
6636
    assert events.filter(booked_places_count=1).count() == 104
6637

  
6638
    # one recurrence is booked separately
6639
    event = Event.objects.filter(primary_event__isnull=False).first()
6640
    Booking.objects.create(event=event)
6641

  
6642
    resp = app.post_json(fillslots_url, params={'slots': 'monday,tuesday'})
6643
    assert resp.json['err'] == 0
6644
    assert not resp.json['full_events']
6645
    assert Booking.objects.count() == 209
6646
    events = Event.annotate_queryset(Event.objects.filter(primary_event__isnull=False))
6647
    assert events.filter(booked_places_count=2).count() == 104
6648
    # one booking has been put in waiting list
6649
    assert events.filter(waiting_list_count=1).count() == 1
6650

  
6651
    resp = app.post_json(fillslots_url, params={'slots': 'monday,tuesday'})
6652
    assert resp.json['err'] == 0
6653
    # everything goes in waiting list
6654
    assert events.filter(waiting_list_count=1).count() == 104
6655
    # but an event was reported full
6656
    assert len(resp.json['full_events']) == 1
6657
    assert resp.json['full_events'][0]['slug'] == event.slug
6658

  
6659
    resp = app.post_json(fillslots_url, params={'slots': 'monday,tuesday'})
6660
    assert resp.json['err'] == 1
6661
    assert resp.json['err_desc'] == 'all events are all full'
6662

  
6663
    resp = app.post_json(
6664
        fillslots_url + '?date_start=2021-10-06&date_end=2021-11-06',
6665
        params={'slots': 'friday', 'user_external_id': 'a'},
6666
    )
6667
    assert resp.json['err'] == 0
6668
    assert Booking.objects.filter(event__slug__startswith='friday').count() == 5
6669

  
6670
    resp = app.post_json(
6671
        fillslots_url + '?date_start=2021-10-06&date_end=2021-11-06',
6672
        params={'slots': 'friday', 'user_external_id': 'a', 'exclude_user': True},
6673
    )
6674
    assert resp.json['err'] == 1
6675
    assert resp.json['err_desc'] == 'events are already booked by user'
6676

  
6677
    resp = app.post_json(
6678
        fillslots_url + '?date_start=2020-10-06&date_end=2020-11-06', params={'slots': 'friday'}
6679
    )
6680
    assert resp.json['err'] == 1
6681
    assert resp.json['err_desc'] == 'no event recurrences to book'
6682

  
6683
    resp = app.post_json(fillslots_url, params={'slots': 'unknown'}, status=400)
6684
    assert resp.json['err'] == 1
6685
    assert resp.json['err_desc'] == 'unknown recurring event slugs'
6566
-