From bfa5cb484646348d4855e11ec1a8e3935772c96e 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 | 203 ++++++++++++++++++++++++++++++++++------------------ tests/test_api.py | 7 +- 3 files changed, 141 insertions(+), 71 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..d815f6a 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): + ''' + 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 Fillslot(APIView): +class SlotsSerializer(SlotSerializer): + ''' + payload to fill multiple slots: same as SlotSerializer, but the + slots list is in the payload. + ''' + slots = serializers.ListField( + child=serializers.CharField(max_length=64, allow_blank=False)) + + +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,135 @@ class Fillslot(APIView): except (ValueError, Agenda.DoesNotExist): raise Http404() - serializer = SlotSerializer(data=request.data) + serializer = self.serializer_class(data=request.data) 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 + }) + 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 datetimes_by_desk: + 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, try to 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 +473,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..b7efa1f 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): -- 2.16.3