Projet

Général

Profil

0003-api-split-fillslot-endpoints-43077.patch

Lauréline Guérin, 01 octobre 2020 16:22

Télécharger (62 ko)

Voir les différences:

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(-)
chrono/api/serializers.py
17 17
from rest_framework import serializers
18 18

  
19 19

  
20
class SlotSerializer(serializers.Serializer):
20
class BaseSlotSerializer(serializers.Serializer):
21 21
    '''
22 22
    payload to fill one slot. The slot (event id) is in the URL.
23 23
    '''
......
31 31
    form_url = serializers.CharField(max_length=250, allow_blank=True)
32 32
    backoffice_url = serializers.URLField(allow_blank=True)
33 33
    cancel_callback_url = serializers.URLField(allow_blank=True)
34

  
35

  
36
class SlotSerializer(BaseSlotSerializer):
34 37
    count = serializers.IntegerField(min_value=1)
35 38
    cancel_booking_id = serializers.CharField(max_length=250, allow_blank=True, allow_null=True)
36 39
    force_waiting_list = serializers.BooleanField(default=False)
37 40

  
38 41

  
42
class MeetingsSlotSerializer(BaseSlotSerializer):
43
    pass
44

  
45

  
39 46
class StringOrListField(serializers.ListField):
40 47
    def to_internal_value(self, data):
41 48
        if isinstance(data, str):
......
52 59
    slots = StringOrListField(required=True, child=serializers.CharField(max_length=64, allow_blank=False))
53 60

  
54 61

  
62
class MeetingsSlotsSerializer(MeetingsSlotSerializer):
63
    '''
64
    payload to fill multiple slots: same as MeetingsSlotSerializer, but the
65
    slots list is in the payload.
66
    '''
67

  
68
    slots = StringOrListField(required=True, child=serializers.CharField(max_length=64, allow_blank=False))
69

  
70

  
55 71
class ResizeSerializer(serializers.Serializer):
56 72
    count = serializers.IntegerField(min_value=1)
chrono/api/urls.py
44 44
        name='api-agenda-meeting-datetimes-legacy',
45 45
    ),
46 46
    url(r'^agenda/(?P<agenda_identifier>[\w-]+)/meetings/$', views.meeting_list, name='api-agenda-meetings'),
47
    url(
48
        r'^agenda/(?P<agenda_identifier>[\w-]+)/meetings/(?P<meeting_identifier>[\w-]+)/datetimes/$',
49
        views.meeting_datetimes,
50
        name='api-agenda-meeting-datetimes',
51
    ),
52
    url(
53
        r'^agenda/(?P<agenda_identifier>[\w-]+)/meetings/fillslot/(?P<event_identifier>[\w:-]+)/$',
54
        views.meetings_fillslot,
55
        name='api-agenda-meetings-fillslot',
56
    ),
57
    url(
58
        r'^agenda/(?P<agenda_identifier>[\w-]+)/meetings/fillslots/$',
59
        views.meetings_fillslots,
60
        name='api-agenda-meetings-fillslots',
61
    ),
47 62
    url(
48 63
        r'^agenda/(?P<agenda_identifier>[\w-]+)/meetings/(?P<meeting_identifier>[\w-]+)/$',
49 64
        views.meeting_info,
50 65
        name='api-agenda-meetings',
51 66
    ),
52 67
    url(r'^agenda/(?P<agenda_identifier>[\w-]+)/desks/$', views.agenda_desk_list, name='api-agenda-desks'),
53
    url(
54
        r'^agenda/(?P<agenda_identifier>[\w-]+)/meetings/(?P<meeting_identifier>[\w-]+)/datetimes/$',
55
        views.meeting_datetimes,
56
        name='api-agenda-meeting-datetimes',
57
    ),
58 68
    url(r'^booking/(?P<booking_pk>\w+)/$', views.booking),
59 69
    url(r'^booking/(?P<booking_pk>\w+)/cancel/$', views.cancel_booking, name='api-cancel-booking'),
60 70
    url(r'^booking/(?P<booking_pk>\w+)/accept/$', views.accept_booking, name='api-accept-booking'),
chrono/api/views.py
29 29
from django.utils.encoding import force_text
30 30
from django.utils.formats import date_format
31 31
from django.utils.timezone import now, make_aware, localtime
32
from django.utils.translation import gettext_noop
33 32
from django.utils.translation import ugettext_lazy as _
34 33

  
35 34
from rest_framework import permissions, status
......
290 289
        agenda_detail['api'] = {
291 290
            'datetimes_url': request.build_absolute_uri(
292 291
                reverse('api-agenda-datetimes', kwargs={'agenda_identifier': agenda.slug})
293
            )
292
            ),
293
            'fillslots_url': request.build_absolute_uri(
294
                reverse('api-agenda-fillslots', kwargs={'agenda_identifier': agenda.slug})
295
            ),
294 296
        }
295 297
        if check_events:
296 298
            agenda_detail['opened_events_available'] = agenda.get_open_events().filter(full=False).exists()
......
302 304
            'desks_url': request.build_absolute_uri(
303 305
                reverse('api-agenda-desks', kwargs={'agenda_identifier': agenda.slug})
304 306
            ),
307
            'fillslots_url': request.build_absolute_uri(
308
                reverse('api-agenda-meetings-fillslots', kwargs={'agenda_identifier': agenda.slug})
309
            ),
305 310
        }
306
    agenda_detail['api']['fillslots_url'] = request.build_absolute_uri(
307
        reverse('api-agenda-fillslots', kwargs={'agenda_identifier': agenda.slug})
308
    )
309 311

  
310 312
    return agenda_detail
311 313

  
......
523 525
        fake_event_identifier = '__event_identifier__'
524 526
        fillslot_url = request.build_absolute_uri(
525 527
            reverse(
526
                'api-fillslot',
528
                'api-agenda-meetings-fillslot',
527 529
                kwargs={'agenda_identifier': agenda.slug, 'event_identifier': fake_event_identifier},
528 530
            )
529 531
        )
......
653 655
agenda_desk_list = AgendaDeskList.as_view()
654 656

  
655 657

  
656
class Fillslots(APIView):
658
class FillSlotsMixin(object):
657 659
    permission_classes = (permissions.IsAuthenticated,)
658
    serializer_class = serializers.SlotsSerializer
659 660

  
660
    def post(self, request, agenda_identifier=None, event_identifier=None, format=None):
661
    def post(self, *args, **kwargs):
661 662
        try:
662
            return self.fillslot(request=request, agenda_identifier=agenda_identifier, format=format)
663
            serializer = self.serializer_class(data=self.request.data, partial=True)
664
            if not serializer.is_valid():
665
                raise APIError(
666
                    _('invalid payload'),
667
                    err_class='invalid payload',
668
                    errors=serializer.errors,
669
                    http_status=status.HTTP_400_BAD_REQUEST,
670
                )
671
            self.agenda = self.get_agenda()
672
            return self._post(payload=serializer.validated_data)
663 673
        except APIError as e:
664 674
            return e.to_response()
665 675

  
666
    def fillslot(self, request, agenda_identifier=None, slots=[], format=None):
667
        multiple_booking = bool(not slots)
676
    def validate_slots(self, slots):
677
        if not slots:
678
            raise APIError(
679
                _('slots list cannot be empty'),
680
                err_class='slots list cannot be empty',
681
                http_status=status.HTTP_400_BAD_REQUEST,
682
            )
683
        return slots
684

  
685
    def get_agenda(self):
686
        agenda_identifier = self.kwargs['agenda_identifier']
668 687
        try:
669
            agenda = Agenda.objects.get(slug=agenda_identifier)
688
            return Agenda.objects.get(slug=agenda_identifier)
670 689
        except Agenda.DoesNotExist:
671 690
            try:
672 691
                # legacy access by agenda id
673
                agenda = Agenda.objects.get(id=int(agenda_identifier))
