From f64bb487a2015f9be0533a986aa4fcbc331dd6e8 Mon Sep 17 00:00:00 2001
From: Emmanuel Cazenave <ecazenave@entrouvert.com>
Date: Wed, 26 Feb 2020 18:18:15 +0100
Subject: [PATCH 8/8] api: restrict slots with exluded timeperiods (#40058)

---
 chrono/agendas/models.py |  41 ++++++++++++++
 chrono/api/views.py      |  26 +++++----
 tests/test_agendas.py    |  90 ++++++++++++++++++++++++++++++
 tests/test_api.py        | 117 +++++++++++++++++++++++++++++++++++++++
 4 files changed, 263 insertions(+), 11 deletions(-)

diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py
index 09f958a..a8e6177 100644
--- a/chrono/agendas/models.py
+++ b/chrono/agendas/models.py
@@ -333,6 +333,47 @@ class TimePeriod(models.Model):
             'end_time': self.end_time.strftime('%H:%M'),
         }
 
+    def get_effective_timeperiods(self, excluded_timeperiods):
+        effective_timeperiods = [self]
+        for excluded_timeperiod in excluded_timeperiods:
+            res = []
+            for effective_timeperiod in effective_timeperiods:
+                if (
+                    excluded_timeperiod.weekday != effective_timeperiod.weekday
+                    or excluded_timeperiod.start_time >= effective_timeperiod.end_time
+                    or excluded_timeperiod.end_time <= effective_timeperiod.start_time
+                ):
+                    res.append(effective_timeperiod)
+                    continue
+                if (
+                    excluded_timeperiod.start_time <= effective_timeperiod.start_time
+                    and excluded_timeperiod.end_time >= effective_timeperiod.end_time
+                ):
+                    # completely exclude
+                    continue
+                if excluded_timeperiod.start_time > effective_timeperiod.start_time:
+                    res.append(
+                        TimePeriod(
+                            weekday=effective_timeperiod.weekday,
+                            start_time=effective_timeperiod.start_time,
+                            end_time=excluded_timeperiod.start_time,
+                            desk=effective_timeperiod.desk,
+                        )
+                    )
+                if excluded_timeperiod.end_time < effective_timeperiod.end_time:
+                    res.append(
+                        TimePeriod(
+                            weekday=effective_timeperiod.weekday,
+                            start_time=excluded_timeperiod.end_time,
+                            end_time=effective_timeperiod.end_time,
+                            desk=effective_timeperiod.desk,
+                        )
+                    )
+
+            effective_timeperiods = res
+
+        return effective_timeperiods
+
     def get_time_slots(self, min_datetime, max_datetime, meeting_type):
         meeting_duration = datetime.timedelta(minutes=meeting_type.duration)
         duration = datetime.timedelta(minutes=self.desk.agenda.get_base_meeting_duration())
diff --git a/chrono/api/views.py b/chrono/api/views.py
index 33583ae..98b208a 100644
--- a/chrono/api/views.py
+++ b/chrono/api/views.py
@@ -76,6 +76,7 @@ def get_all_slots(agenda, meeting_type):
     base_date = now().date()
 
     agendas = agenda.get_real_agendas()
+    base_agenda = agenda
 
     open_slots = {}
     for agenda in agendas:
@@ -88,17 +89,20 @@ def get_all_slots(agenda, meeting_type):
         if used_time_period_filters['max_datetime'] is None:
             used_time_period_filters['max_datetime'] = get_max_datetime(agenda)
 
