From 42766e15a900716ad31bfa33e9f79bb9e0a46d18 Mon Sep 17 00:00:00 2001
From: Emmanuel Cazenave <ecazenave@entrouvert.com>
Date: Wed, 30 Sep 2020 17:53:42 +0200
Subject: [PATCH] start unavailability calendars (#46555)

---
 .../0065_unavailability_calendar.py           |  68 ++++
 chrono/agendas/models.py                      |  57 ++-
 chrono/api/views.py                           |  12 +
 chrono/manager/forms.py                       |  22 ++
 ...r_agenda_unavailability_calendar_form.html |  23 ++
 .../templates/chrono/manager_home.html        |   7 +-
 .../chrono/manager_import_exceptions.html     |   8 +-
 .../manager_time_period_exception_form.html   |   2 +-
 ...anager_unavailability_calendar_detail.html |  51 +++
 .../manager_unavailability_calendar_form.html |  22 ++
 .../manager_unavailability_calendar_list.html |  38 ++
 ...ager_unavailability_calendar_settings.html |  52 +++
 chrono/manager/urls.py                        |  40 ++
 chrono/manager/views.py                       | 255 ++++++++++++-
 chrono/urls_utils.py                          |   9 +-
 tests/test_api.py                             | 131 ++++++-
 tests/test_manager.py                         | 346 +++++++++++++++++-
 17 files changed, 1124 insertions(+), 19 deletions(-)
 create mode 100644 chrono/agendas/migrations/0065_unavailability_calendar.py
 create mode 100644 chrono/manager/templates/chrono/manager_agenda_unavailability_calendar_form.html
 create mode 100644 chrono/manager/templates/chrono/manager_unavailability_calendar_detail.html
 create mode 100644 chrono/manager/templates/chrono/manager_unavailability_calendar_form.html
 create mode 100644 chrono/manager/templates/chrono/manager_unavailability_calendar_list.html
 create mode 100644 chrono/manager/templates/chrono/manager_unavailability_calendar_settings.html

diff --git a/chrono/agendas/migrations/0065_unavailability_calendar.py b/chrono/agendas/migrations/0065_unavailability_calendar.py
new file mode 100644
index 0000000..931d1fa
--- /dev/null
+++ b/chrono/agendas/migrations/0065_unavailability_calendar.py
@@ -0,0 +1,68 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.18 on 2020-10-05 12:37
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('auth', '0008_alter_user_username_max_length'),
+        ('agendas', '0064_booking_form_url'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='UnavailabilityCalendar',
+            fields=[
+                (
+                    'id',
+                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
+                ),
+                ('label', models.CharField(max_length=150, verbose_name='Label')),
+                ('slug', models.SlugField(max_length=160, unique=True, verbose_name='Identifier')),
+                ('desks', models.ManyToManyField(related_name='unavailability_calendars', to='agendas.Desk')),
+                (
+                    'edit_role',
+                    models.ForeignKey(
+                        blank=True,
+                        default=None,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name='+',
+                        to='auth.Group',
+                        verbose_name='Edit Role',
+                    ),
+                ),
+                (
+                    'view_role',
+                    models.ForeignKey(
+                        blank=True,
+                        default=None,
+                        null=True,
+                        on_delete=django.db.models.deletion.SET_NULL,
+                        related_name='+',
+                        to='auth.Group',
+                        verbose_name='View Role',
+                    ),
+                ),
+            ],
+            options={'ordering': ['label'],},
+        ),
+        migrations.AlterField(
+            model_name='timeperiodexception',
+            name='desk',
+            field=models.ForeignKey(
+                null=True, on_delete=django.db.models.deletion.CASCADE, to='agendas.Desk'
+            ),
+        ),
+        migrations.AddField(
+            model_name='timeperiodexception',
+            name='unavailability_calendar',
+            field=models.ForeignKey(
+                null=True, on_delete=django.db.models.deletion.CASCADE, to='agendas.UnavailabilityCalendar'
+            ),
+        ),
+    ]
diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py
index 61ea011..431b4b8 100644
--- a/chrono/agendas/models.py
+++ b/chrono/agendas/models.py
@@ -1486,8 +1486,63 @@ class TimePeriodExceptionSource(models.Model):
         self.save()
 
 
+class UnavailabilityCalendar(models.Model):
+    label = models.CharField(_('Label'), max_length=150)
+    slug = models.SlugField(_('Identifier'), max_length=160, unique=True)
+    desks = models.ManyToManyField(Desk, related_name='unavailability_calendars')
+    edit_role = models.ForeignKey(
+        Group,
+        blank=True,
+        null=True,
+        default=None,
+        related_name='+',
+        verbose_name=_('Edit Role'),
+        on_delete=models.SET_NULL,
+    )
+    view_role = models.ForeignKey(
+        Group,
+        blank=True,
+        null=True,
+        default=None,
+        related_name='+',
+        verbose_name=_('View Role'),
+        on_delete=models.SET_NULL,
+    )
+
+    class Meta:
+        ordering = ['label']
+
+    def __str__(self):
+        return self.label
+
+    @property
+    def base_slug(self):
+        return slugify(self.label)
+
+    def save(self, *args, **kwargs):
+        if not self.slug:
+            self.slug = generate_slug(self)
+        super(UnavailabilityCalendar, self).save(*args, **kwargs)
+
+    def can_be_managed(self, user):
+        if user.is_staff:
+            return True
+        group_ids = [x.id for x in user.groups.all()]
+        return bool(self.edit_role_id in group_ids)
+
+    def can_be_viewed(self, user):
+        if self.can_be_managed(user):
+            return True
+        group_ids = [x.id for x in user.groups.all()]
+        return bool(self.view_role_id in group_ids)
+
+    def get_absolute_url(self):
+        return reverse('chrono-manager-unavailability-calendar-view', kwargs={'pk': self.id})
+
+
 class TimePeriodException(models.Model):
-    desk = models.ForeignKey(Desk, on_delete=models.CASCADE)
+    desk = models.ForeignKey(Desk, on_delete=models.CASCADE, null=True)
+    unavailability_calendar = models.ForeignKey(UnavailabilityCalendar, on_delete=models.CASCADE, null=True)
     source = models.ForeignKey(TimePeriodExceptionSource, on_delete=models.CASCADE, null=True)
     label = models.CharField(_('Optional Label'), max_length=150, blank=True, null=True)
     start_datetime = models.DateTimeField(_('Exception start time'))
diff --git a/chrono/api/views.py b/chrono/api/views.py
index 7d20ca6..126d924 100644
--- a/chrono/api/views.py
+++ b/chrono/api/views.py
@@ -141,6 +141,18 @@ def get_all_slots(base_agenda, meeting_type, resources=None, unique=False):
             key=lambda time_period: time_period.desk,
         )
     }
+
+    # add exceptions from unavailability calendar
+    for time_period_exception in TimePeriodException.objects.filter(
+        unavailability_calendar__desks__agenda__in=agendas
+    ).order_by('start_datetime', 'end_datetime'):
+        for desk in time_period_exception.unavailability_calendar.desks.all():
+            if desk not in desks_exceptions:
+                desks_exceptions[desk] = IntervalSet()
+            desks_exceptions[desk].add(
+                time_period_exception.start_datetime, time_period_exception.end_datetime
+            )
+
     # compute reduced min/max_datetime windows by desks based on exceptions
     desk_min_max_datetime = {}
     for desk, desk_exception in desks_exceptions.items():
diff --git a/chrono/manager/forms.py b/chrono/manager/forms.py
index b9c8042..f606121 100644
--- a/chrono/manager/forms.py
+++ b/chrono/manager/forms.py
@@ -44,6 +44,7 @@ from chrono.agendas.models import (
     AgendaNotificationsSettings,
     AgendaReminderSettings,
     WEEKDAYS_LIST,
+    UnavailabilityCalendar,
 )
 
 from . import widgets
@@ -89,6 +90,25 @@ class AgendaEditForm(AgendaAddForm):
             del self.fields['booking_form_url']
 
 