692
                return Agenda.objects.get(pk=int(agenda_identifier))
674 693
            except (ValueError, Agenda.DoesNotExist):
675 694
                raise Http404()
676 695

  
677
        serializer = self.serializer_class(data=request.data, partial=True)
678
        if not serializer.is_valid():
696
    def get_booking_to_cancel(self, payload, places_count=None):
697
        if not payload.get('cancel_booking_id'):
698
            return
699

  
700
        try:
701
            to_cancel_booking = Booking.objects.get(pk=int(payload['cancel_booking_id']))
702
        except (ValueError, TypeError):
679 703
            raise APIError(
680
                _('invalid payload'),
681
                err_class='invalid payload',
682
                errors=serializer.errors,
704
                _('cancel_booking_id is not an integer'),
705
                err_class='cancel_booking_id is not an integer',
683 706
                http_status=status.HTTP_400_BAD_REQUEST,
684 707
            )
685
        payload = serializer.validated_data
708
        except Booking.DoesNotExist:
709
            raise APIError(
710
                _('cancel booking: booking does no exist'), err_class='cancel booking: booking does no exist'
711
            )
686 712

  
687
        if 'slots' in payload:
688
            slots = payload['slots']
689
        if not slots:
713
        if to_cancel_booking.cancellation_datetime:
690 714
            raise APIError(
691
                _('slots list cannot be empty'),
692
                err_class='slots list cannot be empty',
693
                http_status=status.HTTP_400_BAD_REQUEST,
715
                _('cancel booking: booking already cancelled'),
716
                err_class='cancel booking: booking already cancelled',
717
            )
718

  
719
        if places_count is not None:
720
            # events booking
721
            to_cancel_places_count = (
722
                to_cancel_booking.secondary_booking_set.filter(event=to_cancel_booking.event).count() + 1
694 723
            )
724
            if places_count != to_cancel_places_count:
725
                raise APIError(
726
                    _('cancel booking: count is different'), err_class='cancel booking: count is different'
727
                )
728

  
729
        return to_cancel_booking
730

  
695 731

  
732
class EventFillSlotsMixin(FillSlotsMixin):
733
    def get_agenda(self):
734
        agenda = super().get_agenda()
735
        if agenda.kind != 'events':
736
            raise Http404()
737
        return agenda
738

  
739
    def get_places_count(self, payload):
696 740
        if 'count' in payload:
697 741
            places_count = payload['count']
698
        elif 'count' in request.query_params:
742
        elif 'count' in self.request.query_params:
699 743
            # legacy: count in the query string
700 744
            try:
701
                places_count = int(request.query_params['count'])
745
                places_count = int(self.request.query_params['count'])
702 746
            except ValueError:
703 747
                raise APIError(
704
                    _('invalid value for count (%s)') % request.query_params['count'],
705
                    err_class='invalid value for count (%s)' % request.query_params['count'],
748
                    _('invalid value for count (%s)') % self.request.query_params['count'],
749
                    err_class='invalid value for count (%s)' % self.request.query_params['count'],
706 750
                    http_status=status.HTTP_400_BAD_REQUEST,
707 751
                )
708 752
        else:
......
714 758
                err_class='count cannot be less than or equal to zero',
715 759
                http_status=status.HTTP_400_BAD_REQUEST,
716 760
            )
761
        return places_count
717 762

  
718
        to_cancel_booking = None
719
        cancel_booking_id = None
720
        if payload.get('cancel_booking_id'):
721
            try:
722
                cancel_booking_id = int(payload.get('cancel_booking_id'))
723
            except (ValueError, TypeError):
724
                raise APIError(
725
                    _('cancel_booking_id is not an integer'),
726
                    err_class='cancel_booking_id is not an integer',
727
                    http_status=status.HTTP_400_BAD_REQUEST,
728
                )
729

  
730
        if cancel_booking_id is not None:
731
            cancel_error = None
732
            try:
733
                to_cancel_booking = Booking.objects.get(pk=cancel_booking_id)
734
                if to_cancel_booking.cancellation_datetime:
735
                    cancel_error = gettext_noop('cancel booking: booking already cancelled')
736
                else:
737
                    to_cancel_places_count = (
738
                        to_cancel_booking.secondary_booking_set.filter(event=to_cancel_booking.event).count()
739
                        + 1
740
                    )
741
                    if places_count != to_cancel_places_count:
742
                        cancel_error = gettext_noop('cancel booking: count is different')
743
            except Booking.DoesNotExist:
744
                cancel_error = gettext_noop('cancel booking: booking does no exist')
745

  
746
            if cancel_error:
747
                raise APIError(_(cancel_error), err_class=cancel_error)
748

  
749
        extra_data = {}
750
        for k, v in request.data.items():
751
            if k not in serializer.validated_data:
752
                extra_data[k] = v
753

  
754
        available_desk = None
755

  
756
        if agenda.accept_meetings():
757
            # slots are actually timeslot ids (meeting_type:start_datetime), not events ids.
758
            # split them back to get both parts
759
            meeting_type_id = slots[0].split(':')[0]
760
            datetimes = set()
761
            for slot in slots:
762
                try:
763
                    meeting_type_id_, datetime_str = slot.split(':')
764
                except ValueError:
765
                    raise APIError(
766
                        _('invalid slot: %s') % slot,
767
                        err_class='invalid slot: %s' % slot,
768
                        http_status=status.HTTP_400_BAD_REQUEST,
769
                    )
770
                if meeting_type_id_ != meeting_type_id:
771
                    raise APIError(
772
                        _('all slots must have the same meeting type id (%s)') % meeting_type_id,
773
                        err_class='all slots must have the same meeting type id (%s)' % meeting_type_id,
774
                        http_status=status.HTTP_400_BAD_REQUEST,
775
                    )
776
                datetimes.add(make_aware(datetime.datetime.strptime(datetime_str, '%Y-%m-%d-%H%M')))
777

  
778
            try:
779
                resources = get_resources_from_request(request, agenda)
780
            except APIError as e:
781
                return e.to_response()
782

  
783
            # get all free slots and separate them by desk
784
            try:
785
                try:
786
                    meeting_type = agenda.get_meetingtype(slug=meeting_type_id)
787
                except MeetingType.DoesNotExist:
788
                    # legacy access by id
789
                    meeting_type = agenda.get_meetingtype(id_=meeting_type_id)
790
            except (MeetingType.DoesNotExist, ValueError):
791
                raise APIError(
792
                    _('invalid meeting type id: %s') % meeting_type_id,
793
                    err_class='invalid meeting type id: %s' % meeting_type_id,
794
                    http_status=status.HTTP_400_BAD_REQUEST,
795
                )
