From f53259e7f2f1d09f839a7bbe3952d018c2ab09d3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Wed, 20 May 2020 11:41:02 +0200
Subject: [PATCH 3/4] api: split fillslot endpoints (#43077)

---
 chrono/api/serializers.py |  18 +-
 chrono/api/urls.py        |  20 +-
 chrono/api/views.py       | 580 ++++++++++++++++++++++----------------
 tests/test_api.py         | 134 +++++----
 4 files changed, 451 insertions(+), 301 deletions(-)

diff --git a/chrono/api/serializers.py b/chrono/api/serializers.py
index 37b996b..df7c50f 100644
--- a/chrono/api/serializers.py
+++ b/chrono/api/serializers.py
@@ -17,7 +17,7 @@
 from rest_framework import serializers
 
 
-class SlotSerializer(serializers.Serializer):
+class BaseSlotSerializer(serializers.Serializer):
     '''
     payload to fill one slot. The slot (event id) is in the URL.
     '''
@@ -31,11 +31,18 @@ class SlotSerializer(serializers.Serializer):
     form_url = serializers.CharField(max_length=250, allow_blank=True)
     backoffice_url = serializers.URLField(allow_blank=True)
     cancel_callback_url = serializers.URLField(allow_blank=True)
+
+
+class SlotSerializer(BaseSlotSerializer):
     count = serializers.IntegerField(min_value=1)
     cancel_booking_id = serializers.CharField(max_length=250, allow_blank=True, allow_null=True)
     force_waiting_list = serializers.BooleanField(default=False)
 
 
+class MeetingsSlotSerializer(BaseSlotSerializer):
+    pass
+
+
 class StringOrListField(serializers.ListField):
     def to_internal_value(self, data):
         if isinstance(data, str):
@@ -52,5 +59,14 @@ class SlotsSerializer(SlotSerializer):
     slots = StringOrListField(required=True, child=serializers.CharField(max_length=64, allow_blank=False))
 
 
+class MeetingsSlotsSerializer(MeetingsSlotSerializer):
+    '''
+    payload to fill multiple slots: same as MeetingsSlotSerializer, but the
+    slots list is in the payload.
+    '''
+
+    slots = StringOrListField(required=True, child=serializers.CharField(max_length=64, allow_blank=False))
+
+
 class ResizeSerializer(serializers.Serializer):
     count = serializers.IntegerField(min_value=1)
diff --git a/chrono/api/urls.py b/chrono/api/urls.py
index e2f2df6..c4c7477 100644
--- a/chrono/api/urls.py
+++ b/chrono/api/urls.py
@@ -44,17 +44,27 @@ urlpatterns = [
         name='api-agenda-meeting-datetimes-legacy',
     ),
     url(r'^agenda/(?P<agenda_identifier>[\w-]+)/meetings/$', views.meeting_list, name='api-agenda-meetings'),
+    url(
+        r'^agenda/(?P<agenda_identifier>[\w-]+)/meetings/(?P<meeting_identifier>[\w-]+)/datetimes/$',
+        views.meeting_datetimes,
+        name='api-agenda-meeting-datetimes',
+    ),
+    url(
+        r'^agenda/(?P<agenda_identifier>[\w-]+)/meetings/fillslot/(?P<event_identifier>[\w:-]+)/$',
+        views.meetings_fillslot,
+        name='api-agenda-meetings-fillslot',
+    ),
+    url(
+        r'^agenda/(?P<agenda_identifier>[\w-]+)/meetings/fillslots/$',
+        views.meetings_fillslots,
+        name='api-agenda-meetings-fillslots',
+    ),
     url(
         r'^agenda/(?P<agenda_identifier>[\w-]+)/meetings/(?P<meeting_identifier>[\w-]+)/$',
         views.meeting_info,
         name='api-agenda-meetings',
     ),
     url(r'^agenda/(?P<agenda_identifier>[\w-]+)/desks/$', views.agenda_desk_list, name='api-agenda-desks'),
-    url(
-        r'^agenda/(?P<agenda_identifier>[\w-]+)/meetings/(?P<meeting_identifier>[\w-]+)/datetimes/$',
-        views.meeting_datetimes,
-        name='api-agenda-meeting-datetimes',
-    ),
     url(r'^booking/(?P<booking_pk>\w+)/$', views.booking),
     url(r'^booking/(?P<booking_pk>\w+)/cancel/$', views.cancel_booking, name='api-cancel-booking'),
     url(r'^booking/(?P<booking_pk>\w+)/accept/$', views.accept_booking, name='api-accept-booking'),
diff --git a/chrono/api/views.py b/chrono/api/views.py
index bc8a36b..9e06e44 100644
--- a/chrono/api/views.py
+++ b/chrono/api/views.py
@@ -29,7 +29,6 @@ from django.utils.dateparse import parse_date
 from django.utils.encoding import force_text
 from django.utils.formats import date_format
 from django.utils.timezone import now, make_aware, localtime
-from django.utils.translation import gettext_noop
 from django.utils.translation import ugettext_lazy as _
 
 from rest_framework import permissions, status
@@ -290,7 +289,10 @@ def get_agenda_detail(request, agenda, check_events=False):
         agenda_detail['api'] = {
             'datetimes_url': request.build_absolute_uri(
                 reverse('api-agenda-datetimes', kwargs={'agenda_identifier': agenda.slug})
-            )
+            ),
+            'fillslots_url': request.build_absolute_uri(
+                reverse('api-agenda-fillslots', kwargs={'agenda_identifier': agenda.slug})
+            ),
         }
         if check_events:
             agenda_detail['opened_events_available'] = agenda.get_open_events().filter(full=False).exists()
@@ -302,10 +304,10 @@ def get_agenda_detail(request, agenda, check_events=False):
             'desks_url': request.build_absolute_uri(
                 reverse('api-agenda-desks', kwargs={'agenda_identifier': agenda.slug})
             ),
