From 185e354d633fc030d0ffa01643b8fc886a2e097c Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Tue, 26 May 2020 15:11:39 +0200
Subject: [PATCH 7/8] api: book also resources in fillslot(s) endpoint (#38942)

---
 chrono/api/views.py |  82 ++++++++++++++++++++++----------
 tests/test_api.py   | 112 ++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 169 insertions(+), 25 deletions(-)

diff --git a/chrono/api/views.py b/chrono/api/views.py
index 78e645a..dd3c8ec 100644
--- a/chrono/api/views.py
+++ b/chrono/api/views.py
@@ -40,6 +40,25 @@ from ..agendas.models import Agenda, Event, Booking, MeetingType, TimePeriodExce
 from ..interval import IntervalSet
 
 
+class APIError(Exception):
+    err = 1
+    http_status = 200
+
+    def __init__(self, *args, **kwargs):
+        self.__dict__.update(kwargs)
+        super(APIError, self).__init__(*args)
+
+    def to_response(self):
+        data = {
+            'err': self.err,
+            'err_class': self.err_class,
+            'err_desc': force_text(self),
+        }
+        if hasattr(self, 'errors'):
+            data['errors'] = self.errors
+        return Response(data, status=self.http_status)
+
+
 def format_response_datetime(dt):
     return localtime(dt).strftime('%Y-%m-%d %H:%M:%S')
 
@@ -310,6 +329,22 @@ def get_event_places(event):
     return places
 
 
+def get_resources_from_request(request, agenda):
+    if agenda.kind != 'meetings' or 'resources' not in request.GET:
+        return []
+    resources_slugs = [s for s in request.GET['resources'].split(',') if s]
+    resources = list(agenda.resources.filter(slug__in=resources_slugs))
+    if len(resources) != len(resources_slugs):
+        unknown_slugs = set(resources_slugs) - set([r.slug for r in resources])
+        unknown_slugs = sorted(list(unknown_slugs))
+        raise APIError(
+            _('invalid resource: %s') % ', '.join(unknown_slugs),
+            err_class='invalid resource: %s' % ', '.join(unknown_slugs),
+            http_status=status.HTTP_400_BAD_REQUEST,
+        )
+    return resources
+
+
 class Agendas(APIView):
     permission_classes = ()
 
@@ -447,21 +482,10 @@ class MeetingDatetimes(APIView):
 
         now_datetime = now()
 
-        resources = None
-        if agenda.kind == 'meetings' and 'resources' in request.GET:
-            resources_slugs = [s for s in request.GET['resources'].split(',') if s]
-            resources = list(agenda.resources.filter(slug__in=resources_slugs))
-            if len(resources) != len(resources_slugs):
-                unknown_slugs = set(resources_slugs) - set([r.slug for r in resources])
-                unknown_slugs = sorted(list(unknown_slugs))
-                return Response(
-                    {
-                        'err': 1,
-                        'err_class': 'invalid resource: %s' % ', '.join(unknown_slugs),
-                        'err_desc': _('invalid resource: %s') % ', '.join(unknown_slugs),
-                    },
-                    status=status.HTTP_400_BAD_REQUEST,
-                )
+        try:
+            resources = get_resources_from_request(request, agenda)
+        except APIError as e:
+            return e.to_response()
 
         # Generate an unique slot for each possible meeting [start_datetime,
         # end_datetime] range.
@@ -756,10 +780,15 @@ class Fillslots(APIView):
                     )
                 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:
                 all_slots = sorted(
-                    get_all_slots(agenda, agenda.get_meetingtype(id_=meeting_type_id)),
+                    get_all_slots(agenda, agenda.get_meetingtype(id_=meeting_type_id), resources=resources),
                     key=lambda slot: slot.start_datetime,
                 )
             except (MeetingType.DoesNotExist, ValueError):
@@ -847,16 +876,17 @@ class Fillslots(APIView):
             # create them now, with data from the slots and the desk we found.
             events = []
             for start_datetime in datetimes:
-                events.append(
-                    Event.objects.create(
-                        agenda=available_desk.agenda,
-                        meeting_type_id=meeting_type_id,
-                        start_datetime=start_datetime,
-                        full=False,
-                        places=1,
-                        desk=available_desk,
-                    )
+                event = Event.objects.create(
+                    agenda=available_desk.agenda,
+                    meeting_type_id=meeting_type_id,
+                    start_datetime=start_datetime,
+                    full=False,
+                    places=1,
+                    desk=available_desk,
                 )
+                if resources:
+                    event.resources.add(*resources)
+                events.append(event)
         else:
             try:
                 events = Event.objects.filter(id__in=[int(s) for s in slots]).order_by('start_datetime')
@@ -973,6 +1003,8 @@ class Fillslots(APIView):
                 }
                 for x in events
             ]
+        if agenda.kind == 'meetings':
+            response['resources'] = [r.slug for r in resources]
 
         return Response(response)
 
