From e6c3e7e44339a5bd95c3497a272567fedc869b72 Mon Sep 17 00:00:00 2001 From: Thomas NOEL Date: Wed, 4 Apr 2018 19:54:34 +0200 Subject: [PATCH] api: add endpoint to fill a list of slots (#16238) --- chrono/api/urls.py | 2 + chrono/api/views.py | 210 ++++++++++++++++++++------------ tests/test_api.py | 338 +++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 472 insertions(+), 78 deletions(-) diff --git a/chrono/api/urls.py b/chrono/api/urls.py index da3371d..2383cc9 100644 --- a/chrono/api/urls.py +++ b/chrono/api/urls.py @@ -25,6 +25,8 @@ urlpatterns = [ url(r'agenda/(?P[\w-]+)/datetimes/$', views.datetimes, name='api-agenda-datetimes'), url(r'agenda/(?P[\w-]+)/fillslot/(?P[\w:-]+)/$', views.fillslot, name='api-fillslot'), + url(r'agenda/(?P[\w-]+)/fillslots/$', + views.fillslots, name='api-agenda-fillslots'), url(r'agenda/(?P[\w-]+)/status/(?P\w+)/$', views.slot_status, name='api-event-status'), diff --git a/chrono/api/views.py b/chrono/api/views.py index 2fef90f..b2a4aef 100644 --- a/chrono/api/views.py +++ b/chrono/api/views.py @@ -111,6 +111,9 @@ def get_agenda_detail(request, agenda): reverse('api-agenda-desks', kwargs={'agenda_identifier': agenda.slug})) } + agenda_detail['api']['fillslots_url'] = request.build_absolute_uri( + reverse('api-agenda-fillslots', + kwargs={'agenda_identifier': agenda.slug})) return agenda_detail @@ -298,16 +301,33 @@ agenda_desk_list = AgendaDeskList.as_view() class SlotSerializer(serializers.Serializer): - label = serializers.CharField(max_length=150, allow_blank=True, required=False) - user_name = serializers.CharField(max_length=250, allow_blank=True, required=False) - backoffice_url = serializers.URLField(allow_blank=True, required=False) - count = serializers.IntegerField(min_value=1, required=False) + ''' + payload to fill one slot. The slot (event id) is in the URL. + ''' + label = serializers.CharField(max_length=150, allow_blank=True) #, required=False) + user_name = serializers.CharField(max_length=250, allow_blank=True) #, required=False) + backoffice_url = serializers.URLField(allow_blank=True) # , required=False) + count = serializers.IntegerField(min_value=1) # , required=False) + +class SlotsSerializer(SlotSerializer): + ''' + payload to fill multiple slots: same as SlotSerializer, but the + slots list is in the payload. + ''' + slots = serializers.ListField(required=True, + child=serializers.CharField(max_length=64, allow_blank=False)) -class Fillslot(APIView): + +class Fillslots(APIView): permission_classes = (permissions.IsAuthenticated,) + serializer_class = SlotsSerializer def post(self, request, agenda_identifier=None, event_pk=None, format=None): + return self.fillslot(request=request, agenda_identifier=agenda_identifier, + format=format) + + def fillslot(self, request, agenda_identifier=None, slots=[], format=None): try: agenda = Agenda.objects.get(slug=agenda_identifier) except Agenda.DoesNotExist: @@ -317,102 +337,134 @@ class Fillslot(APIView): except (ValueError, Agenda.DoesNotExist): raise Http404() - serializer = SlotSerializer(data=request.data) + serializer = self.serializer_class(data=request.data, partial=True) if not serializer.is_valid(): return Response({ 'err': 1, 'reason': 'invalid payload', 'errors': serializer.errors }, status=status.HTTP_400_BAD_REQUEST) - label = serializer.validated_data.get('label') or '' - user_name = serializer.validated_data.get('user_name') or '' - backoffice_url = serializer.validated_data.get('backoffice_url') or '' - places_count = serializer.validated_data.get('count') or 1 - extra_data = {} - for k, v in request.data.items(): - if k not in serializer.validated_data: - extra_data[k] = v + payload = serializer.validated_data - if 'count' in request.GET: + if 'slots' in payload: + slots = payload['slots'] + if not slots: + return Response({ + 'err': 1, + 'reason': 'slots list cannot be empty', + }, status=status.HTTP_400_BAD_REQUEST) + + if 'count' in payload: + places_count = payload['count'] + elif 'count' in request.query_params: + # legacy: count in the query string try: - places_count = int(request.GET['count']) + places_count = int(request.query_params['count']) except ValueError: return Response({ 'err': 1, - 'reason': 'invalid value for count (%r)' % request.GET['count'], + 'reason': 'invalid value for count (%r)' % request.query_params['count'], }, status=status.HTTP_400_BAD_REQUEST) + else: + places_count = 1 + + extra_data = {} + for k, v in request.data.items(): + if k not in serializer.validated_data: + extra_data[k] = v available_desk = None + if agenda.kind == 'meetings': - # event_pk is actually a timeslot id (meeting_type:start_datetime); - # split it back to get both parts. - meeting_type_id, start_datetime_str = event_pk.split(':') - start_datetime = make_aware(datetime.datetime.strptime( - start_datetime_str, '%Y-%m-%d-%H%M')) - - slots = get_all_slots(agenda, MeetingType.objects.get(id=meeting_type_id)) - # sort available matching slots by desk id - slots = [slot for slot in slots if not slot.full and slot.start_datetime == start_datetime] - slots.sort(key=lambda x: x.desk.id) - if slots: - # book first available desk - available_desk = slots[0].desk - - if not available_desk: + # slots are actually timeslot ids (meeting_type:start_datetime), not events ids. + # split them back to get both parts + meeting_type_id = slots[0].split(':')[0] + datetimes = set() + for slot in slots: + meeting_type_id_, datetime_str = slot.split(':') + if meeting_type_id_ != meeting_type_id: + return Response({ + 'err': 1, + 'reason': 'all slots must have the same meeting type id (%s)' % meeting_type_id + }, status=status.HTTP_400_BAD_REQUEST) + datetimes.add(make_aware(datetime.datetime.strptime(datetime_str, '%Y-%m-%d-%H%M'))) + + # get all free slots and separate them by desk + all_slots = get_all_slots(agenda, MeetingType.objects.get(id=meeting_type_id)) + all_slots = [slot for slot in all_slots if not slot.full] + datetimes_by_desk = defaultdict(set) + for slot in all_slots: + datetimes_by_desk[slot.desk.id].add(slot.start_datetime) + + # search first desk where all requested slots are free + for available_desk_id in sorted(datetimes_by_desk.keys()): + if datetimes.issubset(datetimes_by_desk[available_desk_id]): + available_desk = Desk.objects.get(id=available_desk_id) + break + else: return Response({'err': 1, 'reason': 'no more desk available'}) - # booking requires a real Event object (not a lazy Timeslot); - # create it now, with data from the timeslot and the desk we - # found. - event = Event.objects.create(agenda=agenda, - meeting_type_id=meeting_type_id, - start_datetime=start_datetime, - full=False, places=1, - desk=available_desk) - - event_pk = event.id - - event = Event.objects.filter(id=event_pk)[0] - new_booking = Booking(event_id=event_pk, extra_data=extra_data, - label=label, user_name=user_name, backoffice_url=backoffice_url) - - if event.waiting_list_places: - if (event.booked_places + places_count) > event.places or event.waiting_list: - # if this is full or there are people waiting, put new bookings - # in the waiting list. - new_booking.in_waiting_list = True - - if (event.waiting_list + places_count) > event.waiting_list_places: + # all datetimes are free, book them in order + datetimes = list(datetimes) + datetimes.sort() + + # booking requires real Event objects (not lazy Timeslots); + # create them now, with data from the slots and the desk we found. + events = [] + for start_datetime in datetimes: + events.append(Event.objects.create(agenda=agenda, + meeting_type_id=meeting_type_id, + start_datetime=start_datetime, + full=False, places=1, + desk=available_desk)) + else: + events = Event.objects.filter(id__in=slots).order_by('start_datetime') + + # search free places. Switch to waiting list if necessary. + in_waiting_list = False + for event in events: + if event.waiting_list_places: + if (event.booked_places + places_count) > event.places or event.waiting_list: + # if this is full or there are people waiting, put new bookings + # in the waiting list. + in_waiting_list = True + if (event.waiting_list + places_count) > event.waiting_list_places: + return Response({'err': 1, 'reason': 'sold out'}) + else: + if (event.booked_places + places_count) > event.places: return Response({'err': 1, 'reason': 'sold out'}) - else: - if (event.booked_places + places_count) > event.places: - return Response({'err': 1, 'reason': 'sold out'}) - - new_booking.save() - for i in range(places_count-1): - additional_booking = Booking(event_id=event_pk, extra_data=extra_data, - label=label, user_name=user_name, - backoffice_url=backoffice_url) - additional_booking.in_waiting_list = new_booking.in_waiting_list - additional_booking.primary_booking = new_booking - additional_booking.save() + # now we have a list of events, book them. + 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_name=payload.get('user_name', ''), + backoffice_url=payload.get('backoffice_url', ''), + extra_data=extra_data) + if primary_booking is not None: + new_booking.primary_booking = primary_booking + new_booking.save() + if primary_booking is None: + primary_booking = new_booking response = { 'err': 0, - 'in_waiting_list': new_booking.in_waiting_list, - 'booking_id': new_booking.id, - 'datetime': localtime(event.start_datetime), + 'in_waiting_list': in_waiting_list, + 'booking_id': primary_booking.id, + 'datetime': localtime(events[0].start_datetime), 'api': { 'cancel_url': request.build_absolute_uri( - reverse('api-cancel-booking', kwargs={'booking_pk': new_booking.id})) + reverse('api-cancel-booking', kwargs={'booking_pk': primary_booking.id})) } } - if new_booking.in_waiting_list: + if in_waiting_list: response['api']['accept_url'] = request.build_absolute_uri( - reverse('api-accept-booking', kwargs={'booking_pk': new_booking.id})) + reverse('api-accept-booking', kwargs={'booking_pk': primary_booking.id})) if agenda.kind == 'meetings': - response['end_datetime'] = localtime(event.end_datetime) + response['end_datetime'] = localtime(events[-1].end_datetime) if available_desk: response['desk'] = { 'label': available_desk.label, @@ -420,6 +472,18 @@ class Fillslot(APIView): return Response(response) +fillslots = Fillslots.as_view() + + +class Fillslot(Fillslots): + serializer_class = SlotSerializer + + def post(self, request, agenda_identifier=None, event_pk=None, format=None): + return self.fillslot(request=request, + agenda_identifier=agenda_identifier, + slots=[event_pk], # fill a "list on one slot" + format=format) + fillslot = Fillslot.as_view() diff --git a/tests/test_api.py b/tests/test_api.py index ce0629c..347669e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -100,15 +100,18 @@ def test_agendas_api(app, some_data, meetings_agenda): resp = app.get('/api/agenda/') assert resp.json == {'data': [ {'text': 'Foo bar', 'id': u'foo-bar', 'slug': 'foo-bar', 'kind': 'events', - 'api': {'datetimes_url': 'http://testserver/api/agenda/%s/datetimes/' % agenda1.slug}}, + 'api': {'datetimes_url': 'http://testserver/api/agenda/%s/datetimes/' % agenda1.slug, + 'fillslots_url': 'http://testserver/api/agenda/%s/fillslots/' % agenda1.slug}}, {'text': 'Foo bar Meeting', 'id': u'foo-bar-meeting', 'slug': 'foo-bar-meeting', 'kind': 'meetings', 'api': {'meetings_url': 'http://testserver/api/agenda/%s/meetings/' % meetings_agenda.slug, 'desks_url': 'http://testserver/api/agenda/%s/desks/' % meetings_agenda.slug, + 'fillslots_url': 'http://testserver/api/agenda/%s/fillslots/' % meetings_agenda.slug, }, }, {'text': 'Foo bar2', 'id': u'foo-bar2', 'kind': 'events', 'slug': 'foo-bar2', - 'api': {'datetimes_url': 'http://testserver/api/agenda/%s/datetimes/' % agenda2.slug}} + 'api': {'datetimes_url': 'http://testserver/api/agenda/%s/datetimes/' % agenda2.slug, + 'fillslots_url': 'http://testserver/api/agenda/%s/fillslots/' % agenda2.slug}} ]} def test_agendas_meetingtypes_api(app, some_data, meetings_agenda): @@ -290,6 +293,7 @@ def test_datetimes_api_meetings_agenda_short_time_periods(app, meetings_agenda, resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.id) assert len(resp.json['data']) == 2 fillslot_url = resp.json['data'][0]['api']['fillslot_url'] + two_slots = [resp.json['data'][0]['id'], resp.json['data'][1]['id']] time_period.end_time = datetime.time(10, 15) time_period.save() @@ -301,6 +305,11 @@ def test_datetimes_api_meetings_agenda_short_time_periods(app, meetings_agenda, resp = app.post(fillslot_url) assert resp.json['err'] == 1 assert resp.json['reason'] == 'no more desk available' + # booking the two slots fails too + fillslots_url = '/api/agenda/%s/fillslots/' % meeting_type.agenda.slug + resp = app.post(fillslots_url, params={'slots': two_slots}) + assert resp.json['err'] == 1 + assert resp.json['reason'] == 'no more desk available' def test_booking_api(app, some_data, user): agenda = Agenda.objects.filter(label=u'Foo bar')[0] @@ -309,9 +318,10 @@ def test_booking_api(app, some_data, user): # unauthenticated resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda.slug, event.id), status=403) - resp_datetimes = app.get('/api/agenda/%s/datetimes/' % agenda.id) - event_fillslot_url = [x for x in resp_datetimes.json['data'] if x['id'] == event.id][0]['api']['fillslot_url'] - assert urlparse.urlparse(event_fillslot_url).path == '/api/agenda/%s/fillslot/%s/' % (agenda.slug, event.id) + for agenda_key in (agenda.slug, agenda.id): # acces datetimes via agenda slug or id (legacy) + resp_datetimes = app.get('/api/agenda/%s/datetimes/' % agenda_key) + event_fillslot_url = [x for x in resp_datetimes.json['data'] if x['id'] == event.id][0]['api']['fillslot_url'] + assert urlparse.urlparse(event_fillslot_url).path == '/api/agenda/%s/fillslot/%s/' % (agenda.slug, event.id) app.authorization = ('Basic', ('john.doe', 'password')) resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda.slug, event.id)) @@ -360,6 +370,102 @@ def test_booking_api(app, some_data, user): resp = app.post('/api/agenda/233/fillslot/%s/' % event.id, status=404) +def test_booking_api_fillslots(app, some_data, user): + agenda = Agenda.objects.filter(label=u'Foo bar')[0] + events_ids = [x.id for x in Event.objects.filter(agenda=agenda) if x.in_bookable_period()] + assert len(events_ids) == 3 + event = [x for x in Event.objects.filter(agenda=agenda) if x.in_bookable_period()][0] # first event + + # unauthenticated + resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, status=403) + + for agenda_key in (agenda.slug, agenda.id): # acces datetimes via agenda slug or id (legacy) + resp_datetimes = app.get('/api/agenda/%s/datetimes/' % agenda_key) + api_event_ids = [x['id'] for x in resp_datetimes.json['data']] + assert api_event_ids == events_ids + + assert Booking.objects.count() == 0 + + app.authorization = ('Basic', ('john.doe', 'password')) + resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, params={'slots': events_ids}) + primary_booking_id = resp.json['booking_id'] + Booking.objects.get(id=primary_booking_id) + assert resp.json['datetime'] == localtime(event.start_datetime).isoformat() + assert 'accept_url' not in resp.json['api'] + assert 'cancel_url' in resp.json['api'] + assert urlparse.urlparse(resp.json['api']['cancel_url']).netloc + assert Booking.objects.count() == 3 + # these 3 bookings are related, the first is the primary one + bookings = Booking.objects.all().order_by('primary_booking') + assert bookings[0].primary_booking is None + assert bookings[1].primary_booking.id == bookings[0].id == primary_booking_id + assert bookings[2].primary_booking.id == bookings[0].id == primary_booking_id + + resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, params={'slots': events_ids}) + primary_booking_id_2 = resp.json['booking_id'] + assert Booking.objects.count() == 6 + assert Booking.objects.filter(event__agenda=agenda).count() == 6 + # 6 = 2 primary + 2*2 secondary + assert Booking.objects.filter(event__agenda=agenda, primary_booking__isnull=True).count() == 2 + assert Booking.objects.filter(event__agenda=agenda, primary_booking=primary_booking_id).count() == 2 + assert Booking.objects.filter(event__agenda=agenda, primary_booking=primary_booking_id_2).count() == 2 + + # test with additional data + resp = app.post_json('/api/agenda/%s/fillslots/' % agenda.id, + params={'slots': events_ids, + 'label': 'foo', 'user_name': 'bar', 'backoffice_url': 'http://example.net/'}) + booking_id = resp.json['booking_id'] + assert Booking.objects.get(id=booking_id).label == 'foo' + assert Booking.objects.get(id=booking_id).user_name == 'bar' + assert Booking.objects.get(id=booking_id).backoffice_url == 'http://example.net/' + assert Booking.objects.filter(primary_booking=booking_id, label='foo').count() == 2 + # cancel + cancel_url = resp.json['api']['cancel_url'] + assert Booking.objects.filter(cancellation_datetime__isnull=False).count() == 0 + assert Booking.objects.get(id=booking_id).cancellation_datetime is None + resp_cancel = app.post(cancel_url) + assert resp_cancel.json['err'] == 0 + assert Booking.objects.filter(cancellation_datetime__isnull=False).count() == 3 + assert Booking.objects.get(id=booking_id).cancellation_datetime is not None + + # extra data stored in extra_data field + resp = app.post_json('/api/agenda/%s/fillslots/' % agenda.id, + params={'slots': events_ids, + 'label': 'l', 'user_name': 'u', 'backoffice_url': '', 'foo': 'bar'}) + assert Booking.objects.get(id=resp.json['booking_id']).label == 'l' + assert Booking.objects.get(id=resp.json['booking_id']).user_name == 'u' + assert Booking.objects.get(id=resp.json['booking_id']).backoffice_url == '' + assert Booking.objects.get(id=resp.json['booking_id']).extra_data == {'foo': 'bar'} + for booking in Booking.objects.filter(primary_booking=resp.json['booking_id']): + assert booking.extra_data == {'foo': 'bar'} + + # test invalid data are refused + resp = app.post_json('/api/agenda/%s/fillslots/' % agenda.id, + params={'slots': events_ids, + 'user_name': {'foo': 'bar'}}, status=400) + assert resp.json['err'] == 1 + assert resp.json['reason'] == 'invalid payload' + assert len(resp.json['errors']) == 1 + assert 'user_name' in resp.json['errors'] + + # empty or missing slots + resp = app.post_json('/api/agenda/%s/fillslots/' % agenda.id, params={'slots': []}, status=400) + assert resp.json['err'] == 1 + assert resp.json['reason'] == 'slots list cannot be empty' + resp = app.post_json('/api/agenda/%s/fillslots/' % agenda.id, status=400) + assert resp.json['err'] == 1 + assert resp.json['reason'] == 'slots list cannot be empty' + # invalid slots format + resp = app.post_json('/api/agenda/%s/fillslots/' % agenda.id, params={'slots': 'foobar'}, status=400) + assert resp.json['err'] == 1 + assert resp.json['reason'] == 'invalid payload' + assert len(resp.json['errors']) == 1 + assert 'slots' in resp.json['errors'] + + # unknown agendas + resp = app.post('/api/agenda/foobar/fillslots/', status=404) + resp = app.post('/api/agenda/233/fillslots/', status=404) + def test_booking_api_meeting(app, meetings_agenda, user): agenda_id = meetings_agenda.slug meeting_type = MeetingType.objects.get(agenda=meetings_agenda) @@ -390,6 +496,58 @@ def test_booking_api_meeting(app, meetings_agenda, user): assert resp.json['err'] == 0 assert Booking.objects.count() == 2 +def test_booking_api_meeting_fillslots(app, meetings_agenda, user): + agenda_id = meetings_agenda.slug + meeting_type = MeetingType.objects.get(agenda=meetings_agenda) + resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.id) + slots = [resp.json['data'][0]['id'], resp.json['data'][1]['id']] + + app.authorization = ('Basic', ('john.doe', 'password')) + resp_booking = app.post('/api/agenda/%s/fillslots/' % agenda_id, params={'slots': slots}) + assert Booking.objects.count() == 2 + primary_booking = Booking.objects.filter(primary_booking__isnull=True).first() + secondary_booking = Booking.objects.filter(primary_booking=primary_booking.id).first() + assert resp_booking.json['datetime'][:16] == localtime(primary_booking.event.start_datetime + ).isoformat()[:16] + assert resp_booking.json['end_datetime'][:16] == localtime(secondary_booking.event.end_datetime + ).isoformat()[:16] + + resp2 = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.id) + assert len(resp.json['data']) == len([x for x in resp2.json['data'] if not x.get('disabled')]) + 2 + + # try booking the same timeslots + resp2 = app.post('/api/agenda/%s/fillslots/' % agenda_id, params={'slots': slots}) + assert resp2.json['err'] == 1 + assert resp2.json['reason'] == 'no more desk available' + + # try booking partially free timeslots (one free, one busy) + nonfree_slots = [resp.json['data'][0]['id'], resp.json['data'][2]['id']] + resp2 = app.post('/api/agenda/%s/fillslots/' % agenda_id, params={'slots': nonfree_slots}) + assert resp2.json['err'] == 1 + assert resp2.json['reason'] == 'no more desk available' + + # booking other free timeslots + free_slots = [resp.json['data'][3]['id'], resp.json['data'][2]['id']] + resp2 = app.post('/api/agenda/%s/fillslots/' % agenda_id, params={'slots': free_slots}) + assert resp2.json['err'] == 0 + cancel_url = resp2.json['api']['cancel_url'] + assert Booking.objects.count() == 4 + # 4 = 2 primary + 2 secondary + assert Booking.objects.filter(primary_booking__isnull=True).count() == 2 + assert Booking.objects.filter(primary_booking__isnull=False).count() == 2 + # cancel + assert Booking.objects.filter(cancellation_datetime__isnull=False).count() == 0 + resp_cancel = app.post(cancel_url) + assert resp_cancel.json['err'] == 0 + assert Booking.objects.filter(cancellation_datetime__isnull=False).count() == 2 + + impossible_slots = ['1:2017-05-22-1130', '2:2017-05-22-1100'] + resp = app.post('/api/agenda/%s/fillslots/' % agenda_id, + params={'slots': impossible_slots}, + status=400) + assert resp.json['err'] == 1 + assert resp.json['reason'] == 'all slots must have the same meeting type id (1)' + def test_booking_api_meeting_across_daylight_saving_time(app, meetings_agenda, user): meetings_agenda.maximal_booking_delay = 365 meetings_agenda.save() @@ -746,6 +904,107 @@ def test_multiple_booking_api(app, some_data, user): assert Event.objects.get(id=event.id).booked_places == 3 assert Event.objects.get(id=event.id).waiting_list == 2 +def test_multiple_booking_api_fillslots(app, some_data, user): + agenda = Agenda.objects.filter(label=u'Foo bar')[0] + # get slots of first 2 events + events = [x for x in Event.objects.filter(agenda=agenda).order_by('start_datetime') if x.in_bookable_period()][:2] + events_ids = [x.id for x in events] + resp_datetimes = app.get('/api/agenda/%s/datetimes/' % agenda.id) + slots = [x['id'] for x in resp_datetimes.json['data'] if x['id'] in events_ids] + + app.authorization = ('Basic', ('john.doe', 'password')) + resp = app.post('/api/agenda/%s/fillslots/?count=NaN' % agenda.slug, params={'slots': slots}, status=400) + assert resp.json['err'] == 1 + assert resp.json['reason'] == "invalid value for count (u'NaN')" + + resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, + params={'slots': slots, 'count': 'NaN'}, status=400) + assert resp.json['err'] == 1 + assert resp.json['reason'] == "invalid payload" + assert 'count' in resp.json['errors'] + + # get 3 places on 2 slots + resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, + params={'slots': slots, 'count': '3'}) + # one booking with 5 children + booking = Booking.objects.get(id=resp.json['booking_id']) + cancel_url = resp.json['api']['cancel_url'] + assert Booking.objects.filter(primary_booking=booking).count() == 5 + assert resp.json['datetime'] == localtime(events[0].start_datetime).isoformat() + assert 'accept_url' not in resp.json['api'] + assert 'cancel_url' in resp.json['api'] + for event in events: + assert Event.objects.get(id=event.id).booked_places == 3 + + resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, + params={'slots': slots, 'count': 2}) + for event in events: + assert Event.objects.get(id=event.id).booked_places == 5 + + resp = app.post(cancel_url) + for event in events: + assert Event.objects.get(id=event.id).booked_places == 2 + + # check available places overflow + # NB: limit only the first event ! + events[0].places = 3 + events[0].waiting_list_places = 8 + events[0].save() + + resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, + params={'slots': slots, 'count': 5}) + for event in events: + assert Event.objects.get(id=event.id).booked_places == 2 + assert Event.objects.get(id=event.id).waiting_list == 5 + accept_url = resp.json['api']['accept_url'] + + return + + resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, + params={'slots': slots, 'count': 5}) + assert resp.json['err'] == 1 + assert resp.json['reason'] == 'sold out' + for event in events: + assert Event.objects.get(id=event.id).booked_places == 2 + assert Event.objects.get(id=event.id).waiting_list == 5 + + # accept the waiting list + resp = app.post(accept_url) + for event in events: + assert Event.objects.get(id=event.id).booked_places == 7 + assert Event.objects.get(id=event.id).waiting_list == 0 + + # check with a short waiting list + Booking.objects.all().delete() + # NB: limit only the first event ! + events[0].places = 4 + events[0].waiting_list_places = 2 + events[0].save() + + resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, + params={'slots': slots, 'count': 5}) + assert resp.json['err'] == 1 + assert resp.json['reason'] == 'sold out' + + resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, + params={'slots': slots, 'count': 3}) + assert resp.json['err'] == 0 + for event in events: + assert Event.objects.get(id=event.id).booked_places == 3 + assert Event.objects.get(id=event.id).waiting_list == 0 + + resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, + params={'slots': slots, 'count': 3}) + assert resp.json['err'] == 1 + assert resp.json['reason'] == 'sold out' + + resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, + params={'slots': slots, 'count': '2'}) + assert resp.json['err'] == 0 + for event in events: + assert Event.objects.get(id=event.id).booked_places == 3 + assert Event.objects.get(id=event.id).waiting_list == 2 + def test_agenda_detail_api(app, some_data): agenda = Agenda.objects.get(slug='foo-bar') resp = app.get('/api/agenda/%s/' % agenda.slug) @@ -898,6 +1157,75 @@ def test_agenda_meeting_api_multiple_desk(app, meetings_agenda, user): app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.id) assert queries_count_datetime1 == len(ctx.captured_queries) +def test_agenda_meeting_api_fillslots_multiple_desks(app, meetings_agenda, user): + app.authorization = ('Basic', ('john.doe', 'password')) + agenda_id = meetings_agenda.slug + meeting_type = MeetingType.objects.get(agenda=meetings_agenda) + + # add a second desk, same timeperiods + time_period = meetings_agenda.desk_set.first().timeperiod_set.first() + desk2 = Desk.objects.create(label='Desk 2', agenda=meetings_agenda) + TimePeriod.objects.create( + start_time=time_period.start_time, end_time=time_period.end_time, + weekday=time_period.weekday, desk=desk2) + + resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.id) + slots = [x['id'] for x in resp.json['data'][:3]] + + def get_free_places(): + resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.id) + return len([x for x in resp.json['data'] if not x['disabled']]) + start_free_places = get_free_places() + + # booking 3 slots on desk 1 + fillslots_url = '/api/agenda/%s/fillslots/' % agenda_id + resp = app.post(fillslots_url, params={'slots': slots}) + assert resp.json['err'] == 0 + desk1 = resp.json['desk']['slug'] + cancel_url = resp.json['api']['cancel_url'] + assert get_free_places() == start_free_places + + # booking same slots again, will be on desk 2 + resp = app.post(fillslots_url, params={'slots': slots}) + assert resp.json['err'] == 0 + assert resp.json['desk']['slug'] != desk2 + # 3 places are disabled in datetimes list + assert get_free_places() == start_free_places - len(slots) + + # try booking again: no desk available + resp = app.post(fillslots_url, params={'slots': slots}) + assert resp.json['err'] == 1 + assert resp.json['reason'] == 'no more desk available' + assert get_free_places() == start_free_places - len(slots) + + # cancel desk 1 booking + resp = app.post(cancel_url) + assert resp.json['err'] == 0 + # all places are free again + assert get_free_places() == start_free_places + + # booking a single slot (must be on desk 1) + resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, slots[1])) + assert resp.json['err'] == 0 + assert resp.json['desk']['slug'] == desk1 + cancel_url = resp.json['api']['cancel_url'] + assert get_free_places() == start_free_places - 1 + + # try booking the 3 slots again: no desk available, one slot is not fully available + resp = app.post(fillslots_url, params={'slots': slots}) + assert resp.json['err'] == 1 + assert resp.json['reason'] == 'no more desk available' + + # cancel last signel slot booking, desk1 will be free + resp = app.post(cancel_url) + assert resp.json['err'] == 0 + assert get_free_places() == start_free_places + + # booking again is ok, on desk 1 + resp = app.post(fillslots_url, params={'slots': slots}) + assert resp.json['err'] == 0 + assert resp.json['desk']['slug'] == desk1 + assert get_free_places() == start_free_places - len(slots) def test_agenda_meeting_same_day(app, meetings_agenda, mock_now, user): app.authorization = ('Basic', ('john.doe', 'password')) -- 2.16.3