796
            all_slots = sorted(
797
                get_all_slots(agenda, meeting_type, resources=resources),
798
                key=lambda slot: slot.start_datetime,
763
    def get_events(self, slots):
764
        try:
765
            events = Event.objects.filter(agenda=self.agenda, id__in=[int(s) for s in slots]).order_by(
766
                'start_datetime'
799 767
            )
768
        except ValueError:
769
            events = Event.objects.filter(agenda=self.agenda, slug__in=slots).order_by('start_datetime')
800 770

  
801
            all_free_slots = [slot for slot in all_slots if not slot.full]
802
            datetimes_by_desk = collections.defaultdict(set)
803
            for slot in all_free_slots:
804
                datetimes_by_desk[slot.desk.id].add(slot.start_datetime)
805

  
806
            available_desk = None
807

  
808
            if agenda.kind == 'virtual':
809
                # Compute fill_rate by agenda/date
810
                fill_rates = collections.defaultdict(dict)
811
                for slot in all_slots:
812
                    ref_date = slot.start_datetime.date()
813
                    if ref_date not in fill_rates[slot.desk.agenda]:
814
                        date_dict = fill_rates[slot.desk.agenda][ref_date] = {'free': 0, 'full': 0}
815
                    else:
816
                        date_dict = fill_rates[slot.desk.agenda][ref_date]
817
                    if slot.full:
818
                        date_dict['full'] += 1
819
                    else:
820
                        date_dict['free'] += 1
821
                for dd in fill_rates.values():
822
                    for date_dict in dd.values():
823
                        date_dict['fill_rate'] = date_dict['full'] / (date_dict['full'] + date_dict['free'])
824

  
825
                # select a desk on the agenda with min fill_rate on the given date
826
                for available_desk_id in sorted(datetimes_by_desk.keys()):
827
                    if datetimes.issubset(datetimes_by_desk[available_desk_id]):
828
                        desk = Desk.objects.get(id=available_desk_id)
829
                        if available_desk is None:
830
                            available_desk = desk
831
                            available_desk_rate = 0
832
                            for dt in datetimes:
833
                                available_desk_rate += fill_rates[available_desk.agenda][dt.date()][
834
                                    'fill_rate'
835
                                ]
836
                        else:
837
                            for dt in datetimes:
838
                                desk_rate = 0
839
                                for dt in datetimes:
840
                                    desk_rate += fill_rates[desk.agenda][dt.date()]['fill_rate']
841
                            if desk_rate < available_desk_rate:
842
                                available_desk = desk
843
                                available_desk_rate = desk_rate
844

  
845
            else:
846
                # meeting agenda
847
                # search first desk where all requested slots are free
848
                for available_desk_id in sorted(datetimes_by_desk.keys()):
849
                    if datetimes.issubset(datetimes_by_desk[available_desk_id]):
850
                        available_desk = Desk.objects.get(id=available_desk_id)
851
                        break
771
        if not events.exists():
772
            raise APIError(
773
                _('unknown event identifiers or slugs'),
774
                err_class='unknown event identifiers or slugs',
775
                http_status=status.HTTP_400_BAD_REQUEST,
776
            )
852 777

  
853
            if available_desk is None:
778
        for event in events:
779
            if not event.in_bookable_period():
854 780
                raise APIError(
855
                    _('no more desk available'), err_class='no more desk available',
856
                )
857

  
858
            # all datetimes are free, book them in order
859
            datetimes = list(datetimes)
860
            datetimes.sort()
861

  
862
            # get a real meeting_type for virtual agenda
863
            if agenda.kind == 'virtual':
864
                meeting_type = MeetingType.objects.get(agenda=available_desk.agenda, slug=meeting_type.slug)
865

  
866
            # booking requires real Event objects (not lazy Timeslots);
867
            # create them now, with data from the slots and the desk we found.
868
            events = []
869
            for start_datetime in datetimes:
870
                event = Event.objects.create(
871
                    agenda=available_desk.agenda,
872
                    slug=str(uuid.uuid4()),  # set slug to avoid queries during slug generation
873
                    meeting_type=meeting_type,
874
                    start_datetime=start_datetime,
875
                    full=False,
876
                    places=1,
877
                    desk=available_desk,
781
                    _('event not bookable'), err_class='event not bookable',
878 782
                )
879
                if resources:
880
                    event.resources.add(*resources)
881
                events.append(event)
882
        else:
883
            try:
884
                events = agenda.event_set.filter(id__in=[int(s) for s in slots]).order_by('start_datetime')
885
            except ValueError:
886
                events = agenda.event_set.filter(slug__in=slots).order_by('start_datetime')
887 783

  
888
            for event in events:
889
                if not event.in_bookable_period():
890
                    return Response(
891
                        {'err': 1, 'err_class': 'event not bookable', 'err_desc': _('event not bookable')}
892
                    )
893
                if event.cancelled:
894
                    return Response(
895
                        {'err': 1, 'err_class': 'event is cancelled', 'err_desc': _('event is cancelled')}
896
                    )
784
        return events
897 785

  
898
            if not events.count():
899
                raise APIError(
900
                    _('unknown event identifiers or slugs'),
901
                    err_class='unknown event identifiers or slugs',
902
                    http_status=status.HTTP_400_BAD_REQUEST,
903
                )
786
    def fillslots(self, payload, slots, multiple_booking=False):
787
        places_count = self.get_places_count(payload)
788
        to_cancel_booking = self.get_booking_to_cancel(payload, places_count)
789
        events = self.get_events(slots)
904 790

  
905 791
        # search free places. Switch to waiting list if necessary.
906 792
        in_waiting_list = False
......
929 815
                        _('sold out'), err_class='sold out',
930 816
                    )
931 817

  
818
        extra_data = {}
819
        for k, v in self.request.data.items():
820
            if k not in payload:
821
                extra_data[k] = v
822

  
932 823
        with transaction.atomic():
933 824
            if to_cancel_booking:
934 825
                cancelled_booking_id = to_cancel_booking.pk
......
938 829
            primary_booking = None
939 830
            for event in events:
940 831
                for i in range(places_count):
