From 85c10920a7519d6bbc65f939eb85b09f0004b1a2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Tue, 10 Aug 2021 14:23:21 +0200
Subject: [PATCH 1/2] misc: split manager tests

---
 tests/manager/test_all.py        | 1813 +++---------------------------
 tests/manager/test_exception.py  | 1316 ++++++++++++++++++++++
 tests/manager/test_timeperiod.py |  233 ++++
 3 files changed, 1707 insertions(+), 1655 deletions(-)
 create mode 100644 tests/manager/test_exception.py
 create mode 100644 tests/manager/test_timeperiod.py

diff --git a/tests/manager/test_all.py b/tests/manager/test_all.py
index 0f52a35..61fc0ad 100644
--- a/tests/manager/test_all.py
+++ b/tests/manager/test_all.py
@@ -1,19 +1,16 @@
 import copy
 import datetime
 import json
-import os
 from unittest import mock
 
 import freezegun
 import pytest
 import requests
 from django.contrib.auth.models import 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
 from django.test.utils import CaptureQueriesContext
-from django.urls import reverse
 from django.utils.encoding import force_text
 from django.utils.timezone import localtime, make_aware, now
 from webtest import Upload
@@ -32,7 +29,6 @@ from chrono.agendas.models import (
     UnavailabilityCalendar,
     VirtualMember,
 )
-from chrono.manager.forms import TimePeriodExceptionForm
 from chrono.utils.signature import check_query
 
 pytestmark = pytest.mark.django_db
@@ -335,43 +331,6 @@ def test_add_agenda(app, admin_user):
     assert agenda.desk_simple_management is False
 
 
-@override_settings(
-    EXCEPTIONS_SOURCES={
-        'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},
-    }
-)
-def test_add_agenda_exceptions_from_settings(app, admin_user):
-    app = login(app)
-    resp = app.get('/manage/', status=200)
-    resp = resp.click('New')
-    resp.form['label'] = 'Foo bar'
-    resp.form['kind'] = 'meetings'
-    resp = resp.form.submit().follow()
-
-    agenda = Agenda.objects.get(label='Foo bar')
-    assert agenda.desk_set.count() == 1
-
-    default_desk = agenda.desk_set.first()
-    assert default_desk.timeperiodexception_set.exists()
-    assert default_desk.timeperiodexceptionsource_set.count() == 1
-
-    source = default_desk.timeperiodexceptionsource_set.first()
-    assert source.enabled
-
-    agenda.desk_simple_management = False
-    agenda.save()
-    resp = app.get('/manage/agendas/%s/add-desk' % agenda.pk)
-    resp.form['label'] = 'Desk A'
-    resp = resp.form.submit().follow()
-
-    desk = Desk.objects.get(slug='desk-a')
-    assert desk.timeperiodexception_set.exists()
-    assert desk.timeperiodexceptionsource_set.count() == 1
-
-    source = desk.timeperiodexceptionsource_set.first()
-    assert source.enabled
-
-
 def test_add_agenda_as_manager(app, manager_user):
     # open /manage/ access to manager_user, and check agenda creation is not
     # allowed.
@@ -909,1420 +868,68 @@ def test_meetings_agenda_delete_meeting_type(app, admin_user):
     meeting_type = MeetingType(agenda=agenda, label='Blah')
     meeting_type.save()
 