+            'fillslots_url': request.build_absolute_uri(
+                reverse('api-agenda-meetings-fillslots', 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
 
@@ -523,7 +525,7 @@ class MeetingDatetimes(APIView):
         fake_event_identifier = '__event_identifier__'
         fillslot_url = request.build_absolute_uri(
             reverse(
-                'api-fillslot',
+                'api-agenda-meetings-fillslot',
                 kwargs={'agenda_identifier': agenda.slug, 'event_identifier': fake_event_identifier},
             )
         )
@@ -653,56 +655,98 @@ class AgendaDeskList(APIView):
 agenda_desk_list = AgendaDeskList.as_view()
 
 
-class Fillslots(APIView):
+class FillSlotsMixin(object):
     permission_classes = (permissions.IsAuthenticated,)
-    serializer_class = serializers.SlotsSerializer
 
-    def post(self, request, agenda_identifier=None, event_identifier=None, format=None):
+    def post(self, *args, **kwargs):
         try:
-            return self.fillslot(request=request, agenda_identifier=agenda_identifier, format=format)
+            serializer = self.serializer_class(data=self.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,
+                )
+            self.agenda = self.get_agenda()
+            return self._post(payload=serializer.validated_data)
         except APIError as e:
             return e.to_response()
 
-    def fillslot(self, request, agenda_identifier=None, slots=[], format=None):
-        multiple_booking = bool(not slots)
+    def validate_slots(self, slots):
+        if not slots:
+            raise APIError(
+                _('slots list cannot be empty'),
+                err_class='slots list cannot be empty',
+                http_status=status.HTTP_400_BAD_REQUEST,
+            )
+        return slots
+
+    def get_agenda(self):
+        agenda_identifier = self.kwargs['agenda_identifier']
         try:
-            agenda = Agenda.objects.get(slug=agenda_identifier)
+            return Agenda.objects.get(slug=agenda_identifier)
         except Agenda.DoesNotExist:
             try:
                 # legacy access by agenda id
-                agenda = Agenda.objects.get(id=int(agenda_identifier))
+                return Agenda.objects.get(pk=int(agenda_identifier))
             except (ValueError, Agenda.DoesNotExist):
                 raise Http404()
 
-        serializer = self.serializer_class(data=request.data, partial=True)
-        if not serializer.is_valid():
+    def get_booking_to_cancel(self, payload, places_count=None):
+        if not payload.get('cancel_booking_id'):
+            return
+
+        try:
+            to_cancel_booking = Booking.objects.get(pk=int(payload['cancel_booking_id']))
+        except (ValueError, TypeError):
             raise APIError(
-                _('invalid payload'),
-                err_class='invalid payload',
-                errors=serializer.errors,
+                _('cancel_booking_id is not an integer'),
+                err_class='cancel_booking_id is not an integer',
                 http_status=status.HTTP_400_BAD_REQUEST,
             )
-        payload = serializer.validated_data
+        except Booking.DoesNotExist:
+            raise APIError(
+                _('cancel booking: booking does no exist'), err_class='cancel booking: booking does no exist'
+            )
 
-        if 'slots' in payload:
-            slots = payload['slots']
-        if not slots:
+        if to_cancel_booking.cancellation_datetime:
             raise APIError(
-                _('slots list cannot be empty'),
-                err_class='slots list cannot be empty',
-                http_status=status.HTTP_400_BAD_REQUEST,
+                _('cancel booking: booking already cancelled'),
+                err_class='cancel booking: booking already cancelled',
+            )
+
+        if places_count is not None:
+            # events booking
+            to_cancel_places_count = (
+                to_cancel_booking.secondary_booking_set.filter(event=to_cancel_booking.event).count() + 1
             )
+            if places_count != to_cancel_places_count:
+                raise APIError(
+                    _('cancel booking: count is different'), err_class='cancel booking: count is different'
+                )
+
+        return to_cancel_booking
+
 
+class EventFillSlotsMixin(FillSlotsMixin):
+    def get_agenda(self):
+        agenda = super().get_agenda()
+        if agenda.kind != 'events':
+            raise Http404()
+        return agenda
+
+    def get_places_count(self, payload):
         if 'count' in payload:
             places_count = payload['count']
-        elif 'count' in request.query_params:
+        elif 'count' in self.request.query_params:
             # legacy: count in the query string
             try:
-                places_count = int(request.query_params['count'])
+                places_count = int(self.request.query_params['count'])
             except ValueError:
                 raise APIError(
-                    _('invalid value for count (%s)') % request.query_params['count'],
-                    err_class='invalid value for count (%s)' % request.query_params['count'],
+                    _('invalid value for count (%s)') % self.request.query_params['count'],
+                    err_class='invalid value for count (%s)' % self.request.query_params['count'],
                     http_status=status.HTTP_400_BAD_REQUEST,
                 )
         else:
@@ -714,193 +758,35 @@ class Fillslots(APIView):
                 err_class='count cannot be less than or equal to zero',
                 http_status=status.HTTP_400_BAD_REQUEST,
             )
+        return places_count
 
-        to_cancel_booking = None
-        cancel_booking_id = None
-        if payload.get('cancel_booking_id'):
-            try:
-                cancel_booking_id = int(payload.get('cancel_booking_id'))
-            except (ValueError, TypeError):
-                raise APIError(
-                    _('cancel_booking_id is not an integer'),
-                    err_class='cancel_booking_id is not an integer',
-                    http_status=status.HTTP_400_BAD_REQUEST,
-                )
-
-        if cancel_booking_id is not None:
-            cancel_error = None
-            try:
-                to_cancel_booking = Booking.objects.get(pk=cancel_booking_id)
-                if to_cancel_booking.cancellation_datetime:
-                    cancel_error = gettext_noop('cancel booking: booking already cancelled')
-                else:
-                    to_cancel_places_count = (
-                        to_cancel_booking.secondary_booking_set.filter(event=to_cancel_booking.event).count()
-                        + 1
-                    )
-                    if places_count != to_cancel_places_count:
-                        cancel_error = gettext_noop('cancel booking: count is different')
-            except Booking.DoesNotExist:
-                cancel_error = gettext_noop('cancel booking: booking does no exist')
-
-            if cancel_error:
-                raise APIError(_(cancel_error), err_class=cancel_error)
-
-        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.accept_meetings():
-            # 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:
-                try:
-                    meeting_type_id_, datetime_str = slot.split(':')
-                except ValueError:
-                    raise APIError(
-                        _('invalid slot: %s') % slot,
-                        err_class='invalid slot: %s' % slot,
-                        http_status=status.HTTP_400_BAD_REQUEST,
-                    )
-                if meeting_type_id_ != meeting_type_id:
-                    raise APIError(
-                        _('all slots must have the same meeting type id (%s)') % meeting_type_id,
-                        err_class='all slots must have the same meeting type id (%s)' % meeting_type_id,
-                        http_status=status.HTTP_400_BAD_REQUEST,
-                    )
-                datetimes.add(make_aware(datetime.datetime.strptime(datetime_str, '%Y-%m-%d-%H%M')))
-
-            try:
-                resources = get_resources_from_request(request, agenda)
-            except APIError as e:
-                return e.to_response()
-
-            # get all free slots and separate them by desk
-            try:
-                try:
-                    meeting_type = agenda.get_meetingtype(slug=meeting_type_id)
-                except MeetingType.DoesNotExist:
-                    # legacy access by id
-                    meeting_type = agenda.get_meetingtype(id_=meeting_type_id)
-            except (MeetingType.DoesNotExist, ValueError):
-                raise APIError(
-                    _('invalid meeting type id: %s') % meeting_type_id,
-                    err_class='invalid meeting type id: %s' % meeting_type_id,
-                    http_status=status.HTTP_400_BAD_REQUEST,
-                )
-            all_slots = sorted(
-                get_all_slots(agenda, meeting_type, resources=resources),
-                key=lambda slot: slot.start_datetime,
+    def get_events(self, slots):
+        try:
+            events = Event.objects.filter(agenda=self.agenda, id__in=[int(s) for s in slots]).order_by(
+                'start_datetime'
             )
+        except ValueError:
+            events = Event.objects.filter(agenda=self.agenda, slug__in=slots).order_by('start_datetime')
 
-            all_free_slots = [slot for slot in all_slots if not slot.full]
-            datetimes_by_desk = collections.defaultdict(set)
-            for slot in all_free_slots:
-                datetimes_by_desk[slot.desk.id].add(slot.start_datetime)
-
-            available_desk = None
-
-            if agenda.kind == 'virtual':
-                # Compute fill_rate by agenda/date
-                fill_rates = collections.defaultdict(dict)
-                for slot in all_slots:
-                    ref_date = slot.start_datetime.date()
-                    if ref_date not in fill_rates[slot.desk.agenda]:
-                        date_dict = fill_rates[slot.desk.agenda][ref_date] = {'free': 0, 'full': 0}
-                    else:
-                        date_dict = fill_rates[slot.desk.agenda][ref_date]
-                    if slot.full:
-                        date_dict['full'] += 1
-                    else:
-                        date_dict['free'] += 1
-                for dd in fill_rates.values():
-                    for date_dict in dd.values():
-                        date_dict['fill_rate'] = date_dict['full'] / (date_dict['full'] + date_dict['free'])
-
-                # select a desk on the agenda with min fill_rate on the given date
-                for available_desk_id in sorted(datetimes_by_desk.keys()):
-                    if datetimes.issubset(datetimes_by_desk[available_desk_id]):
-                        desk = Desk.objects.get(id=available_desk_id)
-                        if available_desk is None:
-                            available_desk = desk
-                            available_desk_rate = 0
-                            for dt in datetimes:
-                                available_desk_rate += fill_rates[available_desk.agenda][dt.date()][
-                                    'fill_rate'
-                                ]
-                        else:
-                            for dt in datetimes:
-                                desk_rate = 0
-                                for dt in datetimes:
-                                    desk_rate += fill_rates[desk.agenda][dt.date()]['fill_rate']
-                            if desk_rate < available_desk_rate:
-                                available_desk = desk
-                                available_desk_rate = desk_rate
-
-            else:
-                # meeting agenda
-                # 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
+        if not events.exists():
+            raise APIError(
+                _('unknown event identifiers or slugs'),
+                err_class='unknown event identifiers or slugs',
+                http_status=status.HTTP_400_BAD_REQUEST,
+            )
 
-            if available_desk is None:
+        for event in events:
+            if not event.in_bookable_period():
                 raise APIError(
-                    _('no more desk available'), err_class='no more desk available',
-                )
-
-            # all datetimes are free, book them in order
-            datetimes = list(datetimes)
-            datetimes.sort()
-
-            # get a real meeting_type for virtual agenda
-            if agenda.kind == 'virtual':
-                meeting_type = MeetingType.objects.get(agenda=available_desk.agenda, slug=meeting_type.slug)
-
-            # 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:
-                event = Event.objects.create(
-                    agenda=available_desk.agenda,
-                    slug=str(uuid.uuid4()),  # set slug to avoid queries during slug generation
-                    meeting_type=meeting_type,
-                    start_datetime=start_datetime,
-                    full=False,
-                    places=1,
-                    desk=available_desk,
+                    _('event not bookable'), err_class='event not bookable',
                 )
-                if resources:
-                    event.resources.add(*resources)
-                events.append(event)
-        else:
-            try:
-                events = agenda.event_set.filter(id__in=[int(s) for s in slots]).order_by('start_datetime')
-            except ValueError:
-                events = agenda.event_set.filter(slug__in=slots).order_by('start_datetime')
 
-            for event in events:
-                if not event.in_bookable_period():
-                    return Response(
-                        {'err': 1, 'err_class': 'event not bookable', 'err_desc': _('event not bookable')}
-                    )
-                if event.cancelled:
-                    return Response(
-                        {'err': 1, 'err_class': 'event is cancelled', 'err_desc': _('event is cancelled')}
-                    )
+        return events
 
-            if not events.count():
-                raise APIError(
-                    _('unknown event identifiers or slugs'),
-                    err_class='unknown event identifiers or slugs',
-                    http_status=status.HTTP_400_BAD_REQUEST,
-                )
+    def fillslots(self, payload, slots, multiple_booking=False):
+        places_count = self.get_places_count(payload)
+        to_cancel_booking = self.get_booking_to_cancel(payload, places_count)
+        events = self.get_events(slots)
 
         # search free places. Switch to waiting list if necessary.
         in_waiting_list = False
@@ -929,6 +815,11 @@ class Fillslots(APIView):
                         _('sold out'), err_class='sold out',
                     )
 
+        extra_data = {}
+        for k, v in self.request.data.items():
+            if k not in payload:
+                extra_data[k] = v
+
         with transaction.atomic():
             if to_cancel_booking:
                 cancelled_booking_id = to_cancel_booking.pk
@@ -938,8 +829,9 @@ class Fillslots(APIView):
             primary_booking = None
             for event in events:
                 for i in range(places_count):
-                    new_booking = Booking(
+                    new_booking = Booking.objects.create(
                         event_id=event.id,
+                        primary_booking=primary_booking,
                         in_waiting_list=in_waiting_list,
                         label=payload.get('label', ''),
                         user_external_id=payload.get('user_external_id', ''),
@@ -952,54 +844,44 @@ class Fillslots(APIView):
                         user_display_label=payload.get('user_display_label', ''),
                         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
+                    primary_booking = primary_booking or new_booking
 
         response = {
             'err': 0,
             'in_waiting_list': in_waiting_list,
             'booking_id': primary_booking.id,
-            'datetime': format_response_datetime(events[0].start_datetime),
+            'datetime': format_response_datetime(primary_booking.event.start_datetime),
+            'end_datetime': format_response_datetime(primary_booking.event.end_datetime)
+            if primary_booking.event.end_datetime
+            else None,
             'agenda': {
                 'label': primary_booking.event.agenda.label,
                 'slug': primary_booking.event.agenda.slug,
             },
             'api': {
-                'cancel_url': request.build_absolute_uri(
+                'cancel_url': self.request.build_absolute_uri(
                     reverse('api-cancel-booking', kwargs={'booking_pk': primary_booking.id})
                 ),
-                'ics_url': request.build_absolute_uri(
+                'ics_url': self.request.build_absolute_uri(
                     reverse('api-booking-ics', kwargs={'booking_pk': primary_booking.id})
                 ),
             },
         }
-        if agenda.kind == 'events':
-            response['api']['accept_url'] = request.build_absolute_uri(
+        if self.agenda.kind == 'events':
+            response['api']['accept_url'] = self.request.build_absolute_uri(
                 reverse('api-accept-booking', kwargs={'booking_pk': primary_booking.pk})
             )
-            response['api']['suspend_url'] = request.build_absolute_uri(
+            response['api']['suspend_url'] = self.request.build_absolute_uri(
                 reverse('api-suspend-booking', kwargs={'booking_pk': primary_booking.pk})
             )
-        if agenda.accept_meetings():
-            response['end_datetime'] = format_response_datetime(events[-1].end_datetime)
-            response['duration'] = (events[-1].end_datetime - events[-1].start_datetime).seconds // 60
-        if available_desk:
-            response['desk'] = {'label': available_desk.label, 'slug': available_desk.slug}
         if to_cancel_booking:
             response['cancelled_booking_id'] = cancelled_booking_id
-        if agenda.kind == 'events' and not multiple_booking:
+        if not multiple_booking:
             event = events[0]
             # event.full is not up to date, it might have been changed by previous new_booking.save().
             event.refresh_from_db()
             response['places'] = get_event_places(event)
-            if event.end_datetime:
-                response['end_datetime'] = format_response_datetime(event.end_datetime)
-            else:
-                response['end_datetime'] = None
-        if agenda.kind == 'events' and multiple_booking:
+        else:
             response['events'] = [
                 {
                     'slug': x.slug,
@@ -1010,31 +892,237 @@ class Fillslots(APIView):
                 }
                 for x in events
             ]
-        if agenda.kind == 'meetings':
-            response['resources'] = [r.slug for r in resources]
 
         return Response(response)
 
 
-fillslots = Fillslots.as_view()
-
+class MeetingsFillSlotsMixin(FillSlotsMixin):
+    def get_agenda(self):
+        agenda = super().get_agenda()
+        if not agenda.accept_meetings():
+            raise Http404()
+        return agenda
 
-class Fillslot(Fillslots):
-    serializer_class = serializers.SlotSerializer
+    def fillslots(self, payload, slots, multiple_booking=False):
+        to_cancel_booking = self.get_booking_to_cancel(payload)
 
-    def post(self, request, agenda_identifier=None, event_identifier=None, format=None):
         try:
-            return self.fillslot(
-                request=request,
-                agenda_identifier=agenda_identifier,
-                slots=[event_identifier],  # fill a "list on one slot"
-                format=format,
-            )
+            resources = get_resources_from_request(self.request, self.agenda)
         except APIError as e:
             return e.to_response()
 
+        # 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:
+            try:
+                meeting_type_id_, datetime_str = slot.split(':')
+            except ValueError:
+                raise APIError(
+                    _('invalid slot: %s') % slot,
+                    err_class='invalid slot: %s' % slot,
+                    http_status=status.HTTP_400_BAD_REQUEST,
+                )
+            if meeting_type_id_ != meeting_type_id:
+                raise APIError(
+                    _('all slots must have the same meeting type id (%s)') % meeting_type_id,
+                    err_class='all slots must have the same meeting type id (%s)' % meeting_type_id,
+                    http_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
+        try:
+            try:
+                meeting_type = self.agenda.get_meetingtype(slug=meeting_type_id)
+            except MeetingType.DoesNotExist:
+                # legacy access by id
+                meeting_type = self.agenda.get_meetingtype(id_=meeting_type_id)
+        except (MeetingType.DoesNotExist, ValueError):
+            raise APIError(
+                _('invalid meeting type id: %s') % meeting_type_id,
+                err_class='invalid meeting type id: %s' % meeting_type_id,
+                http_status=status.HTTP_400_BAD_REQUEST,
+            )
+        all_slots = sorted(
+            get_all_slots(self.agenda, meeting_type, resources=resources),
+            key=lambda slot: slot.start_datetime,
+        )
+
+        all_free_slots = [slot for slot in all_slots if not slot.full]
+        datetimes_by_desk = collections.defaultdict(set)
+        for slot in all_free_slots:
+            datetimes_by_desk[slot.desk.id].add(slot.start_datetime)
+
+        available_desk = None
+
+        if self.agenda.kind == 'virtual':
+            # Compute fill_rate by agenda/date
+            fill_rates = collections.defaultdict(dict)
+            for slot in all_slots:
+                ref_date = slot.start_datetime.date()
+                if ref_date not in fill_rates[slot.desk.agenda]:
+                    date_dict = fill_rates[slot.desk.agenda][ref_date] = {'free': 0, 'full': 0}
+                else:
+                    date_dict = fill_rates[slot.desk.agenda][ref_date]
+                if slot.full:
+                    date_dict['full'] += 1
+                else:
+                    date_dict['free'] += 1
+            for dd in fill_rates.values():
+                for date_dict in dd.values():
+                    date_dict['fill_rate'] = date_dict['full'] / (date_dict['full'] + date_dict['free'])
+
+            # select a desk on the agenda with min fill_rate on the given date
+            for available_desk_id in sorted(datetimes_by_desk.keys()):
+                if datetimes.issubset(datetimes_by_desk[available_desk_id]):
+                    desk = Desk.objects.get(id=available_desk_id)
+                    if available_desk is None:
+                        available_desk = desk
+                        available_desk_rate = 0
+                        for dt in datetimes:
+                            available_desk_rate += fill_rates[available_desk.agenda][dt.date()]['fill_rate']
+                    else:
+                        for dt in datetimes:
+                            desk_rate = 0
+                            for dt in datetimes:
+                                desk_rate += fill_rates[desk.agenda][dt.date()]['fill_rate']
+                        if desk_rate < available_desk_rate:
+                            available_desk = desk
+                            available_desk_rate = desk_rate
+
+        else:
+            # meeting agenda
+            # 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
+
+        if available_desk is None:
+            raise APIError(
+                _('no more desk available'), err_class='no more desk available',
+            )
+
+        # all datetimes are free, book them in order
+        datetimes = list(datetimes)
+        datetimes.sort()
+
+        # get a real meeting_type for virtual agenda
+        if self.agenda.kind == 'virtual':
+            meeting_type = MeetingType.objects.get(agenda=available_desk.agenda, slug=meeting_type.slug)
+
+        extra_data = {}
+        for k, v in self.request.data.items():
+            if k not in payload:
+                extra_data[k] = v
+
+        with transaction.atomic():
+            if to_cancel_booking:
+                cancelled_booking_id = to_cancel_booking.pk
+                to_cancel_booking.cancel()
+
+            # booking requires real Event objects (not lazy Timeslots);
+            # create them now, with data from the slots and the desk we found.
+            first_event = None
+            primary_booking = None
+            for start_datetime in datetimes:
+                event = Event.objects.create(
+                    agenda=available_desk.agenda,
+                    slug=str(uuid.uuid4()),  # set slug to avoid queries during slug generation
+                    meeting_type=meeting_type,
+                    start_datetime=start_datetime,
+                    full=False,
+                    places=1,
+                    desk=available_desk,
+                )
+                if resources:
+                    event.resources.add(*resources)
+                first_event = first_event or event
+                # now book the event
+                booking = Booking.objects.create(
+                    event=event,
+                    primary_booking=primary_booking,
+                    label=payload.get('label', ''),
+                    user_external_id=payload.get('user_external_id', ''),
+                    user_name=payload.get('user_name', ''),
+                    backoffice_url=payload.get('backoffice_url', ''),
+                    user_display_label=payload.get('user_display_label', ''),
+                    extra_data=extra_data,
+                )
+                primary_booking = primary_booking or booking
+            last_event = event
+
+        response = {
+            'err': 0,
+            'booking_id': primary_booking.pk,
+            'datetime': format_response_datetime(first_event.start_datetime),
+            'end_datetime': format_response_datetime(last_event.end_datetime),
+            'duration': (last_event.end_datetime - last_event.start_datetime).seconds // 60,
+            'agenda': {'label': first_event.agenda.label, 'slug': first_event.agenda.slug},
+            'resources': [r.slug for r in resources],
+            'api': {
+                'cancel_url': self.request.build_absolute_uri(
+                    reverse('api-cancel-booking', kwargs={'booking_pk': primary_booking.pk})
+                ),
+                'ics_url': self.request.build_absolute_uri(
+                    reverse('api-booking-ics', kwargs={'booking_pk': primary_booking.pk})
+                ),
+            },
+        }
+        if to_cancel_booking:
+            response['cancelled_booking_id'] = cancelled_booking_id
+        if available_desk:
+            response['desk'] = {'label': available_desk.label, 'slug': available_desk.slug}
+
+        return Response(response)
+
+
+class SingleFillSlotsMixin(object):
+    def get_slots(self):
+        return self.validate_slots([self.kwargs['event_identifier']])
+
+    def _post(self, payload):
+        slots = self.get_slots()
+        return self.fillslots(payload=payload, slots=slots)
+
+
+class MultipleFillSlotsMixin(object):
+    def get_slots(self, payload):
+        return self.validate_slots(payload.get('slots') or [])
+
+    def _post(self, payload):
+        slots = self.get_slots(payload=payload)
+        return self.fillslots(payload=payload, slots=slots, multiple_booking=True)
+
+
+class FillSlots(MultipleFillSlotsMixin, EventFillSlotsMixin, APIView):
+    serializer_class = serializers.SlotsSerializer
+
+
+fillslots = FillSlots.as_view()
+
+
+class MeetingsFillSlots(MultipleFillSlotsMixin, MeetingsFillSlotsMixin, APIView):
+    serializer_class = serializers.MeetingsSlotsSerializer
+
+
+meetings_fillslots = MeetingsFillSlots.as_view()
+
+
+class FillSlot(SingleFillSlotsMixin, EventFillSlotsMixin, APIView):
+    serializer_class = serializers.SlotSerializer
+
+
+fillslot = FillSlot.as_view()
+
+
+class MeetingsFillSlot(SingleFillSlotsMixin, MeetingsFillSlotsMixin, APIView):
+    serializer_class = serializers.MeetingsSlotSerializer
+
 
-fillslot = Fillslot.as_view()
+meetings_fillslot = MeetingsFillSlot.as_view()
 
 
 class BookingAPI(APIView):
diff --git a/tests/test_api.py b/tests/test_api.py
index 1d460d6..decb5b4 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -181,7 +181,7 @@ def test_agendas_api(app):
                 'api': {
                     'meetings_url': 'http://testserver/api/agenda/foo-bar-meeting/meetings/',
                     'desks_url': 'http://testserver/api/agenda/foo-bar-meeting/desks/',
-                    'fillslots_url': 'http://testserver/api/agenda/foo-bar-meeting/fillslots/',
+                    'fillslots_url': 'http://testserver/api/agenda/foo-bar-meeting/meetings/fillslots/',
                 },
             },
             {
@@ -195,7 +195,7 @@ def test_agendas_api(app):
                 'api': {
                     'meetings_url': 'http://testserver/api/agenda/foo-bar-meeting-2/meetings/',
                     'desks_url': 'http://testserver/api/agenda/foo-bar-meeting-2/desks/',
-                    'fillslots_url': 'http://testserver/api/agenda/foo-bar-meeting-2/fillslots/',
+                    'fillslots_url': 'http://testserver/api/agenda/foo-bar-meeting-2/meetings/fillslots/',
                 },
             },
             {
@@ -208,7 +208,7 @@ def test_agendas_api(app):
                 'api': {
                     'meetings_url': 'http://testserver/api/agenda/virtual-agenda/meetings/',
                     'desks_url': 'http://testserver/api/agenda/virtual-agenda/desks/',
-                    'fillslots_url': 'http://testserver/api/agenda/virtual-agenda/fillslots/',
+                    'fillslots_url': 'http://testserver/api/agenda/virtual-agenda/meetings/fillslots/',
                 },
             },
         ]
@@ -753,7 +753,6 @@ def test_datetimes_api_meetings_agenda_short_time_periods(app, meetings_agenda,
 
     default_desk, _ = Desk.objects.get_or_create(agenda=meetings_agenda, slug='desk-1')
     meeting_type = MeetingType.objects.get(agenda=meetings_agenda)
-    api_url = '/api/agenda/%s/meetings/%s/datetimes/' % (meeting_type.agenda.slug, meeting_type.slug)
 
     # test with short time periods
     TimePeriod.objects.filter(desk=default_desk).delete()
@@ -783,7 +782,7 @@ def test_datetimes_api_meetings_agenda_short_time_periods(app, meetings_agenda,
     assert resp.json['err_class'] == 'no more desk available'
     assert resp.json['err_desc'] == 'no more desk available'
     # booking the two slots fails too
-    fillslots_url = '/api/agenda/%s/fillslots/' % meeting_type.agenda.slug
+    fillslots_url = '/api/agenda/%s/meetings/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'  # legacy
@@ -809,6 +808,7 @@ def test_booking_api(app, some_data, user):
         )
 
     app.authorization = ('Basic', ('john.doe', 'password'))
+    app.post('/api/agenda/%s/meeting/fillslot/%s/' % (agenda.slug, event.id), status=404)
     resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda.slug, event.id))
     Booking.objects.get(id=resp.json['booking_id'])
     assert resp.json['datetime'] == localtime(event.start_datetime).strftime('%Y-%m-%d %H:%M:%S')
@@ -985,7 +985,7 @@ def test_booking_ics(app, some_data, meetings_agenda, user):
     meeting_type = MeetingType.objects.get(agenda=meetings_agenda)
     resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.id)
     event = resp.json['data'][2]
-    resp = app.post('/api/agenda/%s/fillslot/%s/' % (meetings_agenda_id, event['id']))
+    resp = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (meetings_agenda_id, event['id']))
     assert Booking.objects.count() == 5
     assert 'ics_url' in resp.json['api']
     booking = Booking.objects.get(id=resp.json['booking_id'])
@@ -1016,6 +1016,7 @@ def test_booking_api_fillslots(app, some_data, user):
     assert Booking.objects.count() == 0
 
     app.authorization = ('Basic', ('john.doe', 'password'))
+    app.post('/api/agenda/%s/meetings/fillslots/' % agenda.slug, params={'slots': events_ids}, status=404)
     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)
@@ -1163,16 +1164,17 @@ def test_booking_api_meeting(app, meetings_agenda, user):
     event_id = resp.json['data'][2]['id']
     assert urlparse.urlparse(
         resp.json['data'][2]['api']['fillslot_url']
-    ).path == '/api/agenda/%s/fillslot/%s/' % (meetings_agenda.slug, event_id)
+    ).path == '/api/agenda/%s/meetings/fillslot/%s/' % (meetings_agenda.slug, event_id)
 
     app.authorization = ('Basic', ('john.doe', 'password'))
 
     # verify malformed event_pk returns a 400
-    resp_booking = app.post('/api/agenda/%s/fillslot/None/' % agenda_id, status=400)
+    resp_booking = app.post('/api/agenda/%s/meetings/fillslot/None/' % agenda_id, status=400)
     assert resp_booking.json['err'] == 1
 
+    app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id), status=404)
     # make a booking