941
                    new_booking = Booking(
832
                    new_booking = Booking.objects.create(
942 833
                        event_id=event.id,
834
                        primary_booking=primary_booking,
943 835
                        in_waiting_list=in_waiting_list,
944 836
                        label=payload.get('label', ''),
945 837
                        user_external_id=payload.get('user_external_id', ''),
......
952 844
                        user_display_label=payload.get('user_display_label', ''),
953 845
                        extra_data=extra_data,
954 846
                    )
955
                    if primary_booking is not None:
956
                        new_booking.primary_booking = primary_booking
957
                    new_booking.save()
958
                    if primary_booking is None:
959
                        primary_booking = new_booking
847
                    primary_booking = primary_booking or new_booking
960 848

  
961 849
        response = {
962 850
            'err': 0,
963 851
            'in_waiting_list': in_waiting_list,
964 852
            'booking_id': primary_booking.id,
965
            'datetime': format_response_datetime(events[0].start_datetime),
853
            'datetime': format_response_datetime(primary_booking.event.start_datetime),
854
            'end_datetime': format_response_datetime(primary_booking.event.end_datetime)
855
            if primary_booking.event.end_datetime
856
            else None,
966 857
            'agenda': {
967 858
                'label': primary_booking.event.agenda.label,
968 859
                'slug': primary_booking.event.agenda.slug,
969 860
            },
970 861
            'api': {
971
                'cancel_url': request.build_absolute_uri(
862
                'cancel_url': self.request.build_absolute_uri(
972 863
                    reverse('api-cancel-booking', kwargs={'booking_pk': primary_booking.id})
973 864
                ),
974
                'ics_url': request.build_absolute_uri(
865
                'ics_url': self.request.build_absolute_uri(
975 866
                    reverse('api-booking-ics', kwargs={'booking_pk': primary_booking.id})
976 867
                ),
977 868
            },
978 869
        }
979
        if agenda.kind == 'events':
980
            response['api']['accept_url'] = request.build_absolute_uri(
870
        if self.agenda.kind == 'events':
871
            response['api']['accept_url'] = self.request.build_absolute_uri(
981 872
                reverse('api-accept-booking', kwargs={'booking_pk': primary_booking.pk})
982 873
            )
983
            response['api']['suspend_url'] = request.build_absolute_uri(
874
            response['api']['suspend_url'] = self.request.build_absolute_uri(
984 875
                reverse('api-suspend-booking', kwargs={'booking_pk': primary_booking.pk})
985 876
            )
986
        if agenda.accept_meetings():
987
            response['end_datetime'] = format_response_datetime(events[-1].end_datetime)
988
            response['duration'] = (events[-1].end_datetime - events[-1].start_datetime).seconds // 60
989
        if available_desk:
990
            response['desk'] = {'label': available_desk.label, 'slug': available_desk.slug}
991 877
        if to_cancel_booking:
992 878
            response['cancelled_booking_id'] = cancelled_booking_id
993
        if agenda.kind == 'events' and not multiple_booking:
879
        if not multiple_booking:
994 880
            event = events[0]
995 881
            # event.full is not up to date, it might have been changed by previous new_booking.save().
996 882
            event.refresh_from_db()
997 883
            response['places'] = get_event_places(event)
998
            if event.end_datetime:
999
                response['end_datetime'] = format_response_datetime(event.end_datetime)
1000
            else:
1001
                response['end_datetime'] = None
1002
        if agenda.kind == 'events' and multiple_booking:
884
        else:
1003 885
            response['events'] = [
1004 886
                {
1005 887
                    'slug': x.slug,
......
1010 892
                }
1011 893
                for x in events
1012 894
            ]
1013
        if agenda.kind == 'meetings':
1014
            response['resources'] = [r.slug for r in resources]
1015 895

  
1016 896
        return Response(response)
1017 897

  
1018 898

  
1019
fillslots = Fillslots.as_view()
1020

  
899
class MeetingsFillSlotsMixin(FillSlotsMixin):
900
    def get_agenda(self):
901
        agenda = super().get_agenda()
902
        if not agenda.accept_meetings():
903
            raise Http404()
904
        return agenda
1021 905

  
1022
class Fillslot(Fillslots):
1023
    serializer_class = serializers.SlotSerializer
906
    def fillslots(self, payload, slots, multiple_booking=False):
907
        to_cancel_booking = self.get_booking_to_cancel(payload)
1024 908

  
1025
    def post(self, request, agenda_identifier=None, event_identifier=None, format=None):
1026 909
        try:
1027
            return self.fillslot(
1028
                request=request,
1029
                agenda_identifier=agenda_identifier,
1030
                slots=[event_identifier],  # fill a "list on one slot"
1031
                format=format,
1032
            )
910
            resources = get_resources_from_request(self.request, self.agenda)
1033 911
        except APIError as e:
1034 912
            return e.to_response()
1035 913

  
914
        # slots are actually timeslot ids (meeting_type:start_datetime), not events ids.
915
        # split them back to get both parts
916
        meeting_type_id = slots[0].split(':')[0]
917
        datetimes = set()
918
        for slot in slots:
919
            try:
920
                meeting_type_id_, datetime_str = slot.split(':')
921
            except ValueError:
922
                raise APIError(
923
                    _('invalid slot: %s') % slot,
924
                    err_class='invalid slot: %s' % slot,
925
                    http_status=status.HTTP_400_BAD_REQUEST,
926
                )
927
            if meeting_type_id_ != meeting_type_id:
928
                raise APIError(
929
                    _('all slots must have the same meeting type id (%s)') % meeting_type_id,
930
                    err_class='all slots must have the same meeting type id (%s)' % meeting_type_id,
931
                    http_status=status.HTTP_400_BAD_REQUEST,
932
                )
933
            datetimes.add(make_aware(datetime.datetime.strptime(datetime_str, '%Y-%m-%d-%H%M')))
934

  
935
        # get all free slots and separate them by desk
936
        try:
937
            try:
938
                meeting_type = self.agenda.get_meetingtype(slug=meeting_type_id)
939
            except MeetingType.DoesNotExist:
940
                # legacy access by id
941
                meeting_type = self.agenda.get_meetingtype(id_=meeting_type_id)
942
        except (MeetingType.DoesNotExist, ValueError):
943
            raise APIError(
944
                _('invalid meeting type id: %s') % meeting_type_id,
945
                err_class='invalid meeting type id: %s' % meeting_type_id,
946
                http_status=status.HTTP_400_BAD_REQUEST,
947
            )
948
        all_slots = sorted(
949
            get_all_slots(self.agenda, meeting_type, resources=resources),
950
            key=lambda slot: slot.start_datetime,
951
        )
952

  
953
        all_free_slots = [slot for slot in all_slots if not slot.full]
954
        datetimes_by_desk = collections.defaultdict(set)
955
        for slot in all_free_slots:
956
            datetimes_by_desk[slot.desk.id].add(slot.start_datetime)
957

  
958
        available_desk = None
959

  
960
        if self.agenda.kind == 'virtual':
961
            # Compute fill_rate by agenda/date
962
            fill_rates = collections.defaultdict(dict)
963
            for slot in all_slots:
964
                ref_date = slot.start_datetime.date()
965
                if ref_date not in fill_rates[slot.desk.agenda]:
966
                    date_dict = fill_rates[slot.desk.agenda][ref_date] = {'free': 0, 'full': 0}
967
                else:
968
                    date_dict = fill_rates[slot.desk.agenda][ref_date]
969
                if slot.full:
970
                    date_dict['full'] += 1
971
                else:
972
                    date_dict['free'] += 1
973
            for dd in fill_rates.values():
974
                for date_dict in dd.values():
975
                    date_dict['fill_rate'] = date_dict['full'] / (date_dict['full'] + date_dict['free'])
976

  
977
            # select a desk on the agenda with min fill_rate on the given date
978
            for available_desk_id in sorted(datetimes_by_desk.keys()):
979
                if datetimes.issubset(datetimes_by_desk[available_desk_id]):
980
                    desk = Desk.objects.get(id=available_desk_id)
981
                    if available_desk is None:
982
                        available_desk = desk
983
                        available_desk_rate = 0
984
                        for dt in datetimes:
985
                            available_desk_rate += fill_rates[available_desk.agenda][dt.date()]['fill_rate']
986
                    else:
987
                        for dt in datetimes:
988
                            desk_rate = 0
989
                            for dt in datetimes:
990
                                desk_rate += fill_rates[desk.agenda][dt.date()]['fill_rate']
991
                        if desk_rate < available_desk_rate:
992
                            available_desk = desk
993
                            available_desk_rate = desk_rate
994

  
995
        else:
996
            # meeting agenda
997
            # search first desk where all requested slots are free
998
            for available_desk_id in sorted(datetimes_by_desk.keys()):
999
                if datetimes.issubset(datetimes_by_desk[available_desk_id]):
1000
                    available_desk = Desk.objects.get(id=available_desk_id)
1001
                    break
1002

  
1003
        if available_desk is None:
1004
            raise APIError(
1005
                _('no more desk available'), err_class='no more desk available',
1006
            )
1007

  
1008
        # all datetimes are free, book them in order
1009
        datetimes = list(datetimes)
1010
        datetimes.sort()
1011

  
1012
        # get a real meeting_type for virtual agenda
1013
        if self.agenda.kind == 'virtual':
1014
            meeting_type = MeetingType.objects.get(agenda=available_desk.agenda, slug=meeting_type.slug)
1015

  
1016
        extra_data = {}
1017
        for k, v in self.request.data.items():
1018
            if k not in payload:
1019
                extra_data[k] = v
1020

  
1021
        with transaction.atomic():
1022
            if to_cancel_booking:
1023
                cancelled_booking_id = to_cancel_booking.pk
1024
                to_cancel_booking.cancel()
1025

  
1026
            # booking requires real Event objects (not lazy Timeslots);
1027
            # create them now, with data from the slots and the desk we found.
1028
            first_event = None
1029
            primary_booking = None
1030
            for start_datetime in datetimes:
1031
                event = Event.objects.create(
1032
                    agenda=available_desk.agenda,
1033
                    slug=str(uuid.uuid4()),  # set slug to avoid queries during slug generation
1034
                    meeting_type=meeting_type,
1035
                    start_datetime=start_datetime,
1036
                    full=False,
1037
                    places=1,
1038
                    desk=available_desk,
1039
                )
1040
                if resources:
1041
                    event.resources.add(*resources)
1042
                first_event = first_event or event
1043
                # now book the event
1044
                booking = Booking.objects.create(
1045
                    event=event,
1046
                    primary_booking=primary_booking,
1047
                    label=payload.get('label', ''),
1048
                    user_external_id=payload.get('user_external_id', ''),
1049
                    user_name=payload.get('user_name', ''),
1050
                    backoffice_url=payload.get('backoffice_url', ''),
1051
                    user_display_label=payload.get('user_display_label', ''),
1052
                    extra_data=extra_data,
1053
                )
1054
                primary_booking = primary_booking or booking
1055
            last_event = event
1056

  
1057
        response = {
1058
            'err': 0,
1059
            'booking_id': primary_booking.pk,
1060
            'datetime': format_response_datetime(first_event.start_datetime),
1061
            'end_datetime': format_response_datetime(last_event.end_datetime),
1062
            'duration': (last_event.end_datetime - last_event.start_datetime).seconds // 60,
1063
            'agenda': {'label': first_event.agenda.label, 'slug': first_event.agenda.slug},
1064
            'resources': [r.slug for r in resources],
1065
            'api': {
1066
                'cancel_url': self.request.build_absolute_uri(
1067
                    reverse('api-cancel-booking', kwargs={'booking_pk': primary_booking.pk})
1068
                ),
1069
                'ics_url': self.request.build_absolute_uri(
1070
                    reverse('api-booking-ics', kwargs={'booking_pk': primary_booking.pk})
1071
                ),
1072
            },
1073
        }
1074
        if to_cancel_booking:
1075
            response['cancelled_booking_id'] = cancelled_booking_id
1076
        if available_desk:
1077
            response['desk'] = {'label': available_desk.label, 'slug': available_desk.slug}
1078

  
1079
        return Response(response)
1080

  
1081

  
1082
class SingleFillSlotsMixin(object):
1083
    def get_slots(self):
1084
        return self.validate_slots([self.kwargs['event_identifier']])
1085

  
1086
    def _post(self, payload):
1087
        slots = self.get_slots()
1088
        return self.fillslots(payload=payload, slots=slots)
1089

  
1090

  
1091
class MultipleFillSlotsMixin(object):
1092
    def get_slots(self, payload):
1093
        return self.validate_slots(payload.get('slots') or [])
1094

  
1095
    def _post(self, payload):
1096
        slots = self.get_slots(payload=payload)
1097
        return self.fillslots(payload=payload, slots=slots, multiple_booking=True)
1098

  
1099

  
1100
class FillSlots(MultipleFillSlotsMixin, EventFillSlotsMixin, APIView):
1101
    serializer_class = serializers.SlotsSerializer
1102

  
1103

  
1104
fillslots = FillSlots.as_view()
1105

  
1106

  
1107
class MeetingsFillSlots(MultipleFillSlotsMixin, MeetingsFillSlotsMixin, APIView):
1108
    serializer_class = serializers.MeetingsSlotsSerializer
1109

  
1110

  
1111
meetings_fillslots = MeetingsFillSlots.as_view()
1112

  
1113

  
1114
class FillSlot(SingleFillSlotsMixin, EventFillSlotsMixin, APIView):
1115
    serializer_class = serializers.SlotSerializer
1116

  
1117

  
1118
fillslot = FillSlot.as_view()
1119

  
1120

  
1121
class MeetingsFillSlot(SingleFillSlotsMixin, MeetingsFillSlotsMixin, APIView):
1122
    serializer_class = serializers.MeetingsSlotSerializer
1123

  
1036 1124

  
1037
fillslot = Fillslot.as_view()
1125
meetings_fillslot = MeetingsFillSlot.as_view()
1038 1126

  
1039 1127

  
1040 1128
class BookingAPI(APIView):
tests/test_api.py
181 181
                'api': {
182 182
                    'meetings_url': 'http://testserver/api/agenda/foo-bar-meeting/meetings/',
183 183
                    'desks_url': 'http://testserver/api/agenda/foo-bar-meeting/desks/',
184
                    'fillslots_url': 'http://testserver/api/agenda/foo-bar-meeting/fillslots/',
184
                    'fillslots_url': 'http://testserver/api/agenda/foo-bar-meeting/meetings/fillslots/',
185 185
                },
186 186
            },
187 187
            {
......
195 195
                'api': {
196 196
                    'meetings_url': 'http://testserver/api/agenda/foo-bar-meeting-2/meetings/',
197 197
                    'desks_url': 'http://testserver/api/agenda/foo-bar-meeting-2/desks/',
198
                    'fillslots_url': 'http://testserver/api/agenda/foo-bar-meeting-2/fillslots/',
198
                    'fillslots_url': 'http://testserver/api/agenda/foo-bar-meeting-2/meetings/fillslots/',
199 199
                },
200 200
            },
201 201
            {
......
208 208
                'api': {
209 209
                    'meetings_url': 'http://testserver/api/agenda/virtual-agenda/meetings/',
210 210
                    'desks_url': 'http://testserver/api/agenda/virtual-agenda/desks/',
211
                    'fillslots_url': 'http://testserver/api/agenda/virtual-agenda/fillslots/',
211
                    'fillslots_url': 'http://testserver/api/agenda/virtual-agenda/meetings/fillslots/',
212 212
                },
213 213
            },
214 214
        ]
......
753 753

  
754 754
    default_desk, _ = Desk.objects.get_or_create(agenda=meetings_agenda, slug='desk-1')
755 755
    meeting_type = MeetingType.objects.get(agenda=meetings_agenda)
756
    api_url = '/api/agenda/%s/meetings/%s/datetimes/' % (meeting_type.agenda.slug, meeting_type.slug)
757 756

  
758 757
    # test with short time periods
759 758
    TimePeriod.objects.filter(desk=default_desk).delete()
......
783 782
    assert resp.json['err_class'] == 'no more desk available'
784 783
    assert resp.json['err_desc'] == 'no more desk available'
785 784
    # booking the two slots fails too
786
    fillslots_url = '/api/agenda/%s/fillslots/' % meeting_type.agenda.slug
785
    fillslots_url = '/api/agenda/%s/meetings/fillslots/' % meeting_type.agenda.slug
787 786
    resp = app.post(fillslots_url, params={'slots': two_slots})
788 787
    assert resp.json['err'] == 1
789 788
    assert resp.json['reason'] == 'no more desk available'  # legacy
......
809 808
        )
810 809

  
811 810
    app.authorization = ('Basic', ('john.doe', 'password'))
811
    app.post('/api/agenda/%s/meeting/fillslot/%s/' % (agenda.slug, event.id), status=404)
812 812
    resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda.slug, event.id))