-    app = login(app)
-    resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
-    resp = resp.click('Blah')
-    resp = resp.click('Delete')
-    assert 'Are you sure you want to delete this?' in resp.text
-    assert 'disabled' not in resp.text
-    resp = resp.form.submit()
-    assert resp.location.endswith('/manage/agendas/%s/settings' % agenda.id)
-    meeting_type.refresh_from_db()
-    assert meeting_type.deleted is True
-    assert '__deleted__' in meeting_type.slug
-
-    # meeting type not showing up anymore
-    resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
-    assert 'Meeting Type Foo' not in resp.text
-
-    # it is possible to add a new meeting type with the same slug
-    new_meeting_type = MeetingType.objects.create(agenda=agenda, label='Blah')
-    assert new_meeting_type.slug == 'blah'
-
-
-def test_meetings_agenda_add_time_period(app, admin_user):
-    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
-    desk = Desk.objects.create(agenda=agenda, label='Desk A')
-    desk2 = Desk.objects.create(agenda=agenda, label='Desk B')
-    MeetingType.objects.create(agenda=agenda, label='Blah')
-    app = login(app)
-    resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
-    resp = resp.click('Add a time period', index=0)
-    resp.form.get('weekdays', index=2).checked = True
-    resp.form['start_time'] = '10:00'
-    resp.form['end_time'] = '17:00'
-    resp = resp.form.submit()
-    assert TimePeriod.objects.get(desk=desk).weekday == 2
-    assert TimePeriod.objects.get(desk=desk).start_time.hour == 10
-    assert TimePeriod.objects.get(desk=desk).start_time.minute == 0
-    assert TimePeriod.objects.get(desk=desk).end_time.hour == 17
-    assert TimePeriod.objects.get(desk=desk).end_time.minute == 0
-    assert desk2.timeperiod_set.exists() is False
-    resp = resp.follow()
-
-    # add a second time period
-    resp = resp.click('Add a time period', index=0)
-    resp.form.get('weekdays', index=0).checked = True
-    resp.form['start_time'] = '10:00'
-    resp.form['end_time'] = '13:00'
-    resp = resp.form.submit()
-    resp = resp.follow()
-    assert 'Monday / 10 a.m. → 1 p.m.' in resp.text
-    assert 'Wednesday / 10 a.m. → 5 p.m.' in resp.text
-    assert resp.text.index('Monday') < resp.text.index('Wednesday')
-
-    # invert start and end
-    resp2 = resp.click('Add a time period', index=0)
-    resp2.form.get('weekdays', index=0).checked = True
-    resp2.form['start_time'] = '13:00'
-    resp2.form['end_time'] = '10:00'
-    resp2 = resp2.form.submit()
-    assert 'End time must come after start time.' in resp2.text
-
-    # and add same time periods on multiple days
-    resp = resp.click('Add a time period', index=0)
-    resp.form.get('weekdays', index=4).checked = True
-    resp.form.get('weekdays', index=5).checked = True
-    resp.form['start_time'] = '10:00'
-    resp.form['end_time'] = '13:00'
-    resp = resp.form.submit()
-    assert TimePeriod.objects.filter(desk=desk).count() == 4
-
-
-def test_meetings_agenda_add_time_period_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')
-    desk2 = Desk.objects.create(agenda=agenda, label='Desk B')
-    assert agenda.is_available_for_simple_management() is True
-
-    app = login(app)
-    resp = app.get('/manage/agendas/%s/desk/%s/add-time-period' % (agenda.pk, desk.pk))
-    resp.form.get('weekdays', index=0).checked = True
-    resp.form['start_time'] = '10:00'
-    resp.form['end_time'] = '13:00'
-    resp.form.submit()
-
-    assert TimePeriod.objects.filter(desk=desk).count() == 1
-    assert TimePeriod.objects.filter(desk=desk2).count() == 1
-    assert agenda.is_available_for_simple_management() is True
-
-
-def test_meetings_agenda_add_time_period_on_missing_desk(app, admin_user):
-    app = login(app)
-    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
-    app.get('/manage/agendas/%s/desk/0/add-time-period' % agenda.pk, status=404)
-
-
-def test_meetings_agenda_add_time_period_as_manager(app, manager_user):
-    agenda = Agenda(label='Foo bar', kind='meetings')
-    agenda.view_role = manager_user.groups.all()[0]
-    agenda.save()
-    desk = Desk.objects.create(agenda=agenda, label='Desk A')
-    app = login(app, username='manager', password='manager')
-    resp = app.get('/manage/agendas/%d/' % agenda.id)
-    assert not 'Settings' in resp.text
-    resp = app.get('/manage/agendas/%d/settings' % agenda.id, status=403)
-    MeetingType(agenda=agenda, label='Blah').save()
-    app.get('/manage/agendas/%d/desk/%d/add-time-period' % (agenda.id, desk.id), status=403)
-    time_period = TimePeriod(
-        desk=desk, weekday=0, start_time=datetime.time(9, 0), end_time=datetime.time(12, 0)
-    )
-    time_period.save()
-    resp = app.get('/manage/agendas/%d/' % agenda.id)
-    app.get('/manage/timeperiods/%d/edit' % time_period.id, status=403)
-    app.get('/manage/timeperiods/%d/delete' % time_period.id, status=403)
-    # grant edit right to manager
-    agenda.edit_role = manager_user.groups.all()[0]
-    agenda.save()
-
-    resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
-    assert 'Add a time period' in resp.text
-    assert '/manage/timeperiods/%s/edit' % time_period.id in resp.text
-    assert '/manage/timeperiods/%s/delete' % time_period.id in resp.text
-
-    app.get('/manage/agendas/%d/desk/%d/add-time-period' % (agenda.id, desk.id), status=200)
-    app.get('/manage/timeperiods/%d/edit' % time_period.id, status=200)
-
-
-def test_meetings_agenda_edit_time_period(app, admin_user):
-    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
-    desk = Desk.objects.create(agenda=agenda, label='Desk A')
-    time_period = TimePeriod.objects.create(
-        desk=desk, weekday=0, start_time=datetime.time(9, 0), end_time=datetime.time(12, 0)
-    )
-    desk2 = desk.duplicate()
-    time_period2 = desk2.timeperiod_set.get()
-
-    app = login(app)
-    # edit
-    resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
-    resp = resp.click('Monday / 9 a.m. → noon', index=0)
-    assert 'Edit Time Period' in resp.text
-    resp.form['start_time'] = '10:00'
-    resp = resp.form.submit()
-    resp = resp.follow()
-    time_period.refresh_from_db()
-    assert time_period.start_time.hour == 10
-    time_period2.refresh_from_db()
-    assert time_period2.start_time.hour == 9
-
-    # edit with inverted start/end
-    resp2 = resp.click('Monday / 10 a.m. → noon')
-    resp2.form['start_time'] = '18:00'
-    resp2 = resp2.form.submit()
-    assert 'End time must come after start time.' in resp2.text
-
-
-def test_meetings_agenda_edit_time_period_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')
-    time_period = TimePeriod.objects.create(
-        desk=desk, weekday=0, start_time=datetime.time(9, 0), end_time=datetime.time(12, 0)
-    )
-    desk2 = desk.duplicate()
-    time_period2 = desk2.timeperiod_set.get()
-    assert agenda.is_available_for_simple_management() is True
-
-    app = login(app)
-    resp = app.get('/manage/timeperiods/%s/edit' % time_period.pk)
-    resp.form['weekday'] = 3
-    resp.form['start_time'] = '10:00'
-    resp.form['end_time'] = '11:00'
-    resp.form.submit()
-    time_period.refresh_from_db()
-    time_period2.refresh_from_db()
-    assert time_period.weekday == 3
-    assert time_period.start_time.hour == 10
-    assert time_period.end_time.hour == 11
-    assert time_period2.weekday == 3
-    assert time_period2.start_time.hour == 10
-    assert time_period2.end_time.hour == 11
-    assert agenda.is_available_for_simple_management() is True
-
-    # should not happen: corresponding time period does not exist
-    time_period2.delete()
-    resp = app.get('/manage/timeperiods/%s/edit' % time_period.pk)
-    resp.form['weekday'] = 3
-    resp.form['start_time'] = '10:00'
-    resp.form['end_time'] = '11:00'
-    # no error
-    resp.form.submit()
-    time_period.refresh_from_db()
-    assert time_period.weekday == 3
-    assert time_period.start_time.hour == 10
-    assert time_period.end_time.hour == 11
-
-
-def test_meetings_agenda_delete_time_period(app, admin_user):
-    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
-    desk = Desk.objects.create(agenda=agenda, label='Desk A')
-    TimePeriod.objects.create(
-        desk=desk, weekday=2, start_time=datetime.time(10, 0), end_time=datetime.time(18, 0)
-    )
-    desk.duplicate()
-    assert TimePeriod.objects.count() == 2
-
-    app = login(app)
-    resp = app.get('/manage/agendas/%s/settings' % agenda.id, status=200)
-    resp = resp.click('Wednesday', index=0)
-    resp = resp.click('Delete')
-    resp = resp.form.submit()
-    assert resp.location.endswith('/manage/agendas/%s/settings' % agenda.id)
-    assert TimePeriod.objects.count() == 1
-
-
-def test_meetings_agenda_delete_time_period_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')
-    time_period = TimePeriod.objects.create(
-        desk=desk, weekday=0, start_time=datetime.time(9, 0), end_time=datetime.time(12, 0)
-    )
-    desk.duplicate()
-    assert TimePeriod.objects.count() == 2
-    assert agenda.is_available_for_simple_management() is True
-
-    app = login(app)
-    resp = app.get('/manage/timeperiods/%s/delete' % time_period.pk)
-    resp.form.submit()
-    assert TimePeriod.objects.count() == 0
-    assert agenda.is_available_for_simple_management() is True
-
-    # should not happen: corresponding time period does not exist
-    time_period = TimePeriod.objects.create(
-        desk=desk, weekday=0, start_time=datetime.time(9, 0), end_time=datetime.time(12, 0)
-    )
-    resp = app.get('/manage/timeperiods/%s/delete' % time_period.pk)
-    resp.form.submit()
-    assert TimePeriod.objects.count() == 0
-
-
-def test_meetings_agenda_add_desk(app, admin_user):
-    app = login(app)
-    resp = app.get('/manage/', status=200)
-    resp = resp.click('New')
-    resp.form['label'] = 'Foo bar'
-    resp.form['kind'] = 'meetings'
-    resp = resp.form.submit()
-    assert Desk.objects.count() == 1
-    assert str(Desk.objects.first()) == 'Desk 1'
-    agenda = Agenda.objects.get(slug='foo-bar')
-    agenda.desk_simple_management = False
-    agenda.save()
-
-    resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
-    resp = resp.click('New Desk')
-    resp.form['label'] = 'Desk A'
-    resp = resp.form.submit().follow()
-    assert Desk.objects.count() == 2
-    desk = Desk.objects.latest('pk')
-    TimePeriod.objects.create(
-        weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)
-    )
-    resp = resp.click('New Desk')
-    resp.form['label'] = 'Desk A'
-    resp = resp.form.submit().follow()
-    assert Desk.objects.count() == 3
-    assert Desk.objects.filter(slug='desk-a-1').count() == 1
-    assert 'Desk A' in resp.text
-    new_desk = Desk.objects.latest('pk')
-    assert new_desk.timeperiod_set.count() == 0
-
-    # unknown pk
-    app.get('/manage/agendas/0/add-desk', status=404)
-
-    # wrong kind
-    agenda.kind = 'virtual'
-    agenda.save()
-    app.get('/manage/agendas/%s/add-desk' % agenda.pk, status=404)
-    agenda.kind = 'events'
-    agenda.save()
-    app.get('/manage/agendas/%s/add-desk' % agenda.pk, status=404)
-
-
-@override_settings(
-    EXCEPTIONS_SOURCES={
-        'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},
-    }
-)
-def test_meetings_agenda_add_desk_from_another(app, admin_user):
-    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
-    desk = Desk.objects.create(agenda=agenda, label='Desk A')
-    TimePeriod.objects.create(
-        weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)
-    )
-    assert Desk.objects.count() == 1
-    assert desk.timeperiodexceptionsource_set.count() == 0
-
-    app = login(app)
-    resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
-    resp = resp.click('New Desk')
-    resp.form['label'] = 'Desk B'
-    resp.form['copy_from'] = desk.pk
-    resp = resp.form.submit().follow()
-    assert Desk.objects.count() == 2
-    new_desk = Desk.objects.latest('pk')
-    assert new_desk.label == 'Desk B'
-    assert new_desk.timeperiod_set.count() == 1
-    assert (
-        new_desk.timeperiodexceptionsource_set.count() == 0
-    )  # holidays not automatically added via duplication
-
-
-@override_settings(
-    EXCEPTIONS_SOURCES={
-        'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},
-    }
-)
-def test_meetings_agenda_add_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')
-    TimePeriod.objects.create(
-        weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)
-    )
-    assert Desk.objects.count() == 1
-    assert desk.timeperiodexceptionsource_set.count() == 0
-
-    app = login(app)
-    resp = app.get('/manage/agendas/%s/add-desk' % agenda.pk)
-    resp.form['label'] = 'Desk B'
-    assert 'copy_from' not in resp.context['form'].fields
-    resp = resp.form.submit().follow()
-    assert Desk.objects.count() == 2
-    new_desk = Desk.objects.latest('pk')
-    assert new_desk.label == 'Desk B'
-    assert new_desk.timeperiod_set.count() == 1
-    assert (
-        new_desk.timeperiodexceptionsource_set.count() == 0
-    )  # holidays not automatically added via duplication
-
-    # ok if no desks (should not happen)
-    Desk.objects.all().delete()
-    resp = app.get('/manage/agendas/%s/add-desk' % agenda.pk)
-    resp.form['label'] = 'Desk'
-    assert 'copy_from' not in resp.context['form'].fields
-    resp = resp.form.submit().follow()
-    assert Desk.objects.count() == 1
-    new_desk = Desk.objects.latest('pk')
-    assert new_desk.timeperiodexceptionsource_set.count() == 1
-
-
-def test_meetings_agenda_edit_desk(app, admin_user):
-    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
-    desk = Desk.objects.create(agenda=agenda, label='Desk A')
-    desk2 = Desk.objects.create(agenda=agenda, label='Desk B')
-    other_agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
-    other_desk = Desk.objects.create(agenda=other_agenda, label='Desk A')
-    assert other_desk.slug == desk.slug
-
-    app = login(app)
-    resp = app.get('/manage/desks/%s/edit' % desk.pk)
-    resp.form['label'] = 'Desk C'
-    resp.form['slug'] = desk.slug
-    resp = resp.form.submit().follow()
-    assert 'Desk A' not in resp.text
-    assert 'Desk B' in resp.text
-    assert 'Desk C' in resp.text
-    desk.refresh_from_db()
-    assert desk.label == 'Desk C'
-    assert desk.slug == other_desk.slug
-
-    # check slug edition
-    resp = app.get('/manage/desks/%s/edit' % desk.pk)
-    resp.form['slug'] = desk2.slug
-    resp = resp.form.submit()
-    assert resp.context['form'].errors['slug'] == ['Another desk exists with the same identifier.']
-
-    # unknown pk
-    app.get('/manage/desks/0/edit', status=404)
-
-    # wrong kind
-    agenda.kind = 'virtual'
-    agenda.save()
-    app.get('/manage/desks/%s/edit' % desk.pk, status=404)
-    agenda.kind = 'events'
-    agenda.save()
-    app.get('/manage/desks/%s/edit' % desk.pk, status=404)
-
-
-def test_meetings_agenda_delete_desk(app, admin_user):
-    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
-    Desk.objects.create(agenda=agenda, label='Desk A')
-    desk_b = Desk.objects.create(agenda=agenda, label='Desk B')
-
-    app = login(app)
-
-    resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
-    resp = resp.click('Desk A')
-    resp = resp.click('Delete')
-    resp = resp.form.submit()
-    assert resp.location.endswith('/manage/agendas/%s/settings' % agenda.pk)
-    assert Desk.objects.count() == 1
-
-    # only one desk
-    app.get('/manage/desks/%s/delete' % desk_b.pk, status=404)
-
-    desk_a = Desk.objects.create(agenda=agenda, label='Desk A')
-
-    # unknown pk
-    app.get('/manage/desks/0/delete', status=404)
-
-    # wrong kind
-    agenda.kind = 'virtual'
-    agenda.save()
-    app.get('/manage/desks/%s/delete' % desk_a.pk, status=404)
-    agenda.kind = 'events'
-    agenda.save()
-    app.get('/manage/desks/%s/delete' % desk_a.pk, status=404)
-
-
-def test_meetings_agenda_add_time_period_exception(app, admin_user):
-    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
-    desk = Desk.objects.create(agenda=agenda, label='Desk A')
-    desk2 = Desk.objects.create(agenda=agenda, label='Desk B')
-
-    app = login(app)
-    resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
-    resp = resp.click('Add a time period exception', index=0)
-    today = datetime.datetime.today().replace(hour=0, minute=0, second=0, microsecond=0)
-    tomorrow = make_aware(today + datetime.timedelta(days=1))
-    dt_format = '%Y-%m-%d %H:%M'
-    resp.form['label'] = 'Exception 1'
-    resp.form['start_datetime_0'] = tomorrow.strftime('%Y-%m-%d')
-    resp.form['start_datetime_1'] = '08:00'
-    resp.form['end_datetime_0'] = tomorrow.strftime('%Y-%m-%d')
-    resp.form['end_datetime_1'] = '16:00'
-    resp = resp.form.submit().follow()
-    assert TimePeriodException.objects.count() == 1
-    assert desk2.timeperiodexception_set.exists() is False
-    time_period_exception = TimePeriodException.objects.first()
-    assert localtime(time_period_exception.start_datetime).strftime(dt_format) == tomorrow.replace(
-        hour=8
-    ).strftime(dt_format)
-    assert localtime(time_period_exception.end_datetime).strftime(dt_format) == tomorrow.replace(
-        hour=16
-    ).strftime(dt_format)
-    # add an exception beyond 2 weeks and make sure it isn't listed
-    resp = resp.click('Add a time period exception', index=1)
-    future = tomorrow + datetime.timedelta(days=15)
-    resp.form['label'] = 'Exception 2'
-    resp.form['start_datetime_0'] = future.strftime('%Y-%m-%d')
-    resp.form['start_datetime_1'] = '00:00'
-    resp.form['end_datetime_0'] = future.strftime('%Y-%m-%d')
-    resp.form['end_datetime_1'] = '16:00'
-    resp = resp.form.submit().follow()
-    assert TimePeriodException.objects.count() == 2
-    assert 'Exception 1' in resp.text
-    assert 'Exception 2' not in resp.text
-    resp = resp.click(href="/manage/time-period-exceptions/%d/exception-extract-list" % desk.pk)
-    assert 'Exception 1' in resp.text
-    assert 'Exception 2' in resp.text
-
-
-def test_meetings_agenda_add_time_period_exception_booking_overlaps(app, admin_user):
-    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
-    desk = Desk.objects.create(agenda=agenda, label='Desk A')
-    meeting_type = MeetingType.objects.create(agenda=agenda, label='Blah')
-
-    # different type of overlap
-    # event.start_datetime <= exception.start_datetime < event.start_datetime + meeting_type.duration
-    event = Event.objects.create(
-        agenda=agenda,
-        places=1,
-        desk=desk,
-        meeting_type=meeting_type,
-        start_datetime=make_aware(datetime.datetime(2017, 5, 22, 10, 30)),
-    )
-    Booking.objects.create(event=event)
-    app = login(app)
-    resp = app.get('/manage/agendas/%s/desk/%s/add-time-period-exception' % (agenda.pk, desk.pk))
-    resp.form['label'] = 'Exception'
-    resp.form['start_datetime_0'] = '2017-05-22'
-    resp.form['start_datetime_1'] = '10:45'
-    resp.form['end_datetime_0'] = '2017-05-22'
-    resp.form['end_datetime_1'] = '17:30'
-    resp = resp.form.submit().follow()
-    assert TimePeriodException.objects.count() == 1
-    assert 'Exception added.' in resp.text
-    assert 'One or several bookings exists within this time slot.' in resp.text
-
-
-def test_meetings_agenda_add_time_period_exception_all_desks(app, admin_user):
-    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
-    desk = Desk.objects.create(agenda=agenda, label='Desk A')
-
-    # add global time exception
-    # only one desk: no option to apply to all desks
-    app = login(app)
-    resp = app.get('/manage/agendas/%s/desk/%s/add-time-period-exception' % (agenda.pk, desk.pk))
-    assert 'all_desks' not in resp.context['form'].fields
-    # more than one desk
-    Desk.objects.create(agenda=agenda, label='Desk B')
-    agenda2 = Agenda.objects.create(label='Foo bar', kind='meetings')
-    Desk.objects.create(agenda=agenda2, label='Other Desk')  # check exception is not created for this one
-    event = Event.objects.create(
-        agenda=agenda, places=1, desk=desk, start_datetime=make_aware(datetime.datetime(2017, 5, 22, 10, 30))
-    )
-    Booking.objects.create(event=event)
-    resp = app.get('/manage/agendas/%s/desk/%s/add-time-period-exception' % (agenda.pk, desk.pk))
-    resp.form['label'] = 'Exception'
-    resp.form['start_datetime_0'] = '2017-05-22'
-    resp.form['start_datetime_1'] = '08:00'
-    resp.form['end_datetime_0'] = '2017-05-26'
-    resp.form['end_datetime_1'] = '17:30'
-    resp.form['all_desks'] = True
-    resp = resp.form.submit().follow()
-    assert TimePeriodException.objects.count() == 2
-    assert 'Exceptions added.' in resp.text
-    assert 'One or several bookings exists within this time slot.' in resp.text
-
-    exception = TimePeriodException.objects.first()
-    resp = app.get('/manage/time-period-exceptions/%s/edit' % exception.pk)
-    assert 'all_desks' not in resp.context['form'].fields
-
-
-def test_meetings_agenda_add_time_period_exception_when_booking_exists(app, admin_user):
-    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)
-    )
-    event = Event.objects.create(
-        agenda=agenda, places=1, desk=desk, start_datetime=make_aware(datetime.datetime(2017, 5, 22, 10, 30))
-    )
-    Booking.objects.create(event=event)
-    login(app)
-    resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
-    resp = resp.click('Add a time period exception')
-    resp = resp.form.submit()  # submit empty form
-    # fields should be marked with errors
-    assert resp.text.count('This field is required.') == 2
-    # try again with data in fields
-    resp.form['start_datetime_0'] = '2017-05-22'
-    resp.form['start_datetime_1'] = '08:00'
-    resp.form['end_datetime_0'] = '2017-05-26'
-    resp.form['end_datetime_1'] = '17:30'
-    resp = resp.form.submit().follow()
-    assert 'Exception added.' in resp.text
-    assert 'One or several bookings exists within this time slot.' in resp.text
-    assert TimePeriodException.objects.count() == 1
-
-
-def test_meetings_agenda_add_time_period_exception_when_cancelled_booking_exists(app, admin_user):
-    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)
-    )
-    event = Event.objects.create(
-        agenda=agenda, places=1, start_datetime=make_aware(datetime.datetime(2017, 5, 22, 10, 30))
-    )
-    Booking.objects.create(
-        event=event, cancellation_datetime=make_aware(datetime.datetime(2017, 5, 20, 10, 30))
-    )
-    login(app)
-    resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
-    resp = resp.click('Add a time period exception')
-    resp.form['start_datetime_0'] = '2017-05-22'
-    resp.form['start_datetime_1'] = '08:00'
-    resp.form['end_datetime_0'] = '2017-05-26'
-    resp.form['end_datetime_1'] = '17:30'
-    resp = resp.form.submit().follow()
-    assert 'Exception added' in resp.text
-    assert 'One or several bookings exists within this time slot.' not in resp.text
-    assert TimePeriodException.objects.count() == 1
-
-
-def test_meetings_agenda_add_time_period_exception_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')
-    desk2 = Desk.objects.create(agenda=agenda, label='Desk B')
-    event = Event.objects.create(
-        agenda=agenda, places=1, desk=desk2, start_datetime=make_aware(datetime.datetime(2017, 5, 22, 10, 30))
-    )
-    Booking.objects.create(event=event)
-    assert agenda.is_available_for_simple_management() is True
-
-    login(app)
-    resp = app.get('/manage/agendas/%s/desk/%s/add-time-period-exception' % (agenda.pk, desk.pk))
-    assert 'all_desks' not in resp.form.fields
-    resp.form['label'] = 'Exception'
-    resp.form['start_datetime_0'] = '2017-05-22'
-    resp.form['start_datetime_1'] = '8:00'
-    resp.form['end_datetime_0'] = '2017-05-22'
-    resp.form['end_datetime_1'] = '17:30'
-    resp = resp.form.submit().follow()
-    assert 'Exception added' in resp.text
-    assert 'One or several bookings exists within this time slot.' in resp.text
-    assert TimePeriodException.objects.count() == 2
-    assert agenda.is_available_for_simple_management() is True
-
-
-def test_meetings_agenda_edit_time_period_exception(app, admin_user):
-    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
-    desk = Desk.objects.create(agenda=agenda, label='Desk A')
-    exception = TimePeriodException.objects.create(
-        label='Exception',
-        desk=desk,
-        start_datetime=make_aware(datetime.datetime(2018, 12, 16, 5, 0)),
-        end_datetime=make_aware(datetime.datetime(2018, 12, 16, 23, 0)),
-    )
-    desk2 = desk.duplicate()
-    exception2 = desk2.timeperiodexception_set.get()
-
-    login(app)
-    resp = app.get('/manage/time-period-exceptions/%s/edit' % exception.pk)
-    resp.form['start_datetime_1'] = '8:00'
-    resp.form.submit()
-    exception.refresh_from_db()
-    assert exception.start_datetime == make_aware(datetime.datetime(2018, 12, 16, 8, 0))
-    exception2.refresh_from_db()
-    assert exception2.start_datetime == make_aware(datetime.datetime(2018, 12, 16, 5, 0))
-
-
-def test_meetings_agenda_edit_time_period_exception_overlaps(app, admin_user):
-    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
-    desk = Desk.objects.create(agenda=agenda, label='Desk A')
-    calendar = UnavailabilityCalendar.objects.create(label='foo')
-    calendar.desks.add(desk)
-    time_period_exception_desk = TimePeriodException.objects.create(
-        label='Exception',
-        desk=desk,
-        start_datetime=make_aware(datetime.datetime(2018, 12, 16, 5, 0)),
-        end_datetime=make_aware(datetime.datetime(2018, 12, 16, 23, 0)),
-    )
-    time_period_exception_calendar = TimePeriodException.objects.create(
-        label='Exception',
-        unavailability_calendar=calendar,
-        start_datetime=make_aware(datetime.datetime(2018, 12, 16, 5, 0)),
-        end_datetime=make_aware(datetime.datetime(2018, 12, 16, 23, 0)),
-    )
-    login(app)
-    for time_period_exception in (time_period_exception_desk, time_period_exception_calendar):
-        event = Event.objects.create(
-            agenda=agenda,
-            places=1,
-            desk=desk,
-            start_datetime=make_aware(datetime.datetime(2018, 12, 16, 10, 30)),
-        )
-
-        resp = app.get('/manage/time-period-exceptions/%s/edit' % time_period_exception.pk)
-        resp = resp.form.submit().follow()
-        assert 'One or several bookings exists within this time slot.' not in resp.text
-
-        booking = Booking.objects.create(event=event)
-        resp = app.get('/manage/time-period-exceptions/%s/edit' % time_period_exception.pk)
-        resp = resp.form.submit().follow()
-        assert 'One or several bookings exists within this time slot.' in resp.text
-
-        booking.cancel()
-        resp = app.get('/manage/time-period-exceptions/%s/edit' % time_period_exception.pk)
-        resp = resp.form.submit().follow()
-        assert 'One or several bookings exists within this time slot.' not in resp.text
-
-        booking.delete()
-        event.delete()
-
-
-def test_meetings_agenda_edit_time_period_exception_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')
-    exception = TimePeriodException.objects.create(
-        label='Exception',
-        desk=desk,
-        start_datetime=make_aware(datetime.datetime(2017, 5, 21, 5, 0)),
-        end_datetime=make_aware(datetime.datetime(2017, 5, 21, 23, 0)),
-    )
-    desk2 = desk.duplicate()
-    event = Event.objects.create(
-        agenda=agenda, places=1, desk=desk2, start_datetime=make_aware(datetime.datetime(2017, 5, 22, 10, 30))
-    )
-    Booking.objects.create(event=event)
-    exception2 = desk2.timeperiodexception_set.get()
-    assert agenda.is_available_for_simple_management() is True
-
-    login(app)
-    resp = app.get('/manage/time-period-exceptions/%s/edit' % exception.pk)
-    resp.form['label'] = 'Exception foo bar'
-    resp.form['start_datetime_0'] = '2017-05-22'
-    resp.form['start_datetime_1'] = '8:00'
-    resp.form['end_datetime_0'] = '2017-05-22'
-    resp.form['end_datetime_1'] = '17:30'
-    resp = resp.form.submit().follow()
-    assert 'One or several bookings exists within this time slot.' in resp.text
-    exception.refresh_from_db()
-    exception2.refresh_from_db()
-    assert exception.label == 'Exception foo bar'
-    assert exception.start_datetime == make_aware(datetime.datetime(2017, 5, 22, 8, 0))
-    assert exception.end_datetime == make_aware(datetime.datetime(2017, 5, 22, 17, 30))
-    assert exception2.label == 'Exception foo bar'
-    assert exception2.start_datetime == make_aware(datetime.datetime(2017, 5, 22, 8, 0))
-    assert exception2.end_datetime == make_aware(datetime.datetime(2017, 5, 22, 17, 30))
-    assert agenda.is_available_for_simple_management() is True
-
-    # should not happen: corresponding exception does not exist
-    exception2.delete()
-    resp = app.get('/manage/time-period-exceptions/%s/edit' % exception.pk)
-    resp.form['label'] = 'Exception'
-    resp.form['start_datetime_0'] = '2017-05-21'
-    resp.form['start_datetime_1'] = '9:00'
-    resp.form['end_datetime_0'] = '2017-05-21'
-    resp.form['end_datetime_1'] = '18:30'
-    # no error
-    resp.form.submit()
-    exception.refresh_from_db()
-    assert exception.label == 'Exception'
-    assert exception.start_datetime == make_aware(datetime.datetime(2017, 5, 21, 9, 0))
-    assert exception.end_datetime == make_aware(datetime.datetime(2017, 5, 21, 18, 30))
-
-
-def test_meetings_agenda_add_invalid_time_period_exception():
-    form = TimePeriodExceptionForm(
-        data={
-            'start_datetime_0': '2017-05-26',
-            'start_datetime_1': '17:30',
-            'end_datetime_0': '2017-05-22',
-            'end_datetime_1': '08:00',
-        }
-    )
-    assert form.is_valid() is False
-    assert form.errors['end_datetime'] == ['End datetime must be greater than start datetime.']
-
-    # start_datetime is invalid
-    form = TimePeriodExceptionForm(
-        data={
-            'start_datetime_0': '2017-05-26',
-            'start_datetime_1': 'foo',
-            'end_datetime_0': '2017-05-22',
-            'end_datetime_1': '08:00',
-        }
-    )
-    assert form.is_valid() is False
-
-    # end_datetime is invalid
-    form = TimePeriodExceptionForm(
-        data={
-            'start_datetime_0': '2017-05-26',
-            'start_datetime_1': '17:30',
-            'end_datetime_0': 'bar',
-            'end_datetime_1': '08:00',
-        }
-    )
-    assert form.is_valid() is False
-
-
-def test_meetings_agenda_delete_time_period_exception(app, admin_user):
-    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
-    desk = Desk.objects.create(agenda=agenda, label='Desk A')
-    TimePeriodException.objects.create(
-        label='Exception Foo',
-        desk=desk,
-        start_datetime=now() + datetime.timedelta(days=1),
-        end_datetime=now() + datetime.timedelta(days=2),
-    )
-    desk.duplicate()
-    assert TimePeriodException.objects.count() == 2
-
-    login(app)
-    resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
-    resp = resp.click('Exception Foo', index=0)
-    resp = resp.click('Delete')
-    resp = resp.form.submit().follow()
-    assert TimePeriodException.objects.count() == 1
-    assert resp.request.url.endswith('/manage/agendas/%d/settings' % agenda.pk)
-
-    # stay on exception list
-    time_period_exception = TimePeriodException.objects.create(
-        label='Future Exception',
-        desk=desk,
-        start_datetime=now() + datetime.timedelta(days=1),
-        end_datetime=now() + datetime.timedelta(days=2),
-    )
-    resp = app.get('/manage/time-period-exceptions/%d/exception-list' % desk.pk)
-    resp = resp.click(href='/manage/time-period-exceptions/%d/delete' % time_period_exception.pk)
-    resp = resp.form.submit(
-        extra_environ={'HTTP_REFERER': str('/manage/time-period-exceptions/%d/exception-list' % desk.pk)}
-    ).follow()
-    assert resp.request.url.endswith('/manage/time-period-exceptions/%d/exception-list' % desk.pk)
-
-
-def test_meetings_agenda_delete_time_period_exception_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')
-    exception = TimePeriodException.objects.create(
-        label='Exception',
-        desk=desk,
-        start_datetime=make_aware(datetime.datetime(2017, 5, 21, 5, 0)),
-        end_datetime=make_aware(datetime.datetime(2017, 5, 21, 23, 0)),
-    )
-    desk.duplicate()
-    assert TimePeriodException.objects.count() == 2
-    assert agenda.is_available_for_simple_management() is True
-
-    login(app)
-    resp = app.get('/manage/time-period-exceptions/%d/delete' % exception.pk)
-    resp.form.submit()
-    assert TimePeriodException.objects.count() == 0
-    assert agenda.is_available_for_simple_management() is True
-
-    # should not happen: corresponding exception does not exist
-    exception = TimePeriodException.objects.create(
-        label='Exception',
-        desk=desk,
-        start_datetime=make_aware(datetime.datetime(2017, 5, 21, 5, 0)),
-        end_datetime=make_aware(datetime.datetime(2017, 5, 21, 23, 0)),
-    )
-    assert TimePeriodException.objects.count() == 1
-    resp = app.get('/manage/time-period-exceptions/%d/delete' % exception.pk)
-    resp.form.submit()
-    assert TimePeriodException.objects.count() == 0
-
-
-@override_settings(
-    EXCEPTIONS_SOURCES={
-        'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},
-    }
-)
-def test_exception_list(app, admin_user):
-    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)
-    )
-    past_exception = TimePeriodException.objects.create(
-        label='Past Exception',
-        desk=desk,
-        start_datetime=now() - datetime.timedelta(days=2),
-        end_datetime=now() - datetime.timedelta(days=1),
-    )
-    current_exception = TimePeriodException.objects.create(
-        label='Current Exception',
-        desk=desk,
-        start_datetime=now() - datetime.timedelta(days=1),
-        end_datetime=now() + datetime.timedelta(days=1),
-    )
-    future_exception = TimePeriodException.objects.create(
-        label='Future Exception',
-        desk=desk,
-        start_datetime=now() + datetime.timedelta(days=1),
-        end_datetime=now() + datetime.timedelta(days=2),
-    )
-
-    login(app)
-    resp = app.get('/manage/agendas/%d/settings' % agenda.pk)
-    assert '/manage/time-period-exceptions/%d/edit' % past_exception.pk not in resp.text
-    assert '/manage/time-period-exceptions/%d/edit' % current_exception.pk in resp.text
-    assert '/manage/time-period-exceptions/%d/edit' % future_exception.pk in resp.text
-
-    resp = resp.click(href="/manage/time-period-exceptions/%d/exception-extract-list" % desk.pk)
-    assert '/manage/time-period-exceptions/%d/edit' % past_exception.pk not in resp.text
-    assert '/manage/time-period-exceptions/%d/edit' % current_exception.pk in resp.text
-    assert '/manage/time-period-exceptions/%d/edit' % future_exception.pk in resp.text
-
-    resp = resp.click(href="/manage/time-period-exceptions/%d/exception-list" % desk.pk)
-    assert '/manage/time-period-exceptions/%d/edit' % past_exception.pk not in resp.text
-    assert '/manage/time-period-exceptions/%d/edit' % current_exception.pk in resp.text
-    assert '/manage/time-period-exceptions/%d/edit' % future_exception.pk in resp.text
-
-    with CaptureQueriesContext(connection) as ctx:
-        app.get("/manage/time-period-exceptions/%d/exception-list" % desk.pk)
-        assert len(ctx.captured_queries) == 6
-
-    desk.import_timeperiod_exceptions_from_settings(enable=True)
-    with CaptureQueriesContext(connection) as ctx:
-        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')
-    desk = Desk.objects.create(agenda=agenda, label='Test Desk')
-    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', 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
-    assert TimePeriodExceptionSource.objects.filter(desk=desk).count() == 0
-    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
-    assert TimePeriodExceptionSource.objects.filter(desk=desk).count() == 0
-    ics_with_no_start_date = b"""BEGIN:VCALENDAR
-VERSION:2.0
-PRODID:-//foo.bar//EN
-BEGIN:VEVENT
-DTEND:20180101
-SUMMARY:New Year's Eve
-END:VEVENT
-END:VCALENDAR"""
-    resp.form['ics_file'] = Upload('exceptions.ics', ics_with_no_start_date, 'text/calendar')
-    resp = resp.form.submit(status=200)
-    assert 'Event &quot;New Year&#39;s Eve&quot; has no start date.' in resp.text
-    assert TimePeriodExceptionSource.objects.filter(desk=desk).count() == 0
-    ics_with_no_events = b"""BEGIN:VCALENDAR
-VERSION:2.0
-PRODID:-//foo.bar//EN
-END:VCALENDAR"""
-    resp.form['ics_file'] = Upload('exceptions.ics', ics_with_no_events, 'text/calendar')
-    resp = resp.form.submit(status=200)
-    assert "The file doesn&#39;t contain any events." in resp.text
-    assert TimePeriodExceptionSource.objects.filter(desk=desk).count() == 0
-
-    ics_with_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 = 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 = 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
-    assert source.ics_url is None
-    resp = resp.follow()
-    assert 'Exceptions will be imported in a few minutes.' in resp.text
-
-
-@pytest.mark.freeze_time('2017-12-01')
-def test_agenda_import_time_period_exception_from_ics_recurrent(app, admin_user):
-    agenda = Agenda.objects.create(label='Example', kind='meetings')
-    desk = Desk.objects.create(agenda=agenda, label='Test Desk')
-    MeetingType(agenda=agenda, label='Foo').save()
-    TimePeriod.objects.create(
-        weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)
-    )
-    login(app)
-    resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
-    resp = resp.click('manage exceptions')
-    ics_with_recurrent_exceptions = b"""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
-
-
-@mock.patch('chrono.agendas.models.requests.get')
-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')
-    desk.duplicate()
-    login(app)
-    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
-    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
-END:VEVENT
-END:VCALENDAR"""
-    mocked_get.return_value = mocked_response
-    resp = resp.form.submit(status=302)
-    assert TimePeriodException.objects.filter(desk=desk).count() == 1
-    assert TimePeriodExceptionSource.objects.filter(desk=desk).count() == 1
-    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 == ''
-    assert source.ics_url == 'http://example.com/foo.ics'
-
-
-@mock.patch('chrono.agendas.models.requests.get')
-def test_agenda_import_time_period_exception_with_remote_ics_no_events(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')
-    login(app)
-    resp = app.get('/manage/agendas/%d/settings' % agenda.pk)
-    resp = resp.click('manage exceptions')
-    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
-END:VEVENT
-END:VCALENDAR"""
-    mocked_get.return_value = mocked_response
-    resp = resp.form.submit(status=302)
-    assert TimePeriodException.objects.filter(desk=desk).count() == 1
-
-
-@mock.patch('chrono.agendas.models.requests.get')
-def test_agenda_import_time_period_exception_from_remote_ics_with_connection_error(
-    mocked_get, app, admin_user
-):
-    agenda = Agenda.objects.create(label='New Example', kind='meetings')
-    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)
-    resp = resp.click('manage exceptions')
-
-    assert 'ics_file' in resp.form.fields
-    assert 'ics_url' in resp.form.fields
-    resp.form['ics_url'] = 'http://example.com/foo.ics'
-    mocked_response = mock.Mock()
-    mocked_get.return_value = mocked_response
-
-    def mocked_requests_connection_error(*args, **kwargs):
-        raise requests.exceptions.ConnectionError('unreachable')
-
-    mocked_get.side_effect = mocked_requests_connection_error
-    resp = resp.form.submit(status=200)
-    assert 'Failed to retrieve remote calendar (http://example.com/foo.ics, unreachable).' in resp.text
-
-
-@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.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)
-    resp = resp.click('manage exceptions')
-    resp.form['ics_url'] = 'http://example.com/foo.ics'
-    mocked_response = mock.Mock()
-    mocked_response.status_code = 403
-    mocked_get.return_value = mocked_response
-
-    def mocked_requests_http_forbidden_error(*args, **kwargs):
-        raise requests.exceptions.HTTPError(response=mocked_response)
-
-    mocked_get.side_effect = mocked_requests_http_forbidden_error
-    resp = resp.form.submit(status=200)
-    assert 'Failed to retrieve remote calendar (http://example.com/foo.ics, HTTP error 403).' in resp.text
-
-
-@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.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)
-    resp = resp.click('manage exceptions')
-    resp.form['ics_url'] = 'https://example.com/foo.ics'
-    mocked_response = mock.Mock()
-    mocked_get.return_value = mocked_response
-
-    def mocked_requests_http_ssl_error(*args, **kwargs):
-        raise requests.exceptions.SSLError('SSL error')
-
-    mocked_get.side_effect = mocked_requests_http_ssl_error
-    resp = resp.form.submit(status=200)
-    assert 'Failed to retrieve remote calendar (https://example.com/foo.ics, SSL error).' in resp.text
-
-
-@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)
-    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
-END:VEVENT
-END:VCALENDAR"""
-    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
-
-
-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 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
-
-
-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')
-    ics_file_content = b"""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"""
-
-    login(app)
-    # import a source from a file
-    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
-    source = TimePeriodExceptionSource.objects.latest('pk')
-    assert source.timeperiodexception_set.count() == 2
-    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/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() == 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
-
-
-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')
-    source = TimePeriodExceptionSource.objects.create(
-        desk=desk,
-        ics_filename='sample.ics',
-        ics_file=ContentFile(ics_file_content, name='sample.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
-    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
-DTSTART:20180101
-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')
+    app = login(app)
+    resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
+    resp = resp.click('Blah')
+    resp = resp.click('Delete')
+    assert 'Are you sure you want to delete this?' in resp.text
+    assert 'disabled' not in resp.text
+    resp = resp.form.submit()
+    assert resp.location.endswith('/manage/agendas/%s/settings' % agenda.id)
+    meeting_type.refresh_from_db()
+    assert meeting_type.deleted is True
+    assert '__deleted__' in meeting_type.slug
 
-    login(app)
-    # import a source from an url
+    # meeting type not showing up anymore
     resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
-    resp = resp.click('manage exceptions')
-    resp.form['ics_url'] = 'http://example.com/foo.ics'
-    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'))
+    assert 'Meeting Type Foo' not in resp.text
 
-    desk2 = desk.duplicate()
-    source2 = desk2.timeperiodexceptionsource_set.get()
-    exceptions2 = list(source2.timeperiodexception_set.order_by('pk'))
-    assert TimePeriodException.objects.count() == 2
+    # it is possible to add a new meeting type with the same slug
+    new_meeting_type = MeetingType.objects.create(agenda=agenda, label='Blah')
+    assert new_meeting_type.slug == 'blah'
 
-    # refresh the source
-    resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
-    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 = """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
 
-    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
+def test_meetings_agenda_add_desk(app, admin_user):
+    app = login(app)
+    resp = app.get('/manage/', status=200)
+    resp = resp.click('New')
+    resp.form['label'] = 'Foo bar'
+    resp.form['kind'] = 'meetings'
+    resp = resp.form.submit()
+    assert Desk.objects.count() == 1
+    assert str(Desk.objects.first()) == 'Desk 1'
+    agenda = Agenda.objects.get(slug='foo-bar')
+    agenda.desk_simple_management = False
+    agenda.save()
 
-    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
+    resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
+    resp = resp.click('New Desk')
+    resp.form['label'] = 'Desk A'
+    resp = resp.form.submit().follow()
+    assert Desk.objects.count() == 2
+    desk = Desk.objects.latest('pk')
+    TimePeriod.objects.create(
+        weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)
+    )
+    resp = resp.click('New Desk')
+    resp.form['label'] = 'Desk A'
+    resp = resp.form.submit().follow()
+    assert Desk.objects.count() == 3
+    assert Desk.objects.filter(slug='desk-a-1').count() == 1
+    assert 'Desk A' in resp.text
+    new_desk = Desk.objects.latest('pk')
+    assert new_desk.timeperiod_set.count() == 0
+
+    # unknown pk
+    app.get('/manage/agendas/0/add-desk', status=404)
 
-    # 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
+    # wrong kind
+    agenda.kind = 'virtual'
+    agenda.save()
+    app.get('/manage/agendas/%s/add-desk' % agenda.pk, status=404)
+    agenda.kind = 'events'
+    agenda.save()
+    app.get('/manage/agendas/%s/add-desk' % agenda.pk, status=404)
 
 
 @override_settings(
@@ -2330,35 +937,28 @@ END:VCALENDAR"""
         'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},
     }
 )
-def test_meetings_agenda_time_period_exception_source_from_settings_toggle(app, admin_user):
+def test_meetings_agenda_add_desk_from_another(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)
-    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/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 desk.timeperiodexception_set.exists()
-    assert desk2.timeperiodexception_set.exists()
-
-    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 desk.timeperiodexception_set.exists()
-    assert desk2.timeperiodexception_set.exists()
+    TimePeriod.objects.create(
+        weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)
+    )
+    assert Desk.objects.count() == 1
+    assert desk.timeperiodexceptionsource_set.count() == 0
 
