From a483544187736c99895aa91b4e48f2404d60f601 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Tue, 15 Feb 2022 17:16:31 +0100
Subject: [PATCH 2/5] manager: timesheet generation (#61070)

---
 chrono/manager/forms.py                       | 122 ++++-
 chrono/manager/static/css/style.scss          |  22 +
 .../chrono/manager_agenda_open_events.html    |   4 +
 .../manager_events_agenda_day_view.html       |   4 +
 .../manager_events_agenda_month_view.html     |   7 +-
 .../chrono/manager_events_timesheet.html      |  54 ++
 chrono/manager/templatetags/__init__.py       |   0
 chrono/manager/templatetags/chrono.py         |  31 ++
 chrono/manager/urls.py                        |   5 +
 chrono/manager/views.py                       |  23 +
 tests/manager/test_all.py                     |   6 +-
 tests/manager/test_event.py                   | 486 ++++++++++++++++++
 12 files changed, 757 insertions(+), 7 deletions(-)
 create mode 100644 chrono/manager/templates/chrono/manager_events_timesheet.html
 create mode 100644 chrono/manager/templatetags/__init__.py
 create mode 100644 chrono/manager/templatetags/chrono.py

diff --git a/chrono/manager/forms.py b/chrono/manager/forms.py
index 916d3b4..886f690 100644
--- a/chrono/manager/forms.py
+++ b/chrono/manager/forms.py
@@ -15,10 +15,14 @@
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 
+import copy
 import csv
 import datetime
+from collections import defaultdict
+from operator import itemgetter
 
 import django_filters
+from dateutil.relativedelta import relativedelta
 from django import forms
 from django.conf import settings
 from django.contrib.auth.models import Group
@@ -27,7 +31,7 @@ from django.db import transaction
 from django.forms import ValidationError
 from django.utils.encoding import force_text
 from django.utils.six import StringIO
-from django.utils.timezone import make_aware, now
+from django.utils.timezone import localtime, make_aware, now
 from django.utils.translation import ugettext_lazy as _
 
 from chrono.agendas.models import (
@@ -357,6 +361,122 @@ class BookingAbsenceReasonForm(forms.Form):
             ]
 
 
+class EventsTimesheetForm(forms.Form):
+    date_start = forms.DateField(
+        label=_('Start date'),
+        widget=forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
+    )
+    date_end = forms.DateField(
+        label=_('End date'),
+        widget=forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'),
+    )
+
+    def __init__(self, *args, **kwargs):
+        self.agenda = kwargs.pop('agenda')
+        super().__init__(*args, **kwargs)
+
+    def get_slots(self):
+        min_start = make_aware(
+            datetime.datetime.combine(self.cleaned_data['date_start'], datetime.time(0, 0))
+        )
+        max_start = make_aware(datetime.datetime.combine(self.cleaned_data['date_end'], datetime.time(0, 0)))
+        max_start = max_start + datetime.timedelta(days=1)
+
+        # fetch all events in this range
+        events_qs = (
+            self.agenda.event_set.filter(
+                recurrence_days__isnull=True,
+                start_datetime__gte=min_start,
+                start_datetime__lt=max_start,
+            )
+            .select_related('primary_event')
+            .order_by('start_datetime', 'label')
+        )
+        all_events = self.agenda.add_event_recurrences(events_qs, min_start, max_start)
+        dates = set()
+        events = []
+        dates_per_event_id = defaultdict(list)
+        for event in all_events:
+            date = localtime(event.start_datetime).date()
+            dates.add(date)
+            real_event = event.primary_event or event
+            if real_event not in events:
+                events.append(real_event)
+            dates_per_event_id[real_event.pk].append(date)
+        dates = sorted(dates)
+
+        event_slots = []
+        for event in events:
+            event_slots.append(
+                {'event': event, 'dates': {date: False for date in dates_per_event_id[event.pk]}}
+            )
+
+        users = {}
+        subscriptions = self.agenda.subscriptions.filter(date_start__lt=max_start, date_end__gt=min_start)
+        for subscription in subscriptions:
+            if subscription.user_external_id in users:
+                continue
+            users[subscription.user_external_id] = {
+                'user_id': subscription.user_external_id,
+                'user_first_name': subscription.user_first_name,
+                'user_last_name': subscription.user_last_name,
+                'events': copy.deepcopy(event_slots),
+            }
+
+        booking_qs_kwargs = {}
+        if not self.agenda.subscriptions.exists():
+            booking_qs_kwargs = {'cancellation_datetime__isnull': True}
+        booked_qs = (
+            Booking.objects.filter(
+                event__in=all_events,
+                in_waiting_list=False,
+                primary_booking__isnull=True,
+                **booking_qs_kwargs,
+            )
+            .exclude(user_external_id='')
+            .select_related('event')
+            .order_by('event__start_datetime')
+        )
+        for booking in booked_qs:
+            user_id = booking.user_external_id
+            if user_id not in users:
+                users[user_id] = {
+                    'user_id': user_id,
+                    'user_first_name': booking.user_first_name,
+                    'user_last_name': booking.user_last_name,
+                    'events': copy.deepcopy(event_slots),
+                }
+            if booking.cancellation_datetime is not None:
+                continue
+            # mark the slot as booked
+            date = localtime(booking.event.start_datetime).date()
+            for event in users[user_id]['events']:
+                if event['event'].pk != (booking.event.primary_event_id or booking.event_id):
+                    continue
+                if date in event['dates']:
+                    event['dates'][date] = True
+                break
+
+        users = sorted(users.values(), key=itemgetter('user_last_name', 'user_first_name', 'user_id'))
+
+        return {
+            'dates': dates,
+            'events': events,
+            'users': users,
+        }
+
+    def clean(self):
+        cleaned_data = super().clean()
+
+        if 'date_start' in cleaned_data and 'date_end' in cleaned_data:
+            if cleaned_data['date_end'] < cleaned_data['date_start']:
+                self.add_error('date_end', _('End date must be greater than start date.'))
+            elif (cleaned_data['date_start'] + relativedelta(months=3)) < cleaned_data['date_end']:
+                self.add_error('date_end', _('Please select an interval of no more than 3 months.'))
+
+        return cleaned_data
+
+
 class AgendaResourceForm(forms.Form):
     resource = forms.ModelChoiceField(label=_('Resource'), queryset=Resource.objects.none())
 
diff --git a/chrono/manager/static/css/style.scss b/chrono/manager/static/css/style.scss
index 0d5c743..faf793e 100644
--- a/chrono/manager/static/css/style.scss
+++ b/chrono/manager/static/css/style.scss
@@ -481,3 +481,25 @@ form div.widget[id^=id_recurrence] {
 div.ui-dialog div.widget .datetime input {
 	width: auto;
 }
+
+table.timesheet {
+	width: auto;
+	th {
+		padding: 0.5em 0.5ex;
+		background: none;
+		border: 1px solid #f3f3f3;
+		&.date {
+			width: 20px;
+			text-align: center;
+		}
+	}
+	td {
+		padding: 0.5em 0.5ex;
+		border: 1px solid #f3f3f3;
+		&.date {
+			text-align: center;
+			padding: 0px;
+			max-width: 2em;
+		}
+	}
+}
diff --git a/chrono/manager/templates/chrono/manager_agenda_open_events.html b/chrono/manager/templates/chrono/manager_agenda_open_events.html
index 1568d89..6b60ba7 100644
--- a/chrono/manager/templates/chrono/manager_agenda_open_events.html
+++ b/chrono/manager/templates/chrono/manager_agenda_open_events.html
@@ -9,6 +9,10 @@
 {% endblock %}
 
 {% block actions %}
+<a class="extra-actions-menu-opener"></a>
+<ul class="extra-actions-menu">
+  <li><a href="{% url 'chrono-manager-events-timesheet' pk=agenda.pk %}">{% trans 'Timesheet' %}</a></li>
+</ul>
 {{ block.super }}
 <a href="{% url 'chrono-manager-agenda-month-redirect-view' pk=agenda.pk %}">{% trans 'Month view' %}</a>
 {% 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 2027934..15dc0c7 100644
--- a/chrono/manager/templates/chrono/manager_events_agenda_day_view.html
+++ b/chrono/manager/templates/chrono/manager_events_agenda_day_view.html
@@ -2,6 +2,10 @@
 {% load i18n %}
 
 {% block actions %}
+<a class="extra-actions-menu-opener"></a>
+<ul class="extra-actions-menu">
+  <li><a href="{% url 'chrono-manager-events-timesheet' pk=agenda.pk %}">{% trans 'Timesheet' %}</a></li>
+</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:"n" %}">{% trans 'Month view' %}</a>
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 f743c86..bd0b357 100644
--- a/chrono/manager/templates/chrono/manager_events_agenda_month_view.html
+++ b/chrono/manager/templates/chrono/manager_events_agenda_month_view.html
@@ -3,12 +3,13 @@
 
 {% block actions %}
 <a class="extra-actions-menu-opener"></a>
-{{ block.super }}
-<a href="{% url 'chrono-manager-agenda-open-events-view' pk=agenda.pk %}">{% trans 'Open events' %}</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>
 <ul class="extra-actions-menu">
   <li><a href="{% url 'chrono-manager-event-cancellation-report-list' pk=agenda.pk %}">{% trans 'Cancellation error reports' %}</a></li>
+  <li><a href="{% url 'chrono-manager-events-timesheet' pk=agenda.pk %}">{% trans 'Timesheet' %}</a></li>
 </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-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 %}
diff --git a/chrono/manager/templates/chrono/manager_events_timesheet.html b/chrono/manager/templates/chrono/manager_events_timesheet.html
new file mode 100644
index 0000000..2a14e2e
--- /dev/null
+++ b/chrono/manager/templates/chrono/manager_events_timesheet.html
@@ -0,0 +1,54 @@
+{% extends "chrono/manager_agenda_view.html" %}
+{% load i18n chrono %}
+
+{% block breadcrumb %}
+{{ block.super }}
+<a href="{% url 'chrono-manager-events-timesheet' pk=agenda.pk %}">{% trans "Timesheet" %}</a>
+{% endblock %}
+
+{% block appbar_actions %}{% endblock %}
+
+{% block content %}
+<div class="section">
+  <h3>{% trans "Timesheet configuration" %}</h3>
+  <div>
+    <form>
+      {{ form.as_p }}
+      <button class="submit-button">{% trans "See timesheet" %}</button>
+    </form>
+
+    {% if request.GET and form.is_valid %}
+    <h4>{% blocktrans with start=form.cleaned_data.date_start end=form.cleaned_data.date_end %}Timesheet from {{ start }} to {{ end }}{% endblocktrans %}</h4>
+
+    {% with slots=form.get_slots %}
+    {% with events_num=slots.events|length %}
+    <table class="main timesheet">
+      <thead>
+        <tr>
+          <th>{% trans "First name" %}</th>
+          <th>{% trans "Last name" %}</th>
+          {% if events_num > 1 %}<th>{% trans "Activity" %}</th>{% endif %}
+          {% for date in slots.dates %}<th class="date">{{ date|date:"D d/m" }}</th>{% endfor %}
+        </tr>
+      </thead>
+      <tbody>
+        {% for user in slots.users %}{% for event in user.events %}
+        <tr>
+          {% if forloop.first %}
+          <td {% if events_num > 1 %}rowspan="{{ events_num }}"{% endif %}>{{ user.user_first_name }}</td>
+          <td {% if events_num > 1 %}rowspan="{{ events_num }}"{% endif %}>{{ user.user_last_name }}</td>
+          {% endif %}
+          {% if events_num > 1 %}<td>{{ event.event }}</td>{% endif %}
+          {% for date in slots.dates %}
+          {% with booked=event.dates|get:date %}<td class="date">{% if booked is True %}☐{% elif booked is None %}-{% endif %}</td>{% endwith %}
+          {% endfor %}
+        </tr>
+        {% endfor %}{% endfor %}
+      </tbody>
+    </table>
+    {% endwith %}
+    {% endwith %}
+    {% endif %}
+  </div>
+</div>
+{% endblock %}
diff --git a/chrono/manager/templatetags/__init__.py b/chrono/manager/templatetags/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/chrono/manager/templatetags/chrono.py b/chrono/manager/templatetags/chrono.py
new file mode 100644
index 0000000..6caf5a1
--- /dev/null
+++ b/chrono/manager/templatetags/chrono.py
@@ -0,0 +1,31 @@
+# chrono - agendas system
+# Copyright (C) 2022  Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+from django import template
+
+register = template.Library()
+
+
+@register.filter(name='get')
+def get(obj, key):
+    try:
+        return obj.get(key)
+    except AttributeError:
+        try:
+            return obj[key]
+        except (IndexError, KeyError, TypeError):
+            return None
diff --git a/chrono/manager/urls.py b/chrono/manager/urls.py
index 83cbb7d..51ffca2 100644
--- a/chrono/manager/urls.py
+++ b/chrono/manager/urls.py
@@ -191,6 +191,11 @@ urlpatterns = [
         views.agenda_reminder_preview,
         name='chrono-manager-agenda-reminder-preview',
     ),
+    url(
+        r'^agendas/(?P<pk>\d+)/events/timesheet$',
+        views.events_timesheet,
+        name='chrono-manager-events-timesheet',
+    ),
     url(
         r'^agendas/(?P<pk>\d+)/events/(?P<event_pk>\d+)/$',
         views.event_view,
diff --git a/chrono/manager/views.py b/chrono/manager/views.py
index 5e5c56d..2d08db7 100644
--- a/chrono/manager/views.py
+++ b/chrono/manager/views.py
@@ -97,6 +97,7 @@ from .forms import (
     DeskForm,
     EventCancelForm,
     EventForm,
+    EventsTimesheetForm,
     ExceptionsImportForm,
     ImportEventsForm,
     MeetingTypeForm,
@@ -1953,6 +1954,28 @@ class AgendaReminderPreviewView(ManagedAgendaMixin, TemplateView):
 agenda_reminder_preview = AgendaReminderPreviewView.as_view()
 
 
+class EventsTimesheetView(ViewableAgendaMixin, DetailView):
+    model = Agenda
+    template_name = 'chrono/manager_events_timesheet.html'
+
+    def set_agenda(self, **kwargs):
+        self.agenda = get_object_or_404(Agenda, pk=kwargs.get('pk'), kind='events')
+
+    def get_object(self, **kwargs):
+        return self.agenda
+
+    def get_context_data(self, **kwargs):
+        context = super().get_context_data(**kwargs)
+        form = EventsTimesheetForm(agenda=self.agenda, data=self.request.GET or None)
+        if self.request.GET:
+            form.is_valid()
+        context['form'] = form
+        return context
+
+
+events_timesheet = EventsTimesheetView.as_view()
+
+
 class EventDetailView(ViewableAgendaMixin, DetailView):
     model = Event
     pk_url_kwarg = 'event_pk'
diff --git a/tests/manager/test_all.py b/tests/manager/test_all.py
index eb2b7aa..3522550 100644
--- a/tests/manager/test_all.py
+++ b/tests/manager/test_all.py
@@ -1448,9 +1448,9 @@ def test_agenda_events_month_view(app, admin_user):
     )
     resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2020, 11))
     assert len(resp.pyquery.find('.event-info')) == 4
