From 2164202332fcf74fd6cde506d85a255d5c721415 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Mon, 1 Feb 2021 15:40:03 +0100
Subject: [PATCH 10/11] manager: simple desk management & sources (#48924)

---
 chrono/manager/forms.py |  22 +-
 chrono/manager/views.py | 137 ++++++++---
 tests/test_manager.py   | 499 +++++++++++++++++++++++++++++-----------
 3 files changed, 492 insertions(+), 166 deletions(-)

diff --git a/chrono/manager/forms.py b/chrono/manager/forms.py
index 890c726..156a1d6 100644
--- a/chrono/manager/forms.py
+++ b/chrono/manager/forms.py
@@ -636,11 +636,23 @@ class TimePeriodExceptionSourceReplaceForm(forms.ModelForm):
         fields = []
 
     def save(self, *args, **kwargs):
-        if bool(self.instance.ics_file):
-            self.instance.ics_file.delete()
-        self.instance.ics_file = self.cleaned_data['ics_newfile']
-        self.instance.ics_filename = self.cleaned_data['ics_newfile'].name
-        self.instance.save()
+        def store_source(source):
+            if bool(source.ics_file):
+                source.ics_file.delete()
+            source.ics_file = self.cleaned_data['ics_newfile']
+            source.ics_filename = self.cleaned_data['ics_newfile'].name
+            source.save()
+            self.cleaned_data['ics_newfile'].seek(0)
+
+        old_filename = self.instance.ics_filename
+        store_source(self.instance)
+        if self.instance.desk.agenda.desk_simple_management:
+            for desk in self.instance.desk.agenda.desk_set.exclude(pk=self.instance.desk_id):
+                source = desk.timeperiodexceptionsource_set.filter(ics_filename=old_filename).first()
+                if source is not None:
+                    store_source(source)
+
+        return self.instance
 
 
 class AgendasImportForm(forms.Form):
diff --git a/chrono/manager/views.py b/chrono/manager/views.py
index 181838a..9f54c95 100644
--- a/chrono/manager/views.py
+++ b/chrono/manager/views.py
@@ -1765,29 +1765,42 @@ class UnavailabilityCalendarToggleView(ManagedDeskMixin, DetailView):
 
     def get(self, request, *args, **kwargs):
         unavailability_calendar = self.get_object()
-        activate = False
+        enabled = False
         try:
             self.desk.unavailability_calendars.get(pk=unavailability_calendar.pk)
             self.desk.unavailability_calendars.remove(unavailability_calendar)
             message = _(
                 'Unavailability calendar %(unavailability_calendar)s has been disabled on desk %(desk)s.'
             )
-
         except UnavailabilityCalendar.DoesNotExist:
-            activate = True
+            enabled = True
             self.desk.unavailability_calendars.add(unavailability_calendar)
             message = _(
                 'Unavailability calendar %(unavailability_calendar)s has been enabled on desk %(desk)s.'
             )
+
+        if self.desk.agenda.desk_simple_management:
+            for desk in self.desk.agenda.desk_set.exclude(pk=self.desk.pk):
+                if enabled:
+                    desk.unavailability_calendars.add(unavailability_calendar)
+                    message = _('Unavailability calendar %(unavailability_calendar)s has been enabled.')
+                else:
+                    desk.unavailability_calendars.remove(unavailability_calendar)
+                    message = _('Unavailability calendar %(unavailability_calendar)s has been disabled.')
+
         messages.info(
             self.request, message % {'unavailability_calendar': unavailability_calendar, 'desk': self.desk}
         )
-        if activate:
+        if enabled:
             for exception in unavailability_calendar.timeperiodexception_set.all():
-                if exception.has_booking_within_time_slot(target_desk=self.desk):
-                    message = _('One or several bookings overlap with exceptions.')
-                    messages.warning(self.request, message)
-                    break
+                desks = [self.desk]
+                if self.desk.agenda.desk_simple_management:
+                    desks = self.desk.agenda.desk_set.all()
+                for desk in desks:
+                    if exception.has_booking_within_time_slot(target_desk=desk):
+                        message = _('One or several bookings overlap with exceptions.')
+                        messages.warning(self.request, message)
+                        break
 
         return HttpResponseRedirect(
             reverse('chrono-manager-agenda-settings', kwargs={'pk': self.desk.agenda_id})
@@ -2197,17 +2210,24 @@ class DeskImportTimePeriodExceptionsView(ManagedAgendaSubobjectMixin, UpdateView
         )
         return context
 
+    def import_file(self, desk, form):
+        if form.cleaned_data['ics_file']:
+            exceptions = desk.import_timeperiod_exceptions_from_ics_file(form.cleaned_data['ics_file'])
+            form.cleaned_data['ics_file'].seek(0)
+            return exceptions
+        elif form.cleaned_data['ics_url']:
+            return desk.import_timeperiod_exceptions_from_remote_ics(form.cleaned_data['ics_url'])
+
     def form_valid(self, form):
         exceptions = None
+        desk = self.get_object()
         try:
-            if form.cleaned_data['ics_file']:
-                exceptions = form.instance.import_timeperiod_exceptions_from_ics_file(
-                    form.cleaned_data['ics_file']
-                )
-            elif form.cleaned_data['ics_url']:
-                exceptions = form.instance.import_timeperiod_exceptions_from_remote_ics(
-                    form.cleaned_data['ics_url']
-                )
+            if desk.agenda.desk_simple_management:
+                for _desk in desk.agenda.desk_set.all():
+                    result = self.import_file(_desk, form)
+                    exceptions = result if exceptions is None else exceptions
+            else:
+                exceptions = self.import_file(desk, form)
         except ICSError as e:
             form.add_error(None, force_text(e))
             return self.form_invalid(form)
@@ -2231,6 +2251,24 @@ class TimePeriodExceptionSourceDeleteView(ManagedDeskSubobjectMixin, DeleteView)
     def get_success_url(self):
         return reverse('chrono-manager-agenda-settings', kwargs={'pk': self.desk.agenda_id})
 
+    def delete(self, request, *args, **kwargs):
+        source = self.get_object()
+        response = super().delete(request, *args, **kwargs)
+
+        if not source.desk.agenda.desk_simple_management:
+            return response
+
+        for desk in source.desk.agenda.desk_set.exclude(pk=source.desk_id):
+            if source.ics_filename:
+                queryset = desk.timeperiodexceptionsource_set.filter(ics_filename=source.ics_filename)
+            else:
+                queryset = desk.timeperiodexceptionsource_set.filter(ics_url=source.ics_url)
+            _source = queryset.first()
+            if _source is not None:
+                _source.delete()
+
+        return response
+
 
 time_period_exception_source_delete = TimePeriodExceptionSourceDeleteView.as_view()
 
@@ -2244,12 +2282,28 @@ class TimePeriodExceptionSourceReplaceView(ManagedDeskSubobjectMixin, UpdateView
         queryset = super(TimePeriodExceptionSourceReplaceView, self).get_queryset()
         return queryset.filter(ics_filename__isnull=False)
 
+    def import_file(self, desk, form):
+        exceptions = None
+        source = desk.timeperiodexceptionsource_set.filter(
+            ics_filename=self.get_object().ics_filename
+        ).first()
+        if source is not None:
+            exceptions = desk.import_timeperiod_exceptions_from_ics_file(
+                form.cleaned_data['ics_newfile'], source=source
+            )
+            form.cleaned_data['ics_newfile'].seek(0)
+        return exceptions
+
     def form_valid(self, form):
         exceptions = None
+        desk = self.get_object().desk
         try:
-            exceptions = form.instance.desk.import_timeperiod_exceptions_from_ics_file(
-                form.cleaned_data['ics_newfile'], source=form.instance
-            )
+            if desk.agenda.desk_simple_management:
+                for _desk in desk.agenda.desk_set.all():
+                    result = self.import_file(_desk, form)
+                    exceptions = result if exceptions is None else exceptions
+            else:
+                exceptions = self.import_file(desk, form)
         except ICSError as e:
             form.add_error(None, force_text(e))
             return self.form_invalid(form)
@@ -2273,24 +2327,35 @@ class TimePeriodExceptionSourceRefreshView(ManagedDeskSubobjectMixin, DetailView
         queryset = super(TimePeriodExceptionSourceRefreshView, self).get_queryset()
         return queryset.filter(ics_url__isnull=False)
 
+    def import_file(self, desk):
+        exceptions = None
+        source = desk.timeperiodexceptionsource_set.filter(ics_url=self.get_object().ics_url).first()
+        if source is not None:
+            exceptions = desk.import_timeperiod_exceptions_from_remote_ics(source.ics_url, source=source)
+        return exceptions
+
     def get(self, request, *args, **kwargs):
+        exceptions = None
+        desk = self.get_object().desk
         try:
-            source = self.get_object()
-            exceptions = source.desk.import_timeperiod_exceptions_from_remote_ics(
-                source.ics_url, source=source
-            )
+            if desk.agenda.desk_simple_management:
+                for _desk in desk.agenda.desk_set.all():
+                    result = self.import_file(_desk)
+                    exceptions = result if exceptions is None else exceptions
+            else:
+                exceptions = self.import_file(desk)
         except ICSError as e:
             messages.error(self.request, force_text(e))
-        else:
+
+        if exceptions is not None:
             message = ungettext(
                 'An exception has been imported.', '%(count)d exceptions have been imported.', exceptions
             )
             message = message % {'count': exceptions}
             messages.info(self.request, message)
+
         # redirect to settings
-        return HttpResponseRedirect(
-            reverse('chrono-manager-agenda-settings', kwargs={'pk': source.desk.agenda_id})
-        )
+        return HttpResponseRedirect(reverse('chrono-manager-agenda-settings', kwargs={'pk': desk.agenda_id}))
 
 
 time_period_exception_source_refresh = TimePeriodExceptionSourceRefreshView.as_view()
@@ -2424,12 +2489,30 @@ class TimePeriodExceptionSourceToggleView(ManagedDeskSubobjectMixin, DetailView)
 
     def get(self, request, *args, **kwargs):
         source = self.get_object()
+
         if source.enabled:
             source.disable()
+            was_enabled = False
             message = _('Exception source %(source)s has been disabled on desk %(desk)s.')
         else:
             source.enable()
+            was_enabled = True
             message = _('Exception source %(source)s has been enabled on desk %(desk)s.')
+
+        if self.desk.agenda.desk_simple_management:
+            for desk in self.desk.agenda.desk_set.exclude(pk=self.desk.pk):
+                _source = desk.timeperiodexceptionsource_set.filter(
+                    settings_slug=source.settings_slug
+                ).first()
+                if _source is None:
+                    continue
+                if was_enabled:
+                    _source.enable()
+                    message = _('Exception source %(source)s has been enabled.')
+                else:
+                    _source.disable()
+                    message = _('Exception source %(source)s has been disabled.')
+
         messages.info(self.request, message % {'source': source, 'desk': source.desk})
         return HttpResponseRedirect(
             reverse('chrono-manager-agenda-settings', kwargs={'pk': source.desk.agenda_id})
diff --git a/tests/test_manager.py b/tests/test_manager.py
index ad5f12f..83c5394 100644
--- a/tests/test_manager.py
+++ b/tests/test_manager.py
@@ -9,6 +9,7 @@ import mock
 import os
 
 from django.contrib.auth.models import User, Group
+from django.core.files.base import ContentFile
 from django.core.management import call_command
 from django.db import connection
 from django.test import override_settings
@@ -2879,16 +2880,14 @@ def test_exception_list(app, admin_user):
 def test_agenda_import_time_period_exception_from_ics(app, admin_user):
     agenda = Agenda.objects.create(label='Example', kind='meetings')
     desk = Desk.objects.create(agenda=agenda, label='Test Desk')
-    MeetingType.objects.create(agenda=agenda, label='Foo')
+    desk.duplicate()
     login(app)
     resp = app.get('/manage/agendas/%d/settings' % agenda.pk)
     assert 'Manage exception sources' in resp.text
-    resp = resp.click('manage exceptions')
+    resp = resp.click('manage exceptions', index=0)
     assert "To add new exceptions, you can upload a file or specify an address to a remote calendar." in resp
     resp = resp.form.submit(status=200)
     assert 'Please provide an ICS File or an URL.' in resp.text
-    resp = app.get('/manage/agendas/%d/settings' % agenda.pk)
-    resp = resp.click('manage exceptions')
     resp.form['ics_file'] = Upload('exceptions.ics', b'invalid content', 'text/calendar')
     resp = resp.form.submit(status=200)
     assert 'File format is invalid' in resp.text
@@ -2920,14 +2919,13 @@ DTEND:20180101
 SUMMARY:New Year's Eve
 END:VEVENT
 END:VCALENDAR"""
-    resp = app.get('/manage/agendas/%d/settings' % agenda.pk)
-    resp = resp.click('manage exceptions')
+    resp = app.get('/manage/agendas/desk/%s/import-exceptions-from-ics/' % desk.pk)
     resp.form['ics_file'] = Upload('exceptions.ics', ics_with_exceptions, 'text/calendar')
     resp = resp.form.submit(status=302)
     assert TimePeriodException.objects.filter(desk=desk).count() == 1
     assert TimePeriodExceptionSource.objects.filter(desk=desk).count() == 1
-    source = TimePeriodExceptionSource.objects.latest('pk')
-    exception = TimePeriodException.objects.latest('pk')
+    source = desk.timeperiodexceptionsource_set.get()
+    exception = desk.timeperiodexception_set.get()
     assert exception.source == source
     assert source.ics_filename == 'exceptions.ics'
     assert 'exceptions.ics' in source.ics_file.name
@@ -2966,10 +2964,9 @@ END:VCALENDAR"""
 def test_agenda_import_time_period_exception_with_remote_ics(mocked_get, app, admin_user):
     agenda = Agenda.objects.create(label='New Example', kind='meetings')
     desk = Desk.objects.create(agenda=agenda, label='New Desk')
-    MeetingType.objects.create(agenda=agenda, label='Bar')
+    desk.duplicate()
     login(app)
-    resp = app.get('/manage/agendas/%d/settings' % agenda.pk)
-    resp = resp.click('manage exceptions')
+    resp = app.get('/manage/agendas/desk/%s/import-exceptions-from-ics/' % desk.pk)
 
     assert 'ics_file' in resp.form.fields
     assert 'ics_url' in resp.form.fields
@@ -2987,10 +2984,9 @@ END:VCALENDAR"""
     mocked_get.return_value = mocked_response
     resp = resp.form.submit(status=302)
     assert TimePeriodException.objects.filter(desk=desk).count() == 1
-    exception = TimePeriodException.objects.get(desk=desk)
     assert TimePeriodExceptionSource.objects.filter(desk=desk).count() == 1
-    source = TimePeriodExceptionSource.objects.latest('pk')
-    exception = TimePeriodException.objects.latest('pk')
+    source = desk.timeperiodexceptionsource_set.get()
+    exception = desk.timeperiodexception_set.get()
     assert exception.source == source
     assert source.ics_filename is None
     assert source.ics_file.name == ''
@@ -3026,7 +3022,7 @@ def test_agenda_import_time_period_exception_from_remote_ics_with_connection_err
     mocked_get, app, admin_user
 ):
     agenda = Agenda.objects.create(label='New Example', kind='meetings')
-    desk = Desk.objects.create(agenda=agenda, label='New Desk')
+    Desk.objects.create(agenda=agenda, label='New Desk')
     MeetingType.objects.create(agenda=agenda, label='Bar')
     login(app)
     resp = app.get('/manage/agendas/%d/settings' % agenda.pk)
@@ -3049,7 +3045,7 @@ def test_agenda_import_time_period_exception_from_remote_ics_with_connection_err
 @mock.patch('chrono.agendas.models.requests.get')
 def test_agenda_import_time_period_exception_from_forbidden_remote_ics(mocked_get, app, admin_user):
     agenda = Agenda.objects.create(label='New Example', kind='meetings')
-    desk = Desk.objects.create(agenda=agenda, label='New Desk')
+    Desk.objects.create(agenda=agenda, label='New Desk')
     MeetingType.objects.create(agenda=agenda, label='Bar')
     login(app)
     resp = app.get('/manage/agendas/%d/settings' % agenda.pk)
@@ -3070,7 +3066,7 @@ def test_agenda_import_time_period_exception_from_forbidden_remote_ics(mocked_ge
 @mock.patch('chrono.agendas.models.requests.get')
 def test_agenda_import_time_period_exception_from_remote_ics_with_ssl_error(mocked_get, app, admin_user):
     agenda = Agenda.objects.create(label='New Example', kind='meetings')
-    desk = Desk.objects.create(agenda=agenda, label='New Desk')
+    Desk.objects.create(agenda=agenda, label='New Desk')
     MeetingType.objects.create(agenda=agenda, label='Bar')
     login(app)
     resp = app.get('/manage/agendas/%d/settings' % agenda.pk)
@@ -3087,69 +3083,111 @@ def test_agenda_import_time_period_exception_from_remote_ics_with_ssl_error(mock
     assert 'Failed to retrieve remote calendar (https://example.com/foo.ics, SSL error).' in resp.text
 
 
-def test_meetings_agenda_delete_time_period_exception_source(app, admin_user, freezer):
-    freezer.move_to('2019-12-01')
-    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
-    desk = Desk.objects.create(agenda=agenda, label='Desk A')
-    MeetingType(agenda=agenda, label='Blah').save()
-    TimePeriod.objects.create(
-        weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)
-    )
+@mock.patch('chrono.agendas.models.requests.get')
+def test_agenda_import_time_period_exception_url_desk_simple_management(mocked_get, app, admin_user):
+    agenda = Agenda.objects.create(label='New Example', kind='meetings', desk_simple_management=True)
+    desk = Desk.objects.create(agenda=agenda, label='New Desk')
+    desk.duplicate()
+    assert agenda.is_available_for_simple_management() is True
+
     login(app)
-    # import a source
-    resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
-    resp = resp.click('manage exceptions')
-    ics_with_recurrent_exceptions = b"""BEGIN:VCALENDAR
+    resp = app.get('/manage/agendas/desk/%s/import-exceptions-from-ics/' % desk.pk)
+    resp.form['ics_url'] = 'http://example.com/foo.ics'
+    mocked_response = mock.Mock()
+    mocked_response.text = """BEGIN:VCALENDAR
 VERSION:2.0
 PRODID:-//foo.bar//EN
 BEGIN:VEVENT
 DTSTART:20180101
 DTEND:20180101
 SUMMARY:New Year's Eve
-RRULE:FREQ=YEARLY
 END:VEVENT
 END:VCALENDAR"""
-    resp.form['ics_file'] = Upload('exceptions.ics', ics_with_recurrent_exceptions, 'text/calendar')
-    resp = resp.form.submit(status=302).follow()
-    assert TimePeriodException.objects.filter(desk=desk).count() == 2
-    source1 = TimePeriodExceptionSource.objects.latest('pk')
-    assert source1.timeperiodexception_set.count() == 2
+    mocked_get.return_value = mocked_response
+    resp = resp.form.submit(status=302)
+    assert TimePeriodException.objects.count() == 2
+    assert agenda.is_available_for_simple_management() is True
 
-    # import another one
-    resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
-    resp = resp.click('manage exceptions')
-    resp.form['ics_file'] = Upload('exceptions.ics', ics_with_recurrent_exceptions, 'text/calendar')
-    resp = resp.form.submit(status=302).follow()
-    assert TimePeriodException.objects.filter(desk=desk).count() == 4
-    source2 = TimePeriodExceptionSource.objects.latest('pk')
-    assert source2.timeperiodexception_set.count() == 2
 
-    # delete the second one
-    resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
-    resp = resp.click('manage exceptions')
-    resp = resp.click(href='/manage/time-period-exceptions-source/%d/delete' % source2.pk)
-    resp = resp.form.submit().follow()
+def test_agenda_import_time_period_exception_file_desk_simple_management(app, admin_user):
+    agenda = Agenda.objects.create(label='New Example', kind='meetings', desk_simple_management=True)
+    desk = Desk.objects.create(agenda=agenda, label='New Desk')
+    desk.duplicate()
+    assert agenda.is_available_for_simple_management() is True
+
+    login(app)
+    resp = app.get('/manage/agendas/desk/%s/import-exceptions-from-ics/' % desk.pk)
+    ics_exceptions = b"""BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//foo.bar//EN
+BEGIN:VEVENT
+DTSTART:20180101
+DTEND:20180101
+SUMMARY:New Year's Eve
+END:VEVENT
+END:VCALENDAR"""
+    resp.form['ics_file'] = Upload('exceptions.ics', ics_exceptions, 'text/calendar')
+    resp = resp.form.submit(status=302)
     assert TimePeriodException.objects.count() == 2
-    assert source1.timeperiodexception_set.count() == 2
+    assert agenda.is_available_for_simple_management() is True
+
+
+def test_meetings_agenda_delete_time_period_exception_source(app, admin_user):
+    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
+    desk = Desk.objects.create(agenda=agenda, label='Desk A')
+    source1 = TimePeriodExceptionSource.objects.create(desk=desk, ics_url='https://example.com/test.ics')
+    TimePeriodException.objects.create(
+        desk=desk,
+        source=source1,
+        start_datetime=now() - datetime.timedelta(days=1),
+        end_datetime=now() + datetime.timedelta(days=1),
+    )
+    source2 = TimePeriodExceptionSource.objects.create(desk=desk, ics_url='https://example.com/test.ics')
+    TimePeriodException.objects.create(
+        desk=desk,
+        source=source2,
+        start_datetime=now() - datetime.timedelta(days=1),
+        end_datetime=now() + datetime.timedelta(days=1),
+    )
+    desk.duplicate()
+    assert TimePeriodException.objects.count() == 4
+    assert TimePeriodExceptionSource.objects.count() == 4
+
+    login(app)
+    resp = app.get('/manage/time-period-exceptions-source/%d/delete' % source2.pk)
+    resp = resp.form.submit()
+    assert TimePeriodException.objects.count() == 3
+    assert TimePeriodExceptionSource.objects.count() == 3
+    assert source1.timeperiodexception_set.count() == 1
     assert TimePeriodExceptionSource.objects.filter(pk=source2.pk).exists() is False
 
-    # delete the first one
-    resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
-    resp = resp.click('manage exceptions')
-    resp = resp.click(href='/manage/time-period-exceptions-source/%d/delete' % source1.pk)
-    resp = resp.form.submit().follow()
-    assert TimePeriodException.objects.count() == 0
-    assert TimePeriodExceptionSource.objects.filter(pk=source1.pk).exists() is False
+
+def test_meetings_agenda_delete_time_period_exception_source_desk_simple_management(app, admin_user):
+    agenda = Agenda.objects.create(label='New Example', kind='meetings', desk_simple_management=True)
+    desk = Desk.objects.create(agenda=agenda, label='New Desk')
+    source = TimePeriodExceptionSource.objects.create(desk=desk, ics_url='https://example.com/test.ics')
+    desk.duplicate()
+    assert TimePeriodExceptionSource.objects.count() == 2
+    assert agenda.is_available_for_simple_management() is True
+
+    login(app)
+    resp = app.get('/manage/time-period-exceptions-source/%d/delete' % source.pk)
+    resp.form.submit()
+    assert TimePeriodExceptionSource.objects.count() == 0
+    assert agenda.is_available_for_simple_management() is True
+
+    # should not happen: corresponding source does not exist
+    source = TimePeriodExceptionSource.objects.create(desk=desk, ics_url='https://example.com/test.ics')
+    assert TimePeriodExceptionSource.objects.count() == 1
+    resp = app.get('/manage/time-period-exceptions-source/%d/delete' % source.pk)
+    resp.form.submit()
+    assert TimePeriodExceptionSource.objects.count() == 0
 
 
 def test_meetings_agenda_replace_time_period_exception_source(app, admin_user, freezer):
     freezer.move_to('2019-12-01')
     agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
     desk = Desk.objects.create(agenda=agenda, label='Desk A')
-    MeetingType(agenda=agenda, label='Blah').save()
-    TimePeriod.objects.create(
-        weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)
-    )
     ics_file_content = b"""BEGIN:VCALENDAR
 VERSION:2.0
 PRODID:-//foo.bar//EN
@@ -3163,8 +3201,7 @@ END:VCALENDAR"""
 
     login(app)
     # import a source from a file
-    resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
-    resp = resp.click('manage exceptions')
+    resp = app.get('/manage/agendas/desk/%s/import-exceptions-from-ics/' % desk.pk)
     resp.form['ics_file'] = Upload('exceptions.ics', ics_file_content, 'text/calendar')
     resp = resp.form.submit(status=302).follow()
     assert TimePeriodException.objects.filter(desk=desk).count() == 2
@@ -3173,32 +3210,93 @@ END:VCALENDAR"""
     exceptions = list(source.timeperiodexception_set.order_by('pk'))
     old_ics_file_path = source.ics_file.path
 
+    desk2 = desk.duplicate()
+    source2 = desk2.timeperiodexceptionsource_set.get()
+    exceptions2 = list(source2.timeperiodexception_set.order_by('pk'))
+    assert TimePeriodException.objects.count() == 4
+    old_ics_file_path2 = source2.ics_file.path
+
     # replace the source
-    resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
-    resp = resp.click('manage exceptions')
-    resp = resp.click(href='/manage/time-period-exceptions-source/%d/replace' % source.pk)
+    resp = app.get('/manage/time-period-exceptions-source/%d/replace' % source.pk)
     resp.form['ics_newfile'] = Upload('exceptions-bis.ics', ics_file_content, 'text/calendar')
     resp = resp.form.submit().follow()
     source.refresh_from_db()
     assert source.ics_file.path != old_ics_file_path
     assert source.ics_filename == 'exceptions-bis.ics'
     assert os.path.exists(old_ics_file_path) is False
-    assert TimePeriodException.objects.count() == 2
+    assert TimePeriodException.objects.count() == 4
     assert source.timeperiodexception_set.count() == 2
     new_exceptions = list(source.timeperiodexception_set.order_by('pk'))
     assert exceptions[0].pk != new_exceptions[0].pk
     assert exceptions[1].pk != new_exceptions[1].pk
+    source2.refresh_from_db()
+    assert source2.ics_file.path == old_ics_file_path2
+    assert source2.ics_filename == 'exceptions.ics'
+    new_exceptions2 = list(source2.timeperiodexception_set.order_by('pk'))
+    assert exceptions2[0].pk == new_exceptions2[0].pk
+    assert exceptions2[1].pk == new_exceptions2[1].pk
 
 
-@mock.patch('chrono.agendas.models.requests.get')
-def test_meetings_agenda_refresh_time_period_exception_source(mocked_get, app, admin_user):
-    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
+def test_meetings_agenda_replace_time_period_exception_source_desk_simple_management(app, admin_user):
+    ics_file_content = b"""BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//foo.bar//EN
+BEGIN:VEVENT
+DTSTART:20180101
+DTEND:20180101
+SUMMARY:New Year's Eve
+END:VEVENT
+END:VCALENDAR"""
+
+    agenda = Agenda.objects.create(label='Foo bar', kind='meetings', desk_simple_management=True)
     desk = Desk.objects.create(agenda=agenda, label='Desk A')
-    MeetingType(agenda=agenda, label='Blah').save()
-    TimePeriod.objects.create(
-        weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)
+    source = TimePeriodExceptionSource.objects.create(
+        desk=desk,
+        ics_filename='sample.ics',
+        ics_file=ContentFile(ics_file_content, name='sample.ics'),
     )
-    ics_url_content = """BEGIN:VCALENDAR
+    desk2 = desk.duplicate()
+    source2 = desk2.timeperiodexceptionsource_set.get()
+    assert TimePeriodExceptionSource.objects.count() == 2
+    assert TimePeriodException.objects.count() == 0  # not imported yet
+    assert agenda.is_available_for_simple_management() is True
+    old_ics_file_path = source.ics_file.path
+    old_ics_file_path2 = source2.ics_file.path
+
+    login(app)
+    resp = app.get('/manage/time-period-exceptions-source/%d/replace' % source.pk)
+    resp.form['ics_newfile'] = Upload('exceptions-bis.ics', ics_file_content, 'text/calendar')
+    resp.form.submit()
+    assert TimePeriodExceptionSource.objects.count() == 2
+    assert TimePeriodException.objects.count() == 2
+    assert desk.timeperiodexception_set.count() == 1
+    assert desk2.timeperiodexception_set.count() == 1
+    source.refresh_from_db()
+    assert source.ics_file.path != old_ics_file_path
+    assert source.ics_filename == 'exceptions-bis.ics'
+    assert os.path.exists(old_ics_file_path) is False
+    source2.refresh_from_db()
+    assert source2.ics_file.path != old_ics_file_path2
+    assert source2.ics_filename == 'exceptions-bis.ics'
+    assert os.path.exists(old_ics_file_path2) is False
+    assert agenda.is_available_for_simple_management() is True
+
+    # should not happen: corresponding source does not exist
+    source2.delete()
+    resp = app.get('/manage/time-period-exceptions-source/%d/replace' % source.pk)
+    resp.form['ics_newfile'] = Upload('exceptions-ter.ics', ics_file_content, 'text/calendar')
+    resp.form.submit()
+    assert TimePeriodExceptionSource.objects.count() == 1
+    assert TimePeriodException.objects.count() == 1
+    assert desk.timeperiodexception_set.count() == 1
+    assert desk2.timeperiodexception_set.count() == 0
+    assert desk.timeperiodexceptionsource_set.get().ics_filename == 'exceptions-ter.ics'
+
+
+@mock.patch('chrono.agendas.models.requests.get')
+def test_meetings_agenda_refresh_time_period_exception_source(mocked_get, app, admin_user):
+    mocked_response = mock.Mock()
+    mocked_response.text = """BEGIN:VCALENDAR
 VERSION:2.0
 PRODID:-//foo.bar//EN
 BEGIN:VEVENT
@@ -3207,32 +3305,77 @@ DTEND:20180101
 SUMMARY:New Year's Eve
 END:VEVENT
 END:VCALENDAR"""
+    mocked_get.return_value = mocked_response
+
+    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
+    desk = Desk.objects.create(agenda=agenda, label='Desk A')
 
     login(app)
     # import a source from an url
     resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
     resp = resp.click('manage exceptions')
     resp.form['ics_url'] = 'http://example.com/foo.ics'
-    mocked_response = mock.Mock()
-    mocked_response.text = ics_url_content
-    mocked_get.return_value = mocked_response
     resp = resp.form.submit(status=302).follow()
     assert TimePeriodException.objects.filter(desk=desk).count() == 1
     source = TimePeriodExceptionSource.objects.latest('pk')
     assert source.timeperiodexception_set.count() == 1
     exceptions = list(source.timeperiodexception_set.order_by('pk'))
 
+    desk2 = desk.duplicate()
+    source2 = desk2.timeperiodexceptionsource_set.get()
+    exceptions2 = list(source2.timeperiodexception_set.order_by('pk'))
+    assert TimePeriodException.objects.count() == 2
+
     # refresh the source
     resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
-    resp = resp.click('manage exceptions')
+    resp = resp.click('manage exceptions', index=0)
+    resp = resp.click(href='/manage/time-period-exceptions-source/%d/refresh' % source.pk)
+    assert TimePeriodException.objects.count() == 2
+    new_exceptions = list(source.timeperiodexception_set.order_by('pk'))
+    assert exceptions[0].pk != new_exceptions[0].pk
+    new_exceptions2 = list(source2.timeperiodexception_set.order_by('pk'))
+    assert exceptions2[0].pk == new_exceptions2[0].pk
+
+
+@mock.patch('chrono.agendas.models.requests.get')
+def test_meetings_agenda_refresh_time_period_exception_source_desk_simple_management(
+    mocked_get, app, admin_user
+):
     mocked_response = mock.Mock()
-    mocked_response.text = ics_url_content
+    mocked_response.text = """BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//foo.bar//EN
+BEGIN:VEVENT
+DTSTART:20180101
+DTEND:20180101
+SUMMARY:New Year's Eve
+END:VEVENT
+END:VCALENDAR"""
     mocked_get.return_value = mocked_response
-    resp = resp.click(href='/manage/time-period-exceptions-source/%d/refresh' % source.pk)
+
+    agenda = Agenda.objects.create(label='Foo bar', kind='meetings', desk_simple_management=True)
+    desk = Desk.objects.create(agenda=agenda, label='Desk A')
+    source = TimePeriodExceptionSource.objects.create(desk=desk, ics_url='https://example.com/test.ics')
+    desk2 = desk.duplicate()
+    source2 = desk2.timeperiodexceptionsource_set.get()
+    assert TimePeriodExceptionSource.objects.count() == 2
+    assert TimePeriodException.objects.count() == 0  # not imported yet
+    assert agenda.is_available_for_simple_management() is True
+
+    login(app)
+    app.get('/manage/time-period-exceptions-source/%d/refresh' % source.pk)
+    assert TimePeriodExceptionSource.objects.count() == 2
+    assert TimePeriodException.objects.count() == 2
+    assert source.timeperiodexception_set.count() == 1
+    assert source2.timeperiodexception_set.count() == 1
+    assert agenda.is_available_for_simple_management() is True
+
+    # should not happen: corresponding source does not exist
+    source2.delete()
+    app.get('/manage/time-period-exceptions-source/%d/refresh' % source.pk)
+    assert TimePeriodExceptionSource.objects.count() == 1
     assert TimePeriodException.objects.count() == 1
     assert source.timeperiodexception_set.count() == 1
-    new_exceptions = list(source.timeperiodexception_set.order_by('pk'))
-    assert exceptions[0].pk != new_exceptions[0].pk
 
 
 @override_settings(
@@ -3240,38 +3383,83 @@ END:VCALENDAR"""
         'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},
     }
 )
-def test_meetings_agenda_time_period_exception_source_from_settings(app, admin_user):
+def test_meetings_agenda_time_period_exception_source_from_settings_toggle(app, admin_user):
     agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
     desk = Desk.objects.create(agenda=agenda, label='Desk A')
     desk.import_timeperiod_exceptions_from_settings(enable=True)
-    MeetingType(agenda=agenda, label='Blah').save()
-    TimePeriod.objects.create(
-        weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)
-    )
-    assert TimePeriodException.objects.exists()
+    desk2 = Desk.objects.create(agenda=agenda, label='Desk B')
+    desk2.import_timeperiod_exceptions_from_settings(enable=True)
+    assert desk.timeperiodexception_set.exists()
+    assert desk2.timeperiodexception_set.exists()
 
     login(app)
-    resp = app.get('/manage/agendas/%d/' % agenda.pk).follow()
-    resp = resp.click('Settings')
-    resp = resp.click('manage exceptions')
+    resp = app.get('/manage/agendas/desk/%s/import-exceptions-from-ics/' % desk.pk)
     assert 'Holidays' in resp.text
     assert 'disabled' not in resp.text
     assert 'refresh' not in resp.text
 
     resp = resp.click('disable').follow()
-    assert not TimePeriodException.objects.exists()
+    assert not desk.timeperiodexception_set.exists()
+    assert desk2.timeperiodexception_set.exists()
 
-    resp = resp.click('manage exceptions')
+    resp = app.get('/manage/agendas/desk/%s/import-exceptions-from-ics/' % desk.pk)
     assert 'Holidays' in resp.text
     assert 'disabled' in resp.text
 
     resp = resp.click('enable').follow()
-    assert TimePeriodException.objects.exists()
+    assert desk.timeperiodexception_set.exists()
+    assert desk2.timeperiodexception_set.exists()
 
-    resp = resp.click('manage exceptions')
+    resp = app.get('/manage/agendas/desk/%s/import-exceptions-from-ics/' % desk.pk)
     assert 'disabled' not in resp.text
 
 
+@override_settings(
+    EXCEPTIONS_SOURCES={
+        'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},
+    }
+)
+def test_meetings_agenda_time_period_exception_source_from_settings_toggle_desk_simple_management(
+    app, admin_user
+):
+    agenda = Agenda.objects.create(label='Foo bar', kind='meetings', desk_simple_management=True)
+    desk = Desk.objects.create(agenda=agenda, label='Desk A')
+    desk.import_timeperiod_exceptions_from_settings(enable=True)
+    source = desk.timeperiodexceptionsource_set.get()
+    desk2 = Desk.objects.create(agenda=agenda, label='Desk B')
+    desk2.import_timeperiod_exceptions_from_settings(enable=True)
+    source2 = desk2.timeperiodexceptionsource_set.get()
+    assert desk.timeperiodexception_set.exists()
+    assert desk2.timeperiodexception_set.exists()
+    assert agenda.is_available_for_simple_management() is True
+
+    login(app)
+
+    app.get('/manage/time-period-exceptions-source/%s/toggle' % source.pk)
+    source.refresh_from_db()
+    source2.refresh_from_db()
+    assert not source.enabled
+    assert not source2.enabled
+    assert not desk.timeperiodexception_set.exists()
+    assert not desk2.timeperiodexception_set.exists()
+    assert agenda.is_available_for_simple_management() is True
+
+    app.get('/manage/time-period-exceptions-source/%s/toggle' % source.pk)
+    source.refresh_from_db()
+    source2.refresh_from_db()
+    assert source.enabled
+    assert source2.enabled
+    assert desk.timeperiodexception_set.exists()
+    assert desk2.timeperiodexception_set.exists()
+    assert agenda.is_available_for_simple_management() is True
+
+    # should not happen: corresponding source does not exist
+    source2.delete()
+    app.get('/manage/time-period-exceptions-source/%s/toggle' % source.pk)
+    source.refresh_from_db()
+    assert not source.enabled
+
+
 def test_meetings_agenda_time_period_exception_source_try_disable_ics(app, admin_user):
     agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
     desk = Desk.objects.create(agenda=agenda, label='Desk A')
@@ -3300,10 +3488,6 @@ def test_meetings_agenda_time_period_exception_source_from_settings(app, admin_u
     agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
     desk = Desk.objects.create(agenda=agenda, label='Desk A')
     desk.import_timeperiod_exceptions_from_settings(enable=True)
-    MeetingType(agenda=agenda, label='Blah').save()
-    TimePeriod.objects.create(
-        weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)
-    )
     new_year = desk.timeperiodexception_set.filter(label='New year').first()
     remove_url = reverse('chrono-manager-time-period-exception-delete', kwargs={'pk': new_year.pk})
     edit_url = reverse('chrono-manager-time-period-exception-edit', kwargs={'pk': new_year.pk})
@@ -3312,11 +3496,11 @@ def test_meetings_agenda_time_period_exception_source_from_settings(app, admin_u
     resp = app.get('/manage/agendas/%d/' % agenda.pk).follow()
     resp = resp.click('Settings')
     assert 'New year' in resp.text
-    assert not remove_url in resp.text and not edit_url in resp.text
+    assert remove_url not in resp.text and edit_url not in resp.text
 
     resp = resp.click('see all')
     assert 'New year' in resp.text
-    assert not remove_url in resp.text and not edit_url in resp.text
+    assert remove_url not in resp.text and edit_url not in resp.text
 
     app.get(remove_url, status=404)
 
@@ -5470,18 +5654,22 @@ def test_unavailability_calendar_delete_time_period_exeptions(app, admin_user):
     assert unavailability_calendar.timeperiodexception_set.count() == 0
 
 
-def test_activate_unavailability_calendar_in_desk(app, admin_user):
+def test_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(
+    today = localtime(now().replace(hour=0, minute=0, second=0, microsecond=0))
+    tomorrow = today + datetime.timedelta(days=1)
+    TimePeriodException.objects.create(
         unavailability_calendar=unavailability_calendar,
-        start_datetime=start_datetime,
-        end_datetime=localtime(now()) + datetime.timedelta(days=2),
+        start_datetime=tomorrow,
+        end_datetime=tomorrow + datetime.timedelta(days=2),
         label='what',
     )
     agenda = Agenda.objects.create(label='Agenda', kind='meetings')
     desk = Desk.objects.create(agenda=agenda, label='desk')
+    desk2 = desk.duplicate()
+    assert not desk.unavailability_calendars.exists()
+    assert not desk2.unavailability_calendars.exists()
     exceptions_url = '/manage/agendas/desk/%s/import-exceptions-from-ics/' % desk.pk
     resp = app.get(exceptions_url)
     assert 'calendar' in resp.text
@@ -5491,7 +5679,7 @@ def test_activate_unavailability_calendar_in_desk(app, admin_user):
     )
     settings_url = '/manage/agendas/%s/settings' % agenda.pk
     assert resp.location.endswith(settings_url)
-    # exception from calendar displayed on the settigns page
+    # exception from calendar displayed on the settings page
     resp = app.get(settings_url)
     assert 'what' in resp.text
     assert 'One or several bookings overlap with exceptions.' not in resp.text
@@ -5500,18 +5688,19 @@ def test_activate_unavailability_calendar_in_desk(app, admin_user):
     assert 'calendar' in resp.text
     assert 'disable' in resp.text
     assert desk.unavailability_calendars.get(pk=unavailability_calendar.pk)
+    assert not desk2.unavailability_calendars.exists()
 
     # reset
-    unavailability_calendar.desks.remove(desk)
+    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)
+    resp = app.get(exceptions_url)
+    assert 'calendar' in resp.text
+    assert 'enable' in resp.text
+    assert not desk.unavailability_calendars.exists()
 
     # info message if some exceptions overlaps with a booking
-    today = datetime.datetime.today().replace(hour=0, minute=0, second=0, microsecond=0)
-    tomorrow = make_aware(today + datetime.timedelta(days=1))
-    TimePeriodException.objects.create(
-        start_datetime=tomorrow,
-        end_datetime=tomorrow.replace(hour=15, minute=30, second=0),
-        unavailability_calendar=unavailability_calendar,
-    )
     meeting_type = MeetingType.objects.create(label='Meeting Type', agenda=agenda)
     event = Event.objects.create(
         agenda=agenda,
@@ -5532,6 +5721,68 @@ def test_activate_unavailability_calendar_in_desk(app, admin_user):
     assert 'One or several bookings overlap with exceptions.' in resp.text
 
 
+def test_unavailability_calendar_in_desk_desk_simple_management(app, admin_user):
+    unavailability_calendar = UnavailabilityCalendar.objects.create(label='Calendar')
+    today = localtime(now().replace(hour=0, minute=0, second=0, microsecond=0))
+    tomorrow = today + datetime.timedelta(days=1)
+    TimePeriodException.objects.create(
+        unavailability_calendar=unavailability_calendar,
+        start_datetime=tomorrow,
+        end_datetime=tomorrow + datetime.timedelta(days=2),
+        label='what',
+    )
+    agenda = Agenda.objects.create(label='Agenda', kind='meetings', desk_simple_management=True)
+    desk = Desk.objects.create(agenda=agenda, label='desk')
+    desk2 = desk.duplicate()
+    assert not desk.unavailability_calendars.exists()
+    assert not desk2.unavailability_calendars.exists()
+    assert agenda.is_available_for_simple_management() is True
+
+    meeting_type = MeetingType.objects.create(label='Meeting Type', agenda=agenda)
+    event = Event.objects.create(
+        agenda=agenda,
+        places=1,
+        desk=desk2,
+        meeting_type=meeting_type,
+        start_datetime=tomorrow.replace(hour=10, minute=30, second=0),
+    )
+    Booking.objects.create(event=event)
+
+    login(app)
+
+    resp = app.get(
+        '/manage/desk/%s/unavailability-calendar/%s/toggle/' % (desk.pk, unavailability_calendar.pk)
+    )
+    assert desk.unavailability_calendars.exists()
+    assert desk2.unavailability_calendars.exists()
+    assert agenda.is_available_for_simple_management() is True
+    resp = resp.follow()
+    assert 'One or several bookings overlap with exceptions.' in resp.text
+
+    app.get('/manage/desk/%s/unavailability-calendar/%s/toggle/' % (desk.pk, unavailability_calendar.pk))
+    assert not desk.unavailability_calendars.exists()
+    assert not desk2.unavailability_calendars.exists()
+    assert agenda.is_available_for_simple_management() is True
+
+    # should not happen: unavailability_calendar is not in the correct state
+    desk2.unavailability_calendars.add(unavailability_calendar)
+    assert not desk.unavailability_calendars.exists()
+    assert desk2.unavailability_calendars.exists()
+    assert agenda.is_available_for_simple_management() is False
+    app.get('/manage/desk/%s/unavailability-calendar/%s/toggle/' % (desk.pk, unavailability_calendar.pk))
+    assert desk.unavailability_calendars.exists()
+    assert desk2.unavailability_calendars.exists()
+    assert agenda.is_available_for_simple_management() is True
+    desk2.unavailability_calendars.remove(unavailability_calendar)
+    assert desk.unavailability_calendars.exists()
+    assert not desk2.unavailability_calendars.exists()
+    assert agenda.is_available_for_simple_management() is False
+    app.get('/manage/desk/%s/unavailability-calendar/%s/toggle/' % (desk.pk, unavailability_calendar.pk))
+    assert not desk.unavailability_calendars.exists()
+    assert not desk2.unavailability_calendars.exists()
+    assert agenda.is_available_for_simple_management() is True
+
+
 def test_unavailability_calendar_exception_in_desk(app, admin_user):
     unavailability_calendar = UnavailabilityCalendar.objects.create(label='Calendar')
     start_datetime = localtime(now()) + datetime.timedelta(days=1)
@@ -5554,26 +5805,6 @@ def test_unavailability_calendar_exception_in_desk(app, admin_user):
     assert resp.text.count('exception foo bar') == 3  # displayed just once per desk
 
 
-def test_deactivate_unavailability_calendar_in_desk(app, admin_user):
-    app = login(app)
-    unavailability_calendar = UnavailabilityCalendar.objects.create(label='Calendar')
-    agenda = Agenda.objects.create(label='Agenda', kind='meetings')
-    desk = Desk.objects.create(agenda=agenda, label='desk')
-    desk.unavailability_calendars.add(unavailability_calendar)
-    exceptions_url = '/manage/agendas/desk/%s/import-exceptions-from-ics/' % desk.pk
-    resp = app.get(exceptions_url)
-    assert 'calendar' in resp.text
-    assert 'disable' in resp.text
-    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)
-    resp = app.get(exceptions_url)
-    assert 'calendar' in resp.text
-    assert 'enable' in resp.text
-    assert desk.unavailability_calendars.count() == 0
-
-
 def test_unavailability_calendar_homepage_permission(app, manager_user):
     unavailability_calendar = UnavailabilityCalendar.objects.create(label='Calendar 1')
     app = login(app, username='manager', password='manager')
-- 
2.29.2