-    resp = app.get('/manage/agendas/desk/%s/import-exceptions-from-ics/' % desk.pk)
-    assert 'disabled' not in resp.text
+    app = login(app)
+    resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
+    resp = resp.click('New Desk')
+    resp.form['label'] = 'Desk B'
+    resp.form['copy_from'] = desk.pk
+    resp = resp.form.submit().follow()
+    assert Desk.objects.count() == 2
+    new_desk = Desk.objects.latest('pk')
+    assert new_desk.label == 'Desk B'
+    assert new_desk.timeperiod_set.count() == 1
+    assert (
+        new_desk.timeperiodexceptionsource_set.count() == 0
+    )  # holidays not automatically added via duplication
 
 
 @override_settings(
@@ -2366,89 +966,106 @@ def test_meetings_agenda_time_period_exception_source_from_settings_toggle(app,
         'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},
     }
 )
-def test_meetings_agenda_time_period_exception_source_from_settings_toggle_desk_simple_management(
-    app, admin_user
-):
+def test_meetings_agenda_add_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
+    TimePeriod.objects.create(
+        weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)
+    )
+    assert Desk.objects.count() == 1
+    assert desk.timeperiodexceptionsource_set.count() == 0
 
-    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
+    app = login(app)
+    resp = app.get('/manage/agendas/%s/add-desk' % agenda.pk)
+    resp.form['label'] = 'Desk B'
+    assert 'copy_from' not in resp.context['form'].fields
+    resp = resp.form.submit().follow()
+    assert Desk.objects.count() == 2
+    new_desk = Desk.objects.latest('pk')
+    assert new_desk.label == 'Desk B'
+    assert new_desk.timeperiod_set.count() == 1
+    assert (
+        new_desk.timeperiodexceptionsource_set.count() == 0
+    )  # holidays not automatically added via duplication
 