-    resp_booking = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
+    resp_booking = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda_id, event_id))
     assert Booking.objects.count() == 1
     assert resp_booking.json['datetime'] == localtime(Booking.objects.all()[0].event.start_datetime).strftime(
         '%Y-%m-%d %H:%M:%S'
@@ -1186,7 +1188,7 @@ def test_booking_api_meeting(app, meetings_agenda, user):
     assert len(resp.json['data']) == len([x for x in resp2.json['data'] if not x.get('disabled')]) + 1
 
     # try booking the same timeslot
-    resp2 = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
+    resp2 = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda_id, event_id))
     assert resp2.json['err'] == 1
     assert resp2.json['reason'] == 'no more desk available'  # legacy
     assert resp2.json['err_class'] == 'no more desk available'
@@ -1194,7 +1196,7 @@ def test_booking_api_meeting(app, meetings_agenda, user):
 
     # try booking another timeslot
     event_id = resp.json['data'][3]['id']
-    resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
+    resp = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda_id, event_id))
     assert resp.json['err'] == 0
     assert Booking.objects.count() == 2
 
@@ -1218,7 +1220,9 @@ def test_booking_api_meeting_with_resources(app, user):
     app.authorization = ('Basic', ('john.doe', 'password'))
 
     # make a booking without resource
