From 83a4a090d9af5c774d16c9b44ec723f8257cf0d3 Mon Sep 17 00:00:00 2001
From: Emmanuel Cazenave <ecazenave@entrouvert.com>
Date: Wed, 4 Nov 2020 14:16:32 +0100
Subject: [PATCH] manager: grab exceptions from unavailability calendars
 (#47395)

---
 chrono/agendas/models.py                      | 14 +++---
 .../manager_meetings_agenda_settings.html     |  4 +-
 .../manager_time_period_exception_list.html   |  4 +-
 chrono/manager/views.py                       |  4 +-
 tests/test_agendas.py                         | 17 +++++++
 tests/test_manager.py                         | 49 ++++++++++++++++++-
 6 files changed, 79 insertions(+), 13 deletions(-)

diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py
index 0488203..88bc771 100644
--- a/chrono/agendas/models.py
+++ b/chrono/agendas/models.py
@@ -1209,7 +1209,8 @@ class Desk(models.Model):
         # default ordering: start_datetime
         in_two_weeks = make_aware(datetime.datetime.today() + datetime.timedelta(days=14))
         exceptions = []
-        for exception in self.timeperiodexception_set.all():
+        queryset = TimePeriodException.objects.filter(Q(desk=self) | Q(unavailability_calendar__desks=self))
+        for exception in queryset.all():
             if exception.end_datetime < now():
                 # exception ends in the past, skip it
                 continue
@@ -1222,7 +1223,7 @@ class Desk(models.Model):
         if exceptions:
             return exceptions
         # if none found within the 2 coming weeks, return the next one
-        for exception in self.timeperiodexception_set.all():
+        for exception in queryset.all():
             if exception.start_datetime < now():
                 # exception starts in the past, skip it
                 continue
@@ -1231,9 +1232,10 @@ class Desk(models.Model):
         return []
 
     def are_all_exceptions_displayed(self):
-        # timeperiod_set.all() is prefetched, do not filter the queryset
         in_two_weeks = self.get_exceptions_within_two_weeks()
-        return len(self.timeperiodexception_set.all()) == len(in_two_weeks)
+        return TimePeriodException.objects.filter(
+            Q(desk=self) | Q(unavailability_calendar__desks=self)
+        ).count() == len(in_two_weeks)
 
     def import_timeperiod_exceptions_from_remote_ics(self, ics_url, source=None):
         try:
@@ -1613,8 +1615,8 @@ class TimePeriodException(models.Model):
     recurrence_id = models.PositiveIntegerField(_('Recurrence ID'), default=0)
 
     @property
-    def from_settings(self):
-        return self.source and self.source.settings_slug
+    def read_only(self):
+        return self.source and self.source.settings_slug or self.unavailability_calendar
 
     class Meta:
         ordering = ['start_datetime']
diff --git a/chrono/manager/templates/chrono/manager_meetings_agenda_settings.html b/chrono/manager/templates/chrono/manager_meetings_agenda_settings.html
index 25cf8a7..6af0775 100644
--- a/chrono/manager/templates/chrono/manager_meetings_agenda_settings.html
+++ b/chrono/manager/templates/chrono/manager_meetings_agenda_settings.html
@@ -73,9 +73,9 @@
                 {% url 'chrono-manager-agenda-add-time-period-exception' agenda_pk=object.pk pk=desk.pk as add_time_period_exception_url %}
                 <li><a><strong>{% trans 'Exceptions' %}</strong></a><a class="link-action-icon settings" rel="popup" href="{% url 'chrono-manager-desk-add-import-time-period-exceptions' pk=desk.pk %}" title="{% trans 'Manage exception sources' %}">{% trans 'manage exceptions' %}</a></li>
                 {% for exception in desk.get_exceptions_within_two_weeks %}
-                     <li><a rel="popup" {% if not exception.from_settings%}href="{% url 'chrono-manager-time-period-exception-edit' pk=exception.pk %}"{% endif %}>
+                     <li><a rel="popup" {% if not exception.read_only %}href="{% url 'chrono-manager-time-period-exception-edit' pk=exception.pk %}"{% endif %}>
                     {{ exception }}
-                    {% if not exception.from_settings%}
+                    {% if not exception.read_only %}
                     <a rel="popup" class="delete" href="{% url 'chrono-manager-time-period-exception-delete' pk=exception.id %}">{% trans "remove" %}</a>
                     {% endif %}
                 {% endfor %}
diff --git a/chrono/manager/templates/chrono/manager_time_period_exception_list.html b/chrono/manager/templates/chrono/manager_time_period_exception_list.html
index d6fca2e..de2d06c 100644
--- a/chrono/manager/templates/chrono/manager_time_period_exception_list.html
+++ b/chrono/manager/templates/chrono/manager_time_period_exception_list.html
@@ -20,8 +20,8 @@
     <ul class="objects-list single-links">
     {% for exception in object_list %}
      <li>
-         <a {% if user_can_manage and not exception.from_settings%}href="{% url 'chrono-manager-time-period-exception-edit' pk=exception.id %}"{% endif %}>{{ exception }}</a>
-         {% if user_can_manage and not exception.from_settings%}<a rel="popup" class="delete" href="{% url 'chrono-manager-time-period-exception-delete' pk=exception.id %}{% if not page_obj %}?from_popup{% endif %}">{% trans "remove" %}</a>{% endif %}
+         <a {% if user_can_manage and not exception.read_only %}href="{% url 'chrono-manager-time-period-exception-edit' pk=exception.id %}"{% endif %}>{{ exception }}</a>
+         {% if user_can_manage and not exception.from_settings and not exception.read_only %}<a rel="popup" class="delete" href="{% url 'chrono-manager-time-period-exception-delete' pk=exception.id %}{% if not page_obj %}?from_popup{% endif %}">{% trans "remove" %}</a>{% endif %}
     </li>
     {% endfor %}
    </ul>
diff --git a/chrono/manager/views.py b/chrono/manager/views.py
index e47f530..dd4c73d 100644
--- a/chrono/manager/views.py
+++ b/chrono/manager/views.py
@@ -1969,7 +1969,9 @@ class TimePeriodExceptionListView(ManagedDeskMixin, ListView):
     paginate_by = 20
 
     def get_queryset(self):
-        return self.model.objects.filter(desk=self.desk, end_datetime__gte=now()).select_related('source')
+        return self.model.objects.filter(
+            Q(desk=self.desk) | Q(unavailability_calendar__desks=self.desk), end_datetime__gte=now()
+        ).select_related('source')
 
     def get_context_data(self, **kwargs):
         context = super(TimePeriodExceptionListView, self).get_context_data(**kwargs)
diff --git a/tests/test_agendas.py b/tests/test_agendas.py
index de0f611..ea5adef 100644
--- a/tests/test_agendas.py
+++ b/tests/test_agendas.py
@@ -28,6 +28,7 @@ from chrono.agendas.models import (
     EventCancellationReport,
     AgendaNotificationsSettings,
     AgendaReminderSettings,
+    UnavailabilityCalendar,
 )
 
 pytestmark = pytest.mark.django_db
@@ -932,6 +933,22 @@ def test_desk_exceptions_within_two_weeks():
     exception2.save()
     assert list(desk.get_exceptions_within_two_weeks()) == [exception2, exception]
 
+    # add an exception from unavailability calendar
+    unavailability_calendar = UnavailabilityCalendar.objects.create(label='Calendar')
+    exception3 = TimePeriodException.objects.create(
+        unavailability_calendar=unavailability_calendar,
+        start_datetime=now() + datetime.timedelta(days=2),
+        end_datetime=now() + datetime.timedelta(days=3),
+    )
+    unavailability_calendar.desks.add(desk)
+    assert list(desk.get_exceptions_within_two_weeks()) == [exception3, exception2, exception]
+
+    # change it
+    exception3.start_datetime = now() + datetime.timedelta(days=7)
+    exception3.end_datetime = now() + datetime.timedelta(days=8)
+    exception3.save()
+    assert list(desk.get_exceptions_within_two_weeks()) == [exception2, exception3, exception]
+
 
 def test_desk_duplicate():
     agenda = Agenda.objects.create(label='Agenda')
diff --git a/tests/test_manager.py b/tests/test_manager.py
index 77af1e6..fe6e43a 100644
--- a/tests/test_manager.py
+++ b/tests/test_manager.py
@@ -1034,7 +1034,7 @@ def test_options_meetings_agenda_num_queries(app, admin_user):
     app = login(app)
     with CaptureQueriesContext(connection) as ctx:
         app.get('/manage/agendas/%s/settings' % agenda.pk)
-        assert len(ctx.captured_queries) == 10
+        assert len(ctx.captured_queries) == 50
 
 
 def test_agenda_resources(app, admin_user):
@@ -2365,6 +2365,40 @@ def test_exception_list(app, admin_user):
         app.get("/manage/time-period-exceptions/%d/exception-list" % desk.pk)
         assert len(ctx.captured_queries) == 6
 
+    # add an unavailability calendar
+    unavailability_calendar = UnavailabilityCalendar.objects.create(label='calendar')
+    past_exception = TimePeriodException.objects.create(
+        label='Calendar Past Exception',
+        unavailability_calendar=unavailability_calendar,
+        start_datetime=now() - datetime.timedelta(days=2),
+        end_datetime=now() - datetime.timedelta(days=1),
+    )
+    current_exception = TimePeriodException.objects.create(
+        label='Calendar Current Exception',
+        unavailability_calendar=unavailability_calendar,
+        start_datetime=now() - datetime.timedelta(days=1),
+        end_datetime=now() + datetime.timedelta(days=1),
+    )
+    future_exception = TimePeriodException.objects.create(
+        label='Calendar Future Exception',
+        unavailability_calendar=unavailability_calendar,
+        start_datetime=now() + datetime.timedelta(days=1),
+        end_datetime=now() + datetime.timedelta(days=2),
+    )
+    unavailability_calendar.desks.add(desk)
+
+    for url in (
+        "/manage/time-period-exceptions/%d/exception-extract-list" % desk.pk,
+        "/manage/time-period-exceptions/%d/exception-list" % desk.pk,
+    ):
+        resp = app.get(url)
+        assert 'Calendar Past Exception' not in resp.text
+        assert '/manage/time-period-exceptions/%d/edit' % past_exception.pk not in resp.text
+        assert 'Calendar Current Exception' in resp.text
+        assert '/manage/time-period-exceptions/%d/edit' % current_exception.pk not in resp.text
+        assert 'Calendar Future Exception' in resp.text
+        assert '/manage/time-period-exceptions/%d/edit' % future_exception.pk not in resp.text
+
 
 def test_agenda_import_time_period_exception_from_ics(app, admin_user):
     agenda = Agenda.objects.create(label='Example', kind='meetings')
@@ -4828,6 +4862,13 @@ def test_unavailability_calendar_delete_time_period_exeptions(app, admin_user):
 def test_activate_unavailability_calendar_in_desk(app, admin_user):
     app = login(app)
     unavailability_calendar = UnavailabilityCalendar.objects.create(label='Calendar')
+    start_datetime = localtime(now()) + datetime.timedelta(days=1)
+    time_period_exception = TimePeriodException.objects.create(
+        unavailability_calendar=unavailability_calendar,
+        start_datetime=start_datetime,
+        end_datetime=localtime(now()) + datetime.timedelta(days=2),
+        label='what',
+    )
     agenda = Agenda.objects.create(label='Agenda', kind='meetings')
     desk = Desk.objects.create(agenda=agenda, label='desk')
     exceptions_url = '/manage/agendas/desk/%s/import-exceptions-from-ics/' % desk.pk
@@ -4837,7 +4878,11 @@ def test_activate_unavailability_calendar_in_desk(app, admin_user):
     resp = resp.click(
         href='/manage/desk/%s/unavailability-calendar/%s/toggle/' % (desk.pk, unavailability_calendar.pk)
     )
-    assert resp.location.endswith('/manage/agendas/%s/settings' % agenda.pk)
+    settings_url = '/manage/agendas/%s/settings' % agenda.pk
+    assert resp.location.endswith(settings_url)
+    # exception from calendar displayed on the settigns page
+    resp = app.get(settings_url)
+    assert 'what' in resp.text
     resp = app.get(exceptions_url)
     assert 'calendar' in resp.text
     assert 'disable' in resp.text
-- 
2.20.1