813 813
    Booking.objects.get(id=resp.json['booking_id'])
814 814
    assert resp.json['datetime'] == localtime(event.start_datetime).strftime('%Y-%m-%d %H:%M:%S')
......
985 985
    meeting_type = MeetingType.objects.get(agenda=meetings_agenda)
986 986
    resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.id)
987 987
    event = resp.json['data'][2]
988
    resp = app.post('/api/agenda/%s/fillslot/%s/' % (meetings_agenda_id, event['id']))
988
    resp = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (meetings_agenda_id, event['id']))
989 989
    assert Booking.objects.count() == 5
990 990
    assert 'ics_url' in resp.json['api']
991 991
    booking = Booking.objects.get(id=resp.json['booking_id'])
......
1016 1016
    assert Booking.objects.count() == 0
1017 1017

  
1018 1018
    app.authorization = ('Basic', ('john.doe', 'password'))
1019
    app.post('/api/agenda/%s/meetings/fillslots/' % agenda.slug, params={'slots': events_ids}, status=404)
1019 1020
    resp = app.post('/api/agenda/%s/fillslots/' % agenda.slug, params={'slots': events_ids})
1020 1021
    primary_booking_id = resp.json['booking_id']
1021 1022
    Booking.objects.get(id=primary_booking_id)
......
1163 1164
    event_id = resp.json['data'][2]['id']
1164 1165
    assert urlparse.urlparse(
1165 1166
        resp.json['data'][2]['api']['fillslot_url']
1166
    ).path == '/api/agenda/%s/fillslot/%s/' % (meetings_agenda.slug, event_id)