-    resp = app.post('/api/agenda/%s/fillslot/%s:%s-1000/' % (agenda.pk, meeting_type.pk, tomorrow_str))
+    resp = app.post(
+        '/api/agenda/%s/meetings/fillslot/%s:%s-1000/' % (agenda.pk, meeting_type.pk, tomorrow_str)
+    )
     assert resp.json['datetime'] == '%s 10:00:00' % tomorrow_str
     assert resp.json['end_datetime'] == '%s 10:30:00' % tomorrow_str
     assert resp.json['duration'] == 30
@@ -1228,7 +1232,7 @@ def test_booking_api_meeting_with_resources(app, user):
 
     # now try to book also a resource - slot not free
     resp = app.post(
-        '/api/agenda/%s/fillslot/%s:%s-1000/?resources=%s'
+        '/api/agenda/%s/meetings/fillslot/%s:%s-1000/?resources=%s'
         % (agenda.pk, meeting_type.pk, tomorrow_str, resource1.slug)
     )
     assert resp.json['err'] == 1
@@ -1238,7 +1242,7 @@ def test_booking_api_meeting_with_resources(app, user):
 
     # slot is free
     resp = app.post(
-        '/api/agenda/%s/fillslot/%s:%s-0900/?resources=%s'
+        '/api/agenda/%s/meetings/fillslot/%s:%s-0900/?resources=%s'
         % (agenda.pk, meeting_type.pk, tomorrow_str, resource1.slug)
     )
     assert resp.json['datetime'] == '%s 09:00:00' % tomorrow_str
