api: allow booking all recurrences of recurring events (#54332)
This commit is contained in:
parent
dcc4b44a67
commit
4fb6581e8d
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
|
|
@ -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')
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'
|
||||
|
|
|
@ -32,3 +32,5 @@ KNOWN_SERVICES = {
|
|||
EXCEPTIONS_SOURCES = {}
|
||||
|
||||
SITE_BASE_URL = 'https://example.com'
|
||||
|
||||
ENABLE_RECURRING_EVENT_BOOKING = True
|
||||
|
|
Loading…
Reference in New Issue