From ed91a17e502d6df8e22e247948dca06cb9c3d753 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Tue, 25 Oct 2022 18:47:48 +0200
Subject: [PATCH] manager: weekly wiew for agendas & resources (#33404)

---
 .../agendas/migrations/0090_default_view.py   |   7 +-
 chrono/agendas/models.py                      |   1 +
 chrono/manager/static/js/chrono.manager.js    |   4 +-
 .../chrono/manager_agenda_week_view.html      |  32 +
 .../manager_events_agenda_day_view.html       |   1 +
 .../manager_events_agenda_month_view.html     |   1 +
 .../manager_events_agenda_week_view.html      |  44 +
 .../manager_meetings_agenda_day_view.html     |   1 +
 .../manager_meetings_agenda_month_view.html   |  58 +-
 ...etings_agenda_week_timetable_fragment.html |  59 ++
 .../manager_meetings_agenda_week_view.html    |  12 +
 .../chrono/manager_resource_day_view.html     |   1 +
 .../chrono/manager_resource_detail.html       |   2 +
 .../chrono/manager_resource_month_view.html   |  44 +-
 ...ager_resource_week_timetable_fragment.html |  43 +
 .../chrono/manager_resource_week_view.html    |  32 +
 chrono/manager/urls.py                        |  15 +
 chrono/manager/views.py                       | 301 ++++--
 tests/manager/test_all.py                     | 860 ++++++++++++++++--
 tests/manager/test_resource.py                | 292 +++++-
 20 files changed, 1530 insertions(+), 280 deletions(-)
 create mode 100644 chrono/manager/templates/chrono/manager_agenda_week_view.html
 create mode 100644 chrono/manager/templates/chrono/manager_events_agenda_week_view.html
 create mode 100644 chrono/manager/templates/chrono/manager_meetings_agenda_week_timetable_fragment.html
 create mode 100644 chrono/manager/templates/chrono/manager_meetings_agenda_week_view.html
 create mode 100644 chrono/manager/templates/chrono/manager_resource_week_timetable_fragment.html
 create mode 100644 chrono/manager/templates/chrono/manager_resource_week_view.html

diff --git a/chrono/agendas/migrations/0090_default_view.py b/chrono/agendas/migrations/0090_default_view.py
index 6e65e3f9..a134ed65 100644
--- a/chrono/agendas/migrations/0090_default_view.py
+++ b/chrono/agendas/migrations/0090_default_view.py
@@ -12,7 +12,12 @@ class Migration(migrations.Migration):
             model_name='agenda',
             name='default_view',
             field=models.CharField(
-                choices=[('day', 'Day view'), ('month', 'Month view'), ('open_events', 'Open events')],
+                choices=[
+                    ('day', 'Day view'),
+                    ('week', 'Week view'),
+                    ('month', 'Month view'),
+                    ('open_events', 'Open events'),
+                ],
                 max_length=20,
                 verbose_name='Default view',
             ),
diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py
index 2ece19a3..02606c58 100644
--- a/chrono/agendas/models.py
+++ b/chrono/agendas/models.py
@@ -70,6 +70,7 @@ AGENDA_KINDS = (
 
 AGENDA_VIEWS = (
     ('day', _('Day view')),
+    ('week', _('Week view')),
     ('month', _('Month view')),
     ('open_events', _('Open events')),
 )
diff --git a/chrono/manager/static/js/chrono.manager.js b/chrono/manager/static/js/chrono.manager.js
index 97d87a18..5b324886 100644
--- a/chrono/manager/static/js/chrono.manager.js
+++ b/chrono/manager/static/js/chrono.manager.js
@@ -10,8 +10,10 @@ $(function() {
     $('.date-picker button').on('click', function() {
     if ($('[name=day]').val()) {
        window.location = '../../../' + $('[name=year]').val() + '/' + $('[name=month]').val() + '/' + $('[name=day]').val() + '/';
-    } else {
+    } else if ($('[name=month]').val()) {
         window.location = '../../' + $('[name=year]').val() + '/' + $('[name=month]').val() + '/';
+    } else {
+        window.location = '../../../' + $('[name=year]').val() + '/week/' + $('[name=week]').val() + '/';
     }
     return false;
   });
diff --git a/chrono/manager/templates/chrono/manager_agenda_week_view.html b/chrono/manager/templates/chrono/manager_agenda_week_view.html
new file mode 100644
index 00000000..3a25fad0
--- /dev/null
+++ b/chrono/manager/templates/chrono/manager_agenda_week_view.html
@@ -0,0 +1,32 @@
+{% extends "chrono/manager_agenda_view.html" %}
+{% load i18n %}
+
+{% block bodyargs %}class="weekview"{% endblock %}
+
+{% block breadcrumb %}
+  {{ block.super }}
+  <a>{{ view.date|date:"F Y" }}</a>
+{% endblock %}
+
+{% block appbar %}
+  <h2>
+    <a href="{{ view.get_previous_week_url }}">←</a>
+    <span class="date-title">{{ view.date|date:_("Y \w\e\e\k W") }}</span>
+    {% with selected_week=view.date|date:"W" selected_year=view.date|date:"Y" %}
+      <div class="date-picker" style="display: none">
+        <select name="week">{% for week, week_label in view.get_weeks %}<option value="{{ week }}" {% if selected_week == week %}selected{% endif %}>{{ week_label }}</option>{% endfor %}</select>
+        <select name="year">{% for year in view.get_years %}<option value="{{ year }}" {% if selected_year == year %}selected{% endif %}>{{ year }}</option>{% endfor %}</select>
+        <button>{% trans 'Set Date' %}</button>
+      </div>
+    {% endwith %}
+    <a href="{{ view.get_next_week_url }}">→</a>
+  </h2>
+  <span class="actions">
+    {% block actions %}
+      {% if user_can_manage %}
+        <a href="{{ agenda.get_settings_url }}">{% trans 'Settings' %}</a>
+      {% endif %}
+      <a href="" onclick="window.print()">{% trans 'Print' %}</a>
+    {% endblock %}
+  </span>
+{% endblock %}
diff --git a/chrono/manager/templates/chrono/manager_events_agenda_day_view.html b/chrono/manager/templates/chrono/manager_events_agenda_day_view.html
index e742bb90..eb1d1709 100644
--- a/chrono/manager/templates/chrono/manager_events_agenda_day_view.html
+++ b/chrono/manager/templates/chrono/manager_events_agenda_day_view.html
@@ -11,6 +11,7 @@
   {{ block.super }}
   <a href="{% url 'chrono-manager-agenda-open-events-view' pk=agenda.pk %}">{% trans 'Open events' %}</a>
   <a href="{% url 'chrono-manager-agenda-month-view' pk=agenda.pk year=view.date|date:"Y" month=view.date|date:"n" %}">{% trans 'Month view' %}</a>
+  <a href="{% url 'chrono-manager-agenda-week-view' pk=agenda.pk year=view.date|date:"Y" week=view.date|date:"W" %}">{% trans 'Week view' %}</a>
 {% endblock %}
 
 {% block content %}
diff --git a/chrono/manager/templates/chrono/manager_events_agenda_month_view.html b/chrono/manager/templates/chrono/manager_events_agenda_month_view.html
index b4982a0a..c386992f 100644
--- a/chrono/manager/templates/chrono/manager_events_agenda_month_view.html
+++ b/chrono/manager/templates/chrono/manager_events_agenda_month_view.html
@@ -11,6 +11,7 @@
   </ul>
   {{ block.super }}
   <a href="{% url 'chrono-manager-agenda-open-events-view' pk=agenda.pk %}">{% trans 'Open events' %}</a>
+  <a href="{% url 'chrono-manager-agenda-week-view' pk=agenda.pk year=view.date|date:"Y" week=view.date|date:"W" %}">{% trans 'Week view' %}</a>
   <a href="{% url 'chrono-manager-agenda-day-view' pk=agenda.pk year=view.date|date:"Y" month=view.date|date:"m" day=view.date|date:"d" %}">{% trans 'Day view' %}</a>
 {% endblock %}
 
diff --git a/chrono/manager/templates/chrono/manager_events_agenda_week_view.html b/chrono/manager/templates/chrono/manager_events_agenda_week_view.html
new file mode 100644
index 00000000..51faa780
--- /dev/null
+++ b/chrono/manager/templates/chrono/manager_events_agenda_week_view.html
@@ -0,0 +1,44 @@
+{% extends "chrono/manager_agenda_week_view.html" %}
+{% load i18n %}
+
+{% block actions %}
+  <a class="extra-actions-menu-opener"></a>
+  <ul class="extra-actions-menu">
+    <li><a href="{% url 'chrono-manager-event-cancellation-report-list' pk=agenda.pk %}">{% trans 'Cancellation error reports' %}</a></li>
+    {% if agenda.subscriptions.exists %}
+      <li><a href="{% url 'chrono-manager-events-timesheet' pk=agenda.pk %}">{% trans 'Timesheet' %}</a></li>
+    {% endif %}
+  </ul>
+  {{ block.super }}
+  <a href="{% url 'chrono-manager-agenda-open-events-view' pk=agenda.pk %}">{% trans 'Open events' %}</a>
+  <a href="{% url 'chrono-manager-agenda-month-view' pk=agenda.pk year=view.date|date:"Y" month=view.date|date:"m" %}">{% trans 'Month view' %}</a>
+  <a href="{% url 'chrono-manager-agenda-day-view' pk=agenda.pk year=view.date|date:"Y" month=view.date|date:"m" day=view.date|date:"d" %}">{% trans 'Day view' %}</a>
+{% endblock %}
+
+{% block content %}
+  <div class="section">
+    <h3>{% trans "Events" %}</h3>
+    {% include 'chrono/manager_event_cancellation_report_notice.html' %}
+    <div>
+      {% if object_list %}
+        <ul class="objects-list single-links">
+          {% for object in object_list %}
+            {% if object.is_exception %}
+              <li><a class="disabled">{% trans "Exception:"%} {{ object }}</a></li>
+            {% else %}
+              {% include 'chrono/manager_agenda_event_fragment.html' with event=object %}
+            {% endif %}
+          {% endfor %}
+        </ul>
+        {% include "gadjo/pagination.html" %}
+      {% else %}
+        <div class="big-msg-info">
+          {% blocktrans trimmed %}
+            This week doesn't have any event configured.
+          {% endblocktrans %}
+        </div>
+      {% endif %}
+    </div>
+  </div>
+
+{% endblock %}
diff --git a/chrono/manager/templates/chrono/manager_meetings_agenda_day_view.html b/chrono/manager/templates/chrono/manager_meetings_agenda_day_view.html
index a5a29ec7..848b28d2 100644
--- a/chrono/manager/templates/chrono/manager_meetings_agenda_day_view.html
+++ b/chrono/manager/templates/chrono/manager_meetings_agenda_day_view.html
@@ -4,6 +4,7 @@
 {% block actions %}
   {{ block.super }}
   <a href="{% url 'chrono-manager-agenda-month-view' pk=agenda.id year=view.date|date:"Y" month=view.date|date:"n" %}">{% trans 'Month view' %}</a>
+  <a href="{% url 'chrono-manager-agenda-week-view' pk=agenda.id year=view.date|date:"Y" week=view.date|date:"W" %}">{% trans 'Week view' %}</a>
 {% endblock %}
 
 {% block content %}
diff --git a/chrono/manager/templates/chrono/manager_meetings_agenda_month_view.html b/chrono/manager/templates/chrono/manager_meetings_agenda_month_view.html
index d525151b..c73f15ca 100644
--- a/chrono/manager/templates/chrono/manager_meetings_agenda_month_view.html
+++ b/chrono/manager/templates/chrono/manager_meetings_agenda_month_view.html
@@ -3,64 +3,10 @@
 
 {% block actions %}
   {{ block.super }}
+  <a href="{% url 'chrono-manager-agenda-week-view' pk=agenda.id year=view.date|date:"Y" week=view.date|date:"W" %}">{% trans 'Week view' %}</a>
   <a href="{% url 'chrono-manager-agenda-day-view' pk=agenda.id year=view.date|date:"Y" month=view.date|date:"m" day=view.date|date:"d" %}">{% trans 'Day view' %}</a>
 {% endblock %}
 
 {% block content %}
-
-  {% for week_days in view.get_timetable_infos %}
-    {% if forloop.first %}
-      <table class="agenda-table month-view {% if single_desk %}single-desk{% endif %}">
-        <tbody>
-    {% endif %}
-    <tr>
-      <th></th>
-      {% for day in week_days.days %}
-        <th class="weekday {% if day.today %}today{% endif %}">{% if not day.other_month %}<a href="{% url 'chrono-manager-agenda-day-view' pk=agenda.id year=day.date|date:"Y" month=day.date|date:"m" day=day.date|date:"d" %}">{{ day.date|date:"l j" }}</a>{% endif %}</th>
-      {% endfor %}
-    </tr>
-    {% for hour in week_days.periods %}
-      <tr class="{% cycle 'odd' 'even' %}">
-        <th class="hour">{{ hour|date:"TIME_FORMAT" }}</th>
-        {% for day in week_days.days %}
-          <td class="{% if day.other_month %}other-month{% endif %} {% if day.today %}today{% endif %}">
-            {% if forloop.parentloop.first %}
-              {% for slot in day.infos.opening_hours %}
-                <div class="opening-hours" style="height:{{ slot.css_height|stringformat:".1f" }}%;top:{{ slot.css_top|stringformat:".1f" }}%;width:{{ slot.css_width|stringformat:".1f" }}%;left:{{ slot.css_left|stringformat:".1f" }}%;"></div>
-              {% endfor %}
-
-              {% for slot in day.infos.exceptions %}
-                <div class="exception-hours" style="height:{{ slot.css_height|stringformat:".1f" }}%;top:{{ slot.css_top|stringformat:".1f" }}%;width:{{ slot.css_width|stringformat:".1f" }}%;left:{{ slot.css_left|stringformat:".1f" }}%;" {% if slot.label %}title="{{ slot.label }}"{% endif %}></div>
-              {% endfor %}
-
-              {% for slot in day.infos.booked_slots %}
-                <div class="booking{% if slot.booking.color %} booking-color-{{ slot.booking.color.index }}{% endif %}" style="left:{{ slot.css_left|stringformat:".1f" }}%;height:{{ slot.css_height|stringformat:".1f" }}%;min-height:{{ slot.css_height|stringformat:".1f" }}%;top:{{ slot.css_top|stringformat:".1f" }}%;width:{{ slot.css_width|stringformat:".1f" }}%;">
-                  <span class="start-time">{{slot.booking.event.start_datetime|date:"TIME_FORMAT"}}</span>
-                  <a {% if slot.booking.get_backoffice_url %}href="{{slot.booking.get_backoffice_url}}"{% endif %}>{{ slot.booking.get_user_block }}</a>
-                  <a rel="popup" class="cancel" href="{% url 'chrono-manager-booking-cancel' pk=slot.booking.event.agenda_id booking_pk=slot.booking.id %}?next={{ request.path }}">{% trans "Cancel" %}</a>
-                  {% if not single_desk %}<span class="desk">{{ slot.desk }}</span>{% endif %}
-                  {% if slot.booking.color %}<span class="booking-color-label booking-bg-color-{{ slot.booking.color.index }}">{{ slot.booking.color }}</span>{% endif %}
-                </div>
-              {% endfor %}
-            {% endif %}
-          </td>
-        {% endfor %}
-      </tr>
-    {% endfor %}
-    {% resetcycle %}
-    {% if forloop.last %}
-      </tbody>
-      </table>
-    {% endif %}
-
-  {% empty %}
-    <div class="closed-for-the-day">
-      <p>{% trans "No opening hours this month." %}</p>
-    </div>
-  {% endfor %}
-
-  {% if booking_colors %}
-    {% include "chrono/booking_color_legend.html" with colors=booking_colors %}
-  {% endif %}
-
+  {% include "chrono/manager_meetings_agenda_week_timetable_fragment.html" %}
 {% endblock %}
diff --git a/chrono/manager/templates/chrono/manager_meetings_agenda_week_timetable_fragment.html b/chrono/manager/templates/chrono/manager_meetings_agenda_week_timetable_fragment.html
new file mode 100644
index 00000000..28904d6d
--- /dev/null
+++ b/chrono/manager/templates/chrono/manager_meetings_agenda_week_timetable_fragment.html
@@ -0,0 +1,59 @@
+{% load i18n %}
+{% for week_days in view.get_timetable_infos %}
+  {% if forloop.first %}
+    <table class="agenda-table {{ kind }}-view {% if kind == 'week' %}hourspan-{{ hour_span }}{% endif %} {% if single_desk %}single-desk{% endif %}">
+      <tbody>
+  {% endif %}
+  <tr>
+    <th></th>
+    {% for day in week_days.days %}
+      <th class="weekday {% if day.today %}today{% endif %}">{% if kind == 'month' and not day.other_month or kind == 'week' %}<a href="{% url 'chrono-manager-agenda-day-view' pk=agenda.id year=day.date|date:"Y" month=day.date|date:"m" day=day.date|date:"d" %}">{{ day.date|date:"l j" }}{% if kind == 'week' %}<br />{{ day.date|date:"F" }}{% endif %}</a>{% endif %}</th>
+    {% endfor %}
+  </tr>
+  {% for hour in week_days.periods %}
+    <tr class="{% cycle 'odd' 'even' %}">
+      <th class="hour">{{ hour|date:"TIME_FORMAT" }}</th>
+      {% for day in week_days.days %}
+        <td class="{% if kind == 'month' and day.other_month %}other-month{% endif %} {% if day.today %}today{% endif %}">
+          {% if forloop.parentloop.first %}
+            {% for slot in day.infos.opening_hours %}
+              <div class="opening-hours" style="height:{{ slot.css_height|stringformat:".1f" }}%;top:{{ slot.css_top|stringformat:".1f" }}%;width:{{ slot.css_width|stringformat:".1f" }}%;left:{{ slot.css_left|stringformat:".1f" }}%;"></div>
+            {% endfor %}
+
+            {% for slot in day.infos.exceptions %}
+              <div class="exception-hours" style="height:{{ slot.css_height|stringformat:".1f" }}%;top:{{ slot.css_top|stringformat:".1f" }}%;width:{{ slot.css_width|stringformat:".1f" }}%;left:{{ slot.css_left|stringformat:".1f" }}%;" {% if slot.label %}title="{{ slot.label }}"{% endif %}></div>
+            {% endfor %}
+
+            {% for slot in day.infos.booked_slots %}
+              <div class="booking{% if slot.booking.color %} booking-color-{{ slot.booking.color.index }}{% endif %}" style="left:{{ slot.css_left|stringformat:".1f" }}%;height:{{ slot.css_height|stringformat:".1f" }}%;min-height:{{ slot.css_height|stringformat:".1f" }}%;top:{{ slot.css_top|stringformat:".1f" }}%;width:{{ slot.css_width|stringformat:".1f" }}%;">
+                <span class="start-time">{{slot.booking.event.start_datetime|date:"TIME_FORMAT"}}</span>
+                <a {% if slot.booking.get_backoffice_url %}href="{{slot.booking.get_backoffice_url}}"{% endif %}>{{ slot.booking.get_user_block }}</a>
+                <a rel="popup" class="cancel" href="{% url 'chrono-manager-booking-cancel' pk=slot.booking.event.agenda_id booking_pk=slot.booking.id %}?next={{ request.path }}">{% trans "Cancel" %}</a>
+                {% if not single_desk %}<span class="desk">{{ slot.desk }}</span>{% endif %}
+                {% if slot.booking.color %}<span class="booking-color-label booking-bg-color-{{ slot.booking.color.index }}">{{ slot.booking.color }}</span>{% endif %}
+              </div>
+            {% endfor %}
+          {% endif %}
+        </td>
+      {% endfor %}
+    </tr>
+  {% endfor %}
+  {% resetcycle %}
+  {% if forloop.last %}
+    </tbody>
+    </table>
+  {% endif %}
+
+{% empty %}
+  <div class="closed-for-the-day">
+    {% if kind == 'month' %}
+      <p>{% trans "No opening hours this month." %}</p>
+    {% else %}
+      <p>{% trans "No opening hours this week." %}</p>
+    {% endif %}
+  </div>
+{% endfor %}
+
+{% if booking_colors %}
+  {% include "chrono/booking_color_legend.html" with colors=booking_colors %}
+{% endif %}
diff --git a/chrono/manager/templates/chrono/manager_meetings_agenda_week_view.html b/chrono/manager/templates/chrono/manager_meetings_agenda_week_view.html
new file mode 100644
index 00000000..78d47a7e
--- /dev/null
+++ b/chrono/manager/templates/chrono/manager_meetings_agenda_week_view.html
@@ -0,0 +1,12 @@
+{% extends "chrono/manager_agenda_week_view.html" %}
+{% load i18n %}
+
+{% block actions %}
+  {{ block.super }}
+  <a href="{% url 'chrono-manager-agenda-month-view' pk=agenda.id year=view.date|date:"Y" month=view.date|date:"m" %}">{% trans 'Month view' %}</a>
+  <a href="{% url 'chrono-manager-agenda-day-view' pk=agenda.id year=view.date|date:"Y" month=view.date|date:"m" day=view.date|date:"d" %}">{% trans 'Day view' %}</a>
+{% endblock %}
+
+{% block content %}
+  {% include "chrono/manager_meetings_agenda_week_timetable_fragment.html" %}
+{% endblock %}
diff --git a/chrono/manager/templates/chrono/manager_resource_day_view.html b/chrono/manager/templates/chrono/manager_resource_day_view.html
index de9041ab..f0c3f9d0 100644
--- a/chrono/manager/templates/chrono/manager_resource_day_view.html
+++ b/chrono/manager/templates/chrono/manager_resource_day_view.html
@@ -23,6 +23,7 @@
 {% endblock %}
 {% block appbar-extras %}
   <a href="{% url 'chrono-manager-resource-month-view' pk=resource.pk year=view.date|date:"Y" month=view.date|date:"n" %}">{% trans 'Month view' %}</a>
+  <a href="{% url 'chrono-manager-resource-week-view' pk=resource.pk year=view.date|date:"Y" week=view.date|date:"W" %}">{% trans 'Week view' %}</a>
 {% endblock %}
 
 {% block content %}
diff --git a/chrono/manager/templates/chrono/manager_resource_detail.html b/chrono/manager/templates/chrono/manager_resource_detail.html
index 38097c8c..4a0195a6 100644
--- a/chrono/manager/templates/chrono/manager_resource_detail.html
+++ b/chrono/manager/templates/chrono/manager_resource_detail.html
@@ -22,8 +22,10 @@
       {% endif %}
       {% now "Y" as today_year %}
       {% now "n" as today_month %}
+      {% now "W" as today_week %}
       {% now "j" as today_day %}
       <a href="{% url 'chrono-manager-resource-month-view' pk=resource.pk year=today_year month=today_month %}">{% trans 'Month view' %}</a>
+      <a href="{% url 'chrono-manager-resource-week-view' pk=resource.pk year=today_year week=today_week %}">{% trans 'Week view' %}</a>
       <a href="{% url 'chrono-manager-resource-day-view' pk=resource.pk year=today_year month=today_month day=today_day %}">{% trans 'Day view' %}</a>
     {% endblock %}
   </span>
diff --git a/chrono/manager/templates/chrono/manager_resource_month_view.html b/chrono/manager/templates/chrono/manager_resource_month_view.html
index 4b7dbace..13387efb 100644
--- a/chrono/manager/templates/chrono/manager_resource_month_view.html
+++ b/chrono/manager/templates/chrono/manager_resource_month_view.html
@@ -23,48 +23,10 @@
   </h2>
 {% endblock %}
 {% block appbar-extras %}
-  <a href="{% url 'chrono-manager-resource-day-view' pk=resource.pk year=view.date|date:"Y" month=view.date|date:"n" day=1 %}">{% trans 'Day view' %}</a>
+  <a href="{% url 'chrono-manager-resource-week-view' pk=resource.pk year=view.date|date:"Y" week=view.date|date:"W" %}">{% trans 'Week view' %}</a>
+  <a href="{% url 'chrono-manager-resource-day-view' pk=resource.pk year=view.date|date:"Y" month=view.date|date:"n" day=view.date|date:"d" %}">{% trans 'Day view' %}</a>
 {% endblock %}
 
 {% block content %}
-
-  {% for week_days in view.get_timetable_infos %}
-
-    {% if forloop.first %}
-      <table class="agenda-table month-view single-desk">
-        <tbody>
-    {% endif %}
-    <tr>
-      <th></th>
-      {% for day in week_days.days %}
-        <th class="weekday {% if day.today %}today{% endif %}">{% if not day.other_month %}<a href="{% url 'chrono-manager-resource-day-view' pk=resource.pk year=day.date|date:"Y" month=day.date|date:"m" day=day.date|date:"j" %}">{{ day.date|date:"l j" }}</a>{% endif %}</th>
-      {% endfor %}
-    </tr>
-    {% for hour in week_days.periods %}
-      <tr class="{% cycle 'odd' 'even' %}">
-        <th class="hour">{{ hour|date:"TIME_FORMAT" }}</th>
-        {% for day in week_days.days %}
-          <td class="{% if day.other_month %}other-month{% endif %} {% if day.today %}today{% endif %}">
-            {% if forloop.parentloop.first %}
-              {% for slot in day.infos.booked_slots %}
-                <div class="booking" style="height:{{ slot.css_height|stringformat:".1f" }}%;min-height:{{ slot.css_height|stringformat:".1f" }}%;top:{{ slot.css_top|stringformat:".1f" }}%">
-                  <span class="start-time">{{ slot.booking.event.start_datetime|date:"TIME_FORMAT" }}</span>
-                  <a {% if slot.booking.get_backoffice_url %}href="{{ slot.booking.get_backoffice_url }}"{% endif %}>{{ slot.booking.get_user_block }}</a>
-                </div>
-              {% endfor %}
-            {% endif %}
-          </td>
-        {% endfor %}
-      </tr>
-    {% endfor %}
-    {% if forloop.last %}
-      </tbody>
-      </table>
-    {% endif %}
-  {% empty %}
-    <div class="closed-for-the-day">
-      <p>{% trans "No bookings this month." %}</p>
-    </div>
-  {% endfor %}
-
+  {% include "chrono/manager_resource_week_timetable_fragment.html" %}
 {% endblock %}
diff --git a/chrono/manager/templates/chrono/manager_resource_week_timetable_fragment.html b/chrono/manager/templates/chrono/manager_resource_week_timetable_fragment.html
new file mode 100644
index 00000000..0b9890ae
--- /dev/null
+++ b/chrono/manager/templates/chrono/manager_resource_week_timetable_fragment.html
@@ -0,0 +1,43 @@
+{% load i18n %}
+{% for week_days in view.get_timetable_infos %}
+
+  {% if forloop.first %}
+    <table class="agenda-table {{ kind }}-view {% if kind == 'week' %}hourspan-{{ hour_span }}{% endif %} single-desk">
+      <tbody>
+  {% endif %}
+  <tr>
+    <th></th>
+    {% for day in week_days.days %}
+      <th class="weekday {% if day.today %}today{% endif %}">{% if kind == 'month' and not day.other_month or kind == 'week' %}<a href="{% url 'chrono-manager-resource-day-view' pk=resource.pk year=day.date|date:"Y" month=day.date|date:"m" day=day.date|date:"d" %}">{{ day.date|date:"l j" }}{% if kind == 'week' %}<br />{{ day.date|date:"F" }}{% endif %}</a>{% endif %}</th>
+    {% endfor %}
+  </tr>
+  {% for hour in week_days.periods %}
+    <tr class="{% cycle 'odd' 'even' %}">
+      <th class="hour">{{ hour|date:"TIME_FORMAT" }}</th>
+      {% for day in week_days.days %}
+        <td class="{% if kind == 'month' and day.other_month %}other-month{% endif %} {% if day.today %}today{% endif %}">
+          {% if forloop.parentloop.first %}
+            {% for slot in day.infos.booked_slots %}
+              <div class="booking" style="height:{{ slot.css_height|stringformat:".1f" }}%;min-height:{{ slot.css_height|stringformat:".1f" }}%;top:{{ slot.css_top|stringformat:".1f" }}%">
+                <span class="start-time">{{ slot.booking.event.start_datetime|date:"TIME_FORMAT" }}</span>
+                <a {% if slot.booking.get_backoffice_url %}href="{{ slot.booking.get_backoffice_url }}"{% endif %}>{{ slot.booking.get_user_block }}</a>
+              </div>
+            {% endfor %}
+          {% endif %}
+        </td>
+      {% endfor %}
+    </tr>
+  {% endfor %}
+  {% if forloop.last %}
+    </tbody>
+    </table>
+  {% endif %}
+{% empty %}
+  <div class="closed-for-the-day">
+    {% if kind == 'month' %}
+      <p>{% trans "No bookings this month." %}</p>
+    {% else %}
+      <p>{% trans "No bookings this week." %}</p>
+    {% endif %}
+  </div>
+{% endfor %}
diff --git a/chrono/manager/templates/chrono/manager_resource_week_view.html b/chrono/manager/templates/chrono/manager_resource_week_view.html
new file mode 100644
index 00000000..91d239a5
--- /dev/null
+++ b/chrono/manager/templates/chrono/manager_resource_week_view.html
@@ -0,0 +1,32 @@
+{% extends "chrono/manager_resource_detail.html" %}
+{% load i18n %}
+
+{% block bodyargs %}class="weekview"{% endblock %}
+
+{% block breadcrumb %}
+  {{ block.super }}
+  <a>{{ view.date|date:"F Y" }}</a>
+{% endblock %}
+
+{% block appbar-title %}
+  <h2>
+    <a href="{{ view.get_previous_week_url }}">←</a>
+    <span class="date-title">{{ view.date|date:_("Y \w\e\e\k W") }}</span>
+    {% with selected_week=view.date|date:"W" selected_year=view.date|date:"Y" %}
+      <div class="date-picker" style="display: none">
+        <select name="week">{% for week, week_label in view.get_weeks %}<option value="{{ week }}" {% if selected_week == week %}selected{% endif %}>{{ week_label }}</option>{% endfor %}</select>
+        <select name="year">{% for year in view.get_years %}<option value="{{ year }}" {% if selected_year == year %}selected{% endif %}>{{ year }}</option>{% endfor %}</select>
+        <button>{% trans 'Set Date' %}</button>
+      </div>
+    {% endwith %}
+    <a href="{{ view.get_next_week_url }}">→</a>
+  </h2>
+{% endblock %}
+{% block appbar-extras %}
+  <a href="{% url 'chrono-manager-resource-month-view' pk=resource.pk year=view.date|date:"Y" month=view.date|date:"n" %}">{% trans 'Month view' %}</a>
+  <a href="{% url 'chrono-manager-resource-day-view' pk=resource.pk year=view.date|date:"Y" month=view.date|date:"n" day=view.date|date:"d" %}">{% trans 'Day view' %}</a>
+{% endblock %}
+
+{% block content %}
+  {% include "chrono/manager_resource_week_timetable_fragment.html" %}
+{% endblock %}
diff --git a/chrono/manager/urls.py b/chrono/manager/urls.py
index cf1b924c..0d578414 100644
--- a/chrono/manager/urls.py
+++ b/chrono/manager/urls.py
@@ -73,6 +73,11 @@ urlpatterns = [
         views.resource_monthly_view,
         name='chrono-manager-resource-month-view',
     ),
+    re_path(
+        r'^resource/(?P<pk>\d+)/(?P<year>[0-9]{4})/week/(?P<week>[0-9]+)/$',
+        views.resource_weekly_view,
+        name='chrono-manager-resource-week-view',
+    ),
     re_path(
         r'^resource/(?P<pk>\d+)/(?P<year>[0-9]{4})/(?P<month>[0-9]+)/(?P<day>[0-9]+)/$',
         views.resource_day_view,
@@ -107,6 +112,16 @@ urlpatterns = [
         views.agenda_monthly_view,
         name='chrono-manager-agenda-month-view',
     ),
+    path(
+        'agendas/<int:pk>/week/',
+        views.agenda_week_redirect_view,
+        name='chrono-manager-agenda-week-redirect-view',
+    ),
+    re_path(
+        r'^agendas/(?P<pk>\d+)/(?P<year>[0-9]{4})/week/(?P<week>[0-9]+)/$',
+        views.agenda_weekly_view,
+        name='chrono-manager-agenda-week-view',
+    ),
     path(
         'agendas/<int:pk>/day/',
         views.agenda_day_redirect_view,
diff --git a/chrono/manager/views.py b/chrono/manager/views.py
index e9b12aed..23bcef9d 100644
--- a/chrono/manager/views.py
+++ b/chrono/manager/views.py
@@ -57,6 +57,7 @@ from django.views.generic import (
     TemplateView,
     UpdateView,
     View,
+    WeekArchiveView,
 )
 from django.views.generic.dates import MonthMixin, YearMixin
 from weasyprint import HTML
@@ -250,6 +251,9 @@ class DateMixin:
     def get_months(self):
         return [(str(x), MONTHS[x]) for x in range(1, 13)]
 
+    def get_weeks(self):
+        return [(str(x), _('Week %s') % x) for x in range(1, 53)]
+
     def get_years(self):
         year = now().year
         return [str(x) for x in range(year - 1, year + 5)]
@@ -384,28 +388,7 @@ class ResourceDayView(DateMixin, DayArchiveView):
 resource_day_view = ResourceDayView.as_view()
 
 
-class ResourceMonthView(DateMixin, MonthArchiveView):
-    template_name = 'chrono/manager_resource_month_view.html'
-    model = Event
-    month_format = '%m'
-    date_field = 'start_datetime'
-    allow_empty = True
-    allow_future = True
-
-    def dispatch(self, request, *args, **kwargs):
-        self.resource = get_object_or_404(Resource, pk=kwargs['pk'])
-        if not self.resource.can_be_viewed(request.user):
-            raise PermissionDenied()
-        try:
-            self.date = make_aware(
-                datetime.datetime.strptime(
-                    '%s-%s-%s 06:00' % (self.get_year(), self.get_month(), 1), '%Y-%m-%d %H:%M'
-                )
-            )
-        except ValueError:
-            raise Http404
-        return super().dispatch(request, *args, **kwargs)
-
+class ResourceWeekMonthMixin:
     def get_queryset(self):
         queryset = (
             self.resource.event_set.all()
@@ -418,22 +401,18 @@ class ResourceMonthView(DateMixin, MonthArchiveView):
         context = super().get_context_data(**kwargs)
 
         context['resource'] = self.resource
-
-        return context
-
-    def get_previous_month_url(self):
-        previous_month = self.get_previous_month(self.date.date())
-        return reverse(
-            'chrono-manager-resource-month-view',
-            kwargs={'pk': self.resource.pk, 'year': previous_month.year, 'month': previous_month.month},
+        context['kind'] = self.kind
+        context['hour_span'] = 1
+        durations = MeetingType.objects.filter(agenda__resources=self.resource).values_list(
+            'duration', flat=True
         )
+        if durations:
+            gcd = durations[0]
+            for duration in durations[1:]:
+                gcd = math.gcd(duration, gcd)
+            context['hour_span'] = max(60 // gcd, 1)
 
-    def get_next_month_url(self):
-        next_month = self.get_next_month(self.date.date())
-        return reverse(
-            'chrono-manager-resource-month-view',
-            kwargs={'pk': self.resource.pk, 'year': next_month.year, 'month': next_month.month},
-        )
+        return context
 
     def get_timetable_infos(self):
         interval = datetime.timedelta(minutes=60)
@@ -469,18 +448,20 @@ class ResourceMonthView(DateMixin, MonthArchiveView):
         hide_weekend = hide_weekend_timeperiod and hide_weekend_event
 
         # avoid displaying empty first week
-        first_week_offset = int(
-            (hide_sunday and self.date.weekday() == 6) or (hide_weekend and self.date.weekday() == 5)
-        )
-
+        first_week_offset = 0
         first_week_number = self.date.isocalendar()[1]
-        if first_week_number >= 52:
-            first_week_number = 0
-        last_month_day = self.get_next_month(self.date.date()) - datetime.timedelta(days=1)
-        last_week_number = last_month_day.isocalendar()[1]
+        last_week_number = first_week_number
+        if self.kind == 'month':
+            first_week_offset = int(
+                (hide_sunday and self.date.weekday() == 6) or (hide_weekend and self.date.weekday() == 5)
+            )
+            if first_week_number >= 52:
+                first_week_number = 0
+            last_day = self.get_next(self.date.date()) - datetime.timedelta(days=1)
+            last_week_number = last_day.isocalendar()[1]
 
-        if last_week_number < first_week_number:  # new year
-            last_week_number = 53
+            if last_week_number < first_week_number:  # new year
+                last_week_number = 53
 
         for week_number in range(first_week_number + first_week_offset, last_week_number + 1):
             yield self.get_week_timetable_infos(
@@ -528,8 +509,8 @@ class ResourceMonthView(DateMixin, MonthArchiveView):
             # until the end of the last hour.
             max_date += datetime.timedelta(hours=1)
 
-        # compute booking and opening hours only for current month
-        if self.date.month != day.month:
+        # compute booking and opening hours only for current month/week
+        if self.kind == 'month' and timetable['other_month']:
             return timetable
 
         while period <= max_date:
@@ -552,6 +533,92 @@ class ResourceMonthView(DateMixin, MonthArchiveView):
         return timetable
 
 
+class ResourceWeekView(ResourceWeekMonthMixin, DateMixin, WeekArchiveView):
+    template_name = 'chrono/manager_resource_week_view.html'
+    model = Event
+    week_format = '%W'
+    date_field = 'start_datetime'
+    allow_empty = True
+    allow_future = True
+    kind = 'week'
+
+    def dispatch(self, request, *args, **kwargs):
+        self.resource = get_object_or_404(Resource, pk=kwargs['pk'])
+        if not self.resource.can_be_viewed(request.user):
+            raise PermissionDenied()
+        try:
+            date = datetime.datetime.strptime('%s-W%s-1' % (self.get_year(), self.get_week()), "%Y-W%W-%w")
+            self.date = make_aware(
+                datetime.datetime.strptime(
+                    '%s-%s-%s 06:00' % (self.get_year(), date.month, date.day), '%Y-%m-%d %H:%M'
+                )
+            )
+        except ValueError:
+            raise Http404
+        return super().dispatch(request, *args, **kwargs)
+
+    def get_previous_week_url(self):
+        previous_week = self.get_previous_week(self.date.date())
+        return reverse(
+            'chrono-manager-resource-week-view',
+            kwargs={'pk': self.resource.pk, 'year': previous_week.year, 'week': previous_week.strftime('%W')},
+        )
+
+    def get_next_week_url(self):
+        next_week = self.get_next_week(self.date.date())
+        return reverse(
+            'chrono-manager-resource-week-view',
+            kwargs={'pk': self.resource.pk, 'year': next_week.year, 'week': next_week.strftime('%W')},
+        )
+
+    def get_next(self, date):
+        return self.get_next_week(date)
+
+
+resource_weekly_view = ResourceWeekView.as_view()
+
+
+class ResourceMonthView(ResourceWeekMonthMixin, DateMixin, MonthArchiveView):
+    template_name = 'chrono/manager_resource_month_view.html'
+    model = Event
+    month_format = '%m'
+    date_field = 'start_datetime'
+    allow_empty = True
+    allow_future = True
+    kind = 'month'
+
+    def dispatch(self, request, *args, **kwargs):
+        self.resource = get_object_or_404(Resource, pk=kwargs['pk'])
+        if not self.resource.can_be_viewed(request.user):
+            raise PermissionDenied()
+        try:
+            self.date = make_aware(
+                datetime.datetime.strptime(
+                    '%s-%s-%s 06:00' % (self.get_year(), self.get_month(), 1), '%Y-%m-%d %H:%M'
+                )
+            )
+        except ValueError:
+            raise Http404
+        return super().dispatch(request, *args, **kwargs)
+
+    def get_previous_month_url(self):
+        previous_month = self.get_previous_month(self.date.date())
+        return reverse(
+            'chrono-manager-resource-month-view',
+            kwargs={'pk': self.resource.pk, 'year': previous_month.year, 'month': previous_month.month},
+        )
+
+    def get_next_month_url(self):
+        next_month = self.get_next_month(self.date.date())
+        return reverse(
+            'chrono-manager-resource-month-view',
+            kwargs={'pk': self.resource.pk, 'year': next_month.year, 'month': next_month.month},
+        )
+
+    def get_next(self, date):
+        return self.get_next_month(date)
+
+
 resource_monthly_view = ResourceMonthView.as_view()
 
 
@@ -1082,6 +1149,9 @@ class AgendaView(ViewableAgendaMixin, View):
         if self.agenda.default_view == 'day':
             return redirect('chrono-manager-agenda-day-redirect-view', pk=self.agenda.pk)
 
+        if self.agenda.default_view == 'week':
+            return redirect('chrono-manager-agenda-week-redirect-view', pk=self.agenda.pk)
+
         if self.agenda.default_view == 'month':
             return redirect('chrono-manager-agenda-month-redirect-view', pk=self.agenda.pk)
 
@@ -1114,6 +1184,17 @@ class AgendaMonthRedirectView(ViewableAgendaMixin, View):
 agenda_month_redirect_view = AgendaMonthRedirectView.as_view()
 
 
+class AgendaWeekRedirectView(AgendaMonthRedirectView):
+    def get(self, request, *args, **kwargs):
+        day = self.get_day()
+        return redirect(
+            'chrono-manager-agenda-week-view', pk=self.agenda.pk, year=day.year, week=day.strftime('%W')
+        )
+
+
+agenda_week_redirect_view = AgendaWeekRedirectView.as_view()
+
+
 class AgendaDayRedirectView(AgendaMonthRedirectView):
     def get(self, request, *args, **kwargs):
         day = self.get_day()
@@ -1128,6 +1209,7 @@ agenda_day_redirect_view = AgendaDayRedirectView.as_view()
 class AgendaDateView(DateMixin, ViewableAgendaMixin):
     model = Event
     month_format = '%m'
+    week_format = '%W'
     date_field = 'start_datetime'
     allow_empty = True
     allow_future = True
@@ -1333,7 +1415,7 @@ class AgendaDayView(AgendaDateView, DayArchiveView):
 agenda_day_view = AgendaDayView.as_view()
 
 
-class AgendaMonthView(AgendaDateView, MonthArchiveView):
+class AgendaWeekMonthMixin:
     def get_queryset(self):
         qs = super().get_queryset()
         if self.agenda.kind != 'events':
@@ -1343,9 +1425,9 @@ class AgendaMonthView(AgendaDateView, MonthArchiveView):
     def get_dated_items(self):
         date_list, object_list, extra_context = super().get_dated_items()
         if self.agenda.kind == 'events':
-            min_start = make_aware(datetime.datetime.combine(extra_context['month'], datetime.time(0, 0)))
+            min_start = make_aware(datetime.datetime.combine(extra_context[self.kind], datetime.time(0, 0)))
             max_start = make_aware(
-                datetime.datetime.combine(extra_context['next_month'], datetime.time(0, 0))
+                datetime.datetime.combine(extra_context['next_%s' % self.kind], datetime.time(0, 0))
             )
             exceptions = TimePeriodException.objects.filter(
                 desk__agenda=self.agenda, start_datetime__gte=min_start, end_datetime__lt=max_start
@@ -1353,11 +1435,6 @@ class AgendaMonthView(AgendaDateView, MonthArchiveView):
             object_list = sorted(itertools.chain(object_list, exceptions), key=lambda x: x.start_datetime)
         return date_list, object_list, extra_context
 
-    def get_template_names(self):
-        if self.agenda.kind == 'virtual':
-            return ['chrono/manager_meetings_agenda_month_view.html']
-        return ['chrono/manager_%s_agenda_month_view.html' % self.agenda.kind]
-
     def get_context_data(self, **kwargs):
         context = super().get_context_data(**kwargs)
         if self.agenda.kind == 'events':
@@ -1367,25 +1444,9 @@ class AgendaMonthView(AgendaDateView, MonthArchiveView):
             ).all()
         else:
             context['single_desk'] = bool(len(self.agenda.prefetched_desks) == 1)
+        context['kind'] = self.kind
         return context
 
-    def get_previous_month_url(self):
-        previous_month = self.get_previous_month(self.date.date())
-        return reverse(
-            'chrono-manager-agenda-month-view',
-            kwargs={'pk': self.agenda.id, 'year': previous_month.year, 'month': previous_month.month},
-        )
-
-    def get_next_month_url(self):
-        next_month = self.get_next_month(self.date.date())
-        return reverse(
-            'chrono-manager-agenda-month-view',
-            kwargs={'pk': self.agenda.id, 'year': next_month.year, 'month': next_month.month},
-        )
-
-    def get_day(self):
-        return '1'
-
     def get_timetable_infos(self):
         timeperiods = itertools.chain(*(d.timeperiod_set.all() for d in self.agenda.prefetched_desks))
         timeperiods = sorted(timeperiods, key=lambda t: (t.weekday, t.start_time))
@@ -1419,18 +1480,21 @@ class AgendaMonthView(AgendaDateView, MonthArchiveView):
         hide_weekend = hide_weekend_timeperiod and hide_weekend_event
 
         # avoid displaying empty first week
-        first_week_offset = int(
-            (hide_sunday and self.date.weekday() == 6) or (hide_weekend and self.date.weekday() == 5)
-        )
-
+        first_week_offset = 0
         first_week_number = self.date.isocalendar()[1]
-        if first_week_number >= 52:
-            first_week_number = 0
-        last_month_day = self.get_next_month(self.date.date()) - datetime.timedelta(days=1)
-        last_week_number = last_month_day.isocalendar()[1]
+        last_week_number = first_week_number
+        if self.kind == 'month':
+            first_week_offset = int(
+                (hide_sunday and self.date.weekday() == 6) or (hide_weekend and self.date.weekday() == 5)
+            )
+            first_week_number = self.date.isocalendar()[1]
+            if first_week_number >= 52:
+                first_week_number = 0
+            last_day = self.get_next(self.date.date()) - datetime.timedelta(days=1)
+            last_week_number = last_day.isocalendar()[1]
 
-        if last_week_number < first_week_number:  # new year
-            last_week_number = 53
+            if last_week_number < first_week_number:  # new year
+                last_week_number = 53
 
         for week_number in range(first_week_number + first_week_offset, last_week_number + 1):
             yield self.get_week_timetable_infos(
@@ -1474,8 +1538,8 @@ class AgendaMonthView(AgendaDateView, MonthArchiveView):
         desks_len = len(desks)
         max_date = day.replace(hour=self.max_display.hour, minute=0)
 
-        # compute booking and opening hours only for current month
-        if self.date.month != day.month:
+        # compute booking and opening hours only for current month/week
+        if self.kind == 'month' and timetable['other_month']:
             return timetable
 
         while period <= max_date:
@@ -1536,6 +1600,73 @@ class AgendaMonthView(AgendaDateView, MonthArchiveView):
         return timetable
 
 
+class AgendaWeekView(AgendaWeekMonthMixin, AgendaDateView, WeekArchiveView):
+    week_format = "%W"
+    kind = 'week'
+
+    def get_template_names(self):
+        if self.agenda.kind == 'virtual':
+            return ['chrono/manager_meetings_agenda_week_view.html']
+        return ['chrono/manager_%s_agenda_week_view.html' % self.agenda.kind]
+
+    def get_previous_week_url(self):
+        previous_week = self.get_previous_week(self.date.date())
+        return reverse(
+            'chrono-manager-agenda-week-view',
+            kwargs={'pk': self.agenda.id, 'year': previous_week.year, 'week': previous_week.strftime('%W')},
+        )
+
+    def get_next_week_url(self):
+        next_week = self.get_next_week(self.date.date())
+        return reverse(
+            'chrono-manager-agenda-week-view',
+            kwargs={'pk': self.agenda.id, 'year': next_week.year, 'week': next_week.strftime('%W')},
+        )
+
+    def get_next(self, date):
+        return self.get_next_week(date)
+
+    def get_month(self):
+        date = datetime.datetime.strptime('%s-W%s-1' % (self.get_year(), self.get_week()), "%Y-W%W-%w")
+        return date.month
+
+    def get_day(self):
+        date = datetime.datetime.strptime('%s-W%s-1' % (self.get_year(), self.get_week()), "%Y-W%W-%w")
+        return date.day
+
+
+agenda_weekly_view = AgendaWeekView.as_view()
+
+
+class AgendaMonthView(AgendaWeekMonthMixin, AgendaDateView, MonthArchiveView):
+    kind = 'month'
+
+    def get_template_names(self):
+        if self.agenda.kind == 'virtual':
+            return ['chrono/manager_meetings_agenda_month_view.html']
+        return ['chrono/manager_%s_agenda_month_view.html' % self.agenda.kind]
+
+    def get_previous_month_url(self):
+        previous_month = self.get_previous_month(self.date.date())
+        return reverse(
+            'chrono-manager-agenda-month-view',
+            kwargs={'pk': self.agenda.id, 'year': previous_month.year, 'month': previous_month.month},
+        )
+
+    def get_next_month_url(self):
+        next_month = self.get_next_month(self.date.date())
+        return reverse(
+            'chrono-manager-agenda-month-view',
+            kwargs={'pk': self.agenda.id, 'year': next_month.year, 'month': next_month.month},
+        )
+
+    def get_next(self, date):
+        return self.get_next_month(date)
+
+    def get_day(self):
+        return '1'
+
+
 agenda_monthly_view = AgendaMonthView.as_view()
 
 
diff --git a/tests/manager/test_all.py b/tests/manager/test_all.py
index bb756887..3d29c922 100644
--- a/tests/manager/test_all.py
+++ b/tests/manager/test_all.py
@@ -127,6 +127,12 @@ def test_events_agenda_redirect(app, admin_user):
         resp = app.get('/manage/agendas/%s/' % agenda_id, status=302)
     assert resp.location.endswith('/manage/agendas/%s/day/' % agenda.pk)
 
+    agenda.default_view = 'week'
+    agenda.save()
+    for agenda_id in [agenda.pk, agenda.slug]:
+        resp = app.get('/manage/agendas/%s/' % agenda_id, status=302)
+    assert resp.location.endswith('/manage/agendas/%s/week/' % agenda.pk)
+
 
 @freezegun.freeze_time('2020-07-12')
 def test_events_agenda_month_redirect(app, admin_user):
@@ -180,6 +186,58 @@ def test_events_agenda_month_redirect(app, admin_user):
     assert resp.location.endswith('/manage/agendas/%s/2020/7/' % agenda.pk)
 
 
+@freezegun.freeze_time('2020-07-12')
+def test_events_agenda_week_redirect(app, admin_user):
+    agenda = Agenda.objects.create(label='Foo Bar', kind='events')
+
+    app = login(app)
+    # no event, redirect to current week
+    resp = app.get('/manage/agendas/%s/week/' % agenda.pk, status=302)
+    assert resp.location.endswith('/manage/agendas/%s/2020/week/27/' % agenda.pk)
+
+    # only past events, redirect to last event month
+    Event.objects.create(
+        agenda=agenda,
+        places=1,
+        start_datetime=now() - datetime.timedelta(days=60),
+    )
+    resp = app.get('/manage/agendas/%s/week/' % agenda.pk, status=302)
+    assert resp.location.endswith('/manage/agendas/%s/2020/week/19/' % agenda.pk)
+    Event.objects.create(
+        agenda=agenda,
+        places=1,
+        start_datetime=now() - datetime.timedelta(days=30),
+    )
+    resp = app.get('/manage/agendas/%s/week/' % agenda.pk, status=302)
+    assert resp.location.endswith('/manage/agendas/%s/2020/week/23/' % agenda.pk)
+
+    # future events
+    Event.objects.create(
+        agenda=agenda,
+        places=1,
+        start_datetime=now() + datetime.timedelta(days=60),
+    )
+    resp = app.get('/manage/agendas/%s/week/' % agenda.pk, status=302)
+    assert resp.location.endswith('/manage/agendas/%s/2020/week/36/' % agenda.pk)
+    Event.objects.create(
+        agenda=agenda,
+        places=1,
+        start_datetime=now() + datetime.timedelta(days=30),
+    )
+    resp = app.get('/manage/agendas/%s/week/' % agenda.pk, status=302)
+    assert resp.location.endswith('/manage/agendas/%s/2020/week/32/' % agenda.pk)
+
+    # don't check events for meetings
+    agenda.kind = 'virtual'
+    agenda.save()
+    resp = app.get('/manage/agendas/%s/week/' % agenda.pk, status=302)
+    assert resp.location.endswith('/manage/agendas/%s/2020/week/27/' % agenda.pk)
+    agenda.kind = 'meetings'
+    agenda.save()
+    resp = app.get('/manage/agendas/%s/week/' % agenda.pk, status=302)
+    assert resp.location.endswith('/manage/agendas/%s/2020/week/27/' % agenda.pk)
+
+
 @freezegun.freeze_time('2020-07-12')
 def test_events_agenda_day_redirect(app, admin_user):
     agenda = Agenda.objects.create(label='Foo Bar', kind='events')
@@ -246,6 +304,12 @@ def test_meetings_agenda_redirect(app, admin_user):
         resp = app.get('/manage/agendas/%s/' % agenda_id, status=302)
     assert resp.location.endswith('/manage/agendas/%s/month/' % agenda.pk)
 
+    agenda.default_view = 'week'
+    agenda.save()
+    for agenda_id in [agenda.pk, agenda.slug]:
+        resp = app.get('/manage/agendas/%s/' % agenda_id, status=302)
+    assert resp.location.endswith('/manage/agendas/%s/week/' % agenda.pk)
+
 
 def test_virtual_agenda_redirect(app, admin_user):
     agenda = Agenda.objects.create(label='Foo Bar', kind='virtual')
@@ -261,6 +325,12 @@ def test_virtual_agenda_redirect(app, admin_user):
         resp = app.get('/manage/agendas/%s/' % agenda_id, status=302)
     assert resp.location.endswith('/manage/agendas/%s/month/' % agenda.pk)
 
+    agenda.default_view = 'week'
+    agenda.save()
+    for agenda_id in [agenda.pk, agenda.slug]:
+        resp = app.get('/manage/agendas/%s/' % agenda_id, status=302)
+    assert resp.location.endswith('/manage/agendas/%s/week/' % agenda.pk)
+
 
 def test_view_agendas_as_admin(app, admin_user):
     Agenda.objects.create(label='Bar Foo')
@@ -929,10 +999,11 @@ def test_agenda_day_view(app, admin_user, manager_user, api_user, get_proper_htm
     'view',
     (
         '/manage/agendas/%(agenda)s/%(year)d/%(month)d/%(day)d/',
+        '/manage/agendas/%(agenda)s/%(year)d/week/%(week)d/',
         '/manage/agendas/%(agenda)s/%(year)d/%(month)d/',
     ),
 )
-def test_agenda_day_month_view_backoffice_url_translation(
+def test_agenda_day_week_month_view_backoffice_url_translation(
     app, admin_user, manager_user, api_user, settings, view
 ):
     agenda = Agenda.objects.create(label='New Example', kind='meetings')
@@ -959,7 +1030,13 @@ def test_agenda_day_month_view_backoffice_url_translation(
     booking = Booking.objects.get(pk=booking_id)
     assert booking.backoffice_url == backoffice_url
     date = booking.event.start_datetime
-    url = view % {'agenda': agenda.id, 'year': date.year, 'month': date.month, 'day': date.day}
+    url = view % {
+        'agenda': agenda.id,
+        'year': date.year,
+        'month': date.month,
+        'week': int(date.strftime('%W')),
+        'day': date.day,
+    }
     resp = app.get(url)
     assert resp.text.count('div class="booking') == 1
     assert backoffice_url in resp.text
@@ -1139,6 +1216,97 @@ def test_agenda_events_day_view_midnight(app, admin_user):
     assert len(resp.pyquery.find('.event-info')) == 1
 
 
+@freezegun.freeze_time('2020-10-01')
+def test_agenda_events_week_view(app, admin_user):
+    agenda = Agenda.objects.create(label='Events', kind='events')
+    Desk.objects.create(agenda=agenda, slug='_exceptions_holder')
+    today = datetime.date.today()
+
+    login(app)
+    resp = app.get('/manage/agendas/%s/%s/week/%s/' % (agenda.pk, today.year, today.strftime('%W')))
+    assert 'Day view' in resp.text
+    assert "This week doesn't have any event configured." in resp.text
+
+    # add event in a future month, a wednesday
+    Event.objects.create(
+        label='xyz', start_datetime=localtime().replace(day=11, month=11, year=2020), places=10, agenda=agenda
+    )
+    # current month still doesn't have events
+    resp = app.get('/manage/agendas/%s/%s/week/%s/' % (agenda.id, today.year, today.strftime('%W')))
+    assert "This week doesn't have any event configured." in resp.text
+
+    # add recurring event on every Wednesday
+    start_datetime = localtime().replace(day=4, month=11, year=2020)
+    event = Event.objects.create(
+        label='abc',
+        start_datetime=start_datetime,
+        places=10,
+        agenda=agenda,
+        recurrence_days=[start_datetime.weekday()],
+        recurrence_end_date=start_datetime + datetime.timedelta(days=60),
+    )
+    event.create_all_recurrences()
+
+    with CaptureQueriesContext(connection) as ctx:
+        resp = app.get('/manage/agendas/%s/%s/week/%s/' % (agenda.id, 2020, 45))
+        assert len(ctx.captured_queries) == 7
+    assert len(resp.pyquery.find('.event-info')) == 2
+    assert 'abc' in resp.pyquery.find('.event-info')[0].text
+    assert 'xyz' in resp.pyquery.find('.event-info')[1].text
+
+    TimePeriodException.objects.create(
+        desk=agenda.desk_set.get(),
+        start_datetime=start_datetime + datetime.timedelta(days=6),
+        end_datetime=start_datetime + datetime.timedelta(days=8),
+    )
+    agenda.update_event_recurrences()
+    resp = app.get('/manage/agendas/%s/%s/week/%s/' % (agenda.id, 2020, 45))
+    assert len(resp.pyquery.find('.event-info')) == 1
+    assert 'Exception: 11/10/2020' in resp.pyquery.find('li')[4].text_content()
+    assert 'xyz' in resp.pyquery.find('li')[5].text_content()
+
+    # create another event with recurrence, the same day/time
+    start_datetime = localtime().replace(day=4, month=11, year=2020)
+    event = Event.objects.create(
+        label='def',
+        start_datetime=start_datetime,
+        places=10,
+        agenda=agenda,
+        recurrence_days=[start_datetime.weekday()],
+        recurrence_end_date=start_datetime + datetime.timedelta(days=60),
+    )
+    event.create_all_recurrences()
+    resp = app.get('/manage/agendas/%s/%s/week/%s/' % (agenda.id, 2020, 49))
+    assert len(resp.pyquery.find('.event-info')) == 2
+
+    time = localtime(event.start_datetime).strftime('%H%M')
+    resp = resp.click('Dec. 9, 2020, 1 a.m.', index=1)
+    resp = resp.click('Options')
+    resp.form['start_datetime_1'] = '13:12'
+    resp = resp.form.submit(status=302).follow()
+    agenda.update_event_recurrences()
+
+    resp = app.get('/manage/agendas/%s/%s/week/%s/' % (agenda.id, 2020, 49))
+    assert len(resp.pyquery.find('.event-info')) == 2
+    assert '1:12 p.m.' in resp.text
+
+    Event.objects.get(slug='abc--2020-12-02-%s' % time).cancel()
+    resp = app.get('/manage/agendas/%s/%s/week/%s/' % (agenda.id, 2020, 48))
+    assert 'Cancelled' in resp.text
+
+
+def test_agenda_events_week_view_midnight(app, admin_user):
+    agenda = Agenda.objects.create(label='Events', kind='events', default_view='day')
+    midnight = localtime(now().replace(day=1, month=11, year=2020)).replace(hour=0, minute=0)
+    Event.objects.create(label='xyz', start_datetime=midnight, places=10, agenda=agenda)
+
+    login(app)
+    resp = app.get('/manage/agendas/%s/week/' % agenda.pk)
+    assert resp.location.endswith('/2020/week/43/')
+    resp = resp.follow()
+    assert len(resp.pyquery.find('.event-info')) == 1
+
+
 @freezegun.freeze_time('2020-10-01')
 def test_agenda_events_month_view(app, admin_user):
     agenda = Agenda.objects.create(label='Events', kind='events')
@@ -1365,7 +1533,8 @@ def test_agenda_month_view(app, admin_user, manager_user, api_user, get_proper_h
     resp = resp.click('Month view')
     assert resp.request.url.endswith('%s/%s/' % (today.year, today.month))
 
-    assert 'Day view' in resp.text  # date view link should be present
+    assert 'Day view' in resp.text  # day view link should be present
+    assert 'Week view' in resp.text  # week view link should be present
     assert 'No opening hours this month.' in resp.text
 
     today = datetime.date(2018, 11, 10)  # fixed day
@@ -1744,106 +1913,483 @@ def test_agenda_month_view_event_outside_timeperiod(app, admin_user, kind):
     assert 'Saturday' in resp.text
 
 
-def test_agenda_view_event(app, manager_user):
-    agenda = Agenda(label='Foo bar')
-    agenda.view_role = manager_user.groups.all()[0]
-    agenda.save()
-    event = Event.objects.create(
-        label='xyz',
-        start_datetime=make_aware(datetime.datetime(2019, 12, 22, 17, 0)),
-        places=10,
-        agenda=agenda,
-    )
-    for i in range(8):
-        booking = Booking.objects.create(event=event)
-        if i < 5:
-            booking.creation_datetime = make_aware(datetime.datetime(2019, 12, 21, 14, 0 + i))
-        if i == 5:
-            booking.creation_datetime = make_aware(datetime.datetime(2019, 12, 21, 15, 0))
-            booking.user_first_name = 'Foo Bar'
-            booking.user_last_name = 'User'
-        if i == 6:
-            booking.creation_datetime = make_aware(datetime.datetime(2019, 12, 21, 16, 0))
-            booking.user_first_name = 'Foo Bar'
-            booking.user_last_name = 'User 2'
-            booking.label = 'Foo Bar Label 2'
-        if i == 7:
-            booking.creation_datetime = make_aware(datetime.datetime(2019, 12, 21, 17, 0))
-            booking.label = 'Foo Bar Label 3'
-        booking.save()
-    Booking.objects.create(event=event, cancellation_datetime=now())
-    app = login(app, username='manager', password='manager')
-    resp = app.get('/manage/agendas/%s/2019/12/' % agenda.id, status=200)
-    resp = resp.click('xyz')
-    assert 'Bookings (8/10): 2 remaining places' in resp.text
-    assert 'Waiting' not in resp.text
-    assert 'This event is overbooked.' not in resp.text
-    assert 'This event is full.' not in resp.text
-    event.waiting_list_places = 5
-    event.save()
-    resp = app.get(resp.request.url)
-    assert 'Waiting List (0/5): 5 remaining places' in resp.text
-    assert 'Anonymous, Dec. 21, 2019, 2 p.m.' in resp.text
-    assert 'Anonymous, Dec. 21, 2019, 2:01 p.m.' in resp.text
-    assert 'Anonymous, Dec. 21, 2019, 2:02 p.m.' in resp.text
-    assert 'Anonymous, Dec. 21, 2019, 2:03 p.m.' in resp.text
-    assert 'Anonymous, Dec. 21, 2019, 2:04 p.m.' in resp.text
-    assert 'Foo Bar User, Dec. 21, 2019, 3 p.m.' in resp.text
-    assert 'Foo Bar User 2, Dec. 21, 2019, 4 p.m.' in resp.text
-    assert 'Foo Bar Label 3, Dec. 21, 2019, 5 p.m.' in resp.text
+def test_agenda_week_view(app, admin_user, manager_user, api_user, get_proper_html_str):
+    agenda = Agenda.objects.create(label='Passeports', kind='meetings')
+    desk = Desk.objects.create(agenda=agenda, label='Desk A')
+    today = datetime.date.today()
 
-    booking = Booking.objects.order_by('pk')[0]
-    booking.in_waiting_list = True
-    booking.save()
-    booking = Booking.objects.order_by('pk')[1]
-    booking.in_waiting_list = True
-    booking.save()
-    resp = app.get(resp.request.url)
-    assert 'Waiting List (2/5): 3 remaining places' in resp.text
-    assert 'Bookings (6/10): 4 remaining places' in resp.text
-    assert list(resp.context['booked']) == list(Booking.objects.order_by('creation_datetime')[2:8])
-    assert list(resp.context['waiting']) == list(Booking.objects.order_by('creation_datetime')[0:2])
+    meetingtype = MeetingType(agenda=agenda, label='passeport', duration=20)
+    meetingtype.save()
 
-    event.places = 5
-    event.save()
-    resp = app.get(resp.request.url)
-    assert 'This event is overbooked.' in resp.text
-    assert 'This event is full.' not in resp.text
+    login(app)
+    resp = app.get('/manage/agendas/%s/%d/%d/%d/' % (agenda.pk, today.year, today.month, today.day))
+    assert 'Week view' in resp.text
+    resp = resp.click('Week view')
+    assert resp.request.url.endswith('%s/week/%s/' % (today.year, today.strftime('%W')))
 
-    event.places = 6
-    event.save()
-    resp = app.get(resp.request.url)
-    assert 'This event is overbooked.' not in resp.text
-    assert 'This event is full.' in resp.text
+    assert 'Day view' in resp.text  # day view link should be present
+    assert 'Month view' in resp.text  # month view link should be present
+    assert 'No opening hours this week.' in resp.text
 
+    today = datetime.date(2018, 11, 10)  # fixed day
+    timeperiod_weekday = today.weekday()
+    timeperiod = TimePeriod(
+        desk=desk, weekday=timeperiod_weekday, start_time=datetime.time(10, 0), end_time=datetime.time(18, 0)
+    )
+    timeperiod.save()
+    app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, today.year, 72), status=404)
+    resp = app.get('/manage/agendas/%s/%s/week/%s/' % (agenda.id, today.year, today.strftime('%W')))
+    assert 'No opening hours this week.' not in resp.text
+    assert '<div class="booking' not in resp.text
+    assert resp.text.count('<tr') == 9
 
-def test_agenda_view_edit_event(app, manager_user):
-    test_agenda_view_event(app, manager_user)
-    agenda = Agenda.objects.first()
-    resp = app.get('/manage/agendas/%s/2019/12/' % agenda.id, status=200)
-    resp = resp.click('xyz')
-    assert 'Options' not in resp.text
-    assert 'Delete' not in resp.text
+    # check opening hours cells
+    assert '<div class="opening-hours" style="height:800.0%;top:0.0%;width:97.0%;left:1.0%' in resp.text
 
-    agenda.edit_role = manager_user.groups.all()[0]
-    agenda.save()
-    event_url = resp.request.url
-    resp = app.get(event_url)
-    assert 'Options' in resp.text
-    resp = resp.click('Options')
-    resp.form['start_datetime_0'] = agenda.event_set.first().start_datetime.strftime('%Y-%m-%d')
-    resp.form['start_datetime_1'] = agenda.event_set.first().start_datetime.strftime('%H:%M')
-    resp = resp.form.submit(status=302).follow()
-    assert event_url == resp.request.url
+    # book some slots
+    app.reset()
+    app.authorization = ('Basic', ('john.doe', 'password'))
+    resp = app.get('/api/agenda/%s/meetings/%s/datetimes/' % (agenda.slug, meetingtype.slug))
+    booking_url = resp.json['data'][0]['api']['fillslot_url']
+    booking_url2 = resp.json['data'][2]['api']['fillslot_url']
+    booking = app.post(booking_url)
+    booking_2 = app.post_json(
+        booking_url2, params={'label': 'foo book', 'user_last_name': "bar's", 'url': 'http://baz/'}
+    )
 
-    resp = resp.click('Delete')
-    resp = resp.form.submit()
-    assert Event.objects.count() == 0
+    app.reset()
+    login(app)
+    date = Booking.objects.all()[0].event.start_datetime
+    resp = app.get('/manage/agendas/%s/%d/week/%s/' % (agenda.id, date.year, date.strftime('%W')))
+    assert resp.text.count('<div class="booking" style="left:1.0%;height:33.0%;') == 2  # booking cells
+    assert resp.pyquery.find('div.booking a').not_('.cancel')[0].text.strip() == 'booked'
+    assert resp.pyquery.find('div.booking a').not_('.cancel')[1].text.strip() == "foo book - bar's"
+    assert get_proper_html_str('foo book - bar&#39;s') in resp
+    assert len(resp.pyquery.find('span.desk')) == 0
 
+    agenda.booking_user_block_template = '<b>{{ booking.user_name }}</b> Foo Bar'
+    agenda.save()
+    resp = app.get('/manage/agendas/%s/%d/week/%s/' % (agenda.id, date.year, date.strftime('%W')))
+    assert resp.pyquery.find('div.booking a').not_('.cancel')[0].text.strip() == '<b></b> Foo Bar'
+    assert resp.pyquery.find('div.booking a').not_('.cancel')[1].text.strip() == "<b>bar's</b> Foo Bar"
+    assert get_proper_html_str('&lt;b&gt;bar&#39;s&lt;/b&gt; Foo Bar') in resp
 
-def test_virtual_agenda_add(app, admin_user):
-    app = login(app)
-    resp = app.get('/manage/', status=200)
+    desk = Desk.objects.create(agenda=agenda, label='Desk B')
+    resp = app.get('/manage/agendas/%s/%d/week/%s/' % (agenda.id, date.year, date.strftime('%W')))
+    assert len(resp.pyquery.find('span.desk')) == 2
+
+    timeperiod = TimePeriod(
+        desk=desk, weekday=timeperiod_weekday, start_time=datetime.time(10, 0), end_time=datetime.time(18, 0)
+    )
+    timeperiod.save()
+
+    app.reset()
+    booking_3 = app.post(booking_url)
+    login(app)
+    resp = app.get('/manage/agendas/%s/%d/week/%s/' % (agenda.id, today.year, today.strftime('%W')))
+
+    # count occurences of timeperiod weekday in current month
+    assert resp.text.count('<div class="opening-hours"') == 2
+    current_month = today.strftime('%Y-%m')
+    if current_month in booking_url or current_month in booking_url2:
+        assert resp.text.count('<div class="booking"') == 3
+
+    # cancel bookings
+    app.reset()
+    app.post(booking.json['api']['cancel_url'])
+    app.post(booking_2.json['api']['cancel_url'])
+    app.post(booking_3.json['api']['cancel_url'])
+
+    # make sure the are not
+    login(app)
+    resp = app.get('/manage/agendas/%s/%d/week/%s/' % (agenda.id, today.year, today.strftime('%W')))
+    assert resp.text.count('<div class="booking"') == 0
+
+    # check December is correctly displayed
+    today = datetime.date(2018, 12, 10)
+    resp = app.get('/manage/agendas/%s/%d/week/%s/' % (agenda.id, today.year, today.strftime('%W')))
+    assert 'No opening hours this week.' not in resp.text
+
+    # display exception
+    unavailability_calendar = UnavailabilityCalendar.objects.create(label='calendar')
+    TimePeriodException.objects.create(
+        label='Calendar exception',
+        unavailability_calendar=unavailability_calendar,
+        start_datetime=make_aware(datetime.datetime(2018, 12, 15, 5, 0)),
+        end_datetime=make_aware(datetime.datetime(2018, 12, 15, 14, 0)),
+    )
+    unavailability_calendar.desks.add(desk)
+    TimePeriodException.objects.create(
+        label='Exception for a December day',
+        desk=desk,
+        start_datetime=make_aware(datetime.datetime(2018, 12, 15, 14, 0)),
+        end_datetime=make_aware(datetime.datetime(2018, 12, 15, 23, 0)),
+    )
+    TimePeriodException.objects.create(
+        label='Exception spanning multiple days',
+        desk=desk,
+        start_datetime=make_aware(datetime.datetime(2018, 12, 11, 14, 0)),
+        end_datetime=make_aware(datetime.datetime(2018, 12, 13, 16, 0)),
+    )
+    with CaptureQueriesContext(connection) as ctx:
+        resp = app.get('/manage/agendas/%s/%d/week/%s/' % (agenda.id, today.year, today.strftime('%W')))
+        assert len(ctx.captured_queries) == 10
+    assert resp.pyquery.find('.exception-hours')[0].attrib == {
+        'class': 'exception-hours',
+        'style': 'height:400.0%;top:400.0%;width:48.0%;left:50.0%;',
+        'title': 'Exception spanning multiple days',
+    }
+    assert resp.pyquery.find('.exception-hours')[1].attrib == {
+        'class': 'exception-hours',
+        'style': 'height:800.0%;top:0.0%;width:48.0%;left:50.0%;',
+        'title': 'Exception spanning multiple days',
+    }
+    assert resp.pyquery.find('.exception-hours')[2].attrib == {
+        'class': 'exception-hours',
+        'style': 'height:600.0%;top:0.0%;width:48.0%;left:50.0%;',
+        'title': 'Exception spanning multiple days',
+    }
+    assert resp.pyquery.find('.exception-hours')[3].attrib == {
+        'class': 'exception-hours',
+        'style': 'height:400.0%;top:0.0%;width:48.0%;left:50.0%;',
+        'title': 'Calendar exception',
+    }
+    assert resp.pyquery.find('.exception-hours')[4].attrib == {
+        'class': 'exception-hours',
+        'style': 'height:400.0%;top:400.0%;width:48.0%;left:50.0%;',
+        'title': 'Exception for a December day',
+    }
+
+
+@pytest.mark.parametrize('kind', ['meetings', 'virtual'])
+def test_agenda_week_view_weekend(app, admin_user, kind):
+    week, year = '02', 2019
+    monday = 0
+    if kind == 'meetings':
+        agenda = Agenda.objects.create(label='Passeports', kind='meetings')
+        desk = Desk.objects.create(agenda=agenda, label='Desk A')
+    else:
+        agenda = Agenda.objects.create(label='Virtual', kind='virtual')
+        real_agenda = Agenda.objects.create(label='Real 1', kind='meetings')
+        VirtualMember.objects.create(virtual_agenda=agenda, real_agenda=real_agenda)
+        desk = Desk.objects.create(agenda=real_agenda, label='New Desk')
+    TimePeriod.objects.create(
+        desk=desk, weekday=monday, start_time=datetime.time(10, 0), end_time=datetime.time(18, 0)
+    )
+
+    login(app)
+    resp = app.get('/manage/agendas/%s/%s/week/%s/' % (agenda.id, year, week))
+    assert 'Sunday' not in resp.text
+    assert 'Saturday' not in resp.text
+    # Month starts a Tuesday, but monday is displayed
+    assert len(resp.pyquery.find('tbody tr:first th.weekday:empty')) == 0
+
+    week, year = 21, 2019  # month starts a Saturday
+    resp = app.get('/manage/agendas/%s/%s/week/%s/' % (agenda.id, year, week))
+    assert len(resp.pyquery.find('tbody tr:first th.weekday:empty')) == 0
+
+    saturday = 5
+    timeperiod_sat = TimePeriod.objects.create(
+        desk=desk, weekday=saturday, start_time=datetime.time(10, 0), end_time=datetime.time(18, 0)
+    )
+    resp = app.get('/manage/agendas/%s/%s/week/%s/' % (agenda.id, year, week))
+    assert 'Sunday' not in resp.text
+    assert 'Saturday' in resp.text
+    assert len(resp.pyquery.find('tbody tr:first th.weekday:empty')) == 0
+
+    sunday = 6
+    TimePeriod.objects.create(
+        desk=desk, weekday=sunday, start_time=datetime.time(10, 0), end_time=datetime.time(18, 0)
+    )
+    resp = app.get('/manage/agendas/%s/%s/week/%s/' % (agenda.id, year, week))
+    assert 'Sunday' in resp.text
+    assert 'Saturday' in resp.text
+
+    timeperiod_sat.delete()
+    resp = app.get('/manage/agendas/%s/%s/week/%s/' % (agenda.id, year, week))
+    assert 'Sunday' in resp.text
+    assert 'Saturday' in resp.text
+
+
+@pytest.mark.parametrize('kind', ['meetings', 'virtual'])
+def test_agenda_week_view_dst_change(app, admin_user, kind):
+    if kind == 'meetings':
+        agenda = Agenda.objects.create(label='Passeports', kind='meetings')
+        desk = Desk.objects.create(agenda=agenda, label='Desk A')
+        meetingtype = MeetingType.objects.create(agenda=agenda, label='passeport', duration=20)
+    else:
+        agenda = Agenda.objects.create(label='Virtual', kind='virtual')
+        real_agenda = Agenda.objects.create(label='Real 1', kind='meetings')
+        VirtualMember.objects.create(virtual_agenda=agenda, real_agenda=real_agenda)
+        desk = Desk.objects.create(agenda=real_agenda, label='New Desk')
+        meetingtype = MeetingType.objects.create(agenda=real_agenda, label='passeport', duration=20)
+
+    for weekday in range(0, 7):  # open all mornings
+        TimePeriod.objects.create(
+            desk=desk, weekday=weekday, start_time=datetime.time(9, 0), end_time=datetime.time(12, 0)
+        )
+
+    login(app)
+    for date in ('2019-10-28', '2019-11-03'):
+        # dst change was on 2019-11-03
+        with freezegun.freeze_time(date):
+            resp = app.get('/manage/agendas/%s/2019/week/43/' % agenda.id)
+            # check all days are correctly aligned
+            assert resp.text.count('height:300.0%;top:0.0%') == 7
+
+    # book some slots
+    for date_tuple in [(2019, 10, 29, 10, 0), (2019, 10, 30, 10, 0)]:
+        event = Event.objects.create(
+            agenda=desk.agenda,
+            places=1,
+            desk=desk,
+            meeting_type=meetingtype,
+            start_datetime=localtime(make_aware(datetime.datetime(*date_tuple))),
+        )
+        Booking.objects.create(event=event)
+
+    # check booked slots are similarly aligned
+    resp = app.get('/manage/agendas/%s/2019/week/43/' % agenda.id)
+    assert resp.text.count('height:33.0%;top:100.0%;') == 2
+
+
+@pytest.mark.parametrize('kind', ['meetings', 'virtual'])
+def test_agenda_week_view_januaries(app, admin_user, kind):
+    if kind == 'meetings':
+        agenda = Agenda.objects.create(label='Passports', kind='meetings')
+        desk = Desk.objects.create(agenda=agenda, label='Desk A')
+        MeetingType.objects.create(agenda=agenda, label='passport', duration=20)
+    else:
+        agenda = Agenda.objects.create(label='Virtual', kind='virtual')
+        real_agenda = Agenda.objects.create(label='Real 1', kind='meetings')
+        VirtualMember.objects.create(virtual_agenda=agenda, real_agenda=real_agenda)
+        desk = Desk.objects.create(agenda=real_agenda, label='New Desk')
+        MeetingType.objects.create(agenda=real_agenda, label='passport', duration=20)
+    TimePeriod(desk=desk, weekday=2, start_time=datetime.time(9, 0), end_time=datetime.time(12, 0)).save()
+
+    for year in range(2020, 2030):
+        date = datetime.date(year, 1, 1)
+        with freezegun.freeze_time(date):
+            login(app)
+            resp = app.get('/manage/agendas/%s/%s/week/01/' % (agenda.id, date.year))
+            assert resp.text.count('<th></th>') == 1
+
+
+@pytest.mark.parametrize('kind', ['meetings', 'virtual'])
+def test_agenda_week_view_event_outside_timeperiod(app, admin_user, kind):
+    middle_day = now().replace(day=15)
+    middle_day = middle_day + datetime.timedelta(days=4 - middle_day.weekday())
+    if kind == 'meetings':
+        agenda = Agenda.objects.create(label='New Example', kind='meetings')
+        desk = Desk.objects.create(agenda=agenda, label='New Desk')
+        meetingtype = MeetingType.objects.create(agenda=agenda, label='Bar', duration=30)
+    else:
+        agenda = Agenda.objects.create(label='Virtual', kind='virtual')
+        real_agenda = Agenda.objects.create(label='Real 1', kind='meetings')
+        VirtualMember.objects.create(virtual_agenda=agenda, real_agenda=real_agenda)
+        desk = Desk.objects.create(agenda=real_agenda, label='New Desk')
+        meetingtype = MeetingType.objects.create(agenda=real_agenda, label='passport', duration=20)
+    login(app)
+
+    # no time period - no events
+    resp = app.get('/manage/agendas/%s/%d/week/%s/' % (agenda.pk, middle_day.year, middle_day.strftime('%W')))
+    assert 'No opening hours this week.' in resp.text
+    assert 'div class="booking' not in resp.text
+
+    # book some slots
+    middle_day = now().replace(day=15)
+    for hour, minute in [(9, 0), (17, 0)]:
+        event = Event.objects.create(
+            agenda=desk.agenda,
+            places=1,
+            desk=desk,
+            meeting_type=meetingtype,
+            start_datetime=localtime(now().replace(day=middle_day.day - middle_day.weekday() + 2)).replace(
+                hour=hour, minute=minute
+            ),
+        )
+        Booking.objects.create(event=event)
+
+    # no time period - events are displayed
+    resp = app.get('/manage/agendas/%s/%d/week/%s/' % (agenda.pk, middle_day.year, middle_day.strftime('%W')))
+    assert resp.text.count('div class="booking') == 2
+
+    # bookings are cancelled
+    Booking.objects.update(cancellation_datetime=now())
+    resp = app.get('/manage/agendas/%s/%d/week/%s/' % (agenda.pk, middle_day.year, middle_day.strftime('%W')))
+    assert 'No opening hours this week.' in resp.text
+    assert resp.text.count('div class="booking') == 0
+
+    # events outside time period
+    Booking.objects.update(cancellation_datetime=None)  # reset
+    TimePeriod.objects.create(
+        desk=desk, weekday=2, start_time=datetime.time(10, 0), end_time=datetime.time(16, 0)
+    )
+    resp = app.get('/manage/agendas/%s/%d/week/%s/' % (agenda.pk, middle_day.year, middle_day.strftime('%W')))
+    assert resp.text.count('div class="booking') == 2
+    assert '<div class="opening-hours" style="height:600.0%;top:100.0%;width:97.0%;left:1.0%' in resp.text
+    assert 'Sunday' not in resp.text
+    assert 'Saturday' not in resp.text
+
+    # create an event on saturday
+    middle_day = now().replace(day=15)
+    middle_day = middle_day + datetime.timedelta(days=5 - middle_day.weekday())
+    event = Event.objects.create(
+        agenda=desk.agenda,
+        places=1,
+        desk=desk,
+        meeting_type=meetingtype,
+        start_datetime=localtime(now()).replace(day=middle_day.day, hour=10, minute=0),
+    )
+    Booking.objects.create(event=event)
+    resp = app.get('/manage/agendas/%s/%d/week/%s/' % (agenda.pk, middle_day.year, middle_day.strftime('%W')))
+    assert resp.text.count('div class="booking') == 3
+    assert 'Sunday' not in resp.text
+    assert 'Saturday' in resp.text
+    # bookings are cancelled
+    Booking.objects.update(cancellation_datetime=now())
+    resp = app.get('/manage/agendas/%s/%d/week/%s/' % (agenda.pk, middle_day.year, middle_day.strftime('%W')))
+    assert resp.text.count('div class="booking') == 0
+    # and a timeperiod
+    Booking.objects.update(cancellation_datetime=None)  # reset
+    TimePeriod.objects.create(
+        desk=desk, weekday=5, start_time=datetime.time(11, 0), end_time=datetime.time(12, 0)
+    )
+    resp = app.get('/manage/agendas/%s/%d/week/%s/' % (agenda.pk, middle_day.year, middle_day.strftime('%W')))
+    assert resp.text.count('div class="booking') == 3
+    assert 'Sunday' not in resp.text
+    assert 'Saturday' in resp.text
+
+    # create an event on sunday
+    middle_day = now().replace(day=15)
+    middle_day = middle_day + datetime.timedelta(days=6 - middle_day.weekday())
+    event = Event.objects.create(
+        agenda=desk.agenda,
+        places=1,
+        desk=desk,
+        meeting_type=meetingtype,
+        start_datetime=localtime(now()).replace(day=middle_day.day, hour=10, minute=0),
+    )
+    Booking.objects.create(event=event)
+    resp = app.get('/manage/agendas/%s/%d/week/%s/' % (agenda.pk, middle_day.year, middle_day.strftime('%W')))
+    assert resp.text.count('div class="booking') == 4
+    assert 'Sunday' in resp.text
+    assert 'Saturday' in resp.text
+    # bookings are cancelled
+    Booking.objects.update(cancellation_datetime=now())
+    resp = app.get('/manage/agendas/%s/%d/week/%s/' % (agenda.pk, middle_day.year, middle_day.strftime('%W')))
+    assert resp.text.count('div class="booking') == 0
+    # and a timeperiod
+    Booking.objects.update(cancellation_datetime=None)  # reset
+    TimePeriod.objects.create(
+        desk=desk, weekday=6, start_time=datetime.time(11, 0), end_time=datetime.time(12, 0)
+    )
+    resp = app.get('/manage/agendas/%s/%d/week/%s/' % (agenda.pk, middle_day.year, middle_day.strftime('%W')))
+    assert resp.text.count('div class="booking') == 4
+    assert 'Sunday' in resp.text
+    assert 'Saturday' in resp.text
+
+
+def test_agenda_view_event(app, manager_user):
+    agenda = Agenda(label='Foo bar')
+    agenda.view_role = manager_user.groups.all()[0]
+    agenda.save()
+    event = Event.objects.create(
+        label='xyz',
+        start_datetime=make_aware(datetime.datetime(2019, 12, 22, 17, 0)),
+        places=10,
+        agenda=agenda,
+    )
+    for i in range(8):
+        booking = Booking.objects.create(event=event)
+        if i < 5:
+            booking.creation_datetime = make_aware(datetime.datetime(2019, 12, 21, 14, 0 + i))
+        if i == 5:
+            booking.creation_datetime = make_aware(datetime.datetime(2019, 12, 21, 15, 0))
+            booking.user_first_name = 'Foo Bar'
+            booking.user_last_name = 'User'
+        if i == 6:
+            booking.creation_datetime = make_aware(datetime.datetime(2019, 12, 21, 16, 0))
+            booking.user_first_name = 'Foo Bar'
+            booking.user_last_name = 'User 2'
+            booking.label = 'Foo Bar Label 2'
+        if i == 7:
+            booking.creation_datetime = make_aware(datetime.datetime(2019, 12, 21, 17, 0))
+            booking.label = 'Foo Bar Label 3'
+        booking.save()
+    Booking.objects.create(event=event, cancellation_datetime=now())
+    app = login(app, username='manager', password='manager')
+    resp = app.get('/manage/agendas/%s/2019/12/' % agenda.id, status=200)
+    resp = resp.click('xyz')
+    assert 'Bookings (8/10): 2 remaining places' in resp.text
+    assert 'Waiting' not in resp.text
+    assert 'This event is overbooked.' not in resp.text
+    assert 'This event is full.' not in resp.text
+    event.waiting_list_places = 5
+    event.save()
+    resp = app.get(resp.request.url)
+    assert 'Waiting List (0/5): 5 remaining places' in resp.text
+    assert 'Anonymous, Dec. 21, 2019, 2 p.m.' in resp.text
+    assert 'Anonymous, Dec. 21, 2019, 2:01 p.m.' in resp.text
+    assert 'Anonymous, Dec. 21, 2019, 2:02 p.m.' in resp.text
+    assert 'Anonymous, Dec. 21, 2019, 2:03 p.m.' in resp.text
+    assert 'Anonymous, Dec. 21, 2019, 2:04 p.m.' in resp.text
+    assert 'Foo Bar User, Dec. 21, 2019, 3 p.m.' in resp.text
+    assert 'Foo Bar User 2, Dec. 21, 2019, 4 p.m.' in resp.text
+    assert 'Foo Bar Label 3, Dec. 21, 2019, 5 p.m.' in resp.text
+
+    booking = Booking.objects.order_by('pk')[0]
+    booking.in_waiting_list = True
+    booking.save()
+    booking = Booking.objects.order_by('pk')[1]
+    booking.in_waiting_list = True
+    booking.save()
+    resp = app.get(resp.request.url)
+    assert 'Waiting List (2/5): 3 remaining places' in resp.text
+    assert 'Bookings (6/10): 4 remaining places' in resp.text
+    assert list(resp.context['booked']) == list(Booking.objects.order_by('creation_datetime')[2:8])
+    assert list(resp.context['waiting']) == list(Booking.objects.order_by('creation_datetime')[0:2])
+
+    event.places = 5
+    event.save()
+    resp = app.get(resp.request.url)
+    assert 'This event is overbooked.' in resp.text
+    assert 'This event is full.' not in resp.text
+
+    event.places = 6
+    event.save()
+    resp = app.get(resp.request.url)
+    assert 'This event is overbooked.' not in resp.text
+    assert 'This event is full.' in resp.text
+
+
+def test_agenda_view_edit_event(app, manager_user):
+    test_agenda_view_event(app, manager_user)
+    agenda = Agenda.objects.first()
+    resp = app.get('/manage/agendas/%s/2019/12/' % agenda.id, status=200)
+    resp = resp.click('xyz')
+    assert 'Options' not in resp.text
+    assert 'Delete' not in resp.text
+
+    agenda.edit_role = manager_user.groups.all()[0]
+    agenda.save()
+    event_url = resp.request.url
+    resp = app.get(event_url)
+    assert 'Options' in resp.text
+    resp = resp.click('Options')
+    resp.form['start_datetime_0'] = agenda.event_set.first().start_datetime.strftime('%Y-%m-%d')
+    resp.form['start_datetime_1'] = agenda.event_set.first().start_datetime.strftime('%H:%M')
+    resp = resp.form.submit(status=302).follow()
+    assert event_url == resp.request.url
+
+    resp = resp.click('Delete')
+    resp = resp.form.submit()
+    assert Event.objects.count() == 0
+
+
+def test_virtual_agenda_add(app, admin_user):
+    app = login(app)
+    resp = app.get('/manage/', status=200)
     resp = resp.click('New')
     resp.form['label'] = 'Virtual agenda'
     resp.form['kind'] = 'virtual'
@@ -1997,6 +2543,129 @@ def test_virtual_agenda_day_view(app, admin_user, manager_user, get_proper_html_
     assert 'exceptions-hours' not in resp.text
 
 
+def test_virtual_agenda_week_view(app, admin_user, get_proper_html_str):
+    agenda = Agenda.objects.create(label='Virtual', kind='virtual')
+    real_agenda_1 = Agenda.objects.create(label='Real 1', kind='meetings')
+    real_agenda_2 = Agenda.objects.create(label='Real 2', kind='meetings')
+    VirtualMember.objects.create(virtual_agenda=agenda, real_agenda=real_agenda_1)
+    VirtualMember.objects.create(virtual_agenda=agenda, real_agenda=real_agenda_2)
+
+    desk1 = Desk.objects.create(agenda=real_agenda_1, label='New Desk')
+    desk2 = Desk.objects.create(agenda=real_agenda_2, label='New Desk')
+    today = datetime.date.today()
+
+    meetingtype1 = MeetingType.objects.create(agenda=real_agenda_1, label='Bar', duration=30)
+    meetingtype2 = MeetingType.objects.create(agenda=real_agenda_2, label='Bar', duration=30)
+
+    login(app)
+    resp = app.get('/manage/agendas/%s/%d/%d/%d/' % (agenda.pk, today.year, today.month, today.day))
+    assert 'Week view' in resp.text
+    resp = resp.click('Week view')
+    assert resp.request.url.endswith('%s/week/%s/' % (today.year, today.strftime('%W')))
+
+    assert 'Day view' in resp.text  # day view link should be present
+    assert 'Month view' in resp.text  # month view link should be present
+    assert 'No opening hours this week.' in resp.text
+
+    today = datetime.date(2018, 11, 10)  # fixed day
+    timeperiod_weekday = today.weekday()
+    TimePeriod.objects.create(
+        desk=desk1, weekday=timeperiod_weekday, start_time=datetime.time(10, 0), end_time=datetime.time(18, 0)
+    )
+    resp = app.get('/manage/agendas/%s/%s/week/%s/' % (agenda.id, today.year, today.strftime('%W')))
+    assert 'No opening hours this week.' not in resp.text
+    assert '<div class="booking' not in resp.text
+    assert resp.text.count('<tr') == 9
+
+    # check opening hours cells
+    assert '<div class="opening-hours" style="height:800.0%;top:0.0%;width:48.0%;left:1.0%' in resp.text
+
+    # book some slots
+    for hour, minute in [(10, 30), (14, 0)]:
+        event = Event.objects.create(
+            agenda=real_agenda_1,
+            places=1,
+            desk=desk1,
+            meeting_type=meetingtype1,
+            start_datetime=now().replace(hour=hour, minute=minute),
+        )
+        Booking.objects.create(event=event)
+        event = Event.objects.create(
+            agenda=real_agenda_2,
+            places=1,
+            desk=desk2,
+            meeting_type=meetingtype2,
+            start_datetime=now().replace(hour=hour, minute=minute),
+        )
+        Booking.objects.create(event=event, label='foo', user_last_name="bar's")
+
+    date = Booking.objects.all()[0].event.start_datetime
+    resp = app.get('/manage/agendas/%s/%d/week/%s/' % (agenda.id, date.year, date.strftime('%W')))
+    assert resp.text.count('<div class="booking" style="left:1.0%;height:50.0%;') == 2  # booking cells
+    assert (
+        resp.text.count('<div class="booking" style="left:50.0%;height:50.0%;min-height:50.0%;') == 2
+    )  # booking cells
+    assert resp.pyquery.find('div.booking a').not_('.cancel')[0].text.strip() == 'booked'
+    assert resp.pyquery.find('div.booking a').not_('.cancel')[1].text.strip() == "foo - bar's"
+    assert resp.pyquery.find('div.booking a').not_('.cancel')[2].text.strip() == 'booked'
+    assert resp.pyquery.find('div.booking a').not_('.cancel')[3].text.strip() == "foo - bar's"
+    assert get_proper_html_str('foo - bar&#39;s') in resp
+
+    real_agenda_1.booking_user_block_template = '<b>{{ booking.user_name }}</b> Foo Bar'
+    real_agenda_1.save()
+    real_agenda_2.booking_user_block_template = '<b>{{ booking.user_name }}</b> Bar Foo'
+    real_agenda_2.save()
+    resp = app.get('/manage/agendas/%s/%d/%d/' % (agenda.id, date.year, date.month))
+    assert resp.pyquery.find('div.booking a').not_('.cancel')[0].text.strip() == '<b></b> Foo Bar'
+    assert resp.pyquery.find('div.booking a').not_('.cancel')[1].text.strip() == "<b>bar's</b> Bar Foo"
+    assert resp.pyquery.find('div.booking a').not_('.cancel')[2].text.strip() == '<b></b> Foo Bar'
+    assert resp.pyquery.find('div.booking a').not_('.cancel')[3].text.strip() == "<b>bar's</b> Bar Foo"
+    assert get_proper_html_str('&lt;b&gt;bar&#39;s&lt;/b&gt; Bar Foo') in resp
+
+    # cancel a booking
+    booking = Booking.objects.first()
+    booking.cancel()
+    resp = app.get('/manage/agendas/%s/%d/week/%s/' % (agenda.id, date.year, date.strftime('%W')))
+    assert resp.text.count('<div class="booking"') == 3
+
+    # check December is correctly displayed
+    today = datetime.date(2018, 12, 10)
+    resp = app.get('/manage/agendas/%s/%d/week/%s/' % (agenda.id, today.year, today.strftime('%W')))
+    assert 'No opening hours this month.' not in resp.text
+
+    # display exception
+    TimePeriodException.objects.create(
+        label='Exception for a December day',
+        desk=desk1,
+        start_datetime=make_aware(datetime.datetime(2018, 12, 15, 5, 0)),
+        end_datetime=make_aware(datetime.datetime(2018, 12, 15, 23, 0)),
+    )
+    with CaptureQueriesContext(connection) as ctx:
+        resp = app.get('/manage/agendas/%s/%d/week/%s/' % (agenda.id, today.year, today.strftime('%W')))
+        assert len(ctx.captured_queries) == 12
+    assert len(resp.pyquery.find('.exception-hours')) == 1
+    assert resp.pyquery.find('.exception-hours')[0].attrib == {
+        'class': 'exception-hours',
+        'style': 'height:800.0%;top:0.0%;width:48.0%;left:1.0%;',
+        'title': 'Exception for a December day',
+    }
+
+    # display excluded period
+    TimePeriod.objects.create(
+        agenda=agenda, weekday=today.weekday(), start_time=datetime.time(13, 0), end_time=datetime.time(23, 0)
+    )
+    resp = app.get('/manage/agendas/%s/%d/week/%s/' % (agenda.id, today.year, today.strftime('%W')))
+    assert len(resp.pyquery.find('.exception-hours')) == 3
+    assert resp.pyquery.find('.exception-hours')[0].attrib == {
+        'class': 'exception-hours',
+        'style': 'height:500.0%;top:300.0%;width:48.0%;left:1.0%;',
+    }
+    assert resp.pyquery.find('.exception-hours')[1].attrib == {
+        'class': 'exception-hours',
+        'style': 'height:500.0%;top:300.0%;width:48.0%;left:50.0%;',
+    }
+
+
 def test_virtual_agenda_month_view(app, admin_user, get_proper_html_str):
     agenda = Agenda.objects.create(label='Virtual', kind='virtual')
     real_agenda_1 = Agenda.objects.create(label='Real 1', kind='meetings')
@@ -2017,7 +2686,8 @@ def test_virtual_agenda_month_view(app, admin_user, get_proper_html_str):
     resp = resp.click('Month view')
     assert resp.request.url.endswith('%s/%s/' % (today.year, today.month))
 
-    assert 'Day view' in resp.text  # date view link should be present
+    assert 'Day view' in resp.text  # day view link should be present
+    assert 'Week view' in resp.text  # week view link should be present
     assert 'No opening hours this month.' in resp.text
 
     today = datetime.date(2018, 11, 10)  # fixed day
diff --git a/tests/manager/test_resource.py b/tests/manager/test_resource.py
index c634815c..9d92c3fd 100644
--- a/tests/manager/test_resource.py
+++ b/tests/manager/test_resource.py
@@ -220,6 +220,296 @@ def test_day_view_resource_as_manager(app, manager_user):
     app.get('/manage/resource/%s/%d/%d/%d/' % (resource.pk, today.year, today.month, today.day), status=403)
 
 
+@freezegun.freeze_time('2020-06-15')
+def test_resource_week_view(app, admin_user, get_proper_html_str):
+    resource = Resource.objects.create(label='Foo bar')
+    agenda = Agenda.objects.create(label='Agenda', kind='meetings')
+    agenda.resources.add(resource)
+    desk = Desk.objects.create(agenda=agenda, label='Desk')
+    meetingtype = MeetingType.objects.create(agenda=agenda, label='Bar', duration=20)
+    TimePeriod.objects.create(
+        desk=desk, weekday=0, start_time=datetime.time(10, 0), end_time=datetime.time(18, 0)
+    )
+
+    login(app)
+    today = datetime.date(2018, 11, 10)  # fixed day
+    app.get('/manage/resource/%s/%s/%s/' % (resource.pk, today.year, 72), status=404)
+    resp = app.get('/manage/resource/%s/%s/week/%s/' % (resource.pk, today.year, today.strftime('%W')))
+    assert '<div class="booking' not in resp.text
+    assert resp.text.count('<tr') == 9
+
+    # book some slots
+    for hour, minute in [(10, 30), (14, 0)]:
+        event = Event.objects.create(
+            agenda=agenda,
+            places=1,
+            desk=desk,
+            meeting_type=meetingtype,
+            start_datetime=now().replace(hour=hour, minute=minute),
+        )
+        event.resources.add(resource)
+        if hour == 10:
+            Booking.objects.create(event=event)
+        else:
+            Booking.objects.create(event=event, label='foo', user_last_name="bar's")
+
+    today = datetime.date.today()
+    with CaptureQueriesContext(connection) as ctx:
+        resp = app.get('/manage/resource/%s/%s/week/%s/' % (resource.pk, today.year, today.strftime('%W')))
+        assert len(ctx.captured_queries) == 8
+    assert resp.text.count('<div class="booking" style="height:33.0%;') == 2  # booking cells
+    assert resp.pyquery.find('div.booking a').not_('.cancel')[0].text.strip() == 'booked'
+    assert resp.pyquery.find('div.booking a').not_('.cancel')[1].text.strip() == "foo - bar's"
+    assert get_proper_html_str('foo - bar&#39;s') in resp
+
+    agenda.booking_user_block_template = '<b>{{ booking.user_name }}</b> Foo Bar'
+    agenda.save()
+    resp = app.get('/manage/resource/%s/%s/week/%s/' % (resource.pk, today.year, today.strftime('%W')))
+    assert resp.pyquery.find('div.booking a').not_('.cancel')[0].text.strip() == '<b></b> Foo Bar'
+    assert resp.pyquery.find('div.booking a').not_('.cancel')[1].text.strip() == "<b>bar's</b> Foo Bar"
+    assert get_proper_html_str('&lt;b&gt;bar&#39;s&lt;/b&gt; Foo Bar') in resp
+
+    # cancel booking
+    booking = Booking.objects.first()
+    booking.cancel()
+
+    # make sure the are not
+    resp = app.get('/manage/resource/%s/%s/week/%s/' % (resource.pk, today.year, today.strftime('%W')))
+    assert resp.text.count('<div class="booking"') == 1
+
+
+def test_resource_week_view_weekend(app, admin_user):
+    resource = Resource.objects.create(label='Foo bar')
+    agenda = Agenda.objects.create(label='Agenda', kind='meetings')
+    agenda.resources.add(resource)
+    desk = Desk.objects.create(agenda=agenda, label='Desk')
+    MeetingType.objects.create(agenda=agenda, label='Bar', duration=30)
+    monday = 0
+    TimePeriod.objects.create(
+        desk=desk, weekday=monday, start_time=datetime.time(10, 0), end_time=datetime.time(18, 0)
+    )
+    week, year = '01', 2019
+
+    login(app)
+    resp = app.get('/manage/resource/%s/%s/week/%s/' % (resource.pk, year, week))
+    assert 'Sunday' not in resp.text
+    assert 'Saturday' not in resp.text
+    # Month starts a Tuesday, but monday is displayed
+    assert len(resp.pyquery.find('tbody tr:first th.weekday:empty')) == 0
+
+    week, year = 21, 2019  # month starts a Saturday
+    resp = app.get('/manage/resource/%s/%s/week/%s/' % (resource.pk, year, week))
+    assert len(resp.pyquery.find('tbody tr:first th.weekday:empty')) == 0
+
+    saturday = 5
+    timeperiod_sat = TimePeriod.objects.create(
+        desk=desk, weekday=saturday, start_time=datetime.time(10, 0), end_time=datetime.time(18, 0)
+    )
+    resp = app.get('/manage/resource/%s/%s/week/%s/' % (resource.pk, year, week))
+    assert 'Sunday' not in resp.text
+    assert 'Saturday' in resp.text
+    assert len(resp.pyquery.find('tbody tr:first th.weekday:empty')) == 0
+
+    sunday = 6
+    TimePeriod.objects.create(
+        desk=desk, weekday=sunday, start_time=datetime.time(10, 0), end_time=datetime.time(18, 0)
+    )
+    resp = app.get('/manage/resource/%s/%s/week/%s/' % (resource.pk, year, week))
+    assert 'Sunday' in resp.text
+    assert 'Saturday' in resp.text
+
+    timeperiod_sat.delete()
+    resp = app.get('/manage/resource/%s/%s/week/%s/' % (resource.pk, year, week))
+    assert 'Sunday' in resp.text
+    assert 'Saturday' in resp.text
+
+
+def test_resource_week_view_dst_change(app, admin_user):
+    resource = Resource.objects.create(label='Foo bar')
+    agenda = Agenda.objects.create(label='Agenda', kind='meetings')
+    agenda.resources.add(resource)
+    desk = Desk.objects.create(agenda=agenda, label='Desk')
+    meetingtype = MeetingType.objects.create(agenda=agenda, label='Bar', duration=30)
+    TimePeriod.objects.create(
+        desk=desk, weekday=0, start_time=datetime.time(9, 0), end_time=datetime.time(12, 0)
+    )
+
+    login(app)
+    # book some slots
+    with freezegun.freeze_time('2019-10-01'):
+        for start_datetime in [
+            make_aware(datetime.datetime(2019, 10, 2, 10, 0)),
+            make_aware(datetime.datetime(2019, 10, 29, 10, 0)),
+        ]:
+            event = Event.objects.create(
+                agenda=agenda,
+                places=1,
+                desk=desk,
+                meeting_type=meetingtype,
+                start_datetime=start_datetime,
+            )
+            event.resources.add(resource)
+            Booking.objects.create(event=event)
+
+    # check booked slots are similarly aligned
+    resp = app.get('/manage/resource/%s/2019/week/43/' % resource.pk)
+    assert resp.text.count('height:50.0%;top:100.0%') == 1
+
+
+def test_resource_week_view_januaries(app, admin_user):
+    resource = Resource.objects.create(label='Foo bar')
+    agenda = Agenda.objects.create(label='Agenda', kind='meetings')
+    agenda.resources.add(resource)
+    desk = Desk.objects.create(agenda=agenda, label='Desk')
+    TimePeriod(desk=desk, weekday=2, start_time=datetime.time(9, 0), end_time=datetime.time(12, 0)).save()
+
+    for year in range(2020, 2030):
+        date = datetime.date(year, 1, 1)
+        with freezegun.freeze_time(date):
+            login(app)
+            resp = app.get('/manage/resource/%s/%s/week/01/' % (resource.pk, date.year))
+            assert resp.text.count('<th></th>') == 1
+
+
+def test_resource_week_view_event_outside_timeperiod(app, admin_user):
+    middle_day = now().replace(day=15)
+    middle_day = middle_day + datetime.timedelta(days=4 - middle_day.weekday())
+    resource = Resource.objects.create(label='Foo bar')
+    agenda = Agenda.objects.create(label='Agenda', kind='meetings')
+    agenda.resources.add(resource)
+    desk = Desk.objects.create(agenda=agenda, label='Desk')
+    meetingtype = MeetingType.objects.create(agenda=agenda, label='Bar', duration=30)
+    login(app)
+
+    # no time period - no events
+    resp = app.get(
+        '/manage/resource/%s/%d/week/%s/' % (resource.pk, middle_day.year, middle_day.strftime('%W'))
+    )
+    assert 'No bookings this week.' in resp.text
+    assert 'div class="booking' not in resp.text
+
+    # book some slots
+    for hour, minute in [(9, 0), (17, 0)]:
+        event = Event.objects.create(
+            agenda=agenda,
+            places=1,
+            desk=desk,
+            meeting_type=meetingtype,
+            start_datetime=localtime(now()).replace(
+                day=middle_day.day - middle_day.weekday() + 2, hour=hour, minute=minute
+            ),
+        )
+        event.resources.add(resource)
+        Booking.objects.create(event=event)
+
+    # no time period - events are displayed
+    resp = app.get(
+        '/manage/resource/%s/%d/week/%s/' % (resource.pk, middle_day.year, middle_day.strftime('%W'))
+    )
+    assert resp.text.count('div class="booking') == 2
+
+    # events outside time period
+    TimePeriod.objects.create(
+        desk=desk, weekday=2, start_time=datetime.time(10, 0), end_time=datetime.time(16, 0)
+    )
+    resp = app.get(
+        '/manage/resource/%s/%d/week/%s/' % (resource.pk, middle_day.year, middle_day.strftime('%W'))
+    )
+    assert resp.text.count('div class="booking') == 2
+    assert 'Sunday' not in resp.text
+    assert 'Saturday' not in resp.text
+
+    # bookings are cancelled
+    Booking.objects.update(cancellation_datetime=now())
+    resp = app.get(
+        '/manage/resource/%s/%d/week/%s/' % (resource.pk, middle_day.year, middle_day.strftime('%W'))
+    )
+    assert resp.text.count('div class="booking') == 0
+
+    # create an event on saturday
+    Booking.objects.update(cancellation_datetime=None)  # reset
+    middle_day = now().replace(day=15)
+    middle_day = middle_day + datetime.timedelta(days=5 - middle_day.weekday())
+    event = Event.objects.create(
+        agenda=agenda,
+        places=1,
+        desk=desk,
+        meeting_type=meetingtype,
+        start_datetime=localtime(now()).replace(day=middle_day.day, hour=10, minute=0),
+    )
+    event.resources.add(resource)
+    Booking.objects.create(event=event)
+    resp = app.get(
+        '/manage/resource/%s/%d/week/%s/' % (resource.pk, middle_day.year, middle_day.strftime('%W'))
+    )
+    assert resp.text.count('div class="booking') == 3
+    assert 'Sunday' not in resp.text
+    assert 'Saturday' in resp.text
+    # bookings are cancelled
+    Booking.objects.update(cancellation_datetime=now())
+    resp = app.get(
+        '/manage/resource/%s/%d/week/%s/' % (resource.pk, middle_day.year, middle_day.strftime('%W'))
+    )
+    assert resp.text.count('div class="booking') == 0
+    # and a timeperiod
+    Booking.objects.update(cancellation_datetime=None)  # reset
+    TimePeriod.objects.create(
+        desk=desk, weekday=5, start_time=datetime.time(11, 0), end_time=datetime.time(12, 0)
+    )
+    resp = app.get(
+        '/manage/resource/%s/%d/week/%s/' % (resource.pk, middle_day.year, middle_day.strftime('%W'))
+    )
+    assert resp.text.count('div class="booking') == 3
+    assert 'Sunday' not in resp.text
+    assert 'Saturday' in resp.text
+
+    # create an event on sunday
+    middle_day = now().replace(day=15)
+    middle_day = middle_day + datetime.timedelta(days=6 - middle_day.weekday())
+    event = Event.objects.create(
+        agenda=agenda,
+        places=1,
+        desk=desk,
+        meeting_type=meetingtype,
+        start_datetime=localtime(now()).replace(day=middle_day.day, hour=10, minute=0),
+    )
+    event.resources.add(resource)
+    Booking.objects.create(event=event)
+    resp = app.get(
+        '/manage/resource/%s/%d/week/%s/' % (resource.pk, middle_day.year, middle_day.strftime('%W'))
+    )
+    assert resp.text.count('div class="booking') == 4
+    assert 'Sunday' in resp.text
+    assert 'Saturday' in resp.text
+    # bookings are cancelled
+    Booking.objects.update(cancellation_datetime=now())
+    resp = app.get(
+        '/manage/resource/%s/%d/week/%s/' % (resource.pk, middle_day.year, middle_day.strftime('%W'))
+    )
+    assert resp.text.count('div class="booking') == 0
+    # and a timeperiod
+    Booking.objects.update(cancellation_datetime=None)  # reset
+    TimePeriod.objects.create(
+        desk=desk, weekday=6, start_time=datetime.time(11, 0), end_time=datetime.time(12, 0)
+    )
+    resp = app.get(
+        '/manage/resource/%s/%d/week/%s/' % (resource.pk, middle_day.year, middle_day.strftime('%W'))
+    )
+    assert resp.text.count('div class="booking') == 4
+    assert 'Sunday' in resp.text
+    assert 'Saturday' in resp.text
+
+
+def test_week_view_resource_as_manager(app, manager_user):
+    agenda = Agenda.objects.create(label='Foo Bar', kind='meetings')
+    agenda.view_role = manager_user.groups.all()[0]
+    agenda.save()
+    resource = Resource.objects.create(label='Resource 1')
+    app = login(app, username='manager', password='manager')
+    today = datetime.date.today()
+    app.get('/manage/resource/%s/%d/week/%s/' % (resource.pk, today.year, today.strftime('%W')), status=403)
+
+
 @freezegun.freeze_time('2020-06-15')
 def test_resource_month_view(app, admin_user, get_proper_html_str):
     resource = Resource.objects.create(label='Foo bar')
@@ -261,7 +551,7 @@ def test_resource_month_view(app, admin_user, get_proper_html_str):
     today = datetime.date.today()
     with CaptureQueriesContext(connection) as ctx:
         resp = app.get('/manage/resource/%s/%s/%s/' % (resource.pk, today.year, today.month))
-        assert len(ctx.captured_queries) == 8
+        assert len(ctx.captured_queries) == 9
     assert resp.text.count('<div class="booking" style="height:33.0%;') == 2  # booking cells
     assert resp.pyquery.find('div.booking a').not_('.cancel')[0].text.strip() == 'booked'
     assert resp.pyquery.find('div.booking a').not_('.cancel')[1].text.strip() == "foo - bar's"
-- 
2.35.1