@@ -1248,7 +1252,7 @@ def test_booking_api_meeting_with_resources(app, user):
     booking = Booking.objects.latest('pk')
     assert list(booking.event.resources.all()) == [resource1]
     resp = app.post(
-        '/api/agenda/%s/fillslot/%s:%s-0930/?resources=%s,%s'
+        '/api/agenda/%s/meetings/fillslot/%s:%s-0930/?resources=%s,%s'
         % (agenda.pk, meeting_type.pk, tomorrow_str, resource1.slug, resource2.slug)
     )
     assert resp.json['datetime'] == '%s 09:30:00' % tomorrow_str
@@ -1260,7 +1264,7 @@ def test_booking_api_meeting_with_resources(app, user):
 
     # resource is unknown or not valid for this agenda
     resp = app.post(
-        '/api/agenda/%s/fillslot/%s:%s-0900/?resources=foobarblah'
+        '/api/agenda/%s/meetings/fillslot/%s:%s-0900/?resources=foobarblah'
         % (agenda.pk, meeting_type.pk, tomorrow_str),
         status=400,
     )
@@ -1270,7 +1274,7 @@ def test_booking_api_meeting_with_resources(app, user):
     assert resp.json['err_desc'] == 'invalid resource: foobarblah'
     agenda.resources.remove(resource3)
     resp = app.post(
-        '/api/agenda/%s/fillslot/%s:%s-0900/?resources=%s'
+        '/api/agenda/%s/meetings/fillslot/%s:%s-0900/?resources=%s'
         % (agenda.pk, meeting_type.pk, tomorrow_str, resource3.slug),
         status=400,
     )