-        for time_period in TimePeriod.objects.filter(desk__agenda=agenda):
-            duration = (
-                datetime.datetime.combine(base_date, time_period.end_time)
-                - datetime.datetime.combine(base_date, time_period.start_time)
-            ).seconds / 60
-            if duration < meeting_type.duration:
-                # skip time period that can't even hold a single meeting
-                continue
-            for slot in time_period.get_time_slots(**used_time_period_filters):
-                slot.full = False
-                open_slots[agenda][time_period.desk_id].add(slot.start_datetime, slot.end_datetime, slot)
+        for raw_time_period in TimePeriod.objects.filter(desk__agenda=agenda):
+            for time_period in raw_time_period.get_effective_timeperiods(
+                base_agenda.excluded_timeperiods.all()
+            ):
+                duration = (
+                    datetime.datetime.combine(base_date, time_period.end_time)
+                    - datetime.datetime.combine(base_date, time_period.start_time)
+                ).seconds / 60
+                if duration < meeting_type.duration:
+                    # skip time period that can't even hold a single meeting
+                    continue
+                for slot in time_period.get_time_slots(**used_time_period_filters):
+                    slot.full = False
+                    open_slots[agenda][time_period.desk_id].add(slot.start_datetime, slot.end_datetime, slot)
 
     # remove excluded slot
     for agenda in agendas:
diff --git a/tests/test_agendas.py b/tests/test_agendas.py
index 4df582d..03d28b5 100644
--- a/tests/test_agendas.py
+++ b/tests/test_agendas.py
@@ -16,6 +16,7 @@ from chrono.agendas.models import (
     Event,
     ICSError,
     MeetingType,
+    TimePeriod,
     TimePeriodException,
     TimePeriodExceptionSource,
     VirtualMember,
@@ -594,3 +595,92 @@ def test_virtual_agenda_base_meeting_duration():
     meeting_type = MeetingType(agenda=agenda2, label='Bar', duration=60)
     meeting_type.save()
     assert virt_agenda.get_base_meeting_duration() == 60
+
+
+def test_get_effective_timeperiods():
+    time_period = TimePeriod(weekday=0, start_time=datetime.time(10, 0), end_time=datetime.time(18, 0))
+    # empty exclusion set
+    effective_timeperiods = time_period.get_effective_timeperiods(TimePeriod.objects.none())
+    assert len(effective_timeperiods) == 1
+    effective_timeperiod = effective_timeperiods[0]
+    assert effective_timeperiod.weekday == time_period.weekday
+    assert effective_timeperiod.start_time == time_period.start_time
+    assert effective_timeperiod.end_time == time_period.end_time
+
+    # exclusions are on a different day
+    excluded_timeperiods = [
+        TimePeriod(weekday=1, start_time=datetime.time(17, 0), end_time=datetime.time(18, 0)),
+        TimePeriod(weekday=2, start_time=datetime.time(17, 0), end_time=datetime.time(18, 0)),
+    ]
+    effective_timeperiods = time_period.get_effective_timeperiods(excluded_timeperiods)
+    assert len(effective_timeperiods) == 1
+    effective_timeperiod = effective_timeperiods[0]
+    assert effective_timeperiod.weekday == time_period.weekday
+    assert effective_timeperiod.start_time == time_period.start_time
+    assert effective_timeperiod.end_time == time_period.end_time
+
+    # one exclusion, end_time should be earlier
+    excluded_timeperiods = [
+        TimePeriod(weekday=0, start_time=datetime.time(17, 0), end_time=datetime.time(18, 0))
+    ]
+    effective_timeperiods = time_period.get_effective_timeperiods(excluded_timeperiods)
+    assert len(effective_timeperiods) == 1
+    effective_timeperiod = effective_timeperiods[0]
+    assert effective_timeperiod.weekday == time_period.weekday
+    assert effective_timeperiod.start_time == datetime.time(10, 0)
+    assert effective_timeperiod.end_time == datetime.time(17, 0)
+
+    # one exclusion, start_time should be later
+    excluded_timeperiods = [
+        TimePeriod(weekday=0, start_time=datetime.time(10, 0), end_time=datetime.time(16, 0))
+    ]
+    effective_timeperiods = time_period.get_effective_timeperiods(excluded_timeperiods)
+    assert len(effective_timeperiods) == 1
+    effective_timeperiod = effective_timeperiods[0]
+    assert effective_timeperiod.weekday == time_period.weekday
+    assert effective_timeperiod.start_time == datetime.time(16, 0)
+    assert effective_timeperiod.end_time == datetime.time(18, 0)
+
+    # one exclusion, splits effective timeperiod in two
+    excluded_timeperiods = [
+        TimePeriod(weekday=0, start_time=datetime.time(12, 0), end_time=datetime.time(16, 0))
+    ]
+    effective_timeperiods = time_period.get_effective_timeperiods(excluded_timeperiods)
+    assert len(effective_timeperiods) == 2
+    effective_timeperiod = effective_timeperiods[0]
+    assert effective_timeperiod.weekday == time_period.weekday
+    assert effective_timeperiod.start_time == datetime.time(10, 0)
+    assert effective_timeperiod.end_time == datetime.time(12, 0)
+    effective_timeperiod = effective_timeperiods[1]
+    assert effective_timeperiod.weekday == time_period.weekday
+    assert effective_timeperiod.start_time == datetime.time(16, 0)
+    assert effective_timeperiod.end_time == datetime.time(18, 0)
+
+    # several exclusion, splits effective timeperiod into pieces
+    excluded_timeperiods = [
+        TimePeriod(weekday=0, start_time=datetime.time(12, 0), end_time=datetime.time(13, 0)),
+        TimePeriod(weekday=0, start_time=datetime.time(10, 30), end_time=datetime.time(11, 30)),
+        TimePeriod(weekday=0, start_time=datetime.time(16, 30), end_time=datetime.time(17, 00)),
+    ]
+    effective_timeperiods = time_period.get_effective_timeperiods(excluded_timeperiods)
+    assert len(effective_timeperiods) == 4
+
+    effective_timeperiod = effective_timeperiods[0]
+    assert effective_timeperiod.weekday == time_period.weekday
+    assert effective_timeperiod.start_time == datetime.time(10, 0)
+    assert effective_timeperiod.end_time == datetime.time(10, 30)
+
+    effective_timeperiod = effective_timeperiods[1]
+    assert effective_timeperiod.weekday == time_period.weekday
+    assert effective_timeperiod.start_time == datetime.time(11, 30)
+    assert effective_timeperiod.end_time == datetime.time(12, 0)
+
+    effective_timeperiod = effective_timeperiods[2]
+    assert effective_timeperiod.weekday == time_period.weekday
+    assert effective_timeperiod.start_time == datetime.time(13, 0)
+    assert effective_timeperiod.end_time == datetime.time(16, 30)
+
+    effective_timeperiod = effective_timeperiods[3]
+    assert effective_timeperiod.weekday == time_period.weekday
+    assert effective_timeperiod.start_time == datetime.time(17, 0)
+    assert effective_timeperiod.end_time == datetime.time(18, 0)
diff --git a/tests/test_api.py b/tests/test_api.py
index cc06ac5..3a0c000 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -2505,6 +2505,123 @@ def test_virtual_agendas_meetings_datetimes_delays_api(app, mock_now):
     assert len(resp.json['data']) == 12
 
 
+def test_virtual_agendas_meetings_datetimes_exluded_periods(app, mock_now):
+    foo_agenda = Agenda.objects.create(label='Foo Meeting', kind='meetings', maximal_booking_delay=7)
+    MeetingType.objects.create(agenda=foo_agenda, label='Meeting Type', duration=30)
+    foo_desk_1 = Desk.objects.create(agenda=foo_agenda, label='Foo desk 1')
+    TimePeriod.objects.create(
+        weekday=0, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0), desk=foo_desk_1,
+    )
+    TimePeriod.objects.create(
+        weekday=1, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0), desk=foo_desk_1,
+    )
+    virt_agenda = Agenda.objects.create(label='Virtual Agenda', kind='virtual')
+    VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=foo_agenda)
+
+    api_url = '/api/agenda/%s/meetings/meeting-type/datetimes/' % (virt_agenda.slug)
+    resp = app.get(api_url)
+    # 8 slots
+    data = resp.json['data']
+    assert len(data) == 8
+    assert data[0]['datetime'] == '2017-05-22 10:00:00'
+    assert data[1]['datetime'] == '2017-05-22 10:30:00'
+    assert data[2]['datetime'] == '2017-05-22 11:00:00'
+
+    # exclude one hour the first day
+    tp1 = TimePeriod.objects.create(
+        weekday=0, start_time=datetime.time(11, 0), end_time=datetime.time(12, 0), agenda=virt_agenda
+    )
+    resp = app.get(api_url)
+    data = resp.json['data']
+    assert len(data) == 6
+    assert data[0]['datetime'] == '2017-05-22 10:00:00'
+    assert data[1]['datetime'] == '2017-05-22 10:30:00'
+    # no more slots the 22 thanks to the exclusion period
+    assert data[2]['datetime'] == '2017-05-23 10:00:00'
+
+    # exclude the second day
+    tp2 = TimePeriod.objects.create(
+        weekday=1, start_time=datetime.time(9, 0), end_time=datetime.time(18, 0), agenda=virt_agenda
+    )
+    resp = app.get(api_url)
+    data = resp.json['data']
+    assert len(data) == 2
+    assert data[0]['datetime'] == '2017-05-22 10:00:00'
+    assert data[1]['datetime'] == '2017-05-22 10:30:00'
+
+    # go back to no restriction
+    tp1.delete()
+    tp2.delete()
+    resp = app.get(api_url)
+    data = resp.json['data']
+    assert len(data) == 8
+
+    # excluded period applies to every desk
+    foo_desk_2 = Desk.objects.create(agenda=foo_agenda, label='Foo desk 2')
+    TimePeriod.objects.create(
+        weekday=3, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0), desk=foo_desk_2,
+    )
+    TimePeriod.objects.create(
+        weekday=4, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0), desk=foo_desk_2,
+    )
+    resp = app.get(api_url)
+    data = resp.json['data']
+    assert len(data) == 16
+
+    # exclude one hour the first day
+    tp1 = TimePeriod.objects.create(
+        weekday=0, start_time=datetime.time(11, 0), end_time=datetime.time(12, 0), agenda=virt_agenda
+    )
+    resp = app.get(api_url)
+    data = resp.json['data']
+    assert len(data) == 14
+
+    # exclude one hour the last day
+    tp2 = TimePeriod.objects.create(
+        weekday=4, start_time=datetime.time(11, 0), end_time=datetime.time(12, 0), agenda=virt_agenda
+    )
+    resp = app.get(api_url)
+    data = resp.json['data']
+    assert len(data) == 12
+
+    # go back to no restriction
+    tp1.delete()
+    tp2.delete()
+    resp = app.get(api_url)
+    data = resp.json['data']
+    assert len(data) == 16
+
+    # add a second real agenda
+    bar_agenda = Agenda.objects.create(label='Bar Meeting', kind='meetings', maximal_booking_delay=7)
+    VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=bar_agenda)
+    MeetingType.objects.create(agenda=bar_agenda, label='Meeting Type', duration=30)
+    bar_desk_1 = Desk.objects.create(agenda=bar_agenda, label='Bar desk 1')
+    bar_desk_2 = Desk.objects.create(agenda=bar_agenda, label='Bar desk 2')
+    TimePeriod.objects.create(
+        weekday=0, start_time=datetime.time(14, 0), end_time=datetime.time(16, 0), desk=bar_desk_1,
+    )
+    TimePeriod.objects.create(
+        weekday=1, start_time=datetime.time(14, 0), end_time=datetime.time(16, 0), desk=bar_desk_1,
+    )
+    TimePeriod.objects.create(
+        weekday=2, start_time=datetime.time(14, 0), end_time=datetime.time(16, 0), desk=bar_desk_2,
+    )
+    TimePeriod.objects.create(
+        weekday=3, start_time=datetime.time(14, 0), end_time=datetime.time(16, 0), desk=bar_desk_2,
+    )
+    resp = app.get(api_url)
+    data = resp.json['data']
+    assert len(data) == 32
+
+    # exclude the first day, 11 to 15 : 4 slots
+    tp1 = TimePeriod.objects.create(
+        weekday=0, start_time=datetime.time(11, 0), end_time=datetime.time(15, 0), agenda=virt_agenda
+    )
+    resp = app.get(api_url)
+    data = resp.json['data']
+    assert len(data) == 28
+
+
 def test_virtual_agendas_meetings_exception(app, user, virtual_meetings_agenda):
     app.authorization = ('Basic', ('john.doe', 'password'))
     real_agenda = virtual_meetings_agenda.real_agendas.first()
-- 
2.20.1

