From 6dc40aa83ceef3dfd7292496472abf3f383f5168 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 11:51:40 +0200
Subject: [PATCH 6/8] api: check resources in datetimes endpoint (#38942)

---
 chrono/api/views.py |  67 +++++++++++---
 tests/test_api.py   | 211 ++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 268 insertions(+), 10 deletions(-)

diff --git a/chrono/api/views.py b/chrono/api/views.py
index 6452b78..78e645a 100644
--- a/chrono/api/views.py
+++ b/chrono/api/views.py
@@ -62,24 +62,26 @@ def get_max_datetime(agenda):
 TimeSlot = collections.namedtuple('TimeSlot', ['start_datetime', 'end_datetime', 'full', 'desk'])
 
 
-def get_all_slots(agenda, meeting_type, unique=False):
+def get_all_slots(base_agenda, meeting_type, resources=None, unique=False):
     '''Get all occupation state of all possible slots for the given agenda (of
        its real agendas for a virtual agenda) and the given meeting_type.
 
-       The process is done in three phases:
+       The process is done in four phases:
        - first phase: aggregate time intervals, during which a meeting is impossible
          due to TimePeriodException models, by desk in IntervalSet (compressed
          and ordered list of intervals).
        - second phase: aggregate time intervals by desk for already booked slots, again
          to make IntervalSet,
-       - third and las phase: generate time slots from each time period based
+       - third phase: for a meetings agenda, if resources has to be booked,
+         aggregate time intervals for already booked resources, to make IntervalSet.
+       - fourth and last phase: generate time slots from each time period based
          on the time period definition and on the desk's respective agenda real
          min/max_datetime; for each time slot check its status in the exclusion
          and bookings sets.
-         If it is excluded, ingore it completely.
-         It if is booked, reports the slot as full.
+         If it is excluded, ignore it completely.
+         It if is booked, report the slot as full.
     '''
-    base_agenda = agenda
+    resources = resources or []
     # virtual agendas have one constraint :
     # all the real agendas MUST have the same meetingstypes, the consequence is
     # that the base_meeting_duration for the virtual agenda is always the same
@@ -93,7 +95,7 @@ def get_all_slots(agenda, meeting_type, unique=False):
 
     now_datetime = now()
     base_date = now_datetime.date()
-    agendas = agenda.get_real_agendas()
+    agendas = base_agenda.get_real_agendas()
 
     # regroup agendas by their opening period
     agenda_ids_by_min_max_datetimes = collections.defaultdict(set)
@@ -119,7 +121,7 @@ def get_all_slots(agenda, meeting_type, unique=False):
             key=lambda time_period: time_period.desk,
         )
     }
-    # compute reduced min/max_datetime windows by desks based on exclusions
+    # compute reduced min/max_datetime windows by desks based on exceptions
     desk_min_max_datetime = {}
     for desk, desk_exception in desks_exceptions.items():
         base = IntervalSet([agenda_id_min_max_datetime[desk.agenda_id]])
@@ -164,6 +166,29 @@ def get_all_slots(agenda, meeting_type, unique=False):
             for desk_id, values in itertools.groupby(booked_events, lambda be: be[0])
         )
 
