api: allow booking all recurrences of recurring events (#54332)

This commit is contained in:
Valentin Deniaud 2021-03-02 14:05:13 +01:00
parent dcc4b44a67
commit 4fb6581e8d
7 changed files with 352 additions and 21 deletions

View File

@ -723,6 +723,13 @@ class Agenda(models.Model):
return entries
def get_open_recurring_events(self):
return self.event_set.filter(
Q(publication_date__isnull=True) | Q(publication_date__lte=localtime(now()).date()),
recurrence_days__isnull=False,
recurrence_end_date__gt=localtime(now()).date(),
)
def add_event_recurrences(
self,
events,

View File

@ -22,12 +22,22 @@ urlpatterns = [
url(r'^agenda/$', views.agendas),
url(r'^agenda/(?P<agenda_identifier>[\w-]+)/$', views.agenda_detail),
url(r'^agenda/(?P<agenda_identifier>[\w-]+)/datetimes/$', views.datetimes, name='api-agenda-datetimes'),
url(
r'^agenda/(?P<agenda_identifier>[\w-]+)/recurring_events/$',
views.recurring_events_list,
name='api-agenda-recurring-events',
),
url(
r'^agenda/(?P<agenda_identifier>[\w-]+)/fillslot/(?P<event_identifier>[\w:-]+)/$',
views.fillslot,
name='api-fillslot',
),
url(r'^agenda/(?P<agenda_identifier>[\w-]+)/fillslots/$', views.fillslots, name='api-agenda-fillslots'),
url(
r'^agenda/(?P<agenda_identifier>[\w-]+)/recurring_fillslots/$',
views.recurring_fillslots,
name='api-recurring-fillslots',
),
url(
r'^agenda/(?P<agenda_identifier>[\w-]+)/status/(?P<event_identifier>[\w:-]+)/$',
views.slot_status,

View File

@ -19,14 +19,17 @@ import datetime
import itertools
import uuid
import django
from django.conf import settings
from django.db import transaction
from django.db.models import Count, Prefetch, Q
from django.db.models import BooleanField, Count, ExpressionWrapper, F, Max, Prefetch, Q, Value
from django.db.models.functions import TruncDay
from django.http import Http404, HttpResponse
from django.shortcuts import get_object_or_404
from django.template import Context, Template, TemplateSyntaxError, VariableDoesNotExist
from django.urls import reverse
from django.utils.dateparse import parse_date, parse_datetime
from django.utils.dates import WEEKDAYS
from django.utils.encoding import force_text
from django.utils.formats import date_format
from django.utils.timezone import is_naive, localtime, make_aware, now
@ -421,8 +424,7 @@ def is_event_disabled(event, min_places=1):
return False
def get_event_detail(request, event, agenda=None, min_places=1, booked_user_external_id=None):
agenda = agenda or event.agenda
def get_event_text(event, agenda, day=None):
event_text = force_text(event)
if agenda.event_display_template:
try:
@ -434,10 +436,17 @@ def get_event_detail(request, event, agenda=None, min_places=1, booked_user_exte
event.label,
date_format(localtime(event.start_datetime), 'DATETIME_FORMAT'),
)
elif event.recurrence_days:
event_text = _('%s: %s') % (WEEKDAYS[day].capitalize(), event_text)
return event_text
def get_event_detail(request, event, agenda=None, min_places=1, booked_user_external_id=None):
agenda = agenda or event.agenda
details = {
'id': event.slug,
'slug': event.slug, # kept for compatibility
'text': event_text,
'text': get_event_text(event, agenda),
'label': event.label or '',
'datetime': format_response_datetime(event.start_datetime),
'description': event.description,
@ -581,6 +590,26 @@ def get_start_and_end_datetime_from_request(request):
return start_datetime, end_datetime
def make_booking(event, payload, extra_data, primary_booking=None, in_waiting_list=False, color=None):
return Booking(
event_id=event.pk,
in_waiting_list=getattr(event, 'in_waiting_list', in_waiting_list),
primary_booking=primary_booking,
label=payload.get('label', ''),
user_external_id=payload.get('user_external_id', ''),
user_first_name=payload.get('user_first_name', ''),
user_last_name=payload.get('user_last_name') or payload.get('user_name') or '',
user_email=payload.get('user_email', ''),
user_phone_number=payload.get('user_phone_number', ''),
form_url=payload.get('form_url', ''),
backoffice_url=payload.get('backoffice_url', ''),
cancel_callback_url=payload.get('cancel_callback_url', ''),
user_display_label=payload.get('user_display_label', ''),
extra_data=extra_data,
color=color,
)
class Agendas(APIView):
permission_classes = ()
@ -885,6 +914,37 @@ class MeetingDatetimes(APIView):
meeting_datetimes = MeetingDatetimes.as_view()
class RecurringEventsList(APIView):
permission_classes = ()
def get(self, request, agenda_identifier=None, format=None):
if not settings.ENABLE_RECURRING_EVENT_BOOKING:
raise Http404()
agenda = get_object_or_404(Agenda, slug=agenda_identifier, kind='events')
entries = agenda.get_open_recurring_events()
events = []
for event in entries:
for day in event.recurrence_days:
slug = '%s:%s' % (event.slug, day)
events.append(
{
'id': slug,
'text': get_event_text(event, agenda, day),
'datetime': format_response_datetime(event.start_datetime),
'description': event.description,
'pricing': event.pricing,
'url': event.url,
}
)
return Response({'data': events})
recurring_events_list = RecurringEventsList.as_view()
class MeetingList(APIView):
permission_classes = ()
@ -1362,24 +1422,9 @@ class Fillslots(APIView):
primary_booking = None
for event in events:
for i in range(places_count):
new_booking = Booking(
event_id=event.id,
in_waiting_list=in_waiting_list,
label=payload.get('label', ''),
user_external_id=payload.get('user_external_id', ''),
user_first_name=payload.get('user_first_name', ''),
user_last_name=payload.get('user_last_name') or payload.get('user_name') or '',
user_email=payload.get('user_email', ''),
user_phone_number=payload.get('user_phone_number', ''),
form_url=payload.get('form_url', ''),
backoffice_url=payload.get('backoffice_url', ''),
cancel_callback_url=payload.get('cancel_callback_url', ''),
user_display_label=payload.get('user_display_label', ''),
extra_data=extra_data,
color=color,
new_booking = make_booking(
event, payload, extra_data, primary_booking, in_waiting_list, color
)
if primary_booking is not None:
new_booking.primary_booking = primary_booking
new_booking.save()
if primary_booking is None:
primary_booking = new_booking
@ -1466,6 +1511,127 @@ class Fillslot(Fillslots):
fillslot = Fillslot.as_view()
class RecurringFillslots(APIView):
permission_classes = (permissions.IsAuthenticated,)
serializer_class = SlotsSerializer
def post(self, request, agenda_identifier=None, format=None):
if not settings.ENABLE_RECURRING_EVENT_BOOKING:
raise Http404()
agenda = get_object_or_404(Agenda, slug=agenda_identifier, kind='events')
start_datetime, end_datetime = get_start_and_end_datetime_from_request(request)
if not start_datetime or start_datetime < now():
start_datetime = now()
serializer = self.serializer_class(data=request.data, partial=True)
if not serializer.is_valid():
raise APIError(
_('invalid payload'),
err_class='invalid payload',
errors=serializer.errors,
http_status=status.HTTP_400_BAD_REQUEST,
)
payload = serializer.validated_data
user_external_id = payload.get('user_external_id')
if not user_external_id:
raise APIError(
_('user_external_id is required'),
err_class='user_external_id is required',
errors=serializer.errors,
http_status=status.HTTP_400_BAD_REQUEST,
)
open_event_slugs = set(agenda.get_open_recurring_events().values_list('slug', flat=True))
slots = collections.defaultdict(list)
for slot in payload['slots']:
try:
slug, day = slot.split(':')
day = int(day)
except ValueError:
raise APIError(
_('invalid slot: %s') % slot,
err_class='invalid slot: %s' % slot,
http_status=status.HTTP_400_BAD_REQUEST,
)
if slug not in open_event_slugs:
raise APIError(
_('event %s is not bookable') % slug,
err_class='event %s is not bookable' % slug,
http_status=status.HTTP_400_BAD_REQUEST,
)
# convert ISO day number to db lookup day number
day = (day + 1) % 7 + 1
slots[slug].append(day)
event_filter = Q()
for slug, days in slots.items():
event_filter |= Q(agenda=agenda, primary_event__slug=slug, start_datetime__week_day__in=days)
events_to_book = Event.objects.filter(event_filter)
events_to_book = events_to_book.filter(start_datetime__gte=start_datetime, cancelled=False)
full_events = list(events_to_book.filter(full=True))
events_to_book = events_to_book.filter(full=False)
if end_datetime:
events_to_book = events_to_book.filter(start_datetime__lte=end_datetime)
if not events_to_book.exists():
if full_events:
raise APIError(_('all events are all full'), err_class='all events are all full')
else:
raise APIError(_('no event recurrences to book'), err_class='no event recurrences to book')
events_to_book = Event.annotate_queryset(events_to_book)
events_to_book = events_to_book.annotate(
in_waiting_list=ExpressionWrapper(
Q(booked_places_count__gte=F('places')), output_field=BooleanField()
)
)
extra_data = {k: v for k, v in request.data.items() if k not in payload}
bookings = [make_booking(event, payload, extra_data) for event in events_to_book]
with transaction.atomic():
Booking.objects.bulk_create(bookings)
if django.VERSION < (2, 0):
from django.db.models import Case, When
events_to_book.update(
full=Case(
When(
Q(booked_places_count__gte=F('places'), waiting_list_places=0)
| Q(
waiting_list_places__gt=0,
waiting_list_count__gte=F('waiting_list_places'),
),
then=Value(True),
),
default=Value(False),
),
almost_full=Case(
When(Q(booked_places_count__gte=0.9 * F('places')), then=Value(True)),
default=Value(False),
),
)
else:
events_to_book.update(
full=Q(booked_places_count__gte=F('places'), waiting_list_places=0)
| Q(waiting_list_places__gt=0, waiting_list_count__gte=F('waiting_list_places')),
almost_full=Q(booked_places_count__gte=0.9 * F('places')),
)
response = {
'err': 0,
'booking_count': len(bookings),
'full_events': [get_event_detail(request, x, agenda=agenda) for x in full_events],
}
return Response(response)
recurring_fillslots = RecurringFillslots.as_view()
class BookingSerializer(serializers.ModelSerializer):
user_absence_reason = serializers.CharField(required=False, allow_blank=True, allow_null=True)

View File

@ -188,6 +188,8 @@ SMS_SENDER = ''
REST_FRAMEWORK = {'EXCEPTION_HANDLER': 'chrono.api.utils.exception_handler'}
ENABLE_RECURRING_EVENT_BOOKING = False
local_settings_file = os.environ.get(
'CHRONO_SETTINGS_FILE', os.path.join(os.path.dirname(__file__), 'local_settings.py')
)

View File

@ -1267,3 +1267,54 @@ def test_past_datetimes_recurring_event(app, user):
)
data = resp.json['data']
assert len(data) == 7
def test_recurring_events_api_list(app, freezer):
freezer.move_to('2021-09-06 12:00')
agenda = Agenda.objects.create(label='Foo bar', kind='events')
Event.objects.create(label='Normal event', start_datetime=now(), places=5, agenda=agenda)
event = Event.objects.create(
label='Example Event',
start_datetime=now(),
recurrence_days=[0, 3, 4], # Monday, Thursday, Friday
places=2,
agenda=agenda,
)
resp = app.get('/api/agenda/xxx/recurring_events/', status=404)
# recurring events without recurrence_end_date are not bookable
resp = app.get('/api/agenda/%s/recurring_events/' % agenda.slug)
assert len(resp.json['data']) == 0
event.recurrence_end_date = now() + datetime.timedelta(days=30)
event.save()
start_datetime = now() + datetime.timedelta(days=15)
Event.objects.create(
label='Other',
start_datetime=start_datetime,
recurrence_days=[start_datetime.weekday()],
places=2,
agenda=agenda,
recurrence_end_date=now() + datetime.timedelta(days=45),
)
resp = app.get('/api/agenda/%s/recurring_events/' % agenda.slug)
assert len(resp.json['data']) == 4
assert resp.json['data'][0]['id'] == 'example-event:0'
assert resp.json['data'][0]['text'] == 'Monday: Example Event'
assert resp.json['data'][1]['id'] == 'example-event:3'
assert resp.json['data'][1]['text'] == 'Thursday: Example Event'
assert resp.json['data'][2]['id'] == 'example-event:4'
assert resp.json['data'][2]['text'] == 'Friday: Example Event'
assert resp.json['data'][3]['id'] == 'other:1'
assert resp.json['data'][3]['text'] == 'Tuesday: Other'
event.publication_date = now() + datetime.timedelta(days=2)
event.save()
resp = app.get('/api/agenda/%s/recurring_events/' % agenda.slug)
assert len(resp.json['data']) == 1
freezer.move_to(event.recurrence_end_date)
resp = app.get('/api/agenda/%s/recurring_events/' % agenda.slug)
assert len(resp.json['data']) == 1

View File

@ -2092,3 +2092,96 @@ def test_fillslot_past_events_recurring_event(app, user):
)
assert resp.json['err'] == 1
assert resp.json['err_desc'] == 'event is cancelled'
def test_recurring_events_api_fillslots(app, user, freezer):
freezer.move_to('2021-09-06 12:00')
agenda = Agenda.objects.create(label='Foo bar', kind='events')
event = Event.objects.create(
label='Event',
start_datetime=now(),
recurrence_days=[0, 1, 3, 4], # Monday, Tuesday, Thursday, Friday
places=2,
waiting_list_places=1,
agenda=agenda,
recurrence_end_date=now() + datetime.timedelta(days=364),
)
event.create_all_recurrences()
sunday_event = Event.objects.create(
label='Sunday Event',
start_datetime=now(),
recurrence_days=[6],
places=2,
waiting_list_places=1,
agenda=agenda,
recurrence_end_date=now() + datetime.timedelta(days=364),
)
sunday_event.create_all_recurrences()
resp = app.get('/api/agenda/%s/recurring_events/' % agenda.slug)
assert len(resp.json['data']) == 5
app.authorization = ('Basic', ('john.doe', 'password'))
fillslots_url = '/api/agenda/%s/recurring_fillslots/' % agenda.slug
params = {'user_external_id': 'user_id'}
# Book Monday and Thursday of first event and Sunday of second event
params['slots'] = 'event:0,event:3,sunday-event:6'
resp = app.post_json(fillslots_url, params=params)
assert resp.json['booking_count'] == 156
assert Booking.objects.count() == 156
assert Booking.objects.filter(event__primary_event=event).count() == 104
assert Booking.objects.filter(event__primary_event=sunday_event).count() == 52
events = Event.annotate_queryset(Event.objects.filter(primary_event__isnull=False))
assert events.filter(booked_places_count=1).count() == 156
# one recurrence is booked separately
event = Event.objects.filter(primary_event__isnull=False).first()
Booking.objects.create(event=event)
params['user_external_id'] = 'user_id_2'
resp = app.post_json(fillslots_url, params=params)
assert resp.json['booking_count'] == 156
assert not resp.json['full_events']
assert Booking.objects.count() == 313
events = Event.annotate_queryset(Event.objects.filter(primary_event__isnull=False))
assert events.filter(booked_places_count=2).count() == 156
# one booking has been put in waiting list
assert events.filter(waiting_list_count=1).count() == 1
params['user_external_id'] = 'user_id_3'
resp = app.post_json(fillslots_url, params=params)
# everything goes in waiting list
assert events.filter(waiting_list_count=1).count() == 156
# but an event was full
assert resp.json['booking_count'] == 155
assert len(resp.json['full_events']) == 1
assert resp.json['full_events'][0]['slug'] == event.slug
params['user_external_id'] = 'user_id_4'
resp = app.post_json(fillslots_url, params=params)
assert resp.json['err'] == 1
assert resp.json['err_desc'] == 'all events are all full'
params['slots'] = 'event:1'
resp = app.post_json(fillslots_url + '?date_start=2021-10-06&date_end=2021-11-06', params=params)
assert resp.json['booking_count'] == 4
assert Booking.objects.filter(user_external_id='user_id_4').count() == 4
resp = app.post_json(fillslots_url + '?date_start=2020-10-06&date_end=2020-11-06', params=params)
assert resp.json['err'] == 1
assert resp.json['err_desc'] == 'no event recurrences to book'
del params['user_external_id']
resp = app.post_json(fillslots_url, params=params, status=400)
assert resp.json['err'] == 1
assert resp.json['err_desc'] == 'user_external_id is required'
resp = app.post_json(fillslots_url, params={'user_external_id': 'a', 'slots': 'a:a'}, status=400)
assert resp.json['err'] == 1
assert resp.json['err_desc'] == 'invalid slot: a:a'
resp = app.post_json(fillslots_url, params={'user_external_id': 'a', 'slots': 'a:1'}, status=400)
assert resp.json['err'] == 1
assert resp.json['err_desc'] == 'event a is not bookable'

View File

@ -32,3 +32,5 @@ KNOWN_SERVICES = {
EXCEPTIONS_SOURCES = {}
SITE_BASE_URL = 'https://example.com'
ENABLE_RECURRING_EVENT_BOOKING = True