+class UnavailabilityCalendarAddForm(forms.ModelForm):
+    class Meta:
+        model = UnavailabilityCalendar
+        fields = ['label', 'edit_role', 'view_role']
+
+    edit_role = forms.ModelChoiceField(
+        label=_('Edit Role'), required=False, queryset=Group.objects.all().order_by('name')
+    )
+    view_role = forms.ModelChoiceField(
+        label=_('View Role'), required=False, queryset=Group.objects.all().order_by('name')
+    )
+
+
+class UnavailabilityCalendarEditForm(UnavailabilityCalendarAddForm):
+    class Meta:
+        model = UnavailabilityCalendar
+        fields = ['label', 'slug', 'edit_role', 'view_role']
+
+
 class ResourceAddForm(forms.ModelForm):
     class Meta:
         model = Resource
@@ -272,6 +292,8 @@ class TimePeriodExceptionForm(forms.ModelForm):
         super().__init__(*args, **kwargs)
         if self.instance.pk is not None:
             del self.fields['all_desks']
+        elif self.instance.unavailability_calendar:
+            del self.fields['all_desks']
         elif self.instance.desk_id and self.instance.desk.agenda.desk_set.count() == 1:
             del self.fields['all_desks']
 
diff --git a/chrono/manager/templates/chrono/manager_agenda_unavailability_calendar_form.html b/chrono/manager/templates/chrono/manager_agenda_unavailability_calendar_form.html
new file mode 100644
index 0000000..3afc32d
--- /dev/null
+++ b/chrono/manager/templates/chrono/manager_agenda_unavailability_calendar_form.html
@@ -0,0 +1,23 @@
+{% extends "chrono/manager_agenda_view.html" %}
+{% load i18n %}
+
+{% block breadcrumb %}
+{{ block.super }}
+<a href="">{% trans "Add unavailability calendar" %}</a>
+{% endblock %}
+
+{% block appbar %}
+<h2>{% trans "Add unavailability calendar" %}</h2>
+{% endblock %}
+
+{% block content %}
+
+<form method="post" enctype="multipart/form-data">
+  {% csrf_token %}
+  {{ form.as_p }}
+  <div class="buttons">
+    <button class="submit-button">{% trans "Save" %}</button>
+    <a class="cancel" href="{% url 'chrono-manager-agenda-settings' pk=agenda.pk %}">{% trans 'Cancel' %}</a>
+  </div>
+</form>
+{% endblock %}
diff --git a/chrono/manager/templates/chrono/manager_home.html b/chrono/manager/templates/chrono/manager_home.html
index acd5d97..7a20607 100644
--- a/chrono/manager/templates/chrono/manager_home.html
+++ b/chrono/manager/templates/chrono/manager_home.html
@@ -3,14 +3,17 @@
 
 {% block appbar %}
 <h2>{% trans 'Agendas' %}</h2>
-{% if user.is_staff %}
 <span class="actions">
+{% if user.is_staff %}
 <a href="{% url 'chrono-manager-category-list' %}">{% trans 'Categories' %}</a>
+{% endif %}
+<a href="{% url 'chrono-manager-unavailability-calendar-list' %}">{% trans 'Unavailability calendars' %}</a>
+{% if user.is_staff %}
 <a href="{% url 'chrono-manager-resource-list' %}">{% trans 'Resources' %}</a>
 <a rel="popup" href="{% url 'chrono-manager-agendas-import' %}">{% trans 'Import' %}</a>
 <a rel="popup" href="{% url 'chrono-manager-agenda-add' %}">{% trans 'New' %}</a>
-</span>
 {% endif %}
+</span>
 {% endblock %}
 
 {% block content %}
diff --git a/chrono/manager/templates/chrono/manager_import_exceptions.html b/chrono/manager/templates/chrono/manager_import_exceptions.html
index c266a8f..c326cdd 100644
--- a/chrono/manager/templates/chrono/manager_import_exceptions.html
+++ b/chrono/manager/templates/chrono/manager_import_exceptions.html
@@ -13,7 +13,7 @@
 {% block content %}
 
 <form method="post" enctype="multipart/form-data">
-  {% if exception_sources %}
+  {% if exception_sources or unavailability_calendars %}
   <ul class="objects-list single-links">
       {% for object in exception_sources %}
         <li>
@@ -30,6 +30,12 @@
           {% endif %}
         </li>
       {% endfor %}
+      {% for unavailability_calendar in unavailability_calendars %}
+        <li>
+          <a {% if not unavailability_calendar.enabled %}class="disabled"{% endif %} title="{{ unavailability_calendar }}" href="{{ unavailability_calendar.get_absolute_url }}">{{ unavailability_calendar|truncatechars:50 }}</a>
+          <a class="link-action-text" href="{% url 'chrono-manager-unavailability-calendar-toggle-view' desk.pk unavailability_calendar.pk %}">({{ unavailability_calendar.enabled|yesno:_("disable,enable") }})</a>
+        </li>
+      {% endfor %}
   </ul>
   {% endif %}
 
diff --git a/chrono/manager/templates/chrono/manager_time_period_exception_form.html b/chrono/manager/templates/chrono/manager_time_period_exception_form.html
index b070ae0..bb97fe8 100644
--- a/chrono/manager/templates/chrono/manager_time_period_exception_form.html
+++ b/chrono/manager/templates/chrono/manager_time_period_exception_form.html
@@ -40,7 +40,7 @@
   {% endfor %}
   <div class="buttons">
     <button class="submit-button">{% trans "Save" %}</button>
-    <a class="cancel" href="{% url 'chrono-manager-agenda-settings' pk=desk.agenda.id %}">{% trans 'Cancel' %}</a>
+    <a class="cancel" href="{{ cancel_url }}">{% trans 'Cancel' %}</a>
   </div>
 
   <script>