+    # aggregate already booked time intervals for resources
+    resources_bookings = IntervalSet()
+    if agenda.kind == 'meetings' and resources:
+        used_min_datetime, used_max_datetime = agenda_id_min_max_datetime[base_agenda.pk]
+        event_ids_queryset = Event.resources.through.objects.filter(
+            resource__in=[r.pk for r in resources]
+        ).values('event')
+        booked_events = (
+            Event.objects.filter(
+                pk__in=event_ids_queryset,
+                start_datetime__gte=used_min_datetime,
+                start_datetime__lte=used_max_datetime + meeting_duration_td,
+            )
+            .exclude(booking__cancellation_datetime__isnull=False)
+            .order_by('start_datetime', 'meeting_type__duration')
+            .values_list('start_datetime', 'meeting_type__duration')
+        )
+        # compute exclusion set
+        resources_bookings = IntervalSet.from_ordered(
+            (event_start_datetime, event_start_datetime + datetime.timedelta(minutes=event_duration))
+            for event_start_datetime, event_duration in booked_events
+        )
+
     unique_booked = {}
     for time_period in base_agenda.get_effective_time_periods():
         duration = (
@@ -212,7 +237,13 @@ def get_all_slots(agenda, meeting_type, unique=False):
                         continue
 
                     # slot is full if an already booked event overlaps it
-                    booked = desk.id in bookings and bookings[desk.id].overlaps(start_datetime, end_datetime)
+                    # check resources first
+                    booked = resources_bookings.overlaps(start_datetime, end_datetime)
+                    if not booked:
+                        # then bookings if resources are free
+                        booked = desk.id in bookings and bookings[desk.id].overlaps(
+                            start_datetime, end_datetime
+                        )
                     if unique and unique_booked.get(timestamp) is booked:
                         continue
                     unique_booked[timestamp] = booked
@@ -416,6 +447,22 @@ 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,
+                )
+
         # Generate an unique slot for each possible meeting [start_datetime,
         # end_datetime] range.
         # First use get_all_slots() to get each possible meeting by desk and
@@ -429,7 +476,7 @@ class MeetingDatetimes(APIView):
         # The generator also remove slots starting before the current time.
         def unique_slots():
             last_slot = None
-            all_slots = list(get_all_slots(agenda, meeting_type, unique=True))
+            all_slots = list(get_all_slots(agenda, meeting_type, resources=resources, unique=True))
             for slot in sorted(all_slots, key=lambda slot: slot[:3]):
                 if slot.start_datetime < now_datetime:
                     continue
diff --git a/tests/test_api.py b/tests/test_api.py
index 1ab577b..3e9476a 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -426,6 +426,217 @@ def test_datetimes_api_meetings_agenda(app, meetings_agenda):
     assert len(resp.json['data']) == 3
 
 