diff --git a/tests/test_api.py b/tests/test_api.py
index 3e9476a..dd9727f 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -1080,6 +1080,118 @@ def test_booking_api_meeting(app, meetings_agenda, user):
     assert Booking.objects.count() == 2
 
 
+def test_booking_api_meeting_with_resources(app, user):
+    tomorrow = datetime.date.today() + datetime.timedelta(days=1)
+    tomorrow_str = tomorrow.isoformat()
+    agenda = Agenda.objects.create(
+        label='Agenda', kind='meetings', minimal_booking_delay=0, maximal_booking_delay=10
+    )
+    resource1 = Resource.objects.create(label='Resource 1')
+    resource2 = Resource.objects.create(label='Resource 2')
+    resource3 = Resource.objects.create(label='Resource 3')
+    agenda.resources.add(resource1, resource2, resource3)
+    desk = Desk.objects.create(agenda=agenda, slug='desk')
+    meeting_type = MeetingType.objects.create(agenda=agenda, slug='foo-bar')
+    TimePeriod.objects.create(
+        weekday=tomorrow.weekday(), start_time=datetime.time(9, 0), end_time=datetime.time(17, 00), desk=desk,
+    )
+
+    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))
+    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
+    assert resp.json['resources'] == []
+    booking = Booking.objects.latest('pk')
+    assert list(booking.event.resources.all()) == []
+
+    # now try to book also a resource - slot not free
+    resp = app.post(
+        '/api/agenda/%s/fillslot/%s:%s-1000/?resources=%s'
+        % (agenda.pk, meeting_type.pk, tomorrow_str, resource1.slug)
+    )
+    assert resp.json['err'] == 1
+    assert resp.json['reason'] == 'no more desk available'  # legacy
+    assert resp.json['err_class'] == 'no more desk available'
+    assert resp.json['err_desc'] == 'no more desk available'
+
+    # slot is free
+    resp = app.post(
+        '/api/agenda/%s/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
+    assert resp.json['end_datetime'] == '%s 09:30:00' % tomorrow_str
+    assert resp.json['duration'] == 30
+    assert resp.json['resources'] == [resource1.slug]
+    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'
+        % (agenda.pk, meeting_type.pk, tomorrow_str, resource1.slug, resource2.slug)
+    )
+    assert resp.json['datetime'] == '%s 09:30:00' % tomorrow_str
+    assert resp.json['end_datetime'] == '%s 10:00:00' % tomorrow_str
+    assert resp.json['duration'] == 30
+    assert resp.json['resources'] == [resource1.slug, resource2.slug]
+    booking = Booking.objects.latest('pk')
+    assert list(booking.event.resources.all()) == [resource1, resource2]
+
+    # resource is unknown or not valid for this agenda
+    resp = app.post(
+        '/api/agenda/%s/fillslot/%s:%s-0900/?resources=foobarblah'
+        % (agenda.pk, meeting_type.pk, tomorrow_str),
+        status=400,
+    )
+    assert resp.json['err'] == 1
+    assert resp.json['reason'] == 'invalid resource: foobarblah'  # legacy
+    assert resp.json['err_class'] == 'invalid resource: foobarblah'
+    assert resp.json['err_desc'] == 'invalid resource: foobarblah'
+    agenda.resources.remove(resource3)
+    resp = app.post(
+        '/api/agenda/%s/fillslot/%s:%s-0900/?resources=%s'
+        % (agenda.pk, meeting_type.pk, tomorrow_str, resource3.slug),
+        status=400,
+    )
+    assert resp.json['err'] == 1
+    assert resp.json['reason'] == 'invalid resource: resource-3'  # legacy
+    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'
+        % (agenda.pk, meeting_type.pk, tomorrow_str, resource3.slug),
+        status=400,
+    )
+    assert resp.json['err'] == 1
+    assert resp.json['reason'] == 'invalid resource: foobarblah, resource-3'  # legacy
+    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'
+        % (agenda.pk, meeting_type.pk, tomorrow_str, resource1.slug),
+        status=400,
+    )
+    assert resp.json['err'] == 1
+    assert resp.json['reason'] == 'invalid resource: foobarblah'  # legacy
+    assert resp.json['err_class'] == 'invalid resource: foobarblah'
+    assert resp.json['err_desc'] == 'invalid resource: foobarblah'
+
+    # booking is canceled: slot is free
+    booking.cancel()
+    resp = app.post(
+        '/api/agenda/%s/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
+    assert resp.json['end_datetime'] == '%s 10:00:00' % tomorrow_str
+    assert resp.json['duration'] == 30
+    assert resp.json['resources'] == [resource1.slug, resource2.slug]
+    booking = Booking.objects.latest('pk')
+    assert list(booking.event.resources.all()) == [resource1, resource2]
+
+
 def test_booking_api_meeting_fillslots(app, meetings_agenda, user):
     agenda_id = meetings_agenda.slug
     meeting_type = MeetingType.objects.get(agenda=meetings_agenda)
-- 
2.20.1

