From e665c1c5a7876f2d9615cffdaa561d82450f0e71 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Fri, 29 May 2020 09:41:09 +0200
Subject: [PATCH 8/8] agendas: month/day agenda for resource (#38942)

---
 .../chrono/manager_resource_day_view.html     |  52 ++++
 .../chrono/manager_resource_detail.html       |  21 +-
 .../chrono/manager_resource_month_view.html   |  64 ++++
 chrono/manager/urls.py                        |  10 +
 chrono/manager/views.py                       | 289 +++++++++++++++++-
 tests/test_manager.py                         | 223 ++++++++++++++
 6 files changed, 642 insertions(+), 17 deletions(-)
 create mode 100644 chrono/manager/templates/chrono/manager_resource_day_view.html
 create mode 100644 chrono/manager/templates/chrono/manager_resource_month_view.html

diff --git a/chrono/manager/templates/chrono/manager_resource_day_view.html b/chrono/manager/templates/chrono/manager_resource_day_view.html
new file mode 100644
index 0000000..02168c6
--- /dev/null
+++ b/chrono/manager/templates/chrono/manager_resource_day_view.html
@@ -0,0 +1,52 @@
+{% extends "chrono/manager_resource_detail.html" %}
+{% load i18n %}
+
+{% block breadcrumb %}
+{{ block.super }}
+<a>{{ day|date:"SHORT_DATE_FORMAT" }}</a>
+{% endblock %}
+
+{% block appbar-title %}
+<h2>
+  <a href="{{ view.get_previous_day_url }}">←</a>
+  <span class="date-title">{{ view.date|date:"l j F Y" }}</span>
+  {% with selected_day=view.date|date:"j" selected_month=view.date|date:"n" selected_year=view.date|date:"Y" %}
+    <div class="date-picker" style="display: none">
+    <select name="day">{% for day in view.get_days %}<option value="{{ day }}" {% if selected_day == day %}selected{% endif %}>{{day}}</option>{% endfor %}</select>
+    <select name="month">{% for month, month_label in view.get_months %}<option value="{{ month }}" {% if selected_month == month %}selected{% endif %}>{{ month_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_day_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>
+{% endblock %}
+
+{% block content %}
+
+<table class="agenda-table day-view hourspan-{{ hour_span }}">
+    <tbody>
+        {% for period, resource_info in view.get_timetable_infos %}
+            <tr class="{% cycle 'odd' 'even' %}">
+                <th class="hour">{{ period|date:"TIME_FORMAT" }}</th>
+                <td>
+                    {% for booking in resource_info.bookings %}
+                      <div class="booking"
+                          style="height: {{ booking.css_height }}%; min-height: {{ booking.css_height }}%; top: {{ booking.css_top }}%;"
+                        ><span class="start-time">{{ booking.event.start_datetime|date:"TIME_FORMAT" }}</span>
+                        <a {% if booking.backoffice_url %}href="{{ booking.backoffice_url }}"{% endif %}
+                         >{% if booking.label or booking.user_name %}
+                            {{ booking.label }}{% if booking.label and booking.user_name %} - {% endif %} {{ booking.user_name }}
+                          {% else %}{% trans "booked" %}{% endif %}</a>
+                      </div>
+                    {% endfor %}
+                </td>
+            </tr>
+        {% endfor %}
+    </tbody>
+</table>
+
+{% endblock %}
diff --git a/chrono/manager/templates/chrono/manager_resource_detail.html b/chrono/manager/templates/chrono/manager_resource_detail.html
index 78b54bf..5348c8f 100644
--- a/chrono/manager/templates/chrono/manager_resource_detail.html
+++ b/chrono/manager/templates/chrono/manager_resource_detail.html
@@ -2,19 +2,28 @@
 {% load i18n %}
 
 {% block page-title-extra-label %}
-- {{ object.label }}
+- {{ resource.label }}
 {% endblock %}
 
 {% block breadcrumb %}
 {{ block.super }}
-<a href="{% url 'chrono-manager-resource-view' pk=object.pk %}">{{ object }}</a>
+<a href="{% url 'chrono-manager-resource-view' pk=resource.pk %}">{{ resource.label }}</a>
 {% endblock %}
 
 {% block appbar %}
-<h2>{{ object }}</h2>
+{% block appbar-title %}
+<h2>{{ resource }}</h2>
+{% endblock %}
 <span class="actions">
-<a rel="popup" href="{% url 'chrono-manager-resource-edit' pk=object.pk %}">{% trans 'Edit' %}</a>
-<a rel="popup" href="{% url 'chrono-manager-resource-delete' pk=object.pk %}">{% trans 'Delete' %}</a>
+{% block appbar-extras %}
+<a rel="popup" href="{% url 'chrono-manager-resource-edit' pk=resource.pk %}">{% trans 'Edit' %}</a>
+<a rel="popup" href="{% url 'chrono-manager-resource-delete' pk=resource.pk %}">{% trans 'Delete' %}</a>
+{% now "Y" as today_year %}
+{% now "n" as today_month %}
+{% 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-day-view' pk=resource.pk year=today_year month=today_month day=today_day %}">{% trans 'Day view' %}</a>
+{% endblock %}
 </span>
 {% endblock %}
 
@@ -23,7 +32,7 @@
 <div class="section">
 <h3>{% trans 'Used in meetings agendas' %}</h3>
 <div>
-{% with object.agenda_set.all as agendas %}
+{% with resource.agenda_set.all as agendas %}
 {% if agendas %}
   <ul class="objects-list single-links">
     {% for agenda in agendas %}
diff --git a/chrono/manager/templates/chrono/manager_resource_month_view.html b/chrono/manager/templates/chrono/manager_resource_month_view.html
new file mode 100644
index 0000000..f54a76f
--- /dev/null
+++ b/chrono/manager/templates/chrono/manager_resource_month_view.html
@@ -0,0 +1,64 @@
+{% extends "chrono/manager_resource_detail.html" %}
+{% load i18n %}
+
+{% block bodyargs %}class="monthview"{% endblock %}
+
+{% block breadcrumb %}
+{{ block.super }}
+<a>{{ view.date|date:"F Y" }}</a>
+{% endblock %}
+
+{% block appbar-title %}
+<h2>
+  <a href="{{ view.get_previous_month_url }}">←</a>
+  <span class="date-title">{{ view.date|date:"F Y" }}</span>
+  {% with selected_month=view.date|date:"n" selected_year=view.date|date:"Y" %}
+    <div class="date-picker" style="display: none">
+    <select name="month">{% for month, month_label in view.get_months %}<option value="{{ month }}" {% if selected_month == month %}selected{% endif %}>{{ month_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_month_url }}">→</a>
+</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>
+{% endblock %}
+
+{% block content %}
+
+<table class="agenda-table month-view single-desk">
+    <tbody>
+    {% for week_days in view.get_timetable_infos %}
+        <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.backoffice_url %}href="{{ slot.booking.backoffice_url }}"{% endif %}
+                       >{% if slot.booking.label or slot.booking.user_name %}
+                      {{ slot.booking.label }}{% if slot.booking.label and slot.booking.user_name %} - {% endif %} {{ slot.booking.user_name }}
+                      {% else %}{% trans "booked" %}{% endif %}</a>
+                </div>
+                {% endfor %}
+                {% endif %}
+            </td>
+            {% endfor %}
+        </tr>
+        {% endfor %}
+    {% endfor %}
+    </tbody>
+</table>
+
+{% endblock %}
diff --git a/chrono/manager/urls.py b/chrono/manager/urls.py
index 810223f..19c4ee3 100644
--- a/chrono/manager/urls.py
+++ b/chrono/manager/urls.py
@@ -23,6 +23,16 @@ urlpatterns = [
     url(r'^resources/$', views.resource_list, name='chrono-manager-resource-list'),
     url(r'^resource/add/$', views.resource_add, name='chrono-manager-resource-add'),
     url(r'^resource/(?P<pk>\d+)/$', views.resource_view, name='chrono-manager-resource-view'),
+    url(
+        r'^resource/(?P<pk>\d+)/(?P<year>[0-9]{4})/(?P<month>[0-9]+)/$',
+        views.resource_monthly_view,
+        name='chrono-manager-resource-month-view',
+    ),
+    url(
+        r'^resource/(?P<pk>\d+)/(?P<year>[0-9]{4})/(?P<month>[0-9]+)/(?P<day>[0-9]+)/$',
+        views.resource_day_view,
+        name='chrono-manager-resource-day-view',
+    ),
     url(r'^resource/(?P<pk>\d+)/edit/$', views.resource_edit, name='chrono-manager-resource-edit'),
     url(r'^resource/(?P<pk>\d+)/delete/$', views.resource_delete, name='chrono-manager-resource-delete'),
     url(r'^agendas/add/$', views.agenda_add, name='chrono-manager-agenda-add'),
diff --git a/chrono/manager/views.py b/chrono/manager/views.py
index a45a90e..8eacca9 100644
--- a/chrono/manager/views.py
+++ b/chrono/manager/views.py
@@ -17,11 +17,13 @@
 import datetime
 import itertools
 import json
+import math
 
 from django.contrib import messages
 from django.core.exceptions import PermissionDenied
 from django.db.models import Prefetch
 from django.db.models import Q
+from django.db.models import Min, Max
 from django.forms import ValidationError
 from django.http import Http404, HttpResponse, HttpResponseRedirect
 from django.shortcuts import get_object_or_404
@@ -113,10 +115,285 @@ class ResourceDetailView(DetailView):
     template_name = 'chrono/manager_resource_detail.html'
     model = Resource
 
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        context['resource'] = self.object
+        return context
+
 
 resource_view = ResourceDetailView.as_view()
 
 
+class DateMixin(object):
+    def get_days(self):
+        return [str(x) for x in range(1, 32)]
+
+    def get_months(self):
+        return [(str(x), MONTHS[x]) for x in range(1, 13)]
+
+    def get_years(self):
+        year = now().year
+        return [str(x) for x in range(year - 1, year + 5)]
+
+
+class ResourceDayView(DateMixin, DayArchiveView):
+    template_name = 'chrono/manager_resource_day_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'])
+        # specify 6am time to get the expected timezone on daylight saving time
+        # days.
+        try:
+            self.date = make_aware(
+                datetime.datetime.strptime(
+                    '%s-%s-%s 06:00' % (self.get_year(), self.get_month(), self.get_day()), '%Y-%m-%d %H:%M'
+                )
+            )
+        except ValueError:  # day is out of range for month
+            # redirect to last day of month
+            date = datetime.date(int(self.get_year()), int(self.get_month()), 1)
+            date += datetime.timedelta(days=40)
+            date = date.replace(day=1)
+            date -= datetime.timedelta(days=1)
+            return HttpResponseRedirect(
+                reverse(
+                    'chrono-manager-resource-day-view',
+                    kwargs={'pk': self.resource.pk, 'year': date.year, 'month': date.month, 'day': date.day},
+                )
+            )
+        return super().dispatch(request, *args, **kwargs)
+
+    def get_queryset(self):
+        queryset = (
+            self.resource.event_set.all().select_related('meeting_type').prefetch_related('booking_set')
+        )
+        return queryset
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+
+        context['resource'] = self.resource
+        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)
+
+        return context
+
+    def get_previous_day_url(self):
+        previous_day = self.date.date() - datetime.timedelta(days=1)
+        return reverse(
+            'chrono-manager-resource-day-view',
+            kwargs={
+                'pk': self.resource.pk,
+                'year': previous_day.year,
+                'month': previous_day.month,
+                'day': previous_day.day,
+            },
+        )
+
+    def get_next_day_url(self):
+        next_day = self.date.date() + datetime.timedelta(days=1)
+        return reverse(
+            'chrono-manager-resource-day-view',
+            kwargs={
+                'pk': self.resource.pk,
+                'year': next_day.year,
+                'month': next_day.month,
+                'day': next_day.day,
+            },
+        )
+
+    def get_timetable_infos(self):
+        timeperiods = TimePeriod.objects.filter(desk__agenda__resources=self.resource).aggregate(
+            Min('start_time'), Max('end_time')
+        )
+        min_timeperiod = timeperiods['start_time__min']
+        max_timeperiod = timeperiods['end_time__max']
+
+        interval = datetime.timedelta(minutes=60)
+        current_date = self.date.replace(hour=min_timeperiod and min_timeperiod.hour or 0, minute=0)
+        max_date = self.date.replace(hour=max_timeperiod and max_timeperiod.hour or 23, minute=0)
+        if not max_timeperiod or max_timeperiod.minute != 0:
+            # until the end of the last hour.
+            max_date += datetime.timedelta(hours=1)
+
+        while current_date < max_date:
+            info = {}
+            info['bookings'] = bookings = []  # bookings for this resource
+            finish_datetime = current_date + interval
+            for event in [
+                x
+                for x in self.object_list
+                if x.start_datetime >= current_date and x.start_datetime < finish_datetime
+            ]:
+                # don't consider cancelled bookings
+                for booking in [x for x in event.booking_set.all() if not x.cancellation_datetime]:
+                    booking.css_top = int(100 * event.start_datetime.minute / 60)
+                    booking.css_height = int(100 * event.meeting_type.duration / 60)
+                    bookings.append(booking)
+            yield current_date, info
+            current_date += interval
+
+
+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'])
+        self.date = make_aware(
+            datetime.datetime.strptime(
+                '%s-%s-%s 06:00' % (self.get_year(), self.get_month(), 1), '%Y-%m-%d %H:%M'
+            )
+        )
+        return super().dispatch(request, *args, **kwargs)
+
+    def get_queryset(self):
+        queryset = (
+            self.resource.event_set.all().select_related('meeting_type').prefetch_related('booking_set')
+        )
+        return queryset
+
+    def get_context_data(self, **kwargs):
+        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},
+        )
+
+    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_timetable_infos(self):
+        timeperiods = TimePeriod.objects.filter(desk__agenda__resources=self.resource).aggregate(
+            Min('start_time'), Max('end_time')
+        )
+        self.min_timeperiod = timeperiods['start_time__min']
+        self.max_timeperiod = timeperiods['end_time__max']
+
+        weekdays = TimePeriod.objects.filter(desk__agenda__resources=self.resource).values_list(
+            'weekday', flat=True
+        )
+        hide_sunday = 6 not in weekdays
+        hide_weekend = hide_sunday and 5 not in weekdays
+        # 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_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]
+
+        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(
+                week_number - first_week_number, week_end_offset=int(hide_sunday) + int(hide_weekend),
+            )
+
+    def get_week_timetable_infos(self, week_index, week_end_offset=0):
+        date = self.date + datetime.timedelta(week_index * 7)
+        year, week_number, dow = date.isocalendar()
+        start_date = date - datetime.timedelta(dow)
+
+        interval = datetime.timedelta(minutes=60)
+
+        period = self.date.replace(hour=self.min_timeperiod and self.min_timeperiod.hour or 0, minute=0)
+        max_date = self.date.replace(hour=self.max_timeperiod and self.max_timeperiod.hour or 23, minute=0)
+        if not self.max_timeperiod or self.max_timeperiod.minute != 0:
+            # until the end of the last hour.
+            max_date += datetime.timedelta(hours=1)
+
+        periods = []
+        while period < max_date:
+            periods.append(period)
+            period = period + interval
+
+        return {
+            'days': [
+                self.get_day_timetable_infos(start_date + datetime.timedelta(i), interval)
+                for i in range(1, 8 - week_end_offset)
+            ],
+            'periods': periods,
+        }
+
+    def get_day_timetable_infos(self, day, interval):
+        day = make_aware(make_naive(day))  # give day correct timezone
+        period = current_date = day.replace(
+            hour=self.min_timeperiod and self.min_timeperiod.hour or 0, minute=0
+        )
+        max_date = day.replace(hour=self.max_timeperiod and self.max_timeperiod.hour or 23, minute=0)
+        if not self.max_timeperiod or self.max_timeperiod.minute != 0:
+            # until the end of the last hour.
+            max_date += datetime.timedelta(hours=1)
+
+        timetable = {
+            'date': current_date,
+            'today': day.date() == datetime.date.today(),
+            'other_month': day.month != self.date.month,
+            'infos': {'booked_slots': []},
+        }
+
+        # compute booking and opening hours only for current month
+        if self.date.month != day.month:
+            return timetable
+
+        while period <= max_date:
+            period_end = period + interval
+            for event in [
+                x for x in self.object_list if x.start_datetime >= period and x.start_datetime < period_end
+            ]:
+                # don't consider cancelled bookings
+                bookings = [x for x in event.booking_set.all() if not x.cancellation_datetime]
+                if not bookings:
+                    continue
+                booking = {
+                    'css_top': 100 * (event.start_datetime - current_date).seconds // 3600,
+                    'css_height': 100 * event.meeting_type.duration // 60,
+                    'booking': bookings[0],
+                }
+                timetable['infos']['booked_slots'].append(booking)
+            period += interval
+
+        return timetable
+
+
+resource_monthly_view = ResourceMonthView.as_view()
+
+
 class ResourceAddView(CreateView):
     template_name = 'chrono/manager_resource_form.html'
     model = Resource