+def test_datetimes_api_meetings_agenda_with_resources(app):
+    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
+    )
+    other_agenda = Agenda.objects.create(label='Other', kind='meetings')
+    other_desk = Desk.objects.create(agenda=other_agenda, slug='desk-1')
+    other_meeting_type = MeetingType.objects.create(agenda=other_agenda, slug='foo-bar', duration=90)
+    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-1')
+    desk2 = Desk.objects.create(agenda=agenda, slug='desk-2')
+    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,
+    )
+    api_url = '/api/agenda/%s/meetings/%s/datetimes/' % (agenda.slug, meeting_type.slug)
+    resp = app.get(api_url)
+    assert len(resp.json['data']) == 32
+    assert [s['datetime'] for s in resp.json['data'] if s['disabled'] is True] == []
+
+    # all resources are free
+    api_url = '/api/agenda/%s/meetings/%s/datetimes/?resources=%s,%s' % (
+        agenda.slug,
+        meeting_type.slug,
+        resource1.slug,
+        resource2.slug,
+    )
+    resp = app.get(api_url)
+    assert len(resp.json['data']) == 32
+    assert [s['datetime'] for s in resp.json['data'] if s['disabled'] is True] == []
+
+    # resource 1 is not available from 10h to 11h30 in another agenda
+    dt = make_aware(datetime.datetime.combine(tomorrow, datetime.time(10, 0)))
+    event1 = Event.objects.create(
+        agenda=other_agenda,
+        meeting_type=other_meeting_type,
+        places=1,
+        full=False,
+        start_datetime=dt,
+        desk=other_desk,
+    )
+    event1.resources.add(resource1)
+    booking_r1 = Booking.objects.create(event=event1)
+    # resource 3 is not available from 9h to 10h in this agenda (but desk-1 is free)
+    dt = make_aware(datetime.datetime.combine(tomorrow, datetime.time(9, 0)))
+    event2 = Event.objects.create(
+        agenda=agenda, meeting_type=meeting_type, places=1, full=False, start_datetime=dt, desk=desk2,
+    )
+    event2.resources.add(resource3)
+    Booking.objects.create(event=event2)
+    dt = make_aware(datetime.datetime.combine(tomorrow, datetime.time(9, 30)))
+    event3 = Event.objects.create(
+        agenda=agenda, meeting_type=meeting_type, places=1, full=False, start_datetime=dt, desk=desk2,
+    )
+    event3.resources.add(resource3)
+    Booking.objects.create(event=event3)
+
+    # check for resource 1 and resource 2: not available from 10H to 11H30
+    api_url = '/api/agenda/%s/meetings/%s/datetimes/?resources=%s,%s' % (
+        agenda.slug,
+        meeting_type.slug,
+        resource1.slug,
+        resource2.slug,
+    )
+    resp = app.get(api_url)
+    assert len(resp.json['data']) == 32
+    assert [s['datetime'] for s in resp.json['data'] if s['disabled'] is True] == [
+        '%s 10:00:00' % tomorrow_str,
+        '%s 10:30:00' % tomorrow_str,
+        '%s 11:00:00' % tomorrow_str,
+    ]
+
+    # check for resource 2 only ? it's free
+    api_url = '/api/agenda/%s/meetings/%s/datetimes/?resources=%s' % (
+        agenda.slug,
+        meeting_type.slug,
+        resource2.slug,
+    )
+    resp = app.get(api_url)
+    assert len(resp.json['data']) == 32
+    assert [s['datetime'] for s in resp.json['data'] if s['disabled'] is True] == []
+
+    # check for resource 3: not available from 9H to 10H
+    api_url = '/api/agenda/%s/meetings/%s/datetimes/?resources=%s' % (
+        agenda.slug,
+        meeting_type.slug,
+        resource3.slug,
+    )
+    resp = app.get(api_url)
+    assert len(resp.json['data']) == 32
+    assert [s['datetime'] for s in resp.json['data'] if s['disabled'] is True] == [
+        '%s 09:00:00' % tomorrow_str,
+        '%s 09:30:00' % tomorrow_str,
+    ]
+
+    # check for resource 1 and resource 3
+    api_url = '/api/agenda/%s/meetings/%s/datetimes/?resources=%s,%s' % (
+        agenda.slug,
+        meeting_type.slug,
+        resource1.slug,
+        resource3.slug,
+    )
+    with CaptureQueriesContext(connection) as ctx:
+        resp = app.get(api_url)
+        assert len(ctx.captured_queries) == 9
+    assert len(resp.json['data']) == 32
+    assert [s['datetime'] for s in resp.json['data'] if s['disabled'] is True] == [
+        '%s 09:00:00' % tomorrow_str,
+        '%s 09:30:00' % tomorrow_str,
+        '%s 10:00:00' % tomorrow_str,
+        '%s 10:30:00' % tomorrow_str,
+        '%s 11:00:00' % tomorrow_str,
+    ]
+
+    # no resources to book
+    api_url = '/api/agenda/%s/meetings/%s/datetimes/?resources=' % (agenda.slug, meeting_type.slug,)
+    resp = app.get(api_url)
+    assert len(resp.json['data']) == 32
+    assert [s['datetime'] for s in resp.json['data'] if s['disabled'] is True] == []
+    api_url = '/api/agenda/%s/meetings/%s/datetimes/' % (agenda.slug, meeting_type.slug,)
+    resp = app.get(api_url)
+    assert len(resp.json['data']) == 32
+    assert [s['datetime'] for s in resp.json['data'] if s['disabled'] is True] == []
+
+    # event 3 is booked without resource, only one desk
+    event3.desk = desk
+    event3.save()
+    event3.resources.clear()
+    desk2.delete()
+    api_url = '/api/agenda/%s/meetings/%s/datetimes/' % (agenda.slug, meeting_type.slug,)
+    resp = app.get(api_url)
+    assert len(resp.json['data']) == 32
+    assert [s['datetime'] for s in resp.json['data'] if s['disabled'] is True] == [
+        '%s 09:30:00' % tomorrow_str
+    ]
+    api_url = '/api/agenda/%s/meetings/%s/datetimes/?resources=%s' % (
+        agenda.slug,
+        meeting_type.slug,
+        resource1.slug,
+    )
+    resp = app.get(api_url)
+    assert len(resp.json['data']) == 32
+    assert [s['datetime'] for s in resp.json['data'] if s['disabled'] is True] == [
+        '%s 09:30:00' % tomorrow_str,
+        '%s 10:00:00' % tomorrow_str,
+        '%s 10:30:00' % tomorrow_str,
+        '%s 11:00:00' % tomorrow_str,
+    ]
+    api_url = '/api/agenda/%s/meetings/%s/datetimes/?resources=%s' % (
+        agenda.slug,
+        meeting_type.slug,
+        resource3.slug,
+    )
+    resp = app.get(api_url)
+    assert len(resp.json['data']) == 32
+    assert [s['datetime'] for s in resp.json['data'] if s['disabled'] is True] == [
+        '%s 09:30:00' % tomorrow_str
+    ]
+
+    # resource is unknown or not valid for this agenda
+    api_url = '/api/agenda/%s/meetings/%s/datetimes/?resources=foobarbaz' % (agenda.slug, meeting_type.slug,)
+    resp = app.get(api_url, status=400)
+    assert resp.json['err'] == 1
+    assert resp.json['reason'] == 'invalid resource: foobarbaz'  # legacy
+    assert resp.json['err_class'] == 'invalid resource: foobarbaz'
+    assert resp.json['err_desc'] == 'invalid resource: foobarbaz'
+    api_url = '/api/agenda/%s/meetings/%s/datetimes/?resources=%s,foobarbaz' % (
+        agenda.slug,
+        meeting_type.slug,
+        resource3.slug,
+    )
+    resp = app.get(api_url, status=400)
+    assert resp.json['err'] == 1
+    assert resp.json['reason'] == 'invalid resource: foobarbaz'  # legacy
+    assert resp.json['err_class'] == 'invalid resource: foobarbaz'
+    assert resp.json['err_desc'] == 'invalid resource: foobarbaz'
+    agenda.resources.remove(resource3)
+    resp = app.get(api_url, status=400)
+    assert resp.json['err'] == 1
+    assert resp.json['reason'] == 'invalid resource: foobarbaz, resource-3'  # legacy
+    assert resp.json['err_class'] == 'invalid resource: foobarbaz, resource-3'
+    assert resp.json['err_desc'] == 'invalid resource: foobarbaz, resource-3'
+    api_url = '/api/agenda/%s/meetings/%s/datetimes/?resources=%s' % (
+        agenda.slug,
+        meeting_type.slug,
+        resource3.slug,
+    )
+    resp = app.get(api_url, 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'
+
+    # if booking is canceled the resource is free
+    booking_r1.cancel()
+    api_url = '/api/agenda/%s/meetings/%s/datetimes/?resources=%s' % (
+        agenda.slug,
+        meeting_type.slug,
+        resource1.slug,
+    )
+    resp = app.get(api_url)
+    assert len(resp.json['data']) == 32
+    assert [s['datetime'] for s in resp.json['data'] if s['disabled'] is True] == [
+        '%s 09:30:00' % tomorrow_str
+    ]
+
+
 def test_datetimes_api_meetings_agenda_short_time_periods(app, meetings_agenda, user):
     meetings_agenda.minimal_booking_delay = 0
     meetings_agenda.maximal_booking_delay = 10
-- 
2.20.1