@@ -1279,7 +1283,7 @@ def test_booking_api_meeting_with_resources(app, user):
     assert resp.json['err_class'] == 'invalid resource: resource-3'
     assert resp.json['err_desc'] == 'invalid resource: resource-3'
     resp = app.post(
-        '/api/agenda/%s/fillslot/%s:%s-0900/?resources=%s,foobarblah'
+        '/api/agenda/%s/meetings/fillslot/%s:%s-0900/?resources=%s,foobarblah'
         % (agenda.pk, meeting_type.pk, tomorrow_str, resource3.slug),
         status=400,
     )
@@ -1288,7 +1292,7 @@ def test_booking_api_meeting_with_resources(app, user):
     assert resp.json['err_class'] == 'invalid resource: foobarblah, resource-3'
     assert resp.json['err_desc'] == 'invalid resource: foobarblah, resource-3'
     resp = app.post(
-        '/api/agenda/%s/fillslot/%s:%s-0900/?resources=%s,foobarblah'
+        '/api/agenda/%s/meetings/fillslot/%s:%s-0900/?resources=%s,foobarblah'
         % (agenda.pk, meeting_type.pk, tomorrow_str, resource1.slug),
         status=400,
     )
@@ -1300,7 +1304,7 @@ def test_booking_api_meeting_with_resources(app, user):
     # booking is canceled: slot is free
     booking.cancel()
     resp = app.post(
-        '/api/agenda/%s/fillslot/%s:%s-0930/?resources=%s,%s'
+        '/api/agenda/%s/meetings/fillslot/%s:%s-0930/?resources=%s,%s'
         % (agenda.pk, meeting_type.pk, tomorrow_str, resource1.slug, resource2.slug)
     )
     assert resp.json['datetime'] == '%s 09:30:00' % tomorrow_str
@@ -1318,7 +1322,8 @@ def test_booking_api_meeting_fillslots(app, meetings_agenda, user):
     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})
+    app.post('/api/agenda/%s/fillslots/' % agenda_id, params={'slots': slots}, status=404)
+    resp_booking = app.post('/api/agenda/%s/meetings/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()
@@ -1333,7 +1338,7 @@ def test_booking_api_meeting_fillslots(app, meetings_agenda, user):
     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})