diff --git a/chrono/manager/templates/chrono/manager_unavailability_calendar_detail.html b/chrono/manager/templates/chrono/manager_unavailability_calendar_detail.html
new file mode 100644
index 0000000..de2312d
--- /dev/null
+++ b/chrono/manager/templates/chrono/manager_unavailability_calendar_detail.html
@@ -0,0 +1,51 @@
+{% extends "chrono/manager_unavailability_calendar_list.html" %}
+{% load i18n %}
+
+{% block page-title-extra-label %}
+- {{ unavailability_calendar.label }}
+{% endblock %}
+
+{% block breadcrumb %}
+{{ block.super }}
+<a href="{% url 'chrono-manager-unavailability-calendar-view' pk=unavailability_calendar.pk %}">{{ unavailability_calendar.label }}</a>
+{% endblock %}
+
+{% block appbar %}
+{% block appbar-title %}
+<h2>{{ unavailability_calendar }}</h2>
+{% endblock %}
+{% if user_can_manage %}
+<span class="actions">
+{% block appbar-extras %}
+<a href="{% url 'chrono-manager-unavailability-calendar-settings' pk=unavailability_calendar.pk %}">{% trans 'Settings' %}</a>
+{% endblock %}
+</span>
+{% endif %}
+{% endblock %}
+
+{% block content %}
+
+<div class="section">
+<h3>{% trans 'Used in meetings agendas' %}</h3>
+<div>
+{% if agendas %}
+  <ul class="objects-list single-links">
+    {% for agenda in agendas %}
+    <li>
+        <a href="{% url 'chrono-manager-agenda-view' pk=agenda.pk %}">
+            {{ agenda.label }}
+        </a>
+    </li>
+    {% endfor %}
+  </ul>
+{% else %}
+<div class="big-msg-info">
+  {% blocktrans %}
+  This unavailability calendar is not used yet.
+  {% endblocktrans %}
+</div>
+{% endif %}
+</div>
+</div>
+
+{% endblock %}
diff --git a/chrono/manager/templates/chrono/manager_unavailability_calendar_form.html b/chrono/manager/templates/chrono/manager_unavailability_calendar_form.html
new file mode 100644
index 0000000..4649b26
--- /dev/null
+++ b/chrono/manager/templates/chrono/manager_unavailability_calendar_form.html
@@ -0,0 +1,22 @@
+{% extends "chrono/manager_home.html" %}
+{% load i18n %}
+
+{% block appbar %}
+{% if object.id %}
+<h2>{% trans "Edit Unavailability Calendar" %}</h2>
+{% else %}
+<h2>{% trans "New Unavailability Calendar" %}</h2>
+{% endif %}
+{% endblock %}
+
+{% block content %}
+
+<form method="post" enctype="multipart/form-data">
+  {% csrf_token %}
+  {{ form.as_p }}
+  <div class="buttons">
+    <button class="submit-button">{% trans "Save" %}</button>
+    <a class="cancel" href="{% url 'chrono-manager-unavailability-calendar-list' %}">{% trans 'Cancel' %}</a>
+  </div>
+</form>
+{% endblock %}
diff --git a/chrono/manager/templates/chrono/manager_unavailability_calendar_list.html b/chrono/manager/templates/chrono/manager_unavailability_calendar_list.html
new file mode 100644
index 0000000..e7eb92f
--- /dev/null
+++ b/chrono/manager/templates/chrono/manager_unavailability_calendar_list.html
@@ -0,0 +1,38 @@
+{% extends "chrono/manager_base.html" %}
+{% load i18n %}
+
+{% block breadcrumb %}
+{{ block.super }}
+<a href="{% url 'chrono-manager-unavailability-calendar-list' %}">{% trans "Unavailability Calendars" %}</a>
+{% endblock %}
+
+{% block appbar %}
+<h2>{% trans 'Unavailability Calendars' %}</h2>
+{% if user.is_staff %}
+<span class="actions">
+<a rel="popup" href="{% url 'chrono-manager-unavailability-calendar-add' %}">{% trans 'New' %}</a>
+</span>
+{% endif %}
+{% endblock %}
+
+
+{% block content %}
+{% if object_list %}
+<div>
+  <ul class="objects-list single-links">
+    {% for object in object_list %}
+    <li>
+        <a href="{% url 'chrono-manager-unavailability-calendar-view' pk=object.pk %}">{{ object.label }} ({{ object.slug }})</a>
+    </li>
+    {% endfor %}
+  </ul>
+</div>
+{% else %}
+<div class="big-msg-info">
+  {% blocktrans %}
+  This site doesn't have any unavailability calendar yet. Click on the "New" button in the top
+  right of the page to add a first one.
+  {% endblocktrans %}
+</div>
+{% endif %}
+{% endblock %}
diff --git a/chrono/manager/templates/chrono/manager_unavailability_calendar_settings.html b/chrono/manager/templates/chrono/manager_unavailability_calendar_settings.html
new file mode 100644
index 0000000..d227d69
--- /dev/null
+++ b/chrono/manager/templates/chrono/manager_unavailability_calendar_settings.html
@@ -0,0 +1,52 @@
+{% extends "chrono/manager_unavailability_calendar_detail.html" %}
+{% load i18n %}
+
+{% block breadcrumb %}
+{{ block.super }}
+<a href=".">{% trans "Settings" %}</a>
+{% endblock %}
+
+{% block appbar %}
+<h2>{% trans "Settings" %}
+</h2>
+<span class="actions">
+  <a class="extra-actions-menu-opener"></a>
+  {% block agenda-extra-management-actions %}
+    <a rel="popup" href="{% url 'chrono-manager-unavailability-calendar-add-unavailability' pk=unavailability_calendar.id %}">{% trans 'Add Unavailability' %}</a>
+  {% endblock %}
+  <ul class="extra-actions-menu">
+    <li><a rel="popup" href="{% url 'chrono-manager-unavailability-calendar-edit' pk=unavailability_calendar.id %}">{% trans 'Options' %}</a></li>
+    {% if user.is_staff %}
+      <li><a rel="popup" href="{% url 'chrono-manager-unavailability-calendar-delete' pk=unavailability_calendar.id %}">{% trans 'Delete' %}</a></li>
+    {% endif %}
+  </ul>
+</span>
+{% endblock %}
+
+{% block content %}
+<div class="section">
+<h3>{% trans 'Unavailabilities' %}</h3>
+<div>
+{% block agenda-settings %}
+{% if unavailability_calendar.timeperiodexception_set.count %}
+  <ul class="objects-list single-links">
+    {% for time_period_exception in unavailability_calendar.timeperiodexception_set.all %}
+    <li><a rel="popup" href="{% url 'chrono-manager-time-period-exception-edit' pk=time_period_exception.id %}">{{ time_period_exception }}</a>
+      <a rel="popup" class="delete" href="{% url 'chrono-manager-time-period-exception-delete' pk=time_period_exception.id %}">{% trans "remove" %}</a>
+    </li>
+    {% endfor %}
+  </ul>
+{% else %}
+<div class="big-msg-info">
+  {% blocktrans %}
+  There is no unavailabilities yet. Click on the "Add Unavailabilty" button in
+    the top right of the page to add a first one.
+  {% endblocktrans %}
+</div>
+{% endif %}
+</div>
+</div>
+{% endblock %}
+
+
+{% endblock %}
diff --git a/chrono/manager/urls.py b/chrono/manager/urls.py
index 4cec0b2..1c79a78 100644
--- a/chrono/manager/urls.py
+++ b/chrono/manager/urls.py
@@ -20,6 +20,41 @@ from . import views
 
 urlpatterns = [
     url(r'^$', views.homepage, name='chrono-manager-homepage'),
+    url(
+        r'^unavailability-calendars/$',
+        views.unavailability_calendar_list,
+        name='chrono-manager-unavailability-calendar-list',
+    ),
+    url(
+        r'^unavailability-calendar/add/$',
+        views.unavailability_calendar_add,
+        name='chrono-manager-unavailability-calendar-add',
+    ),
+    url(
+        r'^unavailability-calendar/(?P<pk>\d+)/$',
+        views.unavailability_calendar_view,
+        name='chrono-manager-unavailability-calendar-view',
+    ),
+    url(
+        r'^unavailability-calendar/(?P<pk>\d+)/edit/$',
+        views.unavailability_calendar_edit,
+        name='chrono-manager-unavailability-calendar-edit',
+    ),
+    url(
+        r'^unavailability-calendar/(?P<pk>\d+)/delete/$',
+        views.unavailability_calendar_delete,
+        name='chrono-manager-unavailability-calendar-delete',
+    ),
+    url(
+        r'^unavailability-calendar/(?P<pk>\d+)/settings$',
+        views.unavailability_calendar_settings,
+        name='chrono-manager-unavailability-calendar-settings',
+    ),
+    url(
+        r'^unavailability-calendar/(?P<pk>\d+)/add-unavailability$',
+        views.unavailability_calendar_add_unavailability,
+        name='chrono-manager-unavailability-calendar-add-unavailability',
+    ),
     url(r'^resources/$', views.resource_list, name='chrono-manager-resource-list'),
     url(r'^resource/add/$', views.resource_add, name='chrono-manager-resource-add'),
     url(r'^resource/(?P<pk>\d+)/$', views.resource_view, name='chrono-manager-resource-view'),
@@ -158,6 +193,11 @@ urlpatterns = [
     url(r'^agendas/(?P<pk>\d+)/add-desk$', views.agenda_add_desk, name='chrono-manager-agenda-add-desk'),
     url(r'^desks/(?P<pk>\d+)/edit$', views.desk_edit, name='chrono-manager-desk-edit'),
     url(r'^desks/(?P<pk>\d+)/delete$', views.desk_delete, name='chrono-manager-desk-delete'),
+    url(
+        r'^desk/(?P<pk>\d+)/unavailability-calendar/(?P<unavailability_calendar_pk>\d+)/toogle/$',
+        views.unavailability_calendar_toggle_view,
+        name='chrono-manager-unavailability-calendar-toggle-view',
+    ),
     url(
         r'^agendas/(?P<agenda_pk>\d+)/desk/(?P<pk>\d+)/add-time-period-exception$',
         views.agenda_add_time_period_exception,
diff --git a/chrono/manager/views.py b/chrono/manager/views.py
index c8a095b..4a5f288 100644
--- a/chrono/manager/views.py
+++ b/chrono/manager/views.py
@@ -23,7 +23,7 @@ import uuid
 
 from django.contrib import messages
 from django.core.exceptions import PermissionDenied
-from django.db.models import Q
+from django.db.models import Q, Value, BooleanField
 from django.db.models import Min, Max
 from django.http import Http404, HttpResponse, HttpResponseRedirect
 from django.shortcuts import get_object_or_404
@@ -65,6 +65,7 @@ from chrono.agendas.models import (
     EventCancellationReport,
     AgendaNotificationsSettings,
     AgendaReminderSettings,
+    UnavailabilityCalendar,
 )
 
 from .forms import (
@@ -94,6 +95,8 @@ from .forms import (
     EventCancelForm,
     AgendaNotificationsForm,
     AgendaReminderForm,
+    UnavailabilityCalendarAddForm,
+    UnavailabilityCalendarEditForm,
 )
 from .utils import import_site
 
@@ -1237,6 +1240,40 @@ class ManagedTimePeriodMixin(object):
         return reverse('chrono-manager-agenda-settings', kwargs={'pk': self.agenda.id})
 
 
+class ManagedTimePeriodExceptionMixin(object):
+
+    desk = None
+    unavailability_calendar = None
+
+    def dispatch(self, request, *args, **kwargs):
+        object_ = self.get_object()
+        if object_.desk:
+            self.desk = self.get_object().desk
+            if not self.desk.agenda.can_be_managed(request.user):
+                raise PermissionDenied()
+        elif object_.unavailability_calendar:
+            self.unavailability_calendar = object_.unavailability_calendar
+            if not self.unavailability_calendar.can_be_managed(request.user):
+                raise PermissionDenied()
+        return super().dispatch(request, *args, **kwargs)
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        if self.desk:
+            context['desk'] = self.object.desk
+            context['agenda'] = self.object.desk.agenda
+        return context
+
+    def get_success_url(self):
+        if self.desk:
+            return reverse('chrono-manager-agenda-settings', kwargs={'pk': self.desk.agenda.id})
+        elif self.unavailability_calendar:
+            return reverse(
+                'chrono-manager-unavailability-calendar-settings',
+                kwargs={'pk': self.unavailability_calendar.pk},
+            )
+
+
 class AgendaSettings(ManagedAgendaMixin, DetailView):
     model = Agenda
 
@@ -1258,6 +1295,7 @@ class AgendaSettings(ManagedAgendaMixin, DetailView):
             ]
         if self.agenda.kind == 'meetings':
             context['has_resources'] = Resource.objects.exists()
+            context['has_unavailability_calendars'] = UnavailabilityCalendar.objects.exists()
         return context
 
     def get_events(self):
@@ -1538,6 +1576,35 @@ class AgendaResourceDeleteView(ManagedAgendaMixin, DeleteView):
 agenda_delete_resource = AgendaResourceDeleteView.as_view()
 
 
+class UnavailabilityCalendarToggleView(ManagedDeskMixin, DetailView):
+    model = UnavailabilityCalendar
+    pk_url_kwarg = 'unavailability_calendar_pk'
+
+    def get(self, request, *args, **kwargs):
+        unavailability_calendar = self.get_object()
+        try:
+            self.desk.unavailability_calendars.get(pk=unavailability_calendar.pk)
+            self.desk.unavailability_calendars.remove(unavailability_calendar)
+            message = _(
+                'Unavailability calendar %(unavailability_calendar)s has been disabled on desk %(desk)s.'
+            )
+
+        except UnavailabilityCalendar.DoesNotExist:
+            self.desk.unavailability_calendars.add(unavailability_calendar)
+            message = _(
+                'Unavailability calendar %(unavailability_calendar)s has been enabled on desk %(desk)s.'
+            )
+        messages.info(
+            self.request, message % {'unavailability_calendar': unavailability_calendar, 'desk': self.desk}
+        )
+        return HttpResponseRedirect(
+            reverse('chrono-manager-agenda-settings', kwargs={'pk': self.desk.agenda_id})
+        )
+
+
+unavailability_calendar_toggle_view = UnavailabilityCalendarToggleView.as_view()
+
+
 class AgendaAddMeetingTypeView(ManagedAgendaMixin, CreateView):
     template_name = 'chrono/manager_meeting_type_form.html'
     model = MeetingType
@@ -1745,6 +1812,11 @@ class AgendaAddTimePeriodExceptionView(ManagedDeskMixin, CreateView):
     model = TimePeriodException
     form_class = TimePeriodExceptionForm
 
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        context['cancel_url'] = reverse('chrono-manager-agenda-settings', kwargs={'pk': self.desk.agenda.pk})
+        return context
+
     def form_valid(self, form):
         result = super().form_valid(form)
         exceptions = [self.object]
@@ -1775,11 +1847,24 @@ class AgendaAddTimePeriodExceptionView(ManagedDeskMixin, CreateView):
 agenda_add_time_period_exception = AgendaAddTimePeriodExceptionView.as_view()
 
 
-class TimePeriodExceptionEditView(ManagedDeskSubobjectMixin, UpdateView):
+class TimePeriodExceptionEditView(ManagedTimePeriodExceptionMixin, UpdateView):
     template_name = 'chrono/manager_time_period_exception_form.html'
     model = TimePeriodException
     form_class = TimePeriodExceptionForm
 
+    def get_context_data(self):
+        context = super().get_context_data()
+        if self.desk:
+            context['cancel_url'] = reverse(
+                'chrono-manager-agenda-settings', kwargs={'pk': self.desk.agenda.pk}
+            )
+        elif self.unavailability_calendar:
+            context['cancel_url'] = reverse(
+                'chrono-manager-unavailability-calendar-settings',
+                kwargs={'pk': self.unavailability_calendar.pk},
+            )
+        return context
+
 
 time_period_exception_edit = TimePeriodExceptionEditView.as_view()
 
@@ -1812,18 +1897,19 @@ class TimePeriodExceptionExtractListView(TimePeriodExceptionListView):
 time_period_exception_extract_list = TimePeriodExceptionExtractListView.as_view()
 
 
-class TimePeriodExceptionDeleteView(ManagedDeskSubobjectMixin, DeleteView):
+class TimePeriodExceptionDeleteView(ManagedTimePeriodExceptionMixin, DeleteView):
     template_name = 'chrono/manager_confirm_exception_delete.html'
     model = TimePeriodException
 
     def get_success_url(self):
-        referer = self.request.META.get('HTTP_REFERER')
-        success_url = reverse('chrono-manager-time-period-exception-list', kwargs={'pk': self.desk.pk})
-        if success_url in referer:
-            return success_url
+        if self.desk:
+            referer = self.request.META.get('HTTP_REFERER')
+            success_url = reverse('chrono-manager-time-period-exception-list', kwargs={'pk': self.desk.pk})
+            if success_url in referer:
+                return success_url
 
         success_url = super(TimePeriodExceptionDeleteView, self).get_success_url()
-        if 'from_popup' in self.request.GET:
+        if self.desk and 'from_popup' in self.request.GET:
             success_url = '{}?display_exceptions={}'.format(success_url, self.desk.pk)
         return success_url
 
@@ -1841,7 +1927,18 @@ class DeskImportTimePeriodExceptionsView(ManagedAgendaSubobjectMixin, UpdateView
 
     def get_context_data(self, **kwargs):
         context = super(DeskImportTimePeriodExceptionsView, self).get_context_data(**kwargs)
-        context['exception_sources'] = self.get_object().timeperiodexceptionsource_set.all()
+        desk = self.get_object()
+        context['exception_sources'] = desk.timeperiodexceptionsource_set.all()
+        context['desk'] = desk
+        context['unavailability_calendars'] = (
+            UnavailabilityCalendar.objects.filter(desks=desk)
+            .annotate(enabled=Value(True, BooleanField()))
+            .union(
+                UnavailabilityCalendar.objects.exclude(desks=desk).annotate(
+                    enabled=Value(False, BooleanField())
+                )
+            )
+        )
         return context
 
     def form_valid(self, form):
@@ -2082,6 +2179,146 @@ class TimePeriodExceptionSourceToggleView(ManagedDeskSubobjectMixin, DetailView)
 time_period_exception_source_toggle = TimePeriodExceptionSourceToggleView.as_view()
 
 
+class ViewableUnavailabilityCalendarMixin(object):
+    unavailability_calendar = None
+
+    def set_unavailability_calendar(self, **kwargs):
+        self.unavailability_calendar = get_object_or_404(UnavailabilityCalendar, id=kwargs.get('pk'))
+
+    def dispatch(self, request, *args, **kwargs):
+        self.set_unavailability_calendar(**kwargs)
+        if not self.check_permissions(request.user):
+            raise PermissionDenied()
+        return super().dispatch(request, *args, **kwargs)
+
+    def check_permissions(self, user):
+        return self.unavailability_calendar.can_be_viewed(user)
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        context['unavailability_calendar'] = self.unavailability_calendar
+        context['user_can_manage'] = self.unavailability_calendar.can_be_managed(self.request.user)
+        return context
+
+
+class ManagedUnavailabilityCalendarMixin(ViewableUnavailabilityCalendarMixin):
+    def check_permissions(self, user):
+        return self.unavailability_calendar.can_be_managed(user)
+
+    def get_success_url(self):
+        return reverse(
+            'chrono-manager-unavailability-calendar-settings', kwargs={'pk': self.unavailability_calendar.id}
+        )
+
+
+class UnavailabilityCalendarListView(ListView):
+    template_name = 'chrono/manager_unavailability_calendar_list.html'
+    model = UnavailabilityCalendar
+
+    def get_queryset(self):
+        queryset = super().get_queryset()
+        if not self.request.user.is_staff:
+            group_ids = [x.id for x in self.request.user.groups.all()]
+            queryset = queryset.filter(Q(view_role_id__in=group_ids) | Q(edit_role_id__in=group_ids))
+        return queryset.order_by('label')
+
+
+unavailability_calendar_list = UnavailabilityCalendarListView.as_view()
+
+
+class UnavailabilityCalendarAddView(CreateView):
+    template_name = 'chrono/manager_unavailability_calendar_form.html'
+    model = UnavailabilityCalendar
+    form_class = UnavailabilityCalendarAddForm
+
+    def dispatch(self, request, *args, **kwargs):
+        if not request.user.is_staff:
+            raise PermissionDenied()
+        return super().dispatch(request, *args, **kwargs)
+
+    def get_success_url(self):
+        return reverse('chrono-manager-unavailability-calendar-view', kwargs={'pk': self.object.id})
+
+
+unavailability_calendar_add = UnavailabilityCalendarAddView.as_view()
+
+
+class UnavailabilityCalendarDetailView(ViewableUnavailabilityCalendarMixin, DetailView):
+    template_name = 'chrono/manager_unavailability_calendar_detail.html'
+    model = UnavailabilityCalendar
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        context['agendas'] = Agenda.objects.filter(
+            desk__unavailability_calendars__pk=self.unavailability_calendar.pk
+        ).distinct()
+        return context
+
+
+unavailability_calendar_view = UnavailabilityCalendarDetailView.as_view()
+
+
+class UnavailabilityCalendarEditView(ManagedUnavailabilityCalendarMixin, UpdateView):
+    template_name = 'chrono/manager_unavailability_calendar_form.html'
+    model = UnavailabilityCalendar
+    form_class = UnavailabilityCalendarEditForm
+
+
+unavailability_calendar_edit = UnavailabilityCalendarEditView.as_view()
+
+
+class UnavailabilityCalendarDeleteView(DeleteView):
+    template_name = 'chrono/manager_confirm_delete.html'
+    model = UnavailabilityCalendar
+
+    def dispatch(self, request, *args, **kwargs):
+        if not request.user.is_staff:
+            raise PermissionDenied()
+        return super().dispatch(request, *args, **kwargs)
+
+    def get_success_url(self):
+        return reverse('chrono-manager-unavailability-calendar-list')
+
+
+unavailability_calendar_delete = UnavailabilityCalendarDeleteView.as_view()
+
+
+class UnavailabilityCalendarSettings(ManagedUnavailabilityCalendarMixin, DetailView):
+    template_name = 'chrono/manager_unavailability_calendar_settings.html'
+    model = UnavailabilityCalendar
+
+    def get_context_data(self, **kwargs):
+        context = super(UnavailabilityCalendarSettings, self).get_context_data(**kwargs)
+        context['unavailability_calendar'] = self.object
+        return context
+
+
+unavailability_calendar_settings = UnavailabilityCalendarSettings.as_view()
+
+
+class UnavailabilityCalendarAddUnavailabilityView(ManagedUnavailabilityCalendarMixin, CreateView):
+    template_name = 'chrono/manager_time_period_exception_form.html'
+    form_class = TimePeriodExceptionForm
+    model = TimePeriodException
+
+    def get_form_kwargs(self):
+        kwargs = super().get_form_kwargs()
+        if not kwargs.get('instance'):
+            kwargs['instance'] = self.model()
+        kwargs['instance'].unavailability_calendar = self.unavailability_calendar
+        return kwargs
+
+    def get_context_data(self, **kwargs):
+        context = super(UnavailabilityCalendarAddUnavailabilityView, self).get_context_data(**kwargs)
+        context['cancel_url'] = reverse(
+            'chrono-manager-unavailability-calendar-settings', kwargs={'pk': self.unavailability_calendar.pk,}
+        )
+        return context
+
+
+unavailability_calendar_add_unavailability = UnavailabilityCalendarAddUnavailabilityView.as_view()
+
+
 def menu_json(request):
     label = _('Agendas')
     json_str = json.dumps(
diff --git a/chrono/urls_utils.py b/chrono/urls_utils.py
index 980ad64..53b782b 100644
--- a/chrono/urls_utils.py
+++ b/chrono/urls_utils.py
@@ -22,7 +22,7 @@ from django.contrib.auth.decorators import user_passes_test
 from django.core.exceptions import PermissionDenied
 from django.db.models import Q
 
-from .agendas.models import Agenda
+from .agendas.models import Agenda, UnavailabilityCalendar
 
 if django.VERSION < (2, 0, 0):
     from django.urls.resolvers import RegexURLPattern as URLPattern
@@ -55,7 +55,12 @@ def manager_required(function=None, login_url=None):
         if user and not user.is_anonymous:
             # /manage/ is open to anyone authorized to view or edit an agenda.
             group_ids = [x.id for x in user.groups.all()]
-            if Agenda.objects.filter(Q(view_role_id__in=group_ids) | Q(edit_role_id__in=group_ids)).exists():
+            if (
+                Agenda.objects.filter(Q(view_role_id__in=group_ids) | Q(edit_role_id__in=group_ids)).exists()
+                or UnavailabilityCalendar.objects.filter(
+                    Q(view_role_id__in=group_ids) | Q(edit_role_id__in=group_ids)
+                ).exists()
+            ):
                 return True
             raise PermissionDenied()
         # As the last resort, show the login form
diff --git a/tests/test_api.py b/tests/test_api.py
index 1d460d6..34f6216 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -21,6 +21,7 @@ from chrono.agendas.models import (
     Resource,
     TimePeriod,
     TimePeriodException,
+    UnavailabilityCalendar,
     VirtualMember,
 )
 import chrono.api.views
@@ -643,7 +644,7 @@ def test_datetimes_api_meetings_agenda_with_resources(app):
     )
     with CaptureQueriesContext(connection) as ctx:
         resp = app.get(api_url)
-        assert len(ctx.captured_queries) == 9
+        assert len(ctx.captured_queries) == 10
     assert len(resp.json['data']) == 32
     assert [s['datetime'] for s in resp.json['data'] if s['disabled'] is True] == [
         '%s 09:00:00' % tomorrow_str,
@@ -3802,7 +3803,7 @@ def test_virtual_agendas_meetings_datetimes_multiple_agendas(app, time_zone, moc
     with CaptureQueriesContext(connection) as ctx:
         resp = app.get(api_url)
         assert len(resp.json['data']) == 12
-        assert len(ctx.captured_queries) == 11
+        assert len(ctx.captured_queries) == 12
 
     # simulate booking
     dt = datetime.datetime.strptime(resp.json['data'][2]['id'].split(':')[1], '%Y-%m-%d-%H%M')
@@ -4126,3 +4127,129 @@ def test_duration_on_booking_api_fillslots_response(app, user):
     ics = app.get(resp.json['api']['ics_url']).text
     assert 'DTSTART:20170519T231200Z' in ics
     assert 'DTEND:20170520T004200Z' in ics
+
+
+def test_unavailabilitycalendar_meetings_datetimes(app, user, meetings_agenda):
+    app.authorization = ('Basic', ('john.doe', 'password'))
+    meeting_type = meetings_agenda.meetingtype_set.first()
+    desk = meetings_agenda.desk_set.first()
+    datetimes_url = '/api/agenda/%s/meetings/%s/datetimes/' % (meetings_agenda.slug, meeting_type.slug)
+    resp = app.get(datetimes_url)
+    assert len(resp.json['data']) == 144
+
+    # create an unvalailability calendar
+    unavailability_calendar = UnavailabilityCalendar.objects.create(label='foo holydays')
+    TimePeriodException.objects.create(
+        unavailability_calendar=unavailability_calendar,
+        start_datetime=make_aware(datetime.datetime(2017, 5, 22, 10, 0)),
+        end_datetime=make_aware(datetime.datetime(2017, 5, 22, 11, 0)),
+    )
+    unavailability_calendar.desks.add(desk)
+
+    # 2 slots are gone
+    resp2 = app.get(datetimes_url)
+    assert len(resp.json['data']) == len(resp2.json['data']) + 2
+
+    # add a standard desk exception
+    desk = meetings_agenda.desk_set.first()
+    TimePeriodException.objects.create(
+        desk=desk,
+        start_datetime=make_aware(datetime.datetime(2017, 5, 22, 11, 0)),
+        end_datetime=make_aware(datetime.datetime(2017, 5, 22, 12, 0)),
+    )
+    # 4 slots are gone
+    resp3 = app.get(datetimes_url)
+    assert len(resp.json['data']) == len(resp3.json['data']) + 4
+
+
+def test_unavailabilitycalendar_on_virtual_datetimes(app, user, mock_now):
+    foo_agenda = Agenda.objects.create(label='Foo Meeting', kind='meetings', maximal_booking_delay=7)
+    MeetingType.objects.create(agenda=foo_agenda, label='Meeting Type', duration=30)
+    foo_desk_1 = Desk.objects.create(agenda=foo_agenda, label='Foo desk 1')
+    TimePeriod.objects.create(
+        weekday=0, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0), desk=foo_desk_1,
+    )
+    TimePeriod.objects.create(
+        weekday=1, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0), desk=foo_desk_1,
+    )
+    virt_agenda = Agenda.objects.create(label='Virtual Agenda', kind='virtual')
+    VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=foo_agenda)
+
+    api_url = '/api/agenda/%s/meetings/meeting-type/datetimes/' % (virt_agenda.slug)
+    resp = app.get(api_url)
+    # 8 slots
+    data = resp.json['data']
+    assert len(data) == 8
+    assert data[0]['datetime'] == '2017-05-22 10:00:00'
+    assert data[1]['datetime'] == '2017-05-22 10:30:00'
+    assert data[2]['datetime'] == '2017-05-22 11:00:00'
+
+    # exclude one hour the first day through an unvalailability calendar on the foo agenda
+    unavailability_calendar = UnavailabilityCalendar.objects.create(label='foo holydays')
+    TimePeriodException.objects.create(
+        unavailability_calendar=unavailability_calendar,
+        start_datetime=make_aware(datetime.datetime(2017, 5, 22, 11, 0)),
+        end_datetime=make_aware(datetime.datetime(2017, 5, 22, 12, 0)),
+    )
+    unavailability_calendar.desks.add(foo_desk_1)
+
+    resp = app.get(api_url)
+    data = resp.json['data']
+    assert len(data) == 6
+    assert data[0]['datetime'] == '2017-05-22 10:00:00'
+    assert data[1]['datetime'] == '2017-05-22 10:30:00'
+    # no more slots the 22 thanks to the unavailability calendar
+    assert data[2]['datetime'] == '2017-05-23 10:00:00'
+
+    # exclude the second day
+    TimePeriodException.objects.create(
+        unavailability_calendar=unavailability_calendar,
+        start_datetime=make_aware(datetime.datetime(2017, 5, 23, 9, 0)),
+        end_datetime=make_aware(datetime.datetime(2017, 5, 23, 18, 0)),
+    )
+    resp = app.get(api_url)
+    data = resp.json['data']
+    assert len(data) == 2
+    assert data[0]['datetime'] == '2017-05-22 10:00:00'
+    assert data[1]['datetime'] == '2017-05-22 10:30:00'
+
+    # add a second real agenda
+    bar_agenda = Agenda.objects.create(label='Bar Meeting', kind='meetings', maximal_booking_delay=7)
+    VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=bar_agenda)
+    MeetingType.objects.create(agenda=bar_agenda, label='Meeting Type', duration=30)
+    bar_desk_1 = Desk.objects.create(agenda=bar_agenda, label='Bar desk 1')
+    bar_desk_2 = Desk.objects.create(agenda=bar_agenda, label='Bar desk 2')
+    TimePeriod.objects.create(
+        weekday=0, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0), desk=bar_desk_1,
+    )
+    TimePeriod.objects.create(
+        weekday=1, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0), desk=bar_desk_1,
+    )
+    TimePeriod.objects.create(
+        weekday=0, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0), desk=bar_desk_2,
+    )
+    TimePeriod.objects.create(
+        weekday=1, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0), desk=bar_desk_2,
+    )
+
+    # bar_agenda has the same time periods than foo_agenda, but no unavailability calendar
+    # so we are back at the start : 8 slots
+    resp = app.get(api_url)
+    data = resp.json['data']
+    assert len(data) == 8
+
+    # exclude one hour the second day through another unvalailability calendar on the bar agenda
+    unavailability_calendar = UnavailabilityCalendar.objects.create(label='bar holydays')
+    TimePeriodException.objects.create(
+        unavailability_calendar=unavailability_calendar,
+        start_datetime=make_aware(datetime.datetime(2017, 5, 23, 11, 0)),
+        end_datetime=make_aware(datetime.datetime(2017, 5, 23, 12, 0)),
+    )
+    unavailability_calendar.desks.add(bar_desk_1, bar_desk_2)
+
+    # 2 slots are gone
+    resp = app.get(api_url)
+    data = resp.json['data']
+    assert len(data) == 6
+    assert data[0]['datetime'] == '2017-05-22 10:00:00'
+    assert data[-1]['datetime'] == '2017-05-23 10:30:00'
diff --git a/tests/test_manager.py b/tests/test_manager.py
index 2ac947d..62bf3b9 100644
--- a/tests/test_manager.py
+++ b/tests/test_manager.py
@@ -35,6 +35,7 @@ from chrono.agendas.models import (
     TimePeriodExceptionSource,
     VirtualMember,
     AgendaReminderSettings,
+    UnavailabilityCalendar,
 )
 from chrono.manager.forms import TimePeriodExceptionForm
 from chrono.utils.signature import check_query