@@ -355,7 +632,7 @@ class AgendaView(ViewableAgendaMixin, View):
 agenda_view = AgendaView.as_view()
 
 
-class AgendaDateView(ViewableAgendaMixin):
+class AgendaDateView(DateMixin, ViewableAgendaMixin):
     model = Event
     month_format = '%m'
     date_field = 'start_datetime'
@@ -408,16 +685,6 @@ class AgendaDateView(ViewableAgendaMixin):
             )
         return queryset
 
-    def get_days(self):
-        return [str(x) for x in range(1, 32)]
-
-    def get_months(self):
-        return [(str(x), MONTHS[x]) for x in range(1, 13)]
-
-    def get_years(self):
-        year = now().year
-        return [str(x) for x in range(year - 1, year + 5)]
-
 
 class AgendaDayView(AgendaDateView, DayArchiveView):
     template_name = 'chrono/manager_agenda_day_view.html'
diff --git a/tests/test_manager.py b/tests/test_manager.py
index a489836..901e66e 100644
--- a/tests/test_manager.py
+++ b/tests/test_manager.py
@@ -206,6 +206,229 @@ def test_view_resource(app, admin_user):
     assert '/manage/agendas/%s/settings' % agenda.pk in resp.text
 
 
+def test_resource_day_view(app, admin_user):
+    today = datetime.date.today()
+    resource = Resource.objects.create(label='Foo bar')
+    agenda = Agenda.objects.create(label='Agenda', kind='meetings')
+    desk = Desk.objects.create(agenda=agenda, label='Desk')
+    meetingtype = MeetingType.objects.create(agenda=agenda, label='Bar', duration=30)
+    timeperiod = TimePeriod.objects.create(
+        desk=desk, weekday=today.weekday(), start_time=datetime.time(10, 0), end_time=datetime.time(18, 0)
+    )
+
+    login(app)
+    resp = app.get('/manage/resource/%s/%d/%d/%d/' % (resource.pk, today.year, today.month, today.day))
+    assert 'div class="booking' not in resp.text
+    assert resp.text.count('<tr') == 24  # resource in no agenda
+
+    agenda.resources.add(resource)
+    resp = app.get('/manage/resource/%s/%d/%d/%d/' % (resource.pk, today.year, today.month, today.day))
+    assert 'div class="booking' not in resp.text
+    assert resp.text.count('<tr') == 8  # 10->18 (not included)
+
+    timeperiod.end_time = datetime.time(18, 30)  # end during an hour
+    timeperiod.save()
+    resp = app.get('/manage/resource/%s/%d/%d/%d/' % (resource.pk, today.year, today.month, today.day))
+    assert resp.text.count('<tr') == 9  # 10->18 (included)
+
+    # 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)
+        Booking.objects.create(event=event)
+
+    with CaptureQueriesContext(connection) as ctx:
+        resp = app.get('/manage/resource/%s/%d/%d/%d/' % (resource.pk, today.year, today.month, today.day))
+        assert len(ctx.captured_queries) == 7
+    assert resp.text.count('div class="booking') == 2
+    assert 'hourspan-2' in resp.text  # table CSS class
+    assert 'height: 50%; top: 0%;' in resp.text  # booking cells
+    assert 'height: 50%; top: 50%;' in resp.text  # booking cells
+
+    # create a shorter meeting type, this will change the table CSS class
+    # (and visually this will give more room for events)
+    meetingtype = MeetingType.objects.create(agenda=agenda, label='Baz', duration=15)
+    resp = app.get('/manage/resource/%s/%d/%d/%d/' % (resource.pk, today.year, today.month, today.day))
+    assert resp.text.count('div class="booking') == 2
+    assert 'hourspan-4' in resp.text  # table CSS class
+
+    # cancel a booking
+    booking = Booking.objects.first()
+    booking.cancel()
+    resp = app.get('/manage/resource/%s/%d/%d/%d/' % (resource.pk, today.year, today.month, today.day))
+    assert resp.text.count('div class="booking') == 1
+
+
+def test_resource_day_view_late_meeting(app, admin_user):
+    today = datetime.date.today()
+    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)
+    TimePeriod.objects.create(
+        desk=desk, weekday=today.weekday(), start_time=datetime.time(10, 0), end_time=datetime.time(23, 30)
+    )
+
+    login(app)
+    resp = app.get('/manage/resource/%s/%d/%d/%d/' % (resource.pk, today.year, today.month, today.day))
+    assert resp.text.count('<tr') == 14
+    assert '<th class="hour">midnight</th>' not in resp.text
+    assert '<th class="hour">11 p.m.</th>' in resp.text
+
+
+def test_resource_invalid_day_view(app, admin_user):
+    resource = Resource.objects.create(label='Foo bar')
+
+    login(app)
+    resp = app.get('/manage/resource/%s/%d/%d/%d/' % (resource.pk, 2018, 11, 31), status=302)
+    assert resp.location.endswith('2018/11/30/')
+
+
+def test_resource_month_view(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=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
+    resp = app.get('/manage/resource/%s/%s/%s/' % (resource.pk, today.year, today.month))
+    assert '<div class="booking' not in resp.text
+    first_month_day = today.replace(day=1)
+    last_month_day = today.replace(day=1, month=today.month + 1) - datetime.timedelta(days=1)
+    start_week_number = first_month_day.isocalendar()[1]
+    end_week_number = last_month_day.isocalendar()[1]
+    weeks_number = end_week_number - start_week_number + 1
+    assert resp.text.count('<tr') == 9 * weeks_number
+
+    # 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)
+        Booking.objects.create(event=event)
+
+    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 resp.text.count('<div class="booking" style="height:33.0%;') == 2  # booking cells
+
+    # cancel booking
+    booking = Booking.objects.first()
+    booking.cancel()
+
+    # make sure the are not
+    resp = app.get('/manage/resource/%s/%s/%s/' % (resource.pk, today.year, today.month))
+    assert resp.text.count('<div class="booking"') == 1
+
+
+def test_resource_month_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)
+    )
+    month, year = 1, 2019
+
+    login(app)
+    resp = app.get('/manage/resource/%s/%s/%s/' % (resource.pk, year, month))
+    assert 'Sunday' not in resp.text
+    assert 'Saturday' not in resp.text
+    # No Monday on first row since month starts a Tuesday
+    assert len(resp.pyquery.find('tbody tr:first th.weekday:empty')) == 1
+
+    # When weekend is hidden, do not display an empty first week
+    month, year = 12, 2019  # month starts a Sunday
+    resp = app.get('/manage/resource/%s/%s/%s/' % (resource.pk, year, month))
+    assert len(resp.pyquery.find('tbody tr:first th.weekday:empty')) == 0
+
+    month, year = 6, 2019  # month starts a Saturday
+    resp = app.get('/manage/resource/%s/%s/%s/' % (resource.pk, year, month))
+    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/%s/' % (resource.pk, year, month))
+    assert 'Sunday' not in resp.text
+    assert 'Saturday' in resp.text
+    assert len(resp.pyquery.find('tbody tr:first th.weekday:empty')) == 5
+
+    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/%s/' % (resource.pk, year, month))
+    assert 'Sunday' in resp.text
+    assert 'Saturday' in resp.text
+
+    timeperiod_sat.delete()
+    resp = app.get('/manage/resource/%s/%s/%s/' % (resource.pk, year, month))
+    assert 'Sunday' in resp.text
+    assert 'Saturday' in resp.text
+
+
+def test_resource_month_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/10/' % resource.pk)
+    assert resp.text.count('height:50.0%;top:100.0%') == 2
+
+
+def test_resource_month_view_januaries(app, admin_user):
+    resource = Resource.objects.create(label='Foo bar')
+
+    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/1/' % (resource.pk, date.year))
+            assert resp.text.count('<th></th>') in (4, 5)
+
+
 def test_edit_resource(app, admin_user):
     resource = Resource.objects.create(label='Foo bar')
 
-- 
2.20.1