+    resp2 = app.post('/api/agenda/%s/meetings/fillslots/' % agenda_id, params={'slots': slots})
     assert resp2.json['err'] == 1
     assert resp2.json['reason'] == 'no more desk available'  # legacy
     assert resp2.json['err_class'] == 'no more desk available'
@@ -1341,7 +1346,7 @@ def test_booking_api_meeting_fillslots(app, meetings_agenda, user):
 
     # 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})
+    resp2 = app.post('/api/agenda/%s/meetings/fillslots/' % agenda_id, params={'slots': nonfree_slots})
     assert resp2.json['err'] == 1
     assert resp2.json['reason'] == 'no more desk available'  # legacy
     assert resp2.json['err_class'] == 'no more desk available'
@@ -1349,7 +1354,7 @@ def test_booking_api_meeting_fillslots(app, meetings_agenda, user):
 
     # 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})
+    resp2 = app.post('/api/agenda/%s/meetings/fillslots/' % agenda_id, params={'slots': free_slots})
     assert resp2.json['err'] == 0
     cancel_url = resp2.json['api']['cancel_url']
     assert Booking.objects.count() == 4
@@ -1363,26 +1368,56 @@ def test_booking_api_meeting_fillslots(app, meetings_agenda, user):
     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)
+    resp = app.post(
+        '/api/agenda/%s/meetings/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)'  # legacy
     assert resp.json['err_class'] == 'all slots must have the same meeting type id (1)'
     assert resp.json['err_desc'] == 'all slots must have the same meeting type id (1)'
 
     unknown_slots = ['0:2017-05-22-1130']
-    resp = app.post('/api/agenda/%s/fillslots/' % agenda_id, params={'slots': unknown_slots}, status=400)
+    resp = app.post(
+        '/api/agenda/%s/meetings/fillslots/' % agenda_id, params={'slots': unknown_slots}, status=400
+    )
     assert resp.json['err'] == 1
     assert resp.json['reason'] == 'invalid meeting type id: 0'  # legacy
     assert resp.json['err_class'] == 'invalid meeting type id: 0'
     assert resp.json['err_desc'] == 'invalid meeting type id: 0'
     unknown_slots = ['foobar:2017-05-22-1130']
-    resp = app.post('/api/agenda/%s/fillslots/' % agenda_id, params={'slots': unknown_slots}, status=400)
+    resp = app.post(
+        '/api/agenda/%s/meetings/fillslots/' % agenda_id, params={'slots': unknown_slots}, status=400
+    )
     assert resp.json['err'] == 1
     assert resp.json['reason'] == 'invalid meeting type id: foobar'  # legacy
     assert resp.json['err_class'] == 'invalid meeting type id: foobar'
     assert resp.json['err_desc'] == 'invalid meeting type id: foobar'
 
 
+def test_booking_api_meeting_with_extra_params_in_payload(app, user):
+    agenda = Agenda.objects.create(kind='meetings', slug='slug')
+    meeting_type = MeetingType.objects.create(agenda=agenda, label='Blah', duration=30)
+    desk = Desk.objects.create(label='Desk', agenda=agenda)
+    TimePeriod.objects.create(
+        weekday=3, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0), desk=desk,
+    )
+
+    app.authorization = ('Basic', ('john.doe', 'password'))
+    resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.pk)
+    event_id = resp.json['data'][2]['id']
+    # count is a param of events fillslot endpoints
+    app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda.slug, event_id), params={'count': 42})
+    assert Booking.objects.count() == 1
+    booking = Booking.objects.latest('pk')
+    booking.cancel()
+
+    # count is a param of events fillslot endpoints
+    resp = app.post(
+        '/api/agenda/%s/meetings/fillslots/' % agenda.slug, params={'slots': [event_id], 'count': 42}
+    )
+    assert Booking.objects.count() == 2
+
+
 def test_booking_api_meeting_across_daylight_saving_time(app, meetings_agenda, user):
     meetings_agenda.maximal_booking_delay = 365
     meetings_agenda.save()
@@ -1395,10 +1430,10 @@ def test_booking_api_meeting_across_daylight_saving_time(app, meetings_agenda, u
     assert event_id[-4:] == resp.json['data'][2 * 18]['id'][-4:]
     assert urlparse.urlparse(
         resp.json['data'][event_index]['api']['fillslot_url']
-    ).path == '/api/agenda/%s/fillslot/%s/' % (meetings_agenda.slug, event_id)
+    ).path == '/api/agenda/%s/meetings/fillslot/%s/' % (meetings_agenda.slug, event_id)
 
     app.authorization = ('Basic', ('john.doe', 'password'))
-    resp_booking = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
+    resp_booking = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda_id, event_id))
     assert Booking.objects.count() == 1
     assert resp_booking.json['datetime'] == localtime(Booking.objects.all()[0].event.start_datetime).strftime(
         '%Y-%m-%d %H:%M:%S'
@@ -1423,7 +1458,7 @@ def test_booking_api_meeting_different_durations_book_short(app, meetings_agenda
     event_id = resp.json['data'][0]['id']
 
     app.authorization = ('Basic', ('john.doe', 'password'))
-    app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
+    app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda_id, event_id))
     assert Booking.objects.count() == 1
 
     # the longer event at the same time shouldn't be available anymore
@@ -1449,7 +1484,7 @@ def test_booking_api_meeting_different_durations_book_long(app, meetings_agenda,
     event_id = resp.json['data'][0]['id']
 
     app.authorization = ('Basic', ('john.doe', 'password'))
-    app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
+    app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda_id, event_id))
     assert Booking.objects.count() == 1
 
     # this should have removed two short events
@@ -1462,7 +1497,7 @@ def test_booking_api_meeting_different_durations_book_long(app, meetings_agenda,
     # book another long event
     event_id = resp.json['data'][10]['id']
     app.authorization = ('Basic', ('john.doe', 'password'))
-    app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
+    app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda_id, event_id))
     assert Booking.objects.count() == 2
 
     resp_short2 = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type_2.id)
@@ -1514,7 +1549,7 @@ def test_booking_api_available(app, some_data, meetings_agenda, user):
     meeting_type = MeetingType.objects.get(agenda=meetings_agenda)
     resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.pk)
     event_id = resp.json['data'][2]['id']
-    resp = app.post_json('/api/agenda/%s/fillslot/%s/' % (meetings_agenda.pk, event_id))
+    resp = app.post_json('/api/agenda/%s/meetings/fillslot/%s/' % (meetings_agenda.pk, event_id))
     assert resp.json['err'] == 0
     assert 'places' not in resp.json
 
@@ -1738,7 +1773,7 @@ def test_booking_cancellation_post_meeting_api(app, meetings_agenda, user):
     event_id = resp.json['data'][2]['id']
 
     app.authorization = ('Basic', ('john.doe', 'password'))
-    resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
+    resp = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda_id, event_id))
     assert Booking.objects.count() == 1
 
     booking_id = resp.json['booking_id']