-    # 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
+    # ok if no desks (should not happen)
+    Desk.objects.all().delete()
+    resp = app.get('/manage/agendas/%s/add-desk' % agenda.pk)
+    resp.form['label'] = 'Desk'
+    assert 'copy_from' not in resp.context['form'].fields
+    resp = resp.form.submit().follow()
+    assert Desk.objects.count() == 1
+    new_desk = Desk.objects.latest('pk')
+    assert new_desk.timeperiodexceptionsource_set.count() == 1
 
 
-def test_meetings_agenda_time_period_exception_source_try_disable_ics(app, admin_user):
+def test_meetings_agenda_edit_desk(app, admin_user):
     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)
-    )
-    source = TimePeriodExceptionSource.objects.create(desk=desk, ics_url='https://example.com/test.ics')
+    desk2 = Desk.objects.create(agenda=agenda, label='Desk B')
+    other_agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
+    other_desk = Desk.objects.create(agenda=other_agenda, label='Desk A')
+    assert other_desk.slug == desk.slug
 
-    login(app)
-    resp = app.get('/manage/agendas/%d/' % agenda.pk).follow().follow()
-    resp = resp.click('Settings')
-    resp = resp.click('manage exceptions')
-    assert 'test.ics' in resp.text
+    app = login(app)
+    resp = app.get('/manage/desks/%s/edit' % desk.pk)
+    resp.form['label'] = 'Desk C'
+    resp.form['slug'] = desk.slug
+    resp = resp.form.submit().follow()
+    assert 'Desk A' not in resp.text
+    assert 'Desk B' in resp.text
+    assert 'Desk C' in resp.text
+    desk.refresh_from_db()
+    assert desk.label == 'Desk C'
+    assert desk.slug == other_desk.slug
 