-    assert 'abc' in resp.pyquery.find('li')[4].text_content()
-    assert 'Exception: 11/10/2020' in resp.pyquery.find('li')[5].text_content()
-    assert 'xyz' in resp.pyquery.find('li')[6].text_content()
+    assert 'abc' in resp.pyquery.find('li')[5].text_content()
+    assert 'Exception: 11/10/2020' in resp.pyquery.find('li')[6].text_content()
+    assert 'xyz' in resp.pyquery.find('li')[7].text_content()
 
     # 12/2020 has 5 Wednesday
     resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2020, 12))
diff --git a/tests/manager/test_event.py b/tests/manager/test_event.py
index ea8d511..bbc9361 100644
--- a/tests/manager/test_event.py
+++ b/tests/manager/test_event.py
@@ -2007,3 +2007,489 @@ def test_event_check_primary_booking(app, admin_user):
     user_bookings = resp.pyquery.find('td.booking-username.waiting')
     assert len(user_bookings) == 1
     assert user_bookings[0].text == 'Jane Doe (2 places)'
+
+
+def test_events_timesheet_wrong_kind(app, admin_user):
+    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
+
+    app = login(app)
+    app.get('/manage/agendas/%s/events/timesheet' % agenda.id, status=404)
+    agenda.kind = 'virtual'
+    agenda.save()
+    app.get('/manage/agendas/%s/events/timesheet' % agenda.id, status=404)
+
+
+def test_events_timesheet_form(app, admin_user):
+    agenda = Agenda.objects.create(label='Events', kind='events')
+
+    login(app)
+    resp = app.get('/manage/agendas/%s/events/timesheet' % agenda.pk)
+    resp.form['date_start'] = '2022-01-01'
+    resp.form['date_end'] = '2021-12-31'
+    resp = resp.form.submit()
+    assert resp.context['form'].errors['date_end'] == ['End date must be greater than start date.']
+
+    resp.form['date_end'] = '2022-04-02'
+    resp = resp.form.submit()
+    assert resp.context['form'].errors['date_end'] == ['Please select an interval of no more than 3 months.']
+
+    resp.form['date_end'] = '2022-04-01'
+    resp = resp.form.submit()
+    assert resp.context['form'].errors == {}
+
+
+@pytest.mark.freeze_time('2022-02-15')
+def test_events_timesheet_slots(app, admin_user):
+    start, end = (
+        now() - datetime.timedelta(days=15),
+        now() + datetime.timedelta(days=14),
+    )  # 2022-02-31, 2022-03-01
+    agenda = Agenda.objects.create(label='Events', kind='events')
+    Event.objects.create(label='event 1', start_datetime=start, places=10, agenda=agenda)
+    event2 = Event.objects.create(
+        label='event 2', start_datetime=start + datetime.timedelta(days=1), places=10, agenda=agenda
+    )
+    event3 = Event.objects.create(label='event 3', start_datetime=now(), places=10, agenda=agenda)
+    Event.objects.create(
+        label='event cancelled',
+        start_datetime=now() + datetime.timedelta(days=4),
+        places=10,
+        agenda=agenda,
+        cancelled=True,
+    )
+    event4 = Event.objects.create(
+        label='event 4', start_datetime=end - datetime.timedelta(days=1), places=10, agenda=agenda
+    )
+    Event.objects.create(label='event 5', start_datetime=end, places=10, agenda=agenda)
+    Subscription.objects.create(
+        agenda=agenda,
+        user_external_id='user:1',
+        user_first_name='Subscription',
+        user_last_name='42',
+        date_start=start,
+        date_end=end + datetime.timedelta(days=1),
+    )
+    recurring_event1 = Event.objects.create(
+        label='recurring 1',
+        start_datetime=start,
+        places=10,
+        agenda=agenda,
+        recurrence_days=[0, 1],
+        recurrence_end_date=end,
+    )
+    recurring_event1.create_all_recurrences()
+    recurring_event2 = Event.objects.create(
+        label='recurring 2',
+        start_datetime=start,
+        places=10,
+        agenda=agenda,
+        recurrence_days=[1, 2],
+        recurrence_end_date=end,
+    )
+    recurring_event2.create_all_recurrences()
+
+    login(app)
+    resp = app.get('/manage/agendas/%s/events/timesheet' % agenda.pk)
+    resp.form['date_start'] = '2022-02-01'
+    resp.form['date_end'] = '2022-02-28'
+    with CaptureQueriesContext(connection) as ctx:
+        resp = resp.form.submit()
+        assert len(ctx.captured_queries) == 9
+
+    slots = resp.context['form'].get_slots()
+    assert slots['dates'] == [
+        datetime.date(2022, 2, 1),
+        datetime.date(2022, 2, 2),
+        datetime.date(2022, 2, 7),
+        datetime.date(2022, 2, 8),
+        datetime.date(2022, 2, 9),
+        datetime.date(2022, 2, 14),
+        datetime.date(2022, 2, 15),
+        datetime.date(2022, 2, 16),
+        datetime.date(2022, 2, 21),
+        datetime.date(2022, 2, 22),
+        datetime.date(2022, 2, 23),
+        datetime.date(2022, 2, 28),
+    ]
+    assert slots['events'] == [
+        event2,
+        recurring_event1,
+        recurring_event2,
+        event3,
+        event4,
+    ]
+    assert slots['users'] == [
+        {
+            'user_id': 'user:1',
+            'user_first_name': 'Subscription',
+            'user_last_name': '42',
+            'events': [
+                {
+                    'event': event2,
+                    'dates': {date: False for date in slots['dates'] if date == datetime.date(2022, 2, 1)},
+                },
+                {
+                    'event': recurring_event1,
+                    'dates': {date: False for date in slots['dates'] if date.weekday() in [0, 1]},
+                },
+                {
+                    'event': recurring_event2,
+                    'dates': {date: False for date in slots['dates'] if date.weekday() in [1, 2]},
+                },
+                {
+                    'event': event3,
+                    'dates': {date: False for date in slots['dates'] if date == datetime.date(2022, 2, 15)},
+                },
+                {
+                    'event': event4,
+                    'dates': {date: False for date in slots['dates'] if date == datetime.date(2022, 2, 28)},
+                },
+            ],
+        },
+    ]
+
+
+@pytest.mark.freeze_time('2022-02-15')
+def test_events_timesheet_subscription_limits(app, admin_user):
+    agenda = Agenda.objects.create(label='Events', kind='events')
+    event1 = Event.objects.create(
+        start_datetime=make_aware(datetime.datetime(2022, 2, 1, 17, 0)), places=10, agenda=agenda
+    )
+    event2 = Event.objects.create(
+        start_datetime=make_aware(datetime.datetime(2022, 2, 15, 17, 0)), places=10, agenda=agenda
+    )
+    event3 = Event.objects.create(
+        start_datetime=make_aware(datetime.datetime(2022, 2, 28, 17, 0)), places=10, agenda=agenda
+    )
+
+    dates = [
+        ('2022-01-31', '2022-02-01'),
+        ('2022-02-01', '2022-02-02'),
+        ('2022-02-01', '2022-02-15'),
+        ('2022-02-01', '2022-02-16'),
+        ('2022-02-15', '2022-02-28'),
+        ('2022-02-15', '2022-03-01'),
+        ('2022-02-16', '2022-03-01'),
+        ('2022-02-01', '2022-03-01'),
+        ('2022-02-28', '2022-03-01'),
+        ('2022-03-01', '2022-03-02'),
+    ]
+
+    for start, end in dates:
+        Subscription.objects.create(
+            agenda=agenda,
+            user_external_id='user:%s-%s' % (start, end),
+            user_first_name='Subscription',
+            user_last_name='%s - %s' % (start, end),
+            date_start=datetime.datetime.strptime(start, '%Y-%m-%d'),
+            date_end=datetime.datetime.strptime(end, '%Y-%m-%d'),
+        )
+
+    login(app)
+    resp = app.get('/manage/agendas/%s/events/timesheet' % agenda.pk)
+    resp.form['date_start'] = '2022-02-01'
+    resp.form['date_end'] = '2022-02-28'
+    resp = resp.form.submit()
+
+    slots = resp.context['form'].get_slots()
+    assert slots['dates'] == [
+        datetime.date(2022, 2, 1),
+        datetime.date(2022, 2, 15),
+        datetime.date(2022, 2, 28),
+    ]
+
+    assert slots['events'] == [
+        event1,
+        event2,
+        event3,
+    ]
+    assert len(slots['users']) == 8
+    assert slots['users'][0]['user_id'] == 'user:2022-02-01-2022-02-02'
+    assert slots['users'][1]['user_id'] == 'user:2022-02-01-2022-02-15'
+    assert slots['users'][2]['user_id'] == 'user:2022-02-01-2022-02-16'
+    assert slots['users'][3]['user_id'] == 'user:2022-02-01-2022-03-01'
+    assert slots['users'][4]['user_id'] == 'user:2022-02-15-2022-02-28'
+    assert slots['users'][5]['user_id'] == 'user:2022-02-15-2022-03-01'
+    assert slots['users'][6]['user_id'] == 'user:2022-02-16-2022-03-01'
+    assert slots['users'][7]['user_id'] == 'user:2022-02-28-2022-03-01'
+
+
+def test_events_timesheet_users(app, admin_user):
+    agenda = Agenda.objects.create(label='Events', kind='events')
+    event = Event.objects.create(
+        start_datetime=make_aware(datetime.datetime(2022, 2, 15, 17, 0)), places=10, agenda=agenda
+    )
+
+    booking1 = Booking.objects.create(
+        event=event, user_external_id='user:1', user_first_name='User', user_last_name='42'
+    )
+    Booking.objects.create(
+        event=event, user_external_id='user:2', user_first_name='User', user_last_name='01'
+    )
+    Booking.objects.create(
+        event=event, user_external_id='user:3', user_first_name='User', user_last_name='17'
+    )
+    Booking.objects.create(
+        event=event, user_external_id='user:4', user_first_name='User', user_last_name='35'
+    )
+    Booking.objects.create(
+        event=event, user_external_id='user:5', user_first_name='User', user_last_name='05'
+    )
+    booking6 = Booking.objects.create(
+        event=event, user_external_id='user:6', user_first_name='User', user_last_name='12 Cancelled'
+    )
+    booking6.cancel()
+    Booking.objects.create(
+        event=event,
+        user_external_id='user:7',
+        user_first_name='User',
+        user_last_name='Waiting',
+        in_waiting_list=True,
+    )
+    booking8 = Booking.objects.create(
+        event=event,
+        user_external_id='user:8',
+        user_first_name='User',
+        user_last_name='Waiting and Cancelled',
+        in_waiting_list=True,
+    )
+    booking8.cancel()
+    Booking.objects.create(
+        event=event,
+        user_external_id='user:1',
+        user_first_name='User',
+        user_last_name='Secondary',
+        primary_booking=booking1,
+    )
+
+    login(app)
+    resp = app.get('/manage/agendas/%s/events/timesheet' % agenda.pk)
+    resp.form['date_start'] = '2022-02-01'
+    resp.form['date_end'] = '2022-02-28'
+    resp = resp.form.submit()
+    slots = resp.context['form'].get_slots()
+    assert [u['user_id'] for u in slots['users']] == [
+        'user:2',
+        'user:5',
+        'user:3',
+        'user:4',
+        'user:1',
+    ]
+
+    start = datetime.date(2022, 2, 1)
+    end = datetime.date(2022, 3, 1)
+    Subscription.objects.create(
+        agenda=agenda,
+        user_external_id='user:1',
+        user_first_name='Subscription',
+        user_last_name='42',
+        date_start=start,
+        date_end=end,
+    )
+    Subscription.objects.create(
+        agenda=agenda,
+        user_external_id='user:9',
+        user_first_name='Subscription',
+        user_last_name='43',
+        date_start=start,
+        date_end=end,
+    )
+    Subscription.objects.create(
+        agenda=agenda,
+        user_external_id='user:10',
+        user_first_name='Subscription',
+        user_last_name='14',
+        date_start=start,
+        date_end=end,
+    )
+    Subscription.objects.create(
+        agenda=agenda,
+        user_external_id='user:7',
+        user_first_name='Subscription',
+        user_last_name='Waiting',
+        date_start=start,
+        date_end=end,
+    )
+    Subscription.objects.create(
+        agenda=agenda,
+        user_external_id='user:42',
+        user_first_name='Subscription',
+        user_last_name='Too soon',
+        date_start=start - datetime.timedelta(days=1),
+        date_end=start,
+    )
+    Subscription.objects.create(
+        agenda=agenda,
+        user_external_id='user:43',
+        user_first_name='Subscription',
+        user_last_name='Too late',
+        date_start=end + datetime.timedelta(days=1),
+        date_end=end + datetime.timedelta(days=2),
+    )
+
+    resp = resp.form.submit()
+    slots = resp.context['form'].get_slots()
+    assert [u['user_id'] for u in slots['users']] == [
+        'user:2',
+        'user:5',
+        'user:6',
+        'user:10',
+        'user:3',
+        'user:4',
+        'user:1',
+        'user:9',
+        'user:7',
+    ]
+
+
+def test_events_timesheet_user_ids(app, admin_user):
+    agenda = Agenda.objects.create(label='Events', kind='events')
+    event = Event.objects.create(
+        start_datetime=make_aware(datetime.datetime(2022, 2, 15, 17, 0)), places=10, agenda=agenda
+    )
+    booking = Booking.objects.create(event=event, user_first_name='User', user_last_name='42')
+
+    login(app)
+    resp = app.get('/manage/agendas/%s/events/timesheet' % agenda.pk)
+    resp.form['date_start'] = '2022-02-01'
+    resp.form['date_end'] = '2022-02-28'
+    resp = resp.form.submit()
+    slots = resp.context['form'].get_slots()
+    # no user_id found
+    assert [u['user_id'] for u in slots['users']] == []
+    assert [u['user_first_name'] for u in slots['users']] == []
+    assert [u['user_last_name'] for u in slots['users']] == []
+
+    booking.user_external_id = 'user:1'
+    booking.save()
+
+    resp = resp.form.submit()
+    slots = resp.context['form'].get_slots()
+    assert [u['user_id'] for u in slots['users']] == [
+        'user:1',
+    ]
+    assert [u['user_first_name'] for u in slots['users']] == ['User']
+    assert [u['user_last_name'] for u in slots['users']] == ['42']
+
+    Subscription.objects.create(
+        agenda=agenda,
+        user_external_id='user:1',
+        user_first_name='Subscription',
+        user_last_name='41',
+        date_start=datetime.date(2022, 2, 1),
+        date_end=datetime.date(2022, 3, 1),
+    )
+
+    resp = resp.form.submit()
+    slots = resp.context['form'].get_slots()
+    assert [u['user_id'] for u in slots['users']] == [
+        'user:1',
+    ]
+    assert [u['user_first_name'] for u in slots['users']] == [
+        'Subscription',
+    ]
+    assert [u['user_last_name'] for u in slots['users']] == [
+        '41',
+    ]
+
+
+@pytest.mark.freeze_time('2022-02-01')
+def test_events_timesheet_booked(app, admin_user):
+    agenda = Agenda.objects.create(label='Events', kind='events')
+    event_date = make_aware(datetime.datetime(2022, 2, 15, 17, 0))
+    event1 = Event.objects.create(label='event 1', start_datetime=event_date, places=10, agenda=agenda)
+    event2 = Event.objects.create(label='event 2', start_datetime=event_date, places=10, agenda=agenda)
+    event3 = Event.objects.create(label='event 3', start_datetime=event_date, places=10, agenda=agenda)
+    recurring_event1 = Event.objects.create(
+        label='recurring 1',
+        start_datetime=event_date,
+        places=10,
+        agenda=agenda,
+        recurrence_days=[1],
+        recurrence_end_date=event_date + datetime.timedelta(days=1),
+    )
+    recurring_event1.create_all_recurrences()
+    recurring_event1_occurence = recurring_event1.recurrences.first()
+    recurring_event2 = Event.objects.create(
+        label='recurring 2',
+        start_datetime=event_date,
+        places=10,
+        agenda=agenda,
+        recurrence_days=[1],
+        recurrence_end_date=event_date + datetime.timedelta(days=1),
+    )
+    recurring_event2.create_all_recurrences()
+    recurring_event2_occurence = recurring_event2.recurrences.first()
+
+    Subscription.objects.create(
+        agenda=agenda,
+        user_external_id='user:1',
+        user_first_name='Subscription',
+        user_last_name='42',
+        date_start=datetime.date(2022, 2, 1),
+        date_end=datetime.date(2022, 3, 1),
+    )
+    Booking.objects.create(
+        event=event1,
+        user_external_id='user:1',
+        user_first_name='User',
+        user_last_name='42',
+    )
+    Booking.objects.create(
+        event=event2,
+        user_external_id='user:1',
+        user_first_name='User',
+        user_last_name='42',
+        cancellation_datetime=now(),
+    )
+    Booking.objects.create(
+        event=recurring_event1_occurence,
+        user_external_id='user:1',
+        user_first_name='User',
+        user_last_name='42',
+    )
+    Booking.objects.create(
+        event=recurring_event2_occurence,
+        user_external_id='user:1',
+        user_first_name='User',
+        user_last_name='42',
+        cancellation_datetime=now(),
+    )
+
+    login(app)
+    resp = app.get('/manage/agendas/%s/events/timesheet' % agenda.pk)
+    resp.form['date_start'] = '2022-02-01'
+    resp.form['date_end'] = '2022-02-28'
+    resp = resp.form.submit()
+    slots = resp.context['form'].get_slots()
+
+    assert slots['events'] == [
+        event1,
+        event2,
+        event3,
+        recurring_event1,
+        recurring_event2,
+    ]
+    assert len(slots['users']) == 1
+    assert slots['users'][0]['events'] == [
+        {
+            'event': event1,
+            'dates': {datetime.date(2022, 2, 15): True},
+        },
+        {
+            'event': event2,
+            'dates': {datetime.date(2022, 2, 15): False},
+        },
+        {
+            'event': event3,
+            'dates': {datetime.date(2022, 2, 15): False},
+        },
+        {
+            'event': recurring_event1,
+            'dates': {datetime.date(2022, 2, 15): True},
+        },
+        {
+            'event': recurring_event2,
+            'dates': {datetime.date(2022, 2, 15): False},
+        },
+    ]
-- 
2.34.1

