api: make recurring events fillslots work with multiple agendas (#57957)

This commit is contained in:
Valentin Deniaud 2021-10-21 15:57:02 +02:00
parent bb781f8c83
commit 753c7ad6f1
4 changed files with 158 additions and 53 deletions

View File

@ -1,3 +1,5 @@
import collections
from django.contrib.auth.models import Group
from django.utils.translation import ugettext_lazy as _
from rest_framework import serializers
@ -89,6 +91,33 @@ class MultipleAgendasEventsSlotsSerializer(EventsSlotsSerializer):
return value
class RecurringFillslotsSerializer(MultipleAgendasEventsSlotsSerializer):
def validate_slots(self, value):
super().validate_slots(value)
open_event_slugs = collections.defaultdict(set)
for agenda in self.context['agendas']:
for event in agenda.get_open_recurring_events():
open_event_slugs[agenda.slug].add(event.slug)
slots = collections.defaultdict(lambda: collections.defaultdict(list))
for slot in value:
try:
slugs, day = slot.split(':')
day = int(day)
except ValueError:
raise ValidationError(_('invalid slot: %s') % slot)
agenda_slug, event_slug = slugs.split('@')
if event_slug not in open_event_slugs[agenda_slug]:
raise ValidationError(_('event %s of agenda %s is not bookable') % (event_slug, agenda_slug))
# convert ISO day number to db lookup day number
day = (day + 1) % 7 + 1
slots[agenda_slug][event_slug].append(day)
return slots
class BookingSerializer(serializers.ModelSerializer):
user_absence_reason = serializers.CharField(required=False, allow_blank=True, allow_null=True)

View File

@ -22,6 +22,7 @@ urlpatterns = [
url(r'^agenda/$', views.agendas),
url(r'^agendas/datetimes/$', views.agendas_datetimes, name='api-agendas-datetimes'),
url(r'^agendas/recurring-events/$', views.recurring_events_list, name='api-agenda-recurring-events'),
url(r'^agendas/recurring-events/fillslots/$', views.recurring_fillslots, name='api-recurring-fillslots'),
url(
r'^agendas/events/fillslots/$',
views.agendas_events_fillslots,
@ -40,11 +41,6 @@ urlpatterns = [
views.events_fillslots,
name='api-agenda-events-fillslots',
),
url(
r'^agenda/(?P<agenda_identifier>[\w-]+)/recurring-events/fillslots/$',
views.recurring_fillslots,
name='api-recurring-fillslots',
),
url(
r'^agenda/(?P<agenda_identifier>[\w-]+)/event/$',
views.events,

View File

@ -1601,18 +1601,21 @@ fillslot = Fillslot.as_view()
class RecurringFillslots(APIView):
permission_classes = (permissions.IsAuthenticated,)
serializer_class = serializers.EventsSlotsSerializer
serializer_class = serializers.RecurringFillslotsSerializer
def post(self, request, agenda_identifier):
def post(self, request):
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)
agenda_slugs = get_agendas_from_request(request)
agendas = get_objects_from_slugs(agenda_slugs, qs=Agenda.objects.filter(kind='events'))
context = {'allowed_agenda_slugs': agenda_slugs, 'agendas': agendas}
serializer = self.serializer_class(data=request.data, partial=True, context=context)
if not serializer.is_valid():
raise APIError(
_('invalid payload'),
@ -1623,31 +1626,14 @@ class RecurringFillslots(APIView):
payload = serializer.validated_data
user_external_id = payload['user_external_id']
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)
for agenda_slug, days_by_event in payload['slots'].items():
for event_slug, days in days_by_event.items():
event_filter |= Q(
agenda__slug=agenda_slug,
primary_event__slug=event_slug,
start_datetime__week_day__in=days,
)
events_to_book = Event.objects.filter(event_filter) if event_filter else Event.objects.none()
events_to_book = events_to_book.filter(start_datetime__gte=start_datetime, cancelled=False)
@ -1684,7 +1670,7 @@ class RecurringFillslots(APIView):
'err': 0,
'booking_count': len(bookings),
'cancelled_booking_count': deleted_count,
'full_events': [get_event_detail(request, x, agenda=agenda) for x in full_events],
'full_events': [get_event_detail(request, x, multiple_agendas=True) for x in full_events],
}
return Response(response)

View File

@ -2147,6 +2147,7 @@ def test_fillslot_past_events_recurring_event(app, user):
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')
Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
event = Event.objects.create(
label='Event',
start_datetime=now(),
@ -2168,14 +2169,14 @@ def test_recurring_events_api_fillslots(app, user, freezer):
)
sunday_event.create_all_recurrences()
resp = app.get('/api/agenda/%s/recurring-events/' % agenda.slug)
resp = app.get('/api/agendas/recurring-events/?agendas=%s' % agenda.slug)
assert len(resp.json['data']) == 5
app.authorization = ('Basic', ('john.doe', 'password'))
fillslots_url = '/api/agenda/%s/recurring-events/fillslots/' % agenda.slug
fillslots_url = '/api/agendas/recurring-events/fillslots/?agendas=%s' % 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'
params['slots'] = 'foo-bar@event:0,foo-bar@event:3,foo-bar@sunday-event:6'
resp = app.post_json(fillslots_url, params=params)
assert resp.json['booking_count'] == 156
@ -2216,15 +2217,15 @@ def test_recurring_events_api_fillslots(app, user, freezer):
assert resp.json['booking_count'] == 0
# no event in range
resp = app.post_json(fillslots_url + '?date_start=2020-10-06&date_end=2020-11-06', params=params)
resp = app.post_json(fillslots_url + '&date_start=2020-10-06&date_end=2020-11-06', params=params)
assert resp.json['booking_count'] == 0
params['slots'] = 'event:1'
resp = app.post_json(fillslots_url + '?date_start=2021-10-06&date_end=2021-11-06', params=params)
params['slots'] = 'foo-bar@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, params={'slots': 'event:0'}, status=400)
resp = app.post_json(fillslots_url, params={'slots': 'foo-bar@event:0'}, status=400)
assert resp.json['err'] == 1
assert resp.json['err_desc'] == 'invalid payload'
assert resp.json['errors']['user_external_id'] == ['This field is required.']
@ -2234,18 +2235,23 @@ def test_recurring_events_api_fillslots(app, user, freezer):
assert resp.json['err_desc'] == 'invalid payload'
assert resp.json['errors']['slots'] == ['This field is required.']
resp = app.post_json(fillslots_url, params={'user_external_id': 'a', 'slots': 'a:a'}, status=400)
resp = app.post_json(fillslots_url, params={'user_external_id': 'a', 'slots': 'foo-bar@a:a'}, status=400)
assert resp.json['err'] == 1
assert resp.json['err_desc'] == 'invalid slot: a:a'
assert resp.json['errors']['slots'] == ['invalid slot: foo-bar@a:a']
resp = app.post_json(fillslots_url, params={'user_external_id': 'a', 'slots': 'a:1'}, status=400)
resp = app.post_json(fillslots_url, params={'user_external_id': 'a', 'slots': 'foo-bar@a:1'}, status=400)
assert resp.json['err'] == 1
assert resp.json['err_desc'] == 'event a is not bookable'
assert resp.json['errors']['slots'] == ['event a of agenda foo-bar is not bookable']
resp = app.post_json(fillslots_url, params={'user_external_id': 'a', 'slots': 'foo-bar'}, status=400)
assert resp.json['err'] == 1
assert resp.json['errors']['slots'] == ['Invalid format for slot foo-bar']
def test_recurring_events_api_fillslots_waiting_list(app, user, freezer):
freezer.move_to('2021-09-06 12:00')
agenda = Agenda.objects.create(label='Foo bar', kind='events')
Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
event = Event.objects.create(
label='Event',
start_datetime=now(),
@ -2265,8 +2271,8 @@ def test_recurring_events_api_fillslots_waiting_list(app, user, freezer):
assert events.filter(booked_waiting_list_places=1).count() == 5
# check that new bookings are put in waiting list despite free slots on main list
params = {'user_external_id': 'user_id', 'slots': 'event:0'}
resp = app.post_json('/api/agenda/%s/recurring-events/fillslots/' % agenda.slug, params=params)
params = {'user_external_id': 'user_id', 'slots': 'foo-bar@event:0'}
resp = app.post_json('/api/agendas/recurring-events/fillslots/?agendas=%s' % agenda.slug, params=params)
assert resp.json['booking_count'] == 5
assert events.filter(booked_waiting_list_places=2).count() == 5
@ -2274,6 +2280,7 @@ def test_recurring_events_api_fillslots_waiting_list(app, user, freezer):
def test_recurring_events_api_fillslots_change_bookings(app, user, freezer):
freezer.move_to('2021-09-06 12:00')
agenda = Agenda.objects.create(label='Foo bar', kind='events')
Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
event = Event.objects.create(
label='Event',
start_datetime=now(),
@ -2286,10 +2293,10 @@ def test_recurring_events_api_fillslots_change_bookings(app, user, freezer):
event.create_all_recurrences()
app.authorization = ('Basic', ('john.doe', 'password'))
fillslots_url = '/api/agenda/%s/recurring-events/fillslots/' % agenda.slug
fillslots_url = '/api/agendas/recurring-events/fillslots/?agendas=%s' % agenda.slug
params = {'user_external_id': 'user_id'}
# Book Monday and Thursday
params['slots'] = 'event:0,event:3'
params['slots'] = 'foo-bar@event:0,foo-bar@event:3'
resp = app.post_json(fillslots_url, params=params)
assert resp.json['booking_count'] == 104
assert resp.json['cancelled_booking_count'] == 0
@ -2298,7 +2305,7 @@ def test_recurring_events_api_fillslots_change_bookings(app, user, freezer):
assert Booking.objects.filter(event__start_datetime__week_day=5).count() == 52
# Change booking to Monday and Tuesday
params['slots'] = 'event:0,event:1'
params['slots'] = 'foo-bar@event:0,foo-bar@event:1'
resp = app.post_json(fillslots_url, params=params)
assert resp.json['booking_count'] == 52
assert resp.json['cancelled_booking_count'] == 52
@ -2313,7 +2320,7 @@ def test_recurring_events_api_fillslots_change_bookings(app, user, freezer):
assert Booking.objects.count() == 104
params = {'user_external_id': 'user_id_2'}
params['slots'] = 'event:0,event:3'
params['slots'] = 'foo-bar@event:0,foo-bar@event:3'
resp = app.post_json(fillslots_url, params=params)
assert resp.json['booking_count'] == 104
assert resp.json['cancelled_booking_count'] == 0
@ -2324,7 +2331,7 @@ def test_recurring_events_api_fillslots_change_bookings(app, user, freezer):
assert events.filter(booked_places=1).count() == 156
assert events.filter(booked_waiting_list_places=1).count() == 52
params['slots'] = 'event:1,event:4'
params['slots'] = 'foo-bar@event:1,foo-bar@event:4'
resp = app.post_json(fillslots_url, params=params)
assert resp.json['booking_count'] == 104
assert resp.json['cancelled_booking_count'] == 104
@ -2346,11 +2353,98 @@ def test_recurring_events_api_fillslots_change_bookings(app, user, freezer):
start_datetime=now() + datetime.timedelta(days=1), places=2, agenda=agenda
)
Booking.objects.create(event=normal_event, user_external_id='user_id')
resp = app.post_json(fillslots_url, params={'user_external_id': 'user_id', 'slots': 'event:0'})
resp = app.post_json(fillslots_url, params={'user_external_id': 'user_id', 'slots': 'foo-bar@event:0'})
assert resp.json['cancelled_booking_count'] == 52
assert Booking.objects.filter(user_external_id='user_id', event=normal_event).count() == 1
@pytest.mark.freeze_time('2021-09-06 12:00')
def test_recurring_events_api_fillslots_multiple_agendas(app, user):
agenda = Agenda.objects.create(label='First Agenda', kind='events')
Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
start, end = now(), now() + datetime.timedelta(days=30)
event_a = Event.objects.create(
label='A',
start_datetime=start,
places=2,
recurrence_end_date=end,
recurrence_days=[0, 2, 5],
agenda=agenda,
)
event_a.create_all_recurrences()
event_b = Event.objects.create(
label='B', start_datetime=start, places=2, recurrence_end_date=end, recurrence_days=[1], agenda=agenda
)
event_b.create_all_recurrences()
agenda2 = Agenda.objects.create(label='Second Agenda', kind='events')
Desk.objects.create(agenda=agenda2, slug='_exceptions_holder')
event_c = Event.objects.create(
label='C',
start_datetime=start,
places=2,
recurrence_end_date=end,
recurrence_days=[2, 3],
agenda=agenda2,
)
event_c.create_all_recurrences()
resp = app.get('/api/agendas/recurring-events/?agendas=first-agenda,second-agenda')
assert len(resp.json['data']) == 6
app.authorization = ('Basic', ('john.doe', 'password'))
fillslots_url = '/api/agendas/recurring-events/fillslots/?agendas=%s'
params = {'user_external_id': 'user_id', 'slots': 'first-agenda@a:0,first-agenda@a:5,second-agenda@c:3'}
resp = app.post_json(fillslots_url % 'first-agenda,second-agenda', params=params)
assert resp.json['booking_count'] == 13
assert Booking.objects.count() == 13
assert Booking.objects.filter(event__primary_event=event_a).count() == 9
assert Booking.objects.filter(event__primary_event=event_b).count() == 0
assert Booking.objects.filter(event__primary_event=event_c).count() == 4
# update bookings
params = {'user_external_id': 'user_id', 'slots': 'first-agenda@b:1'}
resp = app.post_json(fillslots_url % 'first-agenda,second-agenda', params=params)
assert resp.json['booking_count'] == 5
assert resp.json['cancelled_booking_count'] == 13
assert Booking.objects.filter(event__primary_event=event_a).count() == 0
assert Booking.objects.filter(event__primary_event=event_b).count() == 5
assert Booking.objects.filter(event__primary_event=event_c).count() == 0
# error if slot's agenda is not in querystring
resp = app.post_json(fillslots_url % 'second-agenda', params=params, status=400)
assert resp.json['err'] == 1
assert resp.json['errors']['slots'] == [
'Some events belong to agendas that are not present in querystring: first-agenda'
]
@pytest.mark.freeze_time('2021-09-06 12:00')
def test_recurring_events_api_fillslots_multiple_agendas_queries(app, user):
for i in range(20):
agenda = Agenda.objects.create(slug=f'{i}', kind='events')
Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
start, end = now(), now() + datetime.timedelta(days=30)
event = Event.objects.create(
start_datetime=start, places=2, recurrence_end_date=end, recurrence_days=[1, 2], agenda=agenda
)
event.create_all_recurrences()
agenda_slugs = ','.join(str(i) for i in range(20))
resp = app.get('/api/agendas/recurring-events/?agendas=%s' % agenda_slugs)
events_to_book = [x['id'] for x in resp.json['data']]
app.authorization = ('Basic', ('john.doe', 'password'))
with CaptureQueriesContext(connection) as ctx:
resp = app.post_json(
'/api/agendas/recurring-events/fillslots/?agendas=%s' % agenda_slugs,
params={'slots': events_to_book, 'user_external_id': 'user'},
)
assert resp.json['booking_count'] == 180
assert len(ctx.captured_queries) == 36
@pytest.mark.freeze_time('2021-09-06 12:00')
def test_api_events_fillslots(app, user):
agenda = Agenda.objects.create(label='Foo bar', kind='events')