-    assert app.get('/manage/time-period-exceptions-source/%s/toggle' % source.pk, status=404)
+    # check slug edition
+    resp = app.get('/manage/desks/%s/edit' % desk.pk)
+    resp.form['slug'] = desk2.slug
+    resp = resp.form.submit()
+    assert resp.context['form'].errors['slug'] == ['Another desk exists with the same identifier.']
 
+    # unknown pk
+    app.get('/manage/desks/0/edit', status=404)
 
-@override_settings(
-    EXCEPTIONS_SOURCES={
-        'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},
-    }
-)
-def test_meetings_agenda_time_period_exception_source_from_settings(app, admin_user, freezer):
-    freezer.move_to('2020-01-01')
+    # wrong kind
+    agenda.kind = 'virtual'
+    agenda.save()
+    app.get('/manage/desks/%s/edit' % desk.pk, status=404)
+    agenda.kind = 'events'
+    agenda.save()
+    app.get('/manage/desks/%s/edit' % desk.pk, status=404)
+
+
+def test_meetings_agenda_delete_desk(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)
-    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})
+    Desk.objects.create(agenda=agenda, label='Desk A')
+    desk_b = Desk.objects.create(agenda=agenda, label='Desk B')
 
-    login(app)
-    resp = app.get('/manage/agendas/%d/settings' % agenda.pk)
-    assert 'New year' in resp.text
-    assert remove_url not in resp.text and edit_url not in resp.text
+    app = login(app)
+
+    resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
+    resp = resp.click('Desk A')
+    resp = resp.click('Delete')
+    resp = resp.form.submit()
+    assert resp.location.endswith('/manage/agendas/%s/settings' % agenda.pk)
+    assert Desk.objects.count() == 1
+
+    # only one desk
+    app.get('/manage/desks/%s/delete' % desk_b.pk, status=404)
 
-    resp = resp.click('see all')
-    assert 'New year' in resp.text
-    assert remove_url not in resp.text and edit_url not in resp.text
+    desk_a = Desk.objects.create(agenda=agenda, label='Desk A')
+
+    # unknown pk
+    app.get('/manage/desks/0/delete', status=404)
 
-    app.get(remove_url, status=404)
+    # wrong kind
+    agenda.kind = 'virtual'
+    agenda.save()
+    app.get('/manage/desks/%s/delete' % desk_a.pk, status=404)
+    agenda.kind = 'events'
+    agenda.save()
+    app.get('/manage/desks/%s/delete' % desk_a.pk, status=404)
 
 
 def test_agenda_day_view(app, admin_user, manager_user, api_user):
@@ -4455,117 +3072,3 @@ def test_agenda_booking_colors(app, admin_user, api_user, view):
     assert resp.text.count('Swimming') == 2  # 1 booking + legend
     assert 'Booking colors:' in resp.text
     assert len(resp.pyquery.find('div.booking-colors span.booking-color-label')) == 2