1167
    ).path == '/api/agenda/%s/meetings/fillslot/%s/' % (meetings_agenda.slug, event_id)
1167 1168

  
1168 1169
    app.authorization = ('Basic', ('john.doe', 'password'))
1169 1170

  
1170 1171
    # verify malformed event_pk returns a 400
1171
    resp_booking = app.post('/api/agenda/%s/fillslot/None/' % agenda_id, status=400)
1172
    resp_booking = app.post('/api/agenda/%s/meetings/fillslot/None/' % agenda_id, status=400)
1172 1173
    assert resp_booking.json['err'] == 1
1173 1174

  
1175
    app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id), status=404)
1174 1176
    # make a booking
1175
    resp_booking = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
1177
    resp_booking = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda_id, event_id))
1176 1178
    assert Booking.objects.count() == 1
1177 1179
    assert resp_booking.json['datetime'] == localtime(Booking.objects.all()[0].event.start_datetime).strftime(
1178 1180
        '%Y-%m-%d %H:%M:%S'
......
1186 1188
    assert len(resp.json['data']) == len([x for x in resp2.json['data'] if not x.get('disabled')]) + 1
1187 1189

  
1188 1190
    # try booking the same timeslot
1189
    resp2 = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
1191
    resp2 = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda_id, event_id))
1190 1192
    assert resp2.json['err'] == 1
1191 1193
    assert resp2.json['reason'] == 'no more desk available'  # legacy
1192 1194
    assert resp2.json['err_class'] == 'no more desk available'
......
1194 1196

  
1195 1197
    # try booking another timeslot
1196 1198
    event_id = resp.json['data'][3]['id']
1197
    resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
1199
    resp = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda_id, event_id))
1198 1200
    assert resp.json['err'] == 0
1199 1201
    assert Booking.objects.count() == 2
1200 1202

  
......
1218 1220
    app.authorization = ('Basic', ('john.doe', 'password'))
1219 1221

  
1220 1222
    # make a booking without resource
1221
    resp = app.post('/api/agenda/%s/fillslot/%s:%s-1000/' % (agenda.pk, meeting_type.pk, tomorrow_str))
1223
    resp = app.post(
1224
        '/api/agenda/%s/meetings/fillslot/%s:%s-1000/' % (agenda.pk, meeting_type.pk, tomorrow_str)
1225
    )
1222 1226
    assert resp.json['datetime'] == '%s 10:00:00' % tomorrow_str
1223 1227
    assert resp.json['end_datetime'] == '%s 10:30:00' % tomorrow_str
1224 1228
    assert resp.json['duration'] == 30
......
1228 1232

  
1229 1233
    # now try to book also a resource - slot not free
1230 1234
    resp = app.post(
1231
        '/api/agenda/%s/fillslot/%s:%s-1000/?resources=%s'
1235
        '/api/agenda/%s/meetings/fillslot/%s:%s-1000/?resources=%s'
1232 1236
        % (agenda.pk, meeting_type.pk, tomorrow_str, resource1.slug)
1233 1237
    )
1234 1238
    assert resp.json['err'] == 1
......
1238 1242

  
1239 1243
    # slot is free
1240 1244
    resp = app.post(
1241
        '/api/agenda/%s/fillslot/%s:%s-0900/?resources=%s'
1245
        '/api/agenda/%s/meetings/fillslot/%s:%s-0900/?resources=%s'
1242 1246
        % (agenda.pk, meeting_type.pk, tomorrow_str, resource1.slug)
1243 1247
    )
1244 1248
    assert resp.json['datetime'] == '%s 09:00:00' % tomorrow_str
......
1248 1252
    booking = Booking.objects.latest('pk')
1249 1253
    assert list(booking.event.resources.all()) == [resource1]
1250 1254
    resp = app.post(
1251
        '/api/agenda/%s/fillslot/%s:%s-0930/?resources=%s,%s'
1255
        '/api/agenda/%s/meetings/fillslot/%s:%s-0930/?resources=%s,%s'
1252 1256
        % (agenda.pk, meeting_type.pk, tomorrow_str, resource1.slug, resource2.slug)
1253 1257
    )
1254 1258
    assert resp.json['datetime'] == '%s 09:30:00' % tomorrow_str
......
1260 1264

  
1261 1265
    # resource is unknown or not valid for this agenda
1262 1266
    resp = app.post(
1263
        '/api/agenda/%s/fillslot/%s:%s-0900/?resources=foobarblah'
1267
        '/api/agenda/%s/meetings/fillslot/%s:%s-0900/?resources=foobarblah'
1264 1268
        % (agenda.pk, meeting_type.pk, tomorrow_str),
1265 1269
        status=400,
1266 1270
    )
......
1270 1274
    assert resp.json['err_desc'] == 'invalid resource: foobarblah'
1271 1275
    agenda.resources.remove(resource3)
1272 1276
    resp = app.post(
1273
        '/api/agenda/%s/fillslot/%s:%s-0900/?resources=%s'
1277
        '/api/agenda/%s/meetings/fillslot/%s:%s-0900/?resources=%s'
1274 1278
        % (agenda.pk, meeting_type.pk, tomorrow_str, resource3.slug),
1275 1279
        status=400,
1276 1280
    )
......
1279 1283
    assert resp.json['err_class'] == 'invalid resource: resource-3'
1280 1284
    assert resp.json['err_desc'] == 'invalid resource: resource-3'
1281 1285
    resp = app.post(
1282
        '/api/agenda/%s/fillslot/%s:%s-0900/?resources=%s,foobarblah'
1286
        '/api/agenda/%s/meetings/fillslot/%s:%s-0900/?resources=%s,foobarblah'
1283 1287
        % (agenda.pk, meeting_type.pk, tomorrow_str, resource3.slug),
1284 1288
        status=400,
1285 1289
    )
......
1288 1292
    assert resp.json['err_class'] == 'invalid resource: foobarblah, resource-3'
1289 1293
    assert resp.json['err_desc'] == 'invalid resource: foobarblah, resource-3'
1290 1294
    resp = app.post(
1291
        '/api/agenda/%s/fillslot/%s:%s-0900/?resources=%s,foobarblah'
1295
        '/api/agenda/%s/meetings/fillslot/%s:%s-0900/?resources=%s,foobarblah'
1292 1296
        % (agenda.pk, meeting_type.pk, tomorrow_str, resource1.slug),
1293 1297
        status=400,
1294 1298
    )
......
1300 1304
    # booking is canceled: slot is free
1301 1305
    booking.cancel()
1302 1306
    resp = app.post(
1303
        '/api/agenda/%s/fillslot/%s:%s-0930/?resources=%s,%s'
1307
        '/api/agenda/%s/meetings/fillslot/%s:%s-0930/?resources=%s,%s'
1304 1308
        % (agenda.pk, meeting_type.pk, tomorrow_str, resource1.slug, resource2.slug)
1305 1309
    )
1306 1310
    assert resp.json['datetime'] == '%s 09:30:00' % tomorrow_str
......
1318 1322
    slots = [resp.json['data'][0]['id'], resp.json['data'][1]['id']]
1319 1323

  
1320 1324
    app.authorization = ('Basic', ('john.doe', 'password'))
1321
    resp_booking = app.post('/api/agenda/%s/fillslots/' % agenda_id, params={'slots': slots})
1325
    app.post('/api/agenda/%s/fillslots/' % agenda_id, params={'slots': slots}, status=404)
1326
    resp_booking = app.post('/api/agenda/%s/meetings/fillslots/' % agenda_id, params={'slots': slots})
1322 1327
    assert Booking.objects.count() == 2
1323 1328
    primary_booking = Booking.objects.filter(primary_booking__isnull=True).first()
1324 1329
    secondary_booking = Booking.objects.filter(primary_booking=primary_booking.id).first()
......
1333 1338
    assert len(resp.json['data']) == len([x for x in resp2.json['data'] if not x.get('disabled')]) + 2