@@ -1000,7 +1001,7 @@ def test_options_meetings_agenda_num_queries(app, admin_user):
     app = login(app)
     with CaptureQueriesContext(connection) as ctx:
         app.get('/manage/agendas/%s/settings' % agenda.pk)
-        assert len(ctx.captured_queries) == 9
+        assert len(ctx.captured_queries) == 10
 
 
 def test_agenda_resources(app, admin_user):
@@ -4376,3 +4377,346 @@ def test_manager_reminders_preview(app, admin_user):
         'Reminder: you have a booking for event "{{ event_label }}", on 02/06 at 2:30 p.m.. Take ID card.'
         in resp.text
     )
+
+
+def test_no_unavailability_calendar(app, admin_user):
+    app = login(app)
+
+    # empty unavailability calendars list
+    resp = app.get('/manage/')
+    resp = resp.click('Unavailability calendars')
+    assert "This site doesn't have any unavailability calendar yet" in resp.text
+
+    # on the agenda settings page, no unavailability calendar reference
+    agenda = Agenda.objects.create(label='Agenda', kind='meetings')
+    resp = app.get('/manage/agendas/%s/settings' % agenda.pk)
+    assert 'unavailability calendar' not in resp.text
+
+
+def test_add_unavailability_calendar(app, admin_user):
+    app = login(app)
+    resp = app.get('/manage/')
+    resp = resp.click('Unavailability calendars')
+    resp = resp.click('New')
+    resp.form['label'] = 'Foo bar'
+    resp = resp.form.submit()
+    unavailability_calendar = UnavailabilityCalendar.objects.latest('pk')
+    assert resp.location.endswith('/manage/unavailability-calendar/%s/' % unavailability_calendar.pk)
+    assert unavailability_calendar.label == 'Foo bar'
+    assert unavailability_calendar.slug == 'foo-bar'
+    resp = resp.follow()
+    assert 'This unavailability calendar is not used yet.' in resp.text
+    resp = app.get('/manage/unavailability-calendars/')
+    assert 'Foo bar' in resp.text
+    assert 'foo-bar' in resp.text
+
+
+def test_used_unavailability_calendar(app, admin_user):
+    unavailability_calendar = UnavailabilityCalendar.objects.create(label='Calendar')
+    agenda = Agenda.objects.create(label='Foo', kind='meetings')
+    desk = Desk.objects.create(agenda=agenda, label='desk')
+
+    app = login(app)
+    url = '/manage/unavailability-calendar/%s/' % unavailability_calendar.pk
+    resp = app.get(url)
+    assert 'This unavailability calendar is not used yet.' in resp.text
+    assert 'Foo' not in resp.text
+
+    desk.unavailability_calendars.add(unavailability_calendar)
+    resp = app.get(url)
+    assert 'This unavailability calendar is not used yet.' not in resp.text
+    assert 'Foo' in resp.text
+
+
+def test_edit_unavailability_calendar(app, admin_user):
+    unavailability_calendar = UnavailabilityCalendar.objects.create(label='Foo')
+    app = login(app)
+    settings_url = '/manage/unavailability-calendar/%s/settings' % unavailability_calendar.pk
+    resp = app.get(settings_url)
+    resp = resp.click('Options')
+    resp.form['label'] = 'Bar'
+    resp = resp.form.submit()
+    assert resp.location.endswith(settings_url)
+    resp = resp.follow()
+    assert 'Bar' in resp.text
+    unavailability_calendar.refresh_from_db()
+    assert unavailability_calendar.label == 'Bar'
+
+
+def test_delete_unavailability_calendar(app, admin_user):
+    unavailability_calendar = UnavailabilityCalendar.objects.create(label='Foo')
+    app = login(app)
+    resp = app.get('/manage/unavailability-calendar/%s/settings' % unavailability_calendar.pk)
+    resp = resp.click('Delete')
+    resp = resp.form.submit()
+    assert resp.location.endswith('/manage/unavailability-calendars/')
+    resp = resp.follow()
+    assert 'Foo' not in resp.text
+    assert UnavailabilityCalendar.objects.count() == 0
+
+
+def test_unavailability_calendar_add_time_period_exeptions(app, admin_user):
+    unavailability_calendar = UnavailabilityCalendar.objects.create(label='Foo')
+    app = login(app)
+    resp = app.get('/manage/unavailability-calendar/%s/' % unavailability_calendar.pk)
+    resp = resp.click('Settings')
+    assert 'There is no unavailabilities yet' in resp.text
+    resp = resp.click('Add Unavailability')
+    assert 'all_desks' not in resp.form.fields
+    today = datetime.datetime.today().replace(hour=0, minute=0, second=0, microsecond=0)
+    tomorrow = make_aware(today + datetime.timedelta(days=1))
+    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 'Exception 1' in resp.text
+
+    time_period_exception = TimePeriodException.objects.first()
+    assert time_period_exception.unavailability_calendar == unavailability_calendar
+    assert time_period_exception.desk is None
+    assert time_period_exception.label == 'Exception 1'
+
+
+def test_unavailability_calendar_edit_time_period_exeptions(app, admin_user):
+    unavailability_calendar = UnavailabilityCalendar.objects.create(label='Foo')
+    time_period_exception = TimePeriodException.objects.create(
+        unavailability_calendar=unavailability_calendar,
+        label='Exception 1',
+        start_datetime=now() - datetime.timedelta(days=2),
+        end_datetime=now() - datetime.timedelta(days=1),
+    )
+
+    app = login(app)
+    resp = app.get('/manage/unavailability-calendar/%s/settings' % unavailability_calendar.pk)
+    assert 'Exception 1' in resp.text
+    resp = resp.click(href='/manage/time-period-exceptions/%s/edit' % time_period_exception.pk)
+    assert 'all_desks' not in resp.form.fields
+    resp.form['label'] = 'Exception foo'
+    resp = resp.form.submit().follow()
+    assert 'Exception foo' in resp.text
+    time_period_exception.refresh_from_db()
+    assert 'Exception foo' == time_period_exception.label
+
+
+def test_unavailability_calendar_delete_time_period_exeptions(app, admin_user):
+    unavailability_calendar = UnavailabilityCalendar.objects.create(label='Foo')
+    time_period_exception = TimePeriodException.objects.create(
+        unavailability_calendar=unavailability_calendar,
+        label='Exception 1',
+        start_datetime=now() - datetime.timedelta(days=2),
+        end_datetime=now() - datetime.timedelta(days=1),
+    )
+
+    app = login(app)
+    resp = app.get('/manage/unavailability-calendar/%s/settings' % unavailability_calendar.pk)
+    assert 'Exception 1' in resp.text
+    resp = resp.click(href='/manage/time-period-exceptions/%s/delete' % time_period_exception.pk)
+    resp = resp.form.submit().follow()
+    assert 'Exception foo' not in resp.text
+    assert unavailability_calendar.timeperiodexception_set.count() == 0
+
+
+def test_activate_unavailability_calendar_in_desk(app, admin_user):
+    app = login(app)
+    unavailability_calendar = UnavailabilityCalendar.objects.create(label='Calendar')
+    agenda = Agenda.objects.create(label='Agenda', kind='meetings')
+    desk = Desk.objects.create(agenda=agenda, label='desk')
+    exceptions_url = '/manage/agendas/desk/%s/import-exceptions-from-ics/' % desk.pk
+    resp = app.get(exceptions_url)
+    assert 'calendar' in resp.text
+    assert 'enable' in resp.text
+    resp = resp.click(
+        href='/manage/desk/%s/unavailability-calendar/%s/toogle/' % (desk.pk, unavailability_calendar.pk)
+    )
+    assert resp.location.endswith('/manage/agendas/%s/settings' % agenda.pk)
+    resp = app.get(exceptions_url)
+    assert 'calendar' in resp.text
+    assert 'disable' in resp.text
+    assert desk.unavailability_calendars.get(pk=unavailability_calendar.pk)
+
+
+def test_deactivate_unavailability_calendar_in_desk(app, admin_user):
+    app = login(app)
+    unavailability_calendar = UnavailabilityCalendar.objects.create(label='Calendar')
+    agenda = Agenda.objects.create(label='Agenda', kind='meetings')
+    desk = Desk.objects.create(agenda=agenda, label='desk')
+    desk.unavailability_calendars.add(unavailability_calendar)
+    exceptions_url = '/manage/agendas/desk/%s/import-exceptions-from-ics/' % desk.pk
+    resp = app.get(exceptions_url)
+    assert 'calendar' in resp.text
+    assert 'disable' in resp.text
+    resp = resp.click(
+        href='/manage/desk/%s/unavailability-calendar/%s/toogle/' % (desk.pk, unavailability_calendar.pk)
+    )
+    assert resp.location.endswith('/manage/agendas/%s/settings' % agenda.pk)
+    resp = app.get(exceptions_url)
+    assert 'calendar' in resp.text
+    assert 'enable' in resp.text
+    assert desk.unavailability_calendars.count() == 0
+
+
+def test_unavailability_calendar_homepage_permission(app, manager_user):
+    unavailability_calendar = UnavailabilityCalendar.objects.create(label='Calendar 1')
+    app = login(app, username='manager', password='manager')
+    resp = app.get('/manage/', status=403)
+    group = manager_user.groups.all()[0]
+    unavailability_calendar.view_role = group
+    unavailability_calendar.edit_role = None
+    unavailability_calendar.save()
+    resp = app.get('/manage/')
+    resp = resp.click('Unavailability calendars')
+    unavailability_calendar.view_role = None
+    unavailability_calendar.edit_role = group
+    unavailability_calendar.save()
+    resp = app.get('/manage/')
+    resp = resp.click('Unavailability calendars')
+
+
+def test_unavailability_calendar_list_permissions(app, manager_user):
+    unavailability_calendar = UnavailabilityCalendar.objects.create(label='Calendar 1')
+    app = login(app, username='manager', password='manager')
+    app.get('/manage/unavailability-calendars/', status=403)
+    group = manager_user.groups.all()[0]
+    unavailability_calendar.view_role = group
+    unavailability_calendar.edit_role = None
+    unavailability_calendar.save()
+    resp = app.get('/manage/unavailability-calendars/')
+    assert 'Calendar 1' in resp.text
+    assert 'New' not in resp.text
+    unavailability_calendar.view_role = None
+    unavailability_calendar.edit_role = group
+    unavailability_calendar.save()
+    assert 'Calendar 1' in resp.text
+    assert 'New' not in resp.text
+
+
+def test_unavailability_calendar_add_permissions(app, manager_user):
+    app = login(app, username='manager', password='manager')
+    url = '/manage/unavailability-calendar/add/'
+    app.get(url, status=403)
+
+
+def test_unavailability_calendar_detail_permissions(app, manager_user):
+    unavailability_calendar = UnavailabilityCalendar.objects.create(label='Calendar 1')
+    app = login(app, username='manager', password='manager')
+    detail_url = '/manage/unavailability-calendar/%s/' % unavailability_calendar.pk
+    resp = app.get(detail_url, status=403)
+    group = manager_user.groups.all()[0]
+    unavailability_calendar.view_role = group
+    unavailability_calendar.edit_role = None
+    unavailability_calendar.save()
+    resp = app.get(detail_url)
+    assert 'Settings' not in resp.text
+    unavailability_calendar.view_role = None
+    unavailability_calendar.edit_role = group
+    unavailability_calendar.save()
+    resp = app.get(detail_url)
+    assert 'Settings' in resp.text
+
+
+def test_unavailability_calendar_edit_permissions(app, manager_user):
+    unavailability_calendar = UnavailabilityCalendar.objects.create(label='Calendar 1')
+    app = login(app, username='manager', password='manager')
+    url = '/manage/unavailability-calendar/%s/edit/' % unavailability_calendar.pk
+    app.get(url, status=403)
+    group = manager_user.groups.all()[0]
+    unavailability_calendar.view_role = group
+    unavailability_calendar.edit_role = None
+    unavailability_calendar.save()
+    app.get(url, status=403)
+    unavailability_calendar.view_role = None
+    unavailability_calendar.edit_role = group
+    unavailability_calendar.save()
+    app.get(url)
+
+
+def test_unavailability_calendar_delete_permissions(app, manager_user):
+    unavailability_calendar = UnavailabilityCalendar.objects.create(label='Calendar 1')
+    app = login(app, username='manager', password='manager')
+    url = '/manage/unavailability-calendar/%s/delete/' % unavailability_calendar.pk
+    app.get(url, status=403)
+    group = manager_user.groups.all()[0]
+    unavailability_calendar.view_role = group
+    unavailability_calendar.edit_role = None
+    unavailability_calendar.save()
+    app.get(url, status=403)
+    unavailability_calendar.view_role = None
+    unavailability_calendar.edit_role = group
+    unavailability_calendar.save()
+    app.get(url, status=403)
+
+
+def test_unavailability_calendar_settings_permissions(app, manager_user):
+    unavailability_calendar = UnavailabilityCalendar.objects.create(label='Calendar 1')
+    app = login(app, username='manager', password='manager')
+    settings_url = '/manage/unavailability-calendar/%s/settings' % unavailability_calendar.pk
+    app.get(settings_url, status=403)
+    group = manager_user.groups.all()[0]
+    unavailability_calendar.view_role = group
+    unavailability_calendar.edit_role = None
+    unavailability_calendar.save()
+    app.get(settings_url, status=403)
+    unavailability_calendar.view_role = None
+    unavailability_calendar.edit_role = group
+    unavailability_calendar.save()
+    app.get(settings_url)
+
+
+def test_unavailability_calendar_add_unavailability_permissions(app, manager_user):
+    unavailability_calendar = UnavailabilityCalendar.objects.create(label='Calendar 1')
+    app = login(app, username='manager', password='manager')
+    url = '/manage/unavailability-calendar/%s/add-unavailability' % unavailability_calendar.pk
+    app.get(url, status=403)
+    group = manager_user.groups.all()[0]
+    unavailability_calendar.view_role = group
+    unavailability_calendar.edit_role = None
+    unavailability_calendar.save()
+    app.get(url, status=403)
+    unavailability_calendar.view_role = None
+    unavailability_calendar.edit_role = group
+    unavailability_calendar.save()
+    app.get(url)
+
+
+def test_unavailability_calendar_edit_unavailability_permissions(app, manager_user):
+    unavailability_calendar = UnavailabilityCalendar.objects.create(label='Calendar 1')
+    time_period_exception = TimePeriodException.objects.create(
+        unavailability_calendar=unavailability_calendar,
+        start_datetime=now() - datetime.timedelta(days=2),
+        end_datetime=now() - datetime.timedelta(days=1),
+    )
+    app = login(app, username='manager', password='manager')
+    url = '/manage/time-period-exceptions/%s/edit' % time_period_exception.pk
+    app.get(url, status=403)
+    group = manager_user.groups.all()[0]
+    unavailability_calendar.view_role = group
+    unavailability_calendar.edit_role = None
+    unavailability_calendar.save()
+    app.get(url, status=403)
+    unavailability_calendar.view_role = None
+    unavailability_calendar.edit_role = group
+    unavailability_calendar.save()
+    app.get(url)
+
+
+def test_unavailability_calendar_delete_unavailability_permissions(app, manager_user):
+    unavailability_calendar = UnavailabilityCalendar.objects.create(label='Calendar 1')
+    time_period_exception = TimePeriodException.objects.create(
+        unavailability_calendar=unavailability_calendar,
+        start_datetime=now() - datetime.timedelta(days=2),
+        end_datetime=now() - datetime.timedelta(days=1),
+    )
+    app = login(app, username='manager', password='manager')
+    url = '/manage/time-period-exceptions/%s/delete' % time_period_exception.pk
+    app.get(url, status=403)
+    group = manager_user.groups.all()[0]
+    unavailability_calendar.view_role = group
+    unavailability_calendar.edit_role = None
+    unavailability_calendar.save()
+    app.get(url, status=403)
+    unavailability_calendar.view_role = None
+    unavailability_calendar.edit_role = group
+    unavailability_calendar.save()
+    app.get(url)
-- 
2.20.1

