0002-api-allow-booking-all-recurrences-of-recurring-event.patch
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 |
- |