@@ -1749,7 +1784,7 @@ def test_booking_cancellation_post_meeting_api(app, meetings_agenda, user):
     assert len(resp.json['data']) == nb_events
 
     # book the same time slot
-    resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
+    resp = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda_id, event_id))
     assert resp.json['err'] == 0
     resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.id)
     assert len([x for x in resp.json['data'] if not x.get('disabled')]) == nb_events - 1
@@ -2762,7 +2797,7 @@ def test_agenda_meeting_api_multiple_desk(app, meetings_agenda, user):
     meeting_type2 = MeetingType.objects.create(agenda=meetings_agenda, label='Tux kart', duration=60)
     resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type2.id)
     event_id = resp.json['data'][0]['id']
-    resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
+    resp = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda_id, event_id))
     cancel_url = resp.json['api']['cancel_url']
 
     # add a second desk
@@ -2777,7 +2812,7 @@ def test_agenda_meeting_api_multiple_desk(app, meetings_agenda, user):
 
     resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.id)
     event_id = resp.json['data'][1]['id']
-    resp_booking = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
+    resp_booking = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda_id, event_id))
     assert Booking.objects.count() == 2
     assert resp_booking.json['datetime'] == localtime(Booking.objects.last().event.start_datetime).strftime(
         '%Y-%m-%d %H:%M:%S'
@@ -2787,7 +2822,7 @@ def test_agenda_meeting_api_multiple_desk(app, meetings_agenda, user):
     assert len(resp.json['data']) == len([x for x in resp2.json['data'] if not x['disabled']]) + 1
 
     # try booking the same timeslot and fail
-    resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
+    resp = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda_id, event_id))
     assert Booking.objects.count() == 2
     assert resp.json['err'] == 1
     assert resp.json['reason'] == 'no more desk available'  # legacy
@@ -2804,7 +2839,7 @@ def test_agenda_meeting_api_multiple_desk(app, meetings_agenda, user):
 
     # capture number of queries made for fillslot endpoint with few bookings
     with CaptureQueriesContext(connection) as ctx:
-        resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
+        resp = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda_id, event_id))
         queries_count_fillslot1 = len(ctx.captured_queries)
 
     assert resp_booking.json['datetime'] == localtime(Booking.objects.last().event.start_datetime).strftime(
@@ -2821,7 +2856,7 @@ def test_agenda_meeting_api_multiple_desk(app, meetings_agenda, user):
     assert len(resp.json['data']) == len(resp2.json['data'])
 
     # try booking the same slot to make sure that cancelled booking has freed the slot
-    resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
+    resp = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda_id, event_id))
     assert Booking.objects.count() == 4
     assert Booking.objects.exclude(cancellation_datetime__isnull=True).count() == 2
     assert resp_booking.json['datetime'] == localtime(Booking.objects.last().event.start_datetime).strftime(
@@ -2829,7 +2864,7 @@ def test_agenda_meeting_api_multiple_desk(app, meetings_agenda, user):
     )
 
     # try booking the same timeslot again and fail
-    resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
+    resp = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda_id, event_id))
     assert resp.json['err'] == 1
     assert resp.json['reason'] == 'no more desk available'  # legacy
     assert resp.json['err_class'] == 'no more desk available'
@@ -2872,7 +2907,7 @@ def test_agenda_meeting_api_fillslots_multiple_desks(app, meetings_agenda, user)
     start_free_places = get_free_places()
 
     # booking 3 slots on desk 1
-    fillslots_url = '/api/agenda/%s/fillslots/' % agenda_id
+    fillslots_url = '/api/agenda/%s/meetings/fillslots/' % agenda_id
     resp = app.post(fillslots_url, params={'slots': slots})
     assert resp.json['err'] == 0
     desk1 = resp.json['desk']['slug']
@@ -2901,7 +2936,7 @@ def test_agenda_meeting_api_fillslots_multiple_desks(app, meetings_agenda, user)
     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]))
+    resp = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda_id, slots[1]))
     assert resp.json['err'] == 0
     assert resp.json['desk']['slug'] == desk1
     cancel_url = resp.json['api']['cancel_url']
@@ -2981,7 +3016,7 @@ def test_agenda_meeting_next_day(app, meetings_agenda, mock_now, user):
     datetime_url = '/api/agenda/meetings/%s/datetimes/' % meeting_type.id
     desk = Desk.objects.create(label='foo', agenda=agenda)
     for weekday in range(7):
-        time_period = TimePeriod.objects.create(
+        TimePeriod.objects.create(
             weekday=weekday, start_time=datetime.time(11, 0), end_time=datetime.time(12, 30), desk=desk
         )
     resp = app.get(datetime_url)
@@ -3206,26 +3241,26 @@ def test_agenda_meeting_gcd_durations(app, meetings_agenda, user):
     resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type_30.id)
     event_id = resp.json['data'][0]['id']
     app.authorization = ('Basic', ('john.doe', 'password'))
-    app.post('/api/agenda/%s/fillslot/%s/' % (meetings_agenda.id, event_id))
+    app.post('/api/agenda/%s/meetings/fillslot/%s/' % (meetings_agenda.id, event_id))
     assert Booking.objects.count() == 1
 
     resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type_20.id)
     assert len([x for x in resp.json['data'] if not x.get('disabled')]) == 55
     event_id = [x for x in resp.json['data'] if not x.get('disabled')][0]['id']
-    resp = app.post('/api/agenda/%s/fillslot/%s/' % (meetings_agenda.id, event_id))
+    resp = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (meetings_agenda.id, event_id))
     assert resp.json['datetime'] == '2017-05-22 10:30:00'
     assert Booking.objects.count() == 2
 
     resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type_30.id)
     event_id = [x for x in resp.json['data'] if not x.get('disabled')][0]['id']
-    resp = app.post('/api/agenda/%s/fillslot/%s/' % (meetings_agenda.id, event_id))
+    resp = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (meetings_agenda.id, event_id))
     assert resp.json['datetime'] == '2017-05-22 10:50:00'
     assert Booking.objects.count() == 3
 
     # create a gap
     resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type_30.id)
     event_id = [x for x in resp.json['data'] if not x.get('disabled')][1]['id']
-    resp = app.post('/api/agenda/%s/fillslot/%s/' % (meetings_agenda.id, event_id))
+    resp = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (meetings_agenda.id, event_id))
     assert resp.json['datetime'] == '2017-05-22 11:30:00'
     assert Booking.objects.count() == 4
 
@@ -3358,7 +3393,8 @@ def test_virtual_agenda_detail(app, virtual_meetings_agenda):
             'api': {
                 'meetings_url': 'http://testserver/api/agenda/%s/meetings/' % virtual_meetings_agenda.slug,
                 'desks_url': 'http://testserver/api/agenda/%s/desks/' % virtual_meetings_agenda.slug,
-                'fillslots_url': 'http://testserver/api/agenda/%s/fillslots/' % virtual_meetings_agenda.slug,
+                'fillslots_url': 'http://testserver/api/agenda/%s/meetings/fillslots/'
+                % virtual_meetings_agenda.slug,
             },
         },
     }
-- 
2.28.0