1334 1339

  
1335 1340
    # try booking the same timeslots
1336
    resp2 = app.post('/api/agenda/%s/fillslots/' % agenda_id, params={'slots': slots})
1341
    resp2 = app.post('/api/agenda/%s/meetings/fillslots/' % agenda_id, params={'slots': slots})
1337 1342
    assert resp2.json['err'] == 1
1338 1343
    assert resp2.json['reason'] == 'no more desk available'  # legacy
1339 1344
    assert resp2.json['err_class'] == 'no more desk available'
......
1341 1346

  
1342 1347
    # try booking partially free timeslots (one free, one busy)
1343 1348
    nonfree_slots = [resp.json['data'][0]['id'], resp.json['data'][2]['id']]
1344
    resp2 = app.post('/api/agenda/%s/fillslots/' % agenda_id, params={'slots': nonfree_slots})
1349
    resp2 = app.post('/api/agenda/%s/meetings/fillslots/' % agenda_id, params={'slots': nonfree_slots})
1345 1350
    assert resp2.json['err'] == 1
1346 1351
    assert resp2.json['reason'] == 'no more desk available'  # legacy
1347 1352
    assert resp2.json['err_class'] == 'no more desk available'
......
1349 1354

  
1350 1355
    # booking other free timeslots
1351 1356
    free_slots = [resp.json['data'][3]['id'], resp.json['data'][2]['id']]
1352
    resp2 = app.post('/api/agenda/%s/fillslots/' % agenda_id, params={'slots': free_slots})
1357
    resp2 = app.post('/api/agenda/%s/meetings/fillslots/' % agenda_id, params={'slots': free_slots})
1353 1358
    assert resp2.json['err'] == 0
1354 1359
    cancel_url = resp2.json['api']['cancel_url']
1355 1360
    assert Booking.objects.count() == 4
......
1363 1368
    assert Booking.objects.filter(cancellation_datetime__isnull=False).count() == 2
1364 1369

  
1365 1370
    impossible_slots = ['1:2017-05-22-1130', '2:2017-05-22-1100']
1366
    resp = app.post('/api/agenda/%s/fillslots/' % agenda_id, params={'slots': impossible_slots}, status=400)
1371
    resp = app.post(
1372
        '/api/agenda/%s/meetings/fillslots/' % agenda_id, params={'slots': impossible_slots}, status=400
1373
    )
1367 1374
    assert resp.json['err'] == 1
1368 1375
    assert resp.json['reason'] == 'all slots must have the same meeting type id (1)'  # legacy
1369 1376
    assert resp.json['err_class'] == 'all slots must have the same meeting type id (1)'
1370 1377
    assert resp.json['err_desc'] == 'all slots must have the same meeting type id (1)'
1371 1378

  
1372 1379
    unknown_slots = ['0:2017-05-22-1130']
1373
    resp = app.post('/api/agenda/%s/fillslots/' % agenda_id, params={'slots': unknown_slots}, status=400)
1380
    resp = app.post(
1381
        '/api/agenda/%s/meetings/fillslots/' % agenda_id, params={'slots': unknown_slots}, status=400
1382
    )
1374 1383
    assert resp.json['err'] == 1
1375 1384
    assert resp.json['reason'] == 'invalid meeting type id: 0'  # legacy
1376 1385
    assert resp.json['err_class'] == 'invalid meeting type id: 0'
1377 1386
    assert resp.json['err_desc'] == 'invalid meeting type id: 0'
1378 1387
    unknown_slots = ['foobar:2017-05-22-1130']
1379
    resp = app.post('/api/agenda/%s/fillslots/' % agenda_id, params={'slots': unknown_slots}, status=400)
1388
    resp = app.post(
1389
        '/api/agenda/%s/meetings/fillslots/' % agenda_id, params={'slots': unknown_slots}, status=400
1390
    )
1380 1391
    assert resp.json['err'] == 1
1381 1392
    assert resp.json['reason'] == 'invalid meeting type id: foobar'  # legacy
1382 1393
    assert resp.json['err_class'] == 'invalid meeting type id: foobar'
1383 1394
    assert resp.json['err_desc'] == 'invalid meeting type id: foobar'
1384 1395

  
1385 1396

  
1397
def test_booking_api_meeting_with_extra_params_in_payload(app, user):
1398
    agenda = Agenda.objects.create(kind='meetings', slug='slug')
1399
    meeting_type = MeetingType.objects.create(agenda=agenda, label='Blah', duration=30)
1400
    desk = Desk.objects.create(label='Desk', agenda=agenda)
1401
    TimePeriod.objects.create(
1402
        weekday=3, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0), desk=desk,
1403
    )
1404

  
1405
    app.authorization = ('Basic', ('john.doe', 'password'))
1406
    resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.pk)
1407
    event_id = resp.json['data'][2]['id']
1408
    # count is a param of events fillslot endpoints
1409
    app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda.slug, event_id), params={'count': 42})
1410
    assert Booking.objects.count() == 1
1411
    booking = Booking.objects.latest('pk')
1412
    booking.cancel()
1413

  
1414
    # count is a param of events fillslot endpoints
1415
    resp = app.post(
1416
        '/api/agenda/%s/meetings/fillslots/' % agenda.slug, params={'slots': [event_id], 'count': 42}
1417
    )
1418
    assert Booking.objects.count() == 2
1419

  
1420

  
1386 1421
def test_booking_api_meeting_across_daylight_saving_time(app, meetings_agenda, user):
1387 1422
    meetings_agenda.maximal_booking_delay = 365
1388 1423
    meetings_agenda.save()
......
1395 1430
    assert event_id[-4:] == resp.json['data'][2 * 18]['id'][-4:]
1396 1431
    assert urlparse.urlparse(
1397 1432
        resp.json['data'][event_index]['api']['fillslot_url']
1398
    ).path == '/api/agenda/%s/fillslot/%s/' % (meetings_agenda.slug, event_id)
1433
    ).path == '/api/agenda/%s/meetings/fillslot/%s/' % (meetings_agenda.slug, event_id)
1399 1434

  
1400 1435
    app.authorization = ('Basic', ('john.doe', 'password'))
1401
    resp_booking = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
1436
    resp_booking = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda_id, event_id))
1402 1437
    assert Booking.objects.count() == 1
1403 1438
    assert resp_booking.json['datetime'] == localtime(Booking.objects.all()[0].event.start_datetime).strftime(
1404 1439
        '%Y-%m-%d %H:%M:%S'
......
1423 1458
    event_id = resp.json['data'][0]['id']
1424 1459

  
1425 1460
    app.authorization = ('Basic', ('john.doe', 'password'))
1426
    app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
1461
    app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda_id, event_id))
1427 1462
    assert Booking.objects.count() == 1
1428 1463

  
1429 1464
    # the longer event at the same time shouldn't be available anymore
......
1449 1484
    event_id = resp.json['data'][0]['id']
1450 1485

  
1451 1486
    app.authorization = ('Basic', ('john.doe', 'password'))
1452
    app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
1487
    app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda_id, event_id))
1453 1488
    assert Booking.objects.count() == 1
1454 1489

  
1455 1490
    # this should have removed two short events
......
1462 1497
    # book another long event
1463 1498
    event_id = resp.json['data'][10]['id']
1464 1499
    app.authorization = ('Basic', ('john.doe', 'password'))
1465
    app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
1500
    app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda_id, event_id))
1466 1501
    assert Booking.objects.count() == 2
1467 1502

  
1468 1503
    resp_short2 = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type_2.id)
......
1514 1549
    meeting_type = MeetingType.objects.get(agenda=meetings_agenda)
1515 1550
    resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.pk)
1516 1551
    event_id = resp.json['data'][2]['id']
1517
    resp = app.post_json('/api/agenda/%s/fillslot/%s/' % (meetings_agenda.pk, event_id))
