From bdc82565930e16c998054139a95c174e18d1d10d Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Thu, 25 Feb 2021 09:57:26 +0100
Subject: [PATCH 3/3] api: exclude slots already booked by user - meetings
 (#51341)

---
 chrono/api/views.py |  42 +++++++++++++-
 tests/test_api.py   | 138 ++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 178 insertions(+), 2 deletions(-)

diff --git a/chrono/api/views.py b/chrono/api/views.py
index 7ad8242..303a4c3 100644
--- a/chrono/api/views.py
+++ b/chrono/api/views.py
@@ -90,7 +90,13 @@ TimeSlot = collections.namedtuple('TimeSlot', ['start_datetime', 'end_datetime',
 
 
 def get_all_slots(
-    base_agenda, meeting_type, resources=None, unique=False, start_datetime=None, end_datetime=None
+    base_agenda,
+    meeting_type,
+    resources=None,
+    unique=False,
+    start_datetime=None,
+    end_datetime=None,
+    excluded_user_external_id=None,
 ):
     """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.
@@ -243,6 +249,32 @@ def get_all_slots(
             for event_start_datetime, event_duration in booked_events
         )
 
+    # aggregate already booked time intervals by excluded_user_external_id
+    user_bookings = IntervalSet()
+    if excluded_user_external_id:
+        used_min_datetime, used_max_datetime = (
+            min([v[0] for v in agenda_id_min_max_datetime.values()]),
+            max([v[1] for v in agenda_id_min_max_datetime.values()]),
+        )
+        booked_events = (
+            Event.objects.filter(
+                agenda__in=agenda_ids,
+                start_datetime__gte=used_min_datetime,
+                start_datetime__lte=used_max_datetime + meeting_duration_td,
+                booking__user_external_id=excluded_user_external_id,
+            )
+            .exclude(booking__cancellation_datetime__isnull=False)
+            # ordering is important for the later groupby, it works like sort | uniq
+            .order_by('start_datetime', 'meeting_type__duration')
+            .values_list('start_datetime', 'meeting_type__duration')
+        )
+        # compute exclusion set by desk from all bookings, using
+        # itertools.groupby() to group them by desk_id
+        user_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 = (
@@ -290,8 +322,11 @@ def get_all_slots(
                     # slot is full if an already booked event overlaps it
                     # check resources first
                     booked = resources_bookings.overlaps(start_datetime, end_datetime)
+                    # then check user boookings
+                    if not booked:
+                        booked = user_bookings.overlaps(start_datetime, end_datetime)
+                    # then bookings if resources are free
                     if not booked:
-                        # then bookings if resources are free
                         booked = desk.id in bookings and bookings[desk.id].overlaps(
                             start_datetime, end_datetime
                         )
@@ -624,6 +659,8 @@ class MeetingDatetimes(APIView):
                     http_status=status.HTTP_400_BAD_REQUEST,
                 )
 
+        user_external_id = request.GET.get('exclude_user_external_id') or None
+
         # 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
@@ -645,6 +682,7 @@ class MeetingDatetimes(APIView):
                     unique=True,
                     start_datetime=start_datetime,
                     end_datetime=end_datetime,
+                    excluded_user_external_id=user_external_id,
                 )
             )
             for slot in sorted(all_slots, key=lambda slot: slot[:3]):
diff --git a/tests/test_api.py b/tests/test_api.py
index 9314d32..d8e4d85 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -986,6 +986,66 @@ def test_datetimes_api_meetings_agenda_short_time_periods(app, meetings_agenda,
     assert resp.json['err_desc'] == 'no more desk available'
 
 
+@pytest.mark.freeze_time('2021-02-25')
+def test_datetimes_api_meetings_agenda_exclude_slots(app):
+    tomorrow = now() + datetime.timedelta(days=1)
+    agenda = Agenda.objects.create(
+        label='Agenda', kind='meetings', minimal_booking_delay=0, maximal_booking_delay=10
+    )
+    desk = Desk.objects.create(agenda=agenda, slug='desk')
+    meeting_type = MeetingType.objects.create(agenda=agenda, slug='foo-bar')
+    TimePeriod.objects.create(
+        weekday=tomorrow.date().weekday(),
+        start_time=datetime.time(9, 0),
+        end_time=datetime.time(17, 00),
+        desk=desk,
+    )
+    desk.duplicate()
+    event = Event.objects.create(
+        agenda=agenda,
+        meeting_type=meeting_type,
+        places=1,
+        start_datetime=localtime(tomorrow).replace(hour=9, minute=0),
+        desk=desk,
+    )
+    Booking.objects.create(event=event, user_external_id='42')
+    event2 = Event.objects.create(
+        agenda=agenda,
+        meeting_type=meeting_type,
+        places=1,
+        start_datetime=localtime(tomorrow).replace(hour=10, minute=0),
+        desk=desk,
+    )
+    cancelled = Booking.objects.create(event=event2, user_external_id='35')
+    cancelled.cancel()
+
+    resp = app.get('/api/agenda/%s/meetings/%s/datetimes/' % (agenda.slug, meeting_type.slug))
+    assert resp.json['data'][0]['id'] == 'foo-bar:2021-02-26-0900'
+    assert resp.json['data'][0]['disabled'] is False
+    assert resp.json['data'][2]['id'] == 'foo-bar:2021-02-26-1000'
+    assert resp.json['data'][2]['disabled'] is False
+
+    resp = app.get(
+        '/api/agenda/%s/meetings/%s/datetimes/' % (agenda.slug, meeting_type.slug),
+        params={'exclude_user_external_id': '35'},
+    )
+    assert resp.json['data'][0]['id'] == 'foo-bar:2021-02-26-0900'
+    assert resp.json['data'][0]['disabled'] is False
+    assert resp.json['data'][2]['id'] == 'foo-bar:2021-02-26-1000'
+    assert resp.json['data'][2]['disabled'] is False
+
+    with CaptureQueriesContext(connection) as ctx:
+        resp = app.get(
+            '/api/agenda/%s/meetings/%s/datetimes/' % (agenda.slug, meeting_type.slug),
+            params={'exclude_user_external_id': '42'},
+        )
+        assert len(ctx.captured_queries) == 9
+    assert resp.json['data'][0]['id'] == 'foo-bar:2021-02-26-0900'
+    assert resp.json['data'][0]['disabled'] is True
+    assert resp.json['data'][2]['id'] == 'foo-bar:2021-02-26-1000'
+    assert resp.json['data'][2]['disabled'] is False
+
+
 def test_booking_api(app, some_data, user):
     agenda = Agenda.objects.filter(label=u'Foo bar')[0]
     event = [x for x in Event.objects.filter(agenda=agenda) if x.in_bookable_period()][0]
@@ -4540,6 +4600,84 @@ def test_virtual_agendas_meetings_datetimes_multiple_agendas(app, mock_now):
     assert len(resp.json['data']) == 20
 
 
+@pytest.mark.freeze_time('2021-02-25')
+def test_virtual_agendas_meetings_datetimes_exclude_slots(app):
+    tomorrow = now() + datetime.timedelta(days=1)
+    agenda = Agenda.objects.create(
+        label='Agenda', kind='meetings', minimal_booking_delay=0, maximal_booking_delay=10
+    )
+    desk = Desk.objects.create(agenda=agenda, slug='desk')
+    meeting_type = MeetingType.objects.create(agenda=agenda, slug='foo-bar')
+    TimePeriod.objects.create(
+        weekday=tomorrow.date().weekday(),
+        start_time=datetime.time(9, 0),
+        end_time=datetime.time(17, 00),
+        desk=desk,
+    )
+    agenda2 = agenda.duplicate()
+    virt_agenda = Agenda.objects.create(
+        label='Virtual Agenda', kind='virtual', minimal_booking_delay=1, maximal_booking_delay=10
+    )
+    VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=agenda)
+    VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=agenda2)
+
+    event = Event.objects.create(
+        agenda=agenda,
+        meeting_type=meeting_type,
+        places=1,
+        start_datetime=localtime(tomorrow).replace(hour=9, minute=0),
+        desk=desk,
+    )
+    Booking.objects.create(event=event, user_external_id='42')
+    event2 = Event.objects.create(
+        agenda=agenda,
+        meeting_type=meeting_type,
+        places=1,
+        start_datetime=localtime(tomorrow).replace(hour=10, minute=0),
+        desk=desk,
+    )
+    cancelled = Booking.objects.create(event=event2, user_external_id='35')
+    cancelled.cancel()
+
+    resp = app.get('/api/agenda/%s/meetings/%s/datetimes/' % (virt_agenda.slug, meeting_type.slug))
+    assert resp.json['data'][0]['id'] == 'foo-bar:2021-02-26-0900'
+    assert resp.json['data'][0]['disabled'] is False
+    assert resp.json['data'][2]['id'] == 'foo-bar:2021-02-26-1000'
+    assert resp.json['data'][2]['disabled'] is False
+
+    resp = app.get(
+        '/api/agenda/%s/meetings/%s/datetimes/' % (virt_agenda.slug, meeting_type.slug),
+        params={'exclude_user_external_id': '35'},
+    )
+    assert resp.json['data'][0]['id'] == 'foo-bar:2021-02-26-0900'
+    assert resp.json['data'][0]['disabled'] is False
+    assert resp.json['data'][2]['id'] == 'foo-bar:2021-02-26-1000'
+    assert resp.json['data'][2]['disabled'] is False
+
+    with CaptureQueriesContext(connection) as ctx:
+        resp = app.get(
+            '/api/agenda/%s/meetings/%s/datetimes/' % (virt_agenda.slug, meeting_type.slug),
+            params={'exclude_user_external_id': '42'},
+        )
+        assert len(ctx.captured_queries) == 13
+    assert resp.json['data'][0]['id'] == 'foo-bar:2021-02-26-0900'
+    assert resp.json['data'][0]['disabled'] is True
+    assert resp.json['data'][2]['id'] == 'foo-bar:2021-02-26-1000'
+    assert resp.json['data'][2]['disabled'] is False
+
+    virt_agenda.minimal_booking_delay = None
+    virt_agenda.maximal_booking_delay = None
+    virt_agenda.save()
+    resp = app.get(
+        '/api/agenda/%s/meetings/%s/datetimes/' % (virt_agenda.slug, meeting_type.slug),
+        params={'exclude_user_external_id': '42'},
+    )
+    assert resp.json['data'][0]['id'] == 'foo-bar:2021-02-26-0900'
+    assert resp.json['data'][0]['disabled'] is True
+    assert resp.json['data'][2]['id'] == 'foo-bar:2021-02-26-1000'
+    assert resp.json['data'][2]['disabled'] is False
+
+
 def test_virtual_agendas_meetings_booking(app, mock_now, user):
     foo_agenda = Agenda.objects.create(
         label='Foo Meeting', kind='meetings', minimal_booking_delay=1, maximal_booking_delay=5
-- 
2.29.2