-
-
-@override_settings(
-    EXCEPTIONS_SOURCES={
-        'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},
-    }
-)
-def test_recurring_events_manage_exceptions(settings, app, admin_user, freezer):
-    freezer.move_to('2021-07-01 12:10')
-
-    app = login(app)
-    resp = app.get('/manage/')
-    resp = resp.click('New')
-    resp.form['label'] = 'Foo bar'
-    resp.form['kind'] = 'events'
-    resp = resp.form.submit().follow()
-
-    agenda = Agenda.objects.get(label='Foo bar')
-    assert agenda.desk_set.count() == 1
-    desk = agenda.desk_set.get(slug='_exceptions_holder')
-
-    event = Event.objects.create(start_datetime=now(), places=10, agenda=agenda)
-    resp = app.get('/manage/agendas/%s/settings' % agenda.id)
-    assert not 'Recurrence exceptions' in resp.text
-
-    event.recurrence_days = list(range(7))
-    event.save()
-
-    resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2021, 7))
-    assert len(resp.pyquery.find('.event-info')) == 31
-
-    resp = app.get('/manage/agendas/%s/settings' % agenda.id)
-    assert 'Recurrence exceptions' in resp.text
-
-    resp = resp.click('Add a time period exception')
-    resp.form['start_datetime_0'] = now().strftime('%Y-%m-%d')
-    resp.form['start_datetime_1'] = now().strftime('%H:%M')
-    resp.form['end_datetime_0'] = (now() + datetime.timedelta(days=7)).strftime('%Y-%m-%d')
-    resp.form['end_datetime_1'] = (now() + datetime.timedelta(days=7)).strftime('%H:%M')
-    resp = resp.form.submit().follow()
-    assert desk.timeperiodexception_set.count() == 1
-
-    resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2021, 7))
-    assert len(resp.pyquery.find('.event-info')) == 24
-
-    resp = app.get('/manage/agendas/%s/settings' % agenda.id)
-    resp = resp.click('Configure', href='exceptions')
-    resp = resp.click('enable').follow()
-    assert TimePeriodException.objects.count() > 1
-    assert 'Bastille Day' in resp.text
-
-    resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2021, 7))
-    assert len(resp.pyquery.find('.event-info')) == 23
-
-    # add recurrence end date, which lead to recurrences creation
-    resp = app.get('/manage/agendas/%s/events/%s/edit' % (agenda.id, event.id))
-    resp.form['recurrence_end_date'] = (now() + datetime.timedelta(days=31)).strftime('%Y-%m-%d')
-    resp = resp.form.submit()
-
-    # recurrences corresponding to exceptions have not been created
-    assert Event.objects.count() == 24
-
-
-def test_recurring_events_exceptions_report(settings, app, admin_user, freezer):
-    freezer.move_to('2021-07-01 12:10')
-    agenda = Agenda.objects.create(label='Foo bar', kind='events')
-    Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
-    event = Event.objects.create(
-        start_datetime=now(),
-        places=10,
-        recurrence_days=list(range(7)),
-        recurrence_end_date=now() + datetime.timedelta(days=30),
-        agenda=agenda,
-    )
-    event.create_all_recurrences()
-
-    app = login(app)
-    resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.pk, 2021, 7))
-    assert len(resp.pyquery.find('.event-info')) == 30
-
-    time_period_exception = TimePeriodException.objects.create(
-        desk=agenda.desk_set.get(),
-        start_datetime=datetime.date(year=2021, month=7, day=5),
-        end_datetime=datetime.date(year=2021, month=7, day=10),
-    )
-    call_command('update_event_recurrences')
-
-    resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.pk, 2021, 7))
-    assert len(resp.pyquery.find('.event-info')) == 25
-
-    resp = app.get('/manage/agendas/%s/settings' % agenda.id)
-    assert not 'warningnotice' in resp.text
-
-    event = Event.objects.get(start_datetime__day=11)
-    booking = Booking.objects.create(event=event)
-    time_period_exception.end_datetime = datetime.date(year=2021, month=7, day=12)
-    time_period_exception.save()
-    call_command('update_event_recurrences')
-
-    resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.pk, 2021, 7))
-    assert len(resp.pyquery.find('.event-info')) == 24
-
-    resp = app.get('/manage/agendas/%s/settings' % agenda.id)
-    assert 'warningnotice' in resp.text
-    assert 'July 11, 2021, 2:10 p.m.' in resp.text
-
-    booking.cancel()
-    call_command('update_event_recurrences')
-
-    resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.pk, 2021, 7))
-    assert len(resp.pyquery.find('.event-info')) == 23
-
-    resp = app.get('/manage/agendas/%s/settings' % agenda.id)
-    assert not 'warningnotice' in resp.text
diff --git a/tests/manager/test_exception.py b/tests/manager/test_exception.py
new file mode 100644
index 0000000..dfed674
--- /dev/null
+++ b/tests/manager/test_exception.py
@@ -0,0 +1,1316 @@
+import datetime
+import os
+from unittest import mock
+
+import pytest
+import requests
+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
+from django.test.utils import CaptureQueriesContext
+from django.urls import reverse
+from django.utils.timezone import localtime, make_aware, now
+from webtest import Upload
+
+from chrono.agendas.models import (
+    Agenda,
+    Booking,
+    Desk,
+    Event,
+    MeetingType,
+    TimePeriod,
+    TimePeriodException,
+    TimePeriodExceptionSource,
+    UnavailabilityCalendar,
+)
+from chrono.manager.forms import TimePeriodExceptionForm
+
+pytestmark = pytest.mark.django_db
+
+
+def login(app, username='admin', password='admin'):
+    login_page = app.get('/login/')
+    login_form = login_page.forms[0]
+    login_form['username'] = username
+    login_form['password'] = password
+    resp = login_form.submit()
+    assert resp.status_int == 302
+    return app
+
+
+@override_settings(
+    EXCEPTIONS_SOURCES={
+        'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},
+    }
+)
+def test_add_agenda_exceptions_from_settings(app, admin_user):
+    app = login(app)
+    resp = app.get('/manage/', status=200)
+    resp = resp.click('New')
+    resp.form['label'] = 'Foo bar'
+    resp.form['kind'] = 'meetings'
+    resp = resp.form.submit().follow()
+
+    agenda = Agenda.objects.get(label='Foo bar')
+    assert agenda.desk_set.count() == 1
+
+    default_desk = agenda.desk_set.first()
+    assert default_desk.timeperiodexception_set.exists()
+    assert default_desk.timeperiodexceptionsource_set.count() == 1
+
+    source = default_desk.timeperiodexceptionsource_set.first()
+    assert source.enabled
+
+    agenda.desk_simple_management = False
+    agenda.save()
+    resp = app.get('/manage/agendas/%s/add-desk' % agenda.pk)
+    resp.form['label'] = 'Desk A'
+    resp = resp.form.submit().follow()
+
+    desk = Desk.objects.get(slug='desk-a')
+    assert desk.timeperiodexception_set.exists()
+    assert desk.timeperiodexceptionsource_set.count() == 1
+
+    source = desk.timeperiodexceptionsource_set.first()
+    assert source.enabled
+
+
+def test_meetings_agenda_add_time_period_exception(app, admin_user):
+    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
+    desk = Desk.objects.create(agenda=agenda, label='Desk A')
+    desk2 = Desk.objects.create(agenda=agenda, label='Desk B')
+
+    app = login(app)
+    resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
+    resp = resp.click('Add a time period exception', index=0)
+    today = datetime.datetime.today().replace(hour=0, minute=0, second=0, microsecond=0)
+    tomorrow = make_aware(today + datetime.timedelta(days=1))
+    dt_format = '%Y-%m-%d %H:%M'
+    resp.form['label'] = 'Exception 1'
+    resp.form['start_datetime_0'] = tomorrow.strftime('%Y-%m-%d')
+    resp.form['start_datetime_1'] = '08:00'
+    resp.form['end_datetime_0'] = tomorrow.strftime('%Y-%m-%d')
+    resp.form['end_datetime_1'] = '16:00'
+    resp = resp.form.submit().follow()
+    assert TimePeriodException.objects.count() == 1
+    assert desk2.timeperiodexception_set.exists() is False
+    time_period_exception = TimePeriodException.objects.first()
+    assert localtime(time_period_exception.start_datetime).strftime(dt_format) == tomorrow.replace(
+        hour=8
+    ).strftime(dt_format)
+    assert localtime(time_period_exception.end_datetime).strftime(dt_format) == tomorrow.replace(
+        hour=16
+    ).strftime(dt_format)
+    # add an exception beyond 2 weeks and make sure it isn't listed
+    resp = resp.click('Add a time period exception', index=1)
+    future = tomorrow + datetime.timedelta(days=15)
+    resp.form['label'] = 'Exception 2'
+    resp.form['start_datetime_0'] = future.strftime('%Y-%m-%d')
+    resp.form['start_datetime_1'] = '00:00'
+    resp.form['end_datetime_0'] = future.strftime('%Y-%m-%d')
+    resp.form['end_datetime_1'] = '16:00'
+    resp = resp.form.submit().follow()
+    assert TimePeriodException.objects.count() == 2
+    assert 'Exception 1' in resp.text
+    assert 'Exception 2' not in resp.text
+    resp = resp.click(href="/manage/time-period-exceptions/%d/exception-extract-list" % desk.pk)
+    assert 'Exception 1' in resp.text
+    assert 'Exception 2' in resp.text
+
+
+def test_meetings_agenda_add_time_period_exception_booking_overlaps(app, admin_user):
+    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
+    desk = Desk.objects.create(agenda=agenda, label='Desk A')
+    meeting_type = MeetingType.objects.create(agenda=agenda, label='Blah')
+
+    # different type of overlap
+    # event.start_datetime <= exception.start_datetime < event.start_datetime + meeting_type.duration
+    event = Event.objects.create(
+        agenda=agenda,
+        places=1,
+        desk=desk,
+        meeting_type=meeting_type,
+        start_datetime=make_aware(datetime.datetime(2017, 5, 22, 10, 30)),
+    )
+    Booking.objects.create(event=event)
+    app = login(app)
+    resp = app.get('/manage/agendas/%s/desk/%s/add-time-period-exception' % (agenda.pk, desk.pk))
+    resp.form['label'] = 'Exception'
+    resp.form['start_datetime_0'] = '2017-05-22'
+    resp.form['start_datetime_1'] = '10:45'
+    resp.form['end_datetime_0'] = '2017-05-22'
+    resp.form['end_datetime_1'] = '17:30'
+    resp = resp.form.submit().follow()
+    assert TimePeriodException.objects.count() == 1
+    assert 'Exception added.' in resp.text
+    assert 'One or several bookings exists within this time slot.' in resp.text
+
+
+def test_meetings_agenda_add_time_period_exception_all_desks(app, admin_user):
+    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
+    desk = Desk.objects.create(agenda=agenda, label='Desk A')
+
+    # add global time exception
+    # only one desk: no option to apply to all desks
+    app = login(app)
+    resp = app.get('/manage/agendas/%s/desk/%s/add-time-period-exception' % (agenda.pk, desk.pk))
+    assert 'all_desks' not in resp.context['form'].fields
+    # more than one desk
+    Desk.objects.create(agenda=agenda, label='Desk B')
+    agenda2 = Agenda.objects.create(label='Foo bar', kind='meetings')
+    Desk.objects.create(agenda=agenda2, label='Other Desk')  # check exception is not created for this one
+    event = Event.objects.create(
+        agenda=agenda, places=1, desk=desk, start_datetime=make_aware(datetime.datetime(2017, 5, 22, 10, 30))
+    )
+    Booking.objects.create(event=event)
+    resp = app.get('/manage/agendas/%s/desk/%s/add-time-period-exception' % (agenda.pk, desk.pk))
+    resp.form['label'] = 'Exception'
+    resp.form['start_datetime_0'] = '2017-05-22'
+    resp.form['start_datetime_1'] = '08:00'
+    resp.form['end_datetime_0'] = '2017-05-26'
+    resp.form['end_datetime_1'] = '17:30'
+    resp.form['all_desks'] = True
+    resp = resp.form.submit().follow()
+    assert TimePeriodException.objects.count() == 2
+    assert 'Exceptions added.' in resp.text
+    assert 'One or several bookings exists within this time slot.' in resp.text
+
+    exception = TimePeriodException.objects.first()
+    resp = app.get('/manage/time-period-exceptions/%s/edit' % exception.pk)
+    assert 'all_desks' not in resp.context['form'].fields
+
+
+def test_meetings_agenda_add_time_period_exception_when_booking_exists(app, admin_user):
+    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)
+    )
+    event = Event.objects.create(
+        agenda=agenda, places=1, desk=desk, start_datetime=make_aware(datetime.datetime(2017, 5, 22, 10, 30))
+    )
+    Booking.objects.create(event=event)
+    login(app)
+    resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
+    resp = resp.click('Add a time period exception')
+    resp = resp.form.submit()  # submit empty form
+    # fields should be marked with errors
+    assert resp.text.count('This field is required.') == 2
+    # try again with data in fields
+    resp.form['start_datetime_0'] = '2017-05-22'
+    resp.form['start_datetime_1'] = '08:00'
+    resp.form['end_datetime_0'] = '2017-05-26'
+    resp.form['end_datetime_1'] = '17:30'
+    resp = resp.form.submit().follow()
+    assert 'Exception added.' in resp.text
+    assert 'One or several bookings exists within this time slot.' in resp.text
+    assert TimePeriodException.objects.count() == 1
+
+
+def test_meetings_agenda_add_time_period_exception_when_cancelled_booking_exists(app, admin_user):
+    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)
+    )
+    event = Event.objects.create(
+        agenda=agenda, places=1, start_datetime=make_aware(datetime.datetime(2017, 5, 22, 10, 30))
+    )
+    Booking.objects.create(
+        event=event, cancellation_datetime=make_aware(datetime.datetime(2017, 5, 20, 10, 30))
+    )
+    login(app)
+    resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
+    resp = resp.click('Add a time period exception')
+    resp.form['start_datetime_0'] = '2017-05-22'
+    resp.form['start_datetime_1'] = '08:00'
+    resp.form['end_datetime_0'] = '2017-05-26'
+    resp.form['end_datetime_1'] = '17:30'
+    resp = resp.form.submit().follow()
+    assert 'Exception added' in resp.text
+    assert 'One or several bookings exists within this time slot.' not in resp.text
+    assert TimePeriodException.objects.count() == 1
+
+
+def test_meetings_agenda_add_time_period_exception_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')
+    desk2 = Desk.objects.create(agenda=agenda, label='Desk B')
+    event = Event.objects.create(
+        agenda=agenda, places=1, desk=desk2, start_datetime=make_aware(datetime.datetime(2017, 5, 22, 10, 30))
+    )
+    Booking.objects.create(event=event)
+    assert agenda.is_available_for_simple_management() is True
+
+    login(app)
+    resp = app.get('/manage/agendas/%s/desk/%s/add-time-period-exception' % (agenda.pk, desk.pk))
+    assert 'all_desks' not in resp.form.fields
+    resp.form['label'] = 'Exception'
+    resp.form['start_datetime_0'] = '2017-05-22'
+    resp.form['start_datetime_1'] = '8:00'
+    resp.form['end_datetime_0'] = '2017-05-22'
+    resp.form['end_datetime_1'] = '17:30'
+    resp = resp.form.submit().follow()
+    assert 'Exception added' in resp.text
+    assert 'One or several bookings exists within this time slot.' in resp.text
+    assert TimePeriodException.objects.count() == 2
+    assert agenda.is_available_for_simple_management() is True
+
+
+def test_meetings_agenda_edit_time_period_exception(app, admin_user):
+    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
+    desk = Desk.objects.create(agenda=agenda, label='Desk A')
+    exception = TimePeriodException.objects.create(
+        label='Exception',
+        desk=desk,
+        start_datetime=make_aware(datetime.datetime(2018, 12, 16, 5, 0)),
+        end_datetime=make_aware(datetime.datetime(2018, 12, 16, 23, 0)),
+    )
+    desk2 = desk.duplicate()
+    exception2 = desk2.timeperiodexception_set.get()
+
+    login(app)
+    resp = app.get('/manage/time-period-exceptions/%s/edit' % exception.pk)
+    resp.form['start_datetime_1'] = '8:00'
+    resp.form.submit()
+    exception.refresh_from_db()
+    assert exception.start_datetime == make_aware(datetime.datetime(2018, 12, 16, 8, 0))
+    exception2.refresh_from_db()
+    assert exception2.start_datetime == make_aware(datetime.datetime(2018, 12, 16, 5, 0))
+
+
+def test_meetings_agenda_edit_time_period_exception_overlaps(app, admin_user):
+    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
+    desk = Desk.objects.create(agenda=agenda, label='Desk A')
+    calendar = UnavailabilityCalendar.objects.create(label='foo')
+    calendar.desks.add(desk)
+    time_period_exception_desk = TimePeriodException.objects.create(
+        label='Exception',
+        desk=desk,
+        start_datetime=make_aware(datetime.datetime(2018, 12, 16, 5, 0)),
+        end_datetime=make_aware(datetime.datetime(2018, 12, 16, 23, 0)),
+    )
+    time_period_exception_calendar = TimePeriodException.objects.create(
+        label='Exception',
+        unavailability_calendar=calendar,
+        start_datetime=make_aware(datetime.datetime(2018, 12, 16, 5, 0)),
+        end_datetime=make_aware(datetime.datetime(2018, 12, 16, 23, 0)),
+    )
+    login(app)
+    for time_period_exception in (time_period_exception_desk, time_period_exception_calendar):
+        event = Event.objects.create(
+            agenda=agenda,
+            places=1,
+            desk=desk,
+            start_datetime=make_aware(datetime.datetime(2018, 12, 16, 10, 30)),
+        )
+
+        resp = app.get('/manage/time-period-exceptions/%s/edit' % time_period_exception.pk)
+        resp = resp.form.submit().follow()
+        assert 'One or several bookings exists within this time slot.' not in resp.text
+
+        booking = Booking.objects.create(event=event)
+        resp = app.get('/manage/time-period-exceptions/%s/edit' % time_period_exception.pk)
+        resp = resp.form.submit().follow()
+        assert 'One or several bookings exists within this time slot.' in resp.text
+
+        booking.cancel()
+        resp = app.get('/manage/time-period-exceptions/%s/edit' % time_period_exception.pk)
+        resp = resp.form.submit().follow()
+        assert 'One or several bookings exists within this time slot.' not in resp.text
+
+        booking.delete()
+        event.delete()
+
+
+def test_meetings_agenda_edit_time_period_exception_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')
+    exception = TimePeriodException.objects.create(
+        label='Exception',
+        desk=desk,
+        start_datetime=make_aware(datetime.datetime(2017, 5, 21, 5, 0)),
+        end_datetime=make_aware(datetime.datetime(2017, 5, 21, 23, 0)),
+    )
+    desk2 = desk.duplicate()
+    event = Event.objects.create(
+        agenda=agenda, places=1, desk=desk2, start_datetime=make_aware(datetime.datetime(2017, 5, 22, 10, 30))
+    )
+    Booking.objects.create(event=event)
+    exception2 = desk2.timeperiodexception_set.get()
+    assert agenda.is_available_for_simple_management() is True
+
+    login(app)
+    resp = app.get('/manage/time-period-exceptions/%s/edit' % exception.pk)
+    resp.form['label'] = 'Exception foo bar'
+    resp.form['start_datetime_0'] = '2017-05-22'
+    resp.form['start_datetime_1'] = '8:00'
+    resp.form['end_datetime_0'] = '2017-05-22'
+    resp.form['end_datetime_1'] = '17:30'
+    resp = resp.form.submit().follow()
+    assert 'One or several bookings exists within this time slot.' in resp.text
+    exception.refresh_from_db()
+    exception2.refresh_from_db()
+    assert exception.label == 'Exception foo bar'
+    assert exception.start_datetime == make_aware(datetime.datetime(2017, 5, 22, 8, 0))
+    assert exception.end_datetime == make_aware(datetime.datetime(2017, 5, 22, 17, 30))
+    assert exception2.label == 'Exception foo bar'
+    assert exception2.start_datetime == make_aware(datetime.datetime(2017, 5, 22, 8, 0))
+    assert exception2.end_datetime == make_aware(datetime.datetime(2017, 5, 22, 17, 30))
+    assert agenda.is_available_for_simple_management() is True
+
+    # should not happen: corresponding exception does not exist
+    exception2.delete()
+    resp = app.get('/manage/time-period-exceptions/%s/edit' % exception.pk)
+    resp.form['label'] = 'Exception'
+    resp.form['start_datetime_0'] = '2017-05-21'
+    resp.form['start_datetime_1'] = '9:00'
+    resp.form['end_datetime_0'] = '2017-05-21'
+    resp.form['end_datetime_1'] = '18:30'
+    # no error
+    resp.form.submit()
+    exception.refresh_from_db()
+    assert exception.label == 'Exception'
+    assert exception.start_datetime == make_aware(datetime.datetime(2017, 5, 21, 9, 0))
+    assert exception.end_datetime == make_aware(datetime.datetime(2017, 5, 21, 18, 30))
+
+
+def test_meetings_agenda_add_invalid_time_period_exception():
+    form = TimePeriodExceptionForm(
+        data={
+            'start_datetime_0': '2017-05-26',
+            'start_datetime_1': '17:30',
+            'end_datetime_0': '2017-05-22',
+            'end_datetime_1': '08:00',
+        }
+    )
+    assert form.is_valid() is False
+    assert form.errors['end_datetime'] == ['End datetime must be greater than start datetime.']
+
+    # start_datetime is invalid
+    form = TimePeriodExceptionForm(
+        data={
+            'start_datetime_0': '2017-05-26',
+            'start_datetime_1': 'foo',
+            'end_datetime_0': '2017-05-22',
+            'end_datetime_1': '08:00',
+        }
+    )
+    assert form.is_valid() is False
+
+    # end_datetime is invalid
+    form = TimePeriodExceptionForm(
+        data={
+            'start_datetime_0': '2017-05-26',
+            'start_datetime_1': '17:30',
+            'end_datetime_0': 'bar',
+            'end_datetime_1': '08:00',
+        }
+    )
+    assert form.is_valid() is False
+
+
+def test_meetings_agenda_delete_time_period_exception(app, admin_user):
+    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
+    desk = Desk.objects.create(agenda=agenda, label='Desk A')
+    TimePeriodException.objects.create(
+        label='Exception Foo',
+        desk=desk,
+        start_datetime=now() + datetime.timedelta(days=1),
+        end_datetime=now() + datetime.timedelta(days=2),
+    )
+    desk.duplicate()
+    assert TimePeriodException.objects.count() == 2
+
+    login(app)
+    resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
+    resp = resp.click('Exception Foo', index=0)
+    resp = resp.click('Delete')
+    resp = resp.form.submit().follow()
+    assert TimePeriodException.objects.count() == 1
+    assert resp.request.url.endswith('/manage/agendas/%d/settings' % agenda.pk)
+
+    # stay on exception list
+    time_period_exception = TimePeriodException.objects.create(
+        label='Future Exception',
+        desk=desk,
+        start_datetime=now() + datetime.timedelta(days=1),
+        end_datetime=now() + datetime.timedelta(days=2),
+    )
+    resp = app.get('/manage/time-period-exceptions/%d/exception-list' % desk.pk)
+    resp = resp.click(href='/manage/time-period-exceptions/%d/delete' % time_period_exception.pk)
+    resp = resp.form.submit(
+        extra_environ={'HTTP_REFERER': str('/manage/time-period-exceptions/%d/exception-list' % desk.pk)}
+    ).follow()
+    assert resp.request.url.endswith('/manage/time-period-exceptions/%d/exception-list' % desk.pk)
+
+
+def test_meetings_agenda_delete_time_period_exception_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')
+    exception = TimePeriodException.objects.create(
+        label='Exception',
+        desk=desk,
+        start_datetime=make_aware(datetime.datetime(2017, 5, 21, 5, 0)),
+        end_datetime=make_aware(datetime.datetime(2017, 5, 21, 23, 0)),
+    )
+    desk.duplicate()
+    assert TimePeriodException.objects.count() == 2
+    assert agenda.is_available_for_simple_management() is True
+
+    login(app)
+    resp = app.get('/manage/time-period-exceptions/%d/delete' % exception.pk)
+    resp.form.submit()
+    assert TimePeriodException.objects.count() == 0
+    assert agenda.is_available_for_simple_management() is True
+
+    # should not happen: corresponding exception does not exist
+    exception = TimePeriodException.objects.create(
+        label='Exception',
+        desk=desk,
+        start_datetime=make_aware(datetime.datetime(2017, 5, 21, 5, 0)),
+        end_datetime=make_aware(datetime.datetime(2017, 5, 21, 23, 0)),
+    )
+    assert TimePeriodException.objects.count() == 1
+    resp = app.get('/manage/time-period-exceptions/%d/delete' % exception.pk)
+    resp.form.submit()
+    assert TimePeriodException.objects.count() == 0
+
+
+@override_settings(
+    EXCEPTIONS_SOURCES={
+        'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},
+    }
+)
+def test_exception_list(app, admin_user):
+    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)
+    )
+    past_exception = TimePeriodException.objects.create(
+        label='Past Exception',
+        desk=desk,
+        start_datetime=now() - datetime.timedelta(days=2),
+        end_datetime=now() - datetime.timedelta(days=1),
+    )
+    current_exception = TimePeriodException.objects.create(
+        label='Current Exception',
+        desk=desk,
+        start_datetime=now() - datetime.timedelta(days=1),
+        end_datetime=now() + datetime.timedelta(days=1),
+    )
+    future_exception = TimePeriodException.objects.create(
+        label='Future Exception',
+        desk=desk,
+        start_datetime=now() + datetime.timedelta(days=1),
+        end_datetime=now() + datetime.timedelta(days=2),
+    )
+
+    login(app)
+    resp = app.get('/manage/agendas/%d/settings' % agenda.pk)
+    assert '/manage/time-period-exceptions/%d/edit' % past_exception.pk not in resp.text
+    assert '/manage/time-period-exceptions/%d/edit' % current_exception.pk in resp.text
+    assert '/manage/time-period-exceptions/%d/edit' % future_exception.pk in resp.text
+
+    resp = resp.click(href="/manage/time-period-exceptions/%d/exception-extract-list" % desk.pk)
+    assert '/manage/time-period-exceptions/%d/edit' % past_exception.pk not in resp.text
+    assert '/manage/time-period-exceptions/%d/edit' % current_exception.pk in resp.text
+    assert '/manage/time-period-exceptions/%d/edit' % future_exception.pk in resp.text
+
+    resp = resp.click(href="/manage/time-period-exceptions/%d/exception-list" % desk.pk)
+    assert '/manage/time-period-exceptions/%d/edit' % past_exception.pk not in resp.text
+    assert '/manage/time-period-exceptions/%d/edit' % current_exception.pk in resp.text
+    assert '/manage/time-period-exceptions/%d/edit' % future_exception.pk in resp.text
+
+    with CaptureQueriesContext(connection) as ctx:
+        app.get("/manage/time-period-exceptions/%d/exception-list" % desk.pk)
+        assert len(ctx.captured_queries) == 6
+
+    desk.import_timeperiod_exceptions_from_settings(enable=True)
+    with CaptureQueriesContext(connection) as ctx:
+        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')
+    desk = Desk.objects.create(agenda=agenda, label='Test Desk')
+    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', 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
+    assert TimePeriodExceptionSource.objects.filter(desk=desk).count() == 0
+    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
+    assert TimePeriodExceptionSource.objects.filter(desk=desk).count() == 0
+    ics_with_no_start_date = b"""BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//foo.bar//EN
+BEGIN:VEVENT
+DTEND:20180101
+SUMMARY:New Year's Eve
+END:VEVENT
+END:VCALENDAR"""
+    resp.form['ics_file'] = Upload('exceptions.ics', ics_with_no_start_date, 'text/calendar')
+    resp = resp.form.submit(status=200)
+    assert 'Event &quot;New Year&#39;s Eve&quot; has no start date.' in resp.text
+    assert TimePeriodExceptionSource.objects.filter(desk=desk).count() == 0
+    ics_with_no_events = b"""BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//foo.bar//EN
+END:VCALENDAR"""
+    resp.form['ics_file'] = Upload('exceptions.ics', ics_with_no_events, 'text/calendar')
+    resp = resp.form.submit(status=200)
+    assert "The file doesn&#39;t contain any events." in resp.text
+    assert TimePeriodExceptionSource.objects.filter(desk=desk).count() == 0
+
+    ics_with_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 = 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 = 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
+    assert source.ics_url is None
+    resp = resp.follow()
+    assert 'Exceptions will be imported in a few minutes.' in resp.text
+
+
+@pytest.mark.freeze_time('2017-12-01')
+def test_agenda_import_time_period_exception_from_ics_recurrent(app, admin_user):
+    agenda = Agenda.objects.create(label='Example', kind='meetings')
+    desk = Desk.objects.create(agenda=agenda, label='Test Desk')
+    MeetingType(agenda=agenda, label='Foo').save()
+    TimePeriod.objects.create(
+        weekday=1, desk=desk, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0)
+    )
+    login(app)
+    resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
+    resp = resp.click('manage exceptions')
+    ics_with_recurrent_exceptions = b"""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
+
+
+@mock.patch('chrono.agendas.models.requests.get')
+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')
+    desk.duplicate()
+    login(app)
+    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
+    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
+END:VEVENT
+END:VCALENDAR"""
+    mocked_get.return_value = mocked_response
+    resp = resp.form.submit(status=302)
+    assert TimePeriodException.objects.filter(desk=desk).count() == 1
+    assert TimePeriodExceptionSource.objects.filter(desk=desk).count() == 1
+    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 == ''
+    assert source.ics_url == 'http://example.com/foo.ics'
+
+
+@mock.patch('chrono.agendas.models.requests.get')
+def test_agenda_import_time_period_exception_with_remote_ics_no_events(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')
+    login(app)
+    resp = app.get('/manage/agendas/%d/settings' % agenda.pk)
+    resp = resp.click('manage exceptions')
+    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
+END:VEVENT
+END:VCALENDAR"""
+    mocked_get.return_value = mocked_response
+    resp = resp.form.submit(status=302)
+    assert TimePeriodException.objects.filter(desk=desk).count() == 1
+
+
+@mock.patch('chrono.agendas.models.requests.get')
+def test_agenda_import_time_period_exception_from_remote_ics_with_connection_error(
+    mocked_get, app, admin_user
+):
+    agenda = Agenda.objects.create(label='New Example', kind='meetings')
+    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)
+    resp = resp.click('manage exceptions')
+
+    assert 'ics_file' in resp.form.fields
+    assert 'ics_url' in resp.form.fields
+    resp.form['ics_url'] = 'http://example.com/foo.ics'
+    mocked_response = mock.Mock()
+    mocked_get.return_value = mocked_response
+
+    def mocked_requests_connection_error(*args, **kwargs):
+        raise requests.exceptions.ConnectionError('unreachable')
+
+    mocked_get.side_effect = mocked_requests_connection_error
+    resp = resp.form.submit(status=200)
+    assert 'Failed to retrieve remote calendar (http://example.com/foo.ics, unreachable).' in resp.text
+
+
+@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.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)
+    resp = resp.click('manage exceptions')
+    resp.form['ics_url'] = 'http://example.com/foo.ics'
+    mocked_response = mock.Mock()
+    mocked_response.status_code = 403
+    mocked_get.return_value = mocked_response
+
+    def mocked_requests_http_forbidden_error(*args, **kwargs):
+        raise requests.exceptions.HTTPError(response=mocked_response)
+
+    mocked_get.side_effect = mocked_requests_http_forbidden_error
+    resp = resp.form.submit(status=200)
+    assert 'Failed to retrieve remote calendar (http://example.com/foo.ics, HTTP error 403).' in resp.text
+
+
+@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.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)
+    resp = resp.click('manage exceptions')
+    resp.form['ics_url'] = 'https://example.com/foo.ics'
+    mocked_response = mock.Mock()
+    mocked_get.return_value = mocked_response
+
+    def mocked_requests_http_ssl_error(*args, **kwargs):
+        raise requests.exceptions.SSLError('SSL error')
+
+    mocked_get.side_effect = mocked_requests_http_ssl_error
+    resp = resp.form.submit(status=200)
+    assert 'Failed to retrieve remote calendar (https://example.com/foo.ics, SSL error).' in resp.text
+
+
+@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)
+    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
+END:VEVENT
+END:VCALENDAR"""
+    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
+
+
+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 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
+
+
+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')
+    ics_file_content = b"""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"""
+
+    login(app)
+    # import a source from a file
+    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
+    source = TimePeriodExceptionSource.objects.latest('pk')
+    assert source.timeperiodexception_set.count() == 2
+    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/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() == 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
+
+
+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')
+    source = TimePeriodExceptionSource.objects.create(
+        desk=desk,
+        ics_filename='sample.ics',
+        ics_file=ContentFile(ics_file_content, name='sample.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
+    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
+DTSTART:20180101
+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'
+    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', 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 = """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
+
+    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
+
+
+@override_settings(
+    EXCEPTIONS_SOURCES={
+        'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},
+    }
+)
+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)
+    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/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 desk.timeperiodexception_set.exists()
+    assert desk2.timeperiodexception_set.exists()
+
+    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 desk.timeperiodexception_set.exists()
+    assert desk2.timeperiodexception_set.exists()
+
+    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')
+    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_url='https://example.com/test.ics')
+
+    login(app)
+    resp = app.get('/manage/agendas/%d/' % agenda.pk).follow().follow()
+    resp = resp.click('Settings')
+    resp = resp.click('manage exceptions')
+    assert 'test.ics' in resp.text
+
+    assert app.get('/manage/time-period-exceptions-source/%s/toggle' % source.pk, status=404)
+
+
+@override_settings(
+    EXCEPTIONS_SOURCES={
+        'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},
+    }
+)
+def test_meetings_agenda_time_period_exception_source_from_settings(app, admin_user, freezer):
+    freezer.move_to('2020-01-01')
+    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)
+    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})
+
+    login(app)
+    resp = app.get('/manage/agendas/%d/settings' % agenda.pk)
+    assert 'New year' 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 remove_url not in resp.text and edit_url not in resp.text
+
+    app.get(remove_url, status=404)
+
+
+@override_settings(
+    EXCEPTIONS_SOURCES={
+        'holidays': {'class': 'workalendar.europe.France', 'label': 'Holidays'},
+    }
+)
+def test_recurring_events_manage_exceptions(settings, app, admin_user, freezer):
+    freezer.move_to('2021-07-01 12:10')
+
+    app = login(app)
+    resp = app.get('/manage/')
+    resp = resp.click('New')
+    resp.form['label'] = 'Foo bar'
+    resp.form['kind'] = 'events'
+    resp = resp.form.submit().follow()
+
+    agenda = Agenda.objects.get(label='Foo bar')
+    assert agenda.desk_set.count() == 1
+    desk = agenda.desk_set.get(slug='_exceptions_holder')
+
+    event = Event.objects.create(start_datetime=now(), places=10, agenda=agenda)
+    resp = app.get('/manage/agendas/%s/settings' % agenda.id)
+    assert 'Recurrence exceptions' not in resp.text
+
+    event.recurrence_days = list(range(7))
+    event.save()
+
+    resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2021, 7))
+    assert len(resp.pyquery.find('.event-info')) == 31
+
+    resp = app.get('/manage/agendas/%s/settings' % agenda.id)
+    assert 'Recurrence exceptions' in resp.text
+
+    resp = resp.click('Add a time period exception')
+    resp.form['start_datetime_0'] = now().strftime('%Y-%m-%d')
+    resp.form['start_datetime_1'] = now().strftime('%H:%M')
+    resp.form['end_datetime_0'] = (now() + datetime.timedelta(days=7)).strftime('%Y-%m-%d')
+    resp.form['end_datetime_1'] = (now() + datetime.timedelta(days=7)).strftime('%H:%M')
+    resp = resp.form.submit().follow()
+    assert desk.timeperiodexception_set.count() == 1
+
+    resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2021, 7))
+    assert len(resp.pyquery.find('.event-info')) == 24
+
+    resp = app.get('/manage/agendas/%s/settings' % agenda.id)
+    resp = resp.click('Configure', href='exceptions')
+    resp = resp.click('enable').follow()
+    assert TimePeriodException.objects.count() > 1
+    assert 'Bastille Day' in resp.text
+
+    resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2021, 7))
+    assert len(resp.pyquery.find('.event-info')) == 23
+
+    # add recurrence end date, which lead to recurrences creation
+    resp = app.get('/manage/agendas/%s/events/%s/edit' % (agenda.id, event.id))
+    resp.form['recurrence_end_date'] = (now() + datetime.timedelta(days=31)).strftime('%Y-%m-%d')
+    resp = resp.form.submit()
+
+    # recurrences corresponding to exceptions have not been created
+    assert Event.objects.count() == 24
+
+
+def test_recurring_events_exceptions_report(settings, app, admin_user, freezer):
+    freezer.move_to('2021-07-01 12:10')
+    agenda = Agenda.objects.create(label='Foo bar', kind='events')
+    Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
+    event = Event.objects.create(
+        start_datetime=now(),
+        places=10,
+        recurrence_days=list(range(7)),
+        recurrence_end_date=now() + datetime.timedelta(days=30),
+        agenda=agenda,
+    )
+    event.create_all_recurrences()
+
+    app = login(app)
+    resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.pk, 2021, 7))
+    assert len(resp.pyquery.find('.event-info')) == 30
+
+    time_period_exception = TimePeriodException.objects.create(
+        desk=agenda.desk_set.get(),
+        start_datetime=datetime.date(year=2021, month=7, day=5),
+        end_datetime=datetime.date(year=2021, month=7, day=10),
+    )
+    call_command('update_event_recurrences')
+
+    resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.pk, 2021, 7))
+    assert len(resp.pyquery.find('.event-info')) == 25
+
+    resp = app.get('/manage/agendas/%s/settings' % agenda.id)
+    assert 'warningnotice' not in resp.text
+
+    event = Event.objects.get(start_datetime__day=11)
+    booking = Booking.objects.create(event=event)
+    time_period_exception.end_datetime = datetime.date(year=2021, month=7, day=12)
+    time_period_exception.save()
+    call_command('update_event_recurrences')
+
+    resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.pk, 2021, 7))
+    assert len(resp.pyquery.find('.event-info')) == 24
+
+    resp = app.get('/manage/agendas/%s/settings' % agenda.id)
+    assert 'warningnotice' in resp.text
+    assert 'July 11, 2021, 2:10 p.m.' in resp.text
+
+    booking.cancel()
+    call_command('update_event_recurrences')
+
+    resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.pk, 2021, 7))
+    assert len(resp.pyquery.find('.event-info')) == 23
+
+    resp = app.get('/manage/agendas/%s/settings' % agenda.id)
+    assert 'warningnotice' not in resp.text
diff --git a/tests/manager/test_timeperiod.py b/tests/manager/test_timeperiod.py
new file mode 100644
index 0000000..7e1ed26
--- /dev/null
+++ b/tests/manager/test_timeperiod.py
@@ -0,0 +1,233 @@
+import datetime
+
+import pytest
+
+from chrono.agendas.models import Agenda, Desk, MeetingType, TimePeriod
+
+pytestmark = pytest.mark.django_db
+
+
+def login(app, username='admin', password='admin'):
+    login_page = app.get('/login/')
+    login_form = login_page.forms[0]
+    login_form['username'] = username
+    login_form['password'] = password
+    resp = login_form.submit()
+    assert resp.status_int == 302
+    return app
+
+
+def test_meetings_agenda_add_time_period(app, admin_user):
+    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
+    desk = Desk.objects.create(agenda=agenda, label='Desk A')
+    desk2 = Desk.objects.create(agenda=agenda, label='Desk B')
+    MeetingType.objects.create(agenda=agenda, label='Blah')
+    app = login(app)
+    resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
+    resp = resp.click('Add a time period', index=0)
+    resp.form.get('weekdays', index=2).checked = True
+    resp.form['start_time'] = '10:00'
+    resp.form['end_time'] = '17:00'
+    resp = resp.form.submit()
+    assert TimePeriod.objects.get(desk=desk).weekday == 2
+    assert TimePeriod.objects.get(desk=desk).start_time.hour == 10
+    assert TimePeriod.objects.get(desk=desk).start_time.minute == 0
+    assert TimePeriod.objects.get(desk=desk).end_time.hour == 17
+    assert TimePeriod.objects.get(desk=desk).end_time.minute == 0
+    assert desk2.timeperiod_set.exists() is False
+    resp = resp.follow()
+
+    # add a second time period
+    resp = resp.click('Add a time period', index=0)
+    resp.form.get('weekdays', index=0).checked = True
+    resp.form['start_time'] = '10:00'
+    resp.form['end_time'] = '13:00'
+    resp = resp.form.submit()
+    resp = resp.follow()
+    assert 'Monday / 10 a.m. → 1 p.m.' in resp.text
+    assert 'Wednesday / 10 a.m. → 5 p.m.' in resp.text
+    assert resp.text.index('Monday') < resp.text.index('Wednesday')
+
+    # invert start and end
+    resp2 = resp.click('Add a time period', index=0)
+    resp2.form.get('weekdays', index=0).checked = True
+    resp2.form['start_time'] = '13:00'
+    resp2.form['end_time'] = '10:00'
+    resp2 = resp2.form.submit()
+    assert 'End time must come after start time.' in resp2.text
+
+    # and add same time periods on multiple days
+    resp = resp.click('Add a time period', index=0)
+    resp.form.get('weekdays', index=4).checked = True
+    resp.form.get('weekdays', index=5).checked = True
+    resp.form['start_time'] = '10:00'
+    resp.form['end_time'] = '13:00'
+    resp = resp.form.submit()
+    assert TimePeriod.objects.filter(desk=desk).count() == 4
+
+
+def test_meetings_agenda_add_time_period_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')
+    desk2 = Desk.objects.create(agenda=agenda, label='Desk B')
+    assert agenda.is_available_for_simple_management() is True
+
+    app = login(app)
+    resp = app.get('/manage/agendas/%s/desk/%s/add-time-period' % (agenda.pk, desk.pk))
+    resp.form.get('weekdays', index=0).checked = True
+    resp.form['start_time'] = '10:00'
+    resp.form['end_time'] = '13:00'
+    resp.form.submit()
+
+    assert TimePeriod.objects.filter(desk=desk).count() == 1
+    assert TimePeriod.objects.filter(desk=desk2).count() == 1
+    assert agenda.is_available_for_simple_management() is True
+
+
+def test_meetings_agenda_add_time_period_on_missing_desk(app, admin_user):
+    app = login(app)
+    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
+    app.get('/manage/agendas/%s/desk/0/add-time-period' % agenda.pk, status=404)
+
+
+def test_meetings_agenda_add_time_period_as_manager(app, manager_user):
+    agenda = Agenda(label='Foo bar', kind='meetings')
+    agenda.view_role = manager_user.groups.all()[0]
+    agenda.save()
+    desk = Desk.objects.create(agenda=agenda, label='Desk A')
+    app = login(app, username='manager', password='manager')
+    resp = app.get('/manage/agendas/%d/' % agenda.id)
+    assert 'Settings' not in resp.text
+    resp = app.get('/manage/agendas/%d/settings' % agenda.id, status=403)
+    MeetingType(agenda=agenda, label='Blah').save()
+    app.get('/manage/agendas/%d/desk/%d/add-time-period' % (agenda.id, desk.id), status=403)
+    time_period = TimePeriod(
+        desk=desk, weekday=0, start_time=datetime.time(9, 0), end_time=datetime.time(12, 0)
+    )
+    time_period.save()
+    resp = app.get('/manage/agendas/%d/' % agenda.id)
+    app.get('/manage/timeperiods/%d/edit' % time_period.id, status=403)
+    app.get('/manage/timeperiods/%d/delete' % time_period.id, status=403)
+    # grant edit right to manager
+    agenda.edit_role = manager_user.groups.all()[0]
+    agenda.save()
+
+    resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
+    assert 'Add a time period' in resp.text
+    assert '/manage/timeperiods/%s/edit' % time_period.id in resp.text
+    assert '/manage/timeperiods/%s/delete' % time_period.id in resp.text
+
+    app.get('/manage/agendas/%d/desk/%d/add-time-period' % (agenda.id, desk.id), status=200)
+    app.get('/manage/timeperiods/%d/edit' % time_period.id, status=200)
+
+
+def test_meetings_agenda_edit_time_period(app, admin_user):
+    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
+    desk = Desk.objects.create(agenda=agenda, label='Desk A')
+    time_period = TimePeriod.objects.create(
+        desk=desk, weekday=0, start_time=datetime.time(9, 0), end_time=datetime.time(12, 0)
+    )
+    desk2 = desk.duplicate()
+    time_period2 = desk2.timeperiod_set.get()
+
+    app = login(app)
+    # edit
+    resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
+    resp = resp.click('Monday / 9 a.m. → noon', index=0)
+    assert 'Edit Time Period' in resp.text
+    resp.form['start_time'] = '10:00'
+    resp = resp.form.submit()
+    resp = resp.follow()
+    time_period.refresh_from_db()
+    assert time_period.start_time.hour == 10
+    time_period2.refresh_from_db()
+    assert time_period2.start_time.hour == 9
+
+    # edit with inverted start/end
+    resp2 = resp.click('Monday / 10 a.m. → noon')
+    resp2.form['start_time'] = '18:00'
+    resp2 = resp2.form.submit()
+    assert 'End time must come after start time.' in resp2.text
+
+
+def test_meetings_agenda_edit_time_period_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')
+    time_period = TimePeriod.objects.create(
+        desk=desk, weekday=0, start_time=datetime.time(9, 0), end_time=datetime.time(12, 0)
+    )
+    desk2 = desk.duplicate()
+    time_period2 = desk2.timeperiod_set.get()
+    assert agenda.is_available_for_simple_management() is True
+
+    app = login(app)
+    resp = app.get('/manage/timeperiods/%s/edit' % time_period.pk)
+    resp.form['weekday'] = 3
+    resp.form['start_time'] = '10:00'
+    resp.form['end_time'] = '11:00'
+    resp.form.submit()
+    time_period.refresh_from_db()
+    time_period2.refresh_from_db()
+    assert time_period.weekday == 3
+    assert time_period.start_time.hour == 10
+    assert time_period.end_time.hour == 11
+    assert time_period2.weekday == 3
+    assert time_period2.start_time.hour == 10
+    assert time_period2.end_time.hour == 11
+    assert agenda.is_available_for_simple_management() is True
+
+    # should not happen: corresponding time period does not exist
+    time_period2.delete()
+    resp = app.get('/manage/timeperiods/%s/edit' % time_period.pk)
+    resp.form['weekday'] = 3
+    resp.form['start_time'] = '10:00'
+    resp.form['end_time'] = '11:00'
+    # no error
+    resp.form.submit()
+    time_period.refresh_from_db()
+    assert time_period.weekday == 3
+    assert time_period.start_time.hour == 10
+    assert time_period.end_time.hour == 11
+
+
+def test_meetings_agenda_delete_time_period(app, admin_user):
+    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
+    desk = Desk.objects.create(agenda=agenda, label='Desk A')
+    TimePeriod.objects.create(
+        desk=desk, weekday=2, start_time=datetime.time(10, 0), end_time=datetime.time(18, 0)
+    )
+    desk.duplicate()
+    assert TimePeriod.objects.count() == 2
+
+    app = login(app)
+    resp = app.get('/manage/agendas/%s/settings' % agenda.id, status=200)
+    resp = resp.click('Wednesday', index=0)
+    resp = resp.click('Delete')
+    resp = resp.form.submit()
+    assert resp.location.endswith('/manage/agendas/%s/settings' % agenda.id)
+    assert TimePeriod.objects.count() == 1
+
+
+def test_meetings_agenda_delete_time_period_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')
+    time_period = TimePeriod.objects.create(
+        desk=desk, weekday=0, start_time=datetime.time(9, 0), end_time=datetime.time(12, 0)
+    )
+    desk.duplicate()
+    assert TimePeriod.objects.count() == 2
+    assert agenda.is_available_for_simple_management() is True
+
+    app = login(app)
+    resp = app.get('/manage/timeperiods/%s/delete' % time_period.pk)
+    resp.form.submit()
+    assert TimePeriod.objects.count() == 0
+    assert agenda.is_available_for_simple_management() is True
+
+    # should not happen: corresponding time period does not exist
+    time_period = TimePeriod.objects.create(
+        desk=desk, weekday=0, start_time=datetime.time(9, 0), end_time=datetime.time(12, 0)
+    )
+    resp = app.get('/manage/timeperiods/%s/delete' % time_period.pk)
+    resp.form.submit()
+    assert TimePeriod.objects.count() == 0
-- 
2.30.2