1552
    resp = app.post_json('/api/agenda/%s/meetings/fillslot/%s/' % (meetings_agenda.pk, event_id))
1518 1553
    assert resp.json['err'] == 0
1519 1554
    assert 'places' not in resp.json
1520 1555

  
......
1738 1773
    event_id = resp.json['data'][2]['id']
1739 1774

  
1740 1775
    app.authorization = ('Basic', ('john.doe', 'password'))
1741
    resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
1776
    resp = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda_id, event_id))
1742 1777
    assert Booking.objects.count() == 1
1743 1778

  
1744 1779
    booking_id = resp.json['booking_id']
......
1749 1784
    assert len(resp.json['data']) == nb_events
1750 1785

  
1751 1786
    # book the same time slot
1752
    resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
1787
    resp = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda_id, event_id))
1753 1788
    assert resp.json['err'] == 0
1754 1789
    resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.id)
1755 1790
    assert len([x for x in resp.json['data'] if not x.get('disabled')]) == nb_events - 1
......
2762 2797
    meeting_type2 = MeetingType.objects.create(agenda=meetings_agenda, label='Tux kart', duration=60)
2763 2798
    resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type2.id)
2764 2799
    event_id = resp.json['data'][0]['id']
2765
    resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
2800
    resp = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda_id, event_id))
2766 2801
    cancel_url = resp.json['api']['cancel_url']
2767 2802

  
2768 2803
    # add a second desk
......
2777 2812

  
2778 2813
    resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.id)
2779 2814
    event_id = resp.json['data'][1]['id']
2780
    resp_booking = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
2815
    resp_booking = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda_id, event_id))
2781 2816
    assert Booking.objects.count() == 2
2782 2817
    assert resp_booking.json['datetime'] == localtime(Booking.objects.last().event.start_datetime).strftime(
2783 2818
        '%Y-%m-%d %H:%M:%S'
......
2787 2822
    assert len(resp.json['data']) == len([x for x in resp2.json['data'] if not x['disabled']]) + 1
2788 2823

  
2789 2824
    # try booking the same timeslot and fail
2790
    resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
2825
    resp = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda_id, event_id))
2791 2826
    assert Booking.objects.count() == 2
2792 2827
    assert resp.json['err'] == 1
2793 2828
    assert resp.json['reason'] == 'no more desk available'  # legacy
......
2804 2839

  
2805 2840
    # capture number of queries made for fillslot endpoint with few bookings
2806 2841
    with CaptureQueriesContext(connection) as ctx:
2807
        resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
2842
        resp = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda_id, event_id))
2808 2843
        queries_count_fillslot1 = len(ctx.captured_queries)
2809 2844

  
2810 2845
    assert resp_booking.json['datetime'] == localtime(Booking.objects.last().event.start_datetime).strftime(
......
2821 2856
    assert len(resp.json['data']) == len(resp2.json['data'])
2822 2857

  
2823 2858
    # try booking the same slot to make sure that cancelled booking has freed the slot
2824
    resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
2859
    resp = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda_id, event_id))
2825 2860
    assert Booking.objects.count() == 4
2826 2861
    assert Booking.objects.exclude(cancellation_datetime__isnull=True).count() == 2
2827 2862
    assert resp_booking.json['datetime'] == localtime(Booking.objects.last().event.start_datetime).strftime(
......
2829 2864
    )
2830 2865

  
2831 2866
    # try booking the same timeslot again and fail
2832
    resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
2867
    resp = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda_id, event_id))
2833 2868
    assert resp.json['err'] == 1
2834 2869
    assert resp.json['reason'] == 'no more desk available'  # legacy
2835 2870
    assert resp.json['err_class'] == 'no more desk available'
......
2872 2907
    start_free_places = get_free_places()
2873 2908

  
2874 2909
    # booking 3 slots on desk 1
2875
    fillslots_url = '/api/agenda/%s/fillslots/' % agenda_id
2910
    fillslots_url = '/api/agenda/%s/meetings/fillslots/' % agenda_id
2876 2911
    resp = app.post(fillslots_url, params={'slots': slots})
2877 2912
    assert resp.json['err'] == 0
2878 2913
    desk1 = resp.json['desk']['slug']
......
2901 2936
    assert get_free_places() == start_free_places
2902 2937

  
2903 2938
    # booking a single slot (must be on desk 1)
2904
    resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, slots[1]))
2939
    resp = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (agenda_id, slots[1]))
2905 2940
    assert resp.json['err'] == 0
2906 2941
    assert resp.json['desk']['slug'] == desk1
2907 2942
    cancel_url = resp.json['api']['cancel_url']
......
2981 3016
    datetime_url = '/api/agenda/meetings/%s/datetimes/' % meeting_type.id
2982 3017
    desk = Desk.objects.create(label='foo', agenda=agenda)
2983 3018
    for weekday in range(7):
2984
        time_period = TimePeriod.objects.create(
3019
        TimePeriod.objects.create(
2985 3020
            weekday=weekday, start_time=datetime.time(11, 0), end_time=datetime.time(12, 30), desk=desk
2986 3021
        )
2987 3022
    resp = app.get(datetime_url)
......
3206 3241
    resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type_30.id)
3207 3242
    event_id = resp.json['data'][0]['id']
3208 3243
    app.authorization = ('Basic', ('john.doe', 'password'))
3209
    app.post('/api/agenda/%s/fillslot/%s/' % (meetings_agenda.id, event_id))
3244
    app.post('/api/agenda/%s/meetings/fillslot/%s/' % (meetings_agenda.id, event_id))
3210 3245
    assert Booking.objects.count() == 1
3211 3246

  
3212 3247
    resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type_20.id)
3213 3248
    assert len([x for x in resp.json['data'] if not x.get('disabled')]) == 55
3214 3249
    event_id = [x for x in resp.json['data'] if not x.get('disabled')][0]['id']
3215
    resp = app.post('/api/agenda/%s/fillslot/%s/' % (meetings_agenda.id, event_id))
3250
    resp = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (meetings_agenda.id, event_id))
3216 3251
    assert resp.json['datetime'] == '2017-05-22 10:30:00'
3217 3252
    assert Booking.objects.count() == 2
3218 3253

  
3219 3254
    resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type_30.id)
3220 3255
    event_id = [x for x in resp.json['data'] if not x.get('disabled')][0]['id']
3221
    resp = app.post('/api/agenda/%s/fillslot/%s/' % (meetings_agenda.id, event_id))
3256
    resp = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (meetings_agenda.id, event_id))
3222 3257
    assert resp.json['datetime'] == '2017-05-22 10:50:00'
3223 3258
    assert Booking.objects.count() == 3
3224 3259

  
3225 3260
    # create a gap
3226 3261
    resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type_30.id)
3227 3262
    event_id = [x for x in resp.json['data'] if not x.get('disabled')][1]['id']
3228
    resp = app.post('/api/agenda/%s/fillslot/%s/' % (meetings_agenda.id, event_id))
3263
    resp = app.post('/api/agenda/%s/meetings/fillslot/%s/' % (meetings_agenda.id, event_id))
3229 3264
    assert resp.json['datetime'] == '2017-05-22 11:30:00'
3230 3265
    assert Booking.objects.count() == 4
3231 3266

  
......
3358 3393
            'api': {
3359 3394
                'meetings_url': 'http://testserver/api/agenda/%s/meetings/' % virtual_meetings_agenda.slug,
3360 3395
                'desks_url': 'http://testserver/api/agenda/%s/desks/' % virtual_meetings_agenda.slug,
3361
                'fillslots_url': 'http://testserver/api/agenda/%s/fillslots/' % virtual_meetings_agenda.slug,
3396
                'fillslots_url': 'http://testserver/api/agenda/%s/meetings/fillslots/'
3397
                % virtual_meetings_agenda.slug,
3362 3398
            },
3363 3399
        },
3364 3400
    }
3365
-