From f7de7d37b3778930a2b61c8d6ae4e44ea77952e2 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Fri, 17 Jun 2022 15:19:20 +0200
Subject: [PATCH 2/2] manager: timesheet for one event (#66358)

---
 chrono/manager/forms.py                       |  43 +++--
 .../chrono/manager_event_detail.html          |  31 ++--
 .../chrono/manager_event_timesheet.html       |  13 ++
 .../chrono/manager_events_timesheet.html      |  39 +---
 ...anager_events_timesheet_form_fragment.html |  44 +++++
 chrono/manager/urls.py                        |   5 +
 chrono/manager/views.py                       |  34 +++-
 tests/manager/test_event_timesheet.py         | 171 ++++++++++++++++++
 8 files changed, 305 insertions(+), 75 deletions(-)
 create mode 100644 chrono/manager/templates/chrono/manager_event_timesheet.html
 create mode 100644 chrono/manager/templates/chrono/manager_events_timesheet_form_fragment.html

diff --git a/chrono/manager/forms.py b/chrono/manager/forms.py
index 358fdb42..c300aaca 100644
--- a/chrono/manager/forms.py
+++ b/chrono/manager/forms.py
@@ -584,7 +584,11 @@ class EventsTimesheetForm(forms.Form):
 
     def __init__(self, *args, **kwargs):
         self.agenda = kwargs.pop('agenda')
+        self.event = kwargs.pop('event', None)
         super().__init__(*args, **kwargs)
+        if self.event is not None:
+            del self.fields['date_start']
+            del self.fields['date_end']
 
     def get_slots(self):
         extra_data = self.cleaned_data['extra_data'].split(',')
@@ -593,23 +597,30 @@ class EventsTimesheetForm(forms.Form):
         all_extra_data = extra_data[:]
         if group_by:
             all_extra_data += [group_by]
-        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
-        all_events = (
-            self.agenda.event_set.filter(
-                recurrence_days__isnull=True,
-                start_datetime__gte=min_start,
-                start_datetime__lt=max_start,
-                cancelled=False,
+        if self.event is not None:
+            all_events = [self.event]
+            min_start = self.event.start_datetime
+            max_start = min_start + datetime.timedelta(days=1)
+        else:
+            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
+            all_events = (
+                self.agenda.event_set.filter(
+                    recurrence_days__isnull=True,
+                    start_datetime__gte=min_start,
+                    start_datetime__lt=max_start,
+                    cancelled=False,
+                )
+                .select_related('primary_event')
+                .order_by('start_datetime', 'label')
             )
-            .select_related('primary_event')
-            .order_by('start_datetime', 'label')
-        )
         dates = set()
         events = []
         dates_per_event_id = defaultdict(list)
diff --git a/chrono/manager/templates/chrono/manager_event_detail.html b/chrono/manager/templates/chrono/manager_event_detail.html
index a9020fa6..d3b50674 100644
--- a/chrono/manager/templates/chrono/manager_event_detail.html
+++ b/chrono/manager/templates/chrono/manager_event_detail.html
@@ -7,47 +7,50 @@
 {% endblock %}
 
 {% block page-title-extra-label %}
-- {% firstof agenda.label object.label %}
+- {% firstof agenda.label event.label %}
 {% endblock %}
 
 {% block breadcrumb %}
 {{ block.super }}
 <a href="{% url 'chrono-manager-agenda-view' pk=agenda.id %}">{{agenda.label}}</a>
-<a href="{% url 'chrono-manager-event-view' pk=agenda.id event_pk=object.id %}">{{object}}</a>
+<a href="{% url 'chrono-manager-event-view' pk=agenda.id event_pk=event.id %}">{{event}}</a>
 {% endblock %}
 
 {% block appbar %}
 <h2>
-{% if object.label %}
-{{ object.label }} — {{object.start_datetime|date:"DATETIME_FORMAT"}}
+{% if event.label %}
+{{ event.label }} — {{event.start_datetime|date:"DATETIME_FORMAT"}}
 {% else %}
-{{ object.start_datetime|date:"DATETIME_FORMAT"}}
+{{ event.start_datetime|date:"DATETIME_FORMAT"}}
 {% endif %}
-{% if object.cancellation_status %}<span class="tag">{{ event.cancellation_status }}</span>{% endif %}
+{% if event.cancellation_status %}<span class="tag">{{ event.cancellation_status }}</span>{% endif %}
 {% if event.main_list_full %}<span class="tag">{% trans "Full" %}</span>{% endif %}
 {% if event.checked %}<span class="tag">{% trans "Checked" %}</span>{% endif %}
 </h2>
 {% block appbar_actions %}
 <span class="actions">
-  {% if user_can_manage or object.agenda.booking_form_url %}
+  {% if user_can_manage or event.agenda.booking_form_url %}
   <a class="extra-actions-menu-opener"></a>
   <ul class="extra-actions-menu">
     {% if user_can_manage %}
-    {% if not object.primary_event %}
-    <li><a rel="popup" href="{% url 'chrono-manager-event-delete' pk=object.agenda.id event_pk=object.id %}">{% trans 'Delete' %}</a></li>
+    {% if not event.primary_event %}
+    <li><a rel="popup" href="{% url 'chrono-manager-event-delete' pk=event.agenda.id event_pk=event.id %}">{% trans 'Delete' %}</a></li>
     {% endif %}
     {% if not event.cancellation_status %}
     <li><a rel="popup" href="{% url 'chrono-manager-event-cancel' pk=agenda.pk event_pk=event.pk %}?next={{ request.path }}">{% trans "Cancel" %}</a></li>
     {% endif %}
-    <li><a href="{% url 'chrono-manager-event-edit' pk=agenda.id event_pk=object.id %}">{% trans "Options" %}</a></li>
+    <li><a href="{% url 'chrono-manager-event-edit' pk=agenda.id event_pk=event.id %}">{% trans "Options" %}</a></li>
     {% endif %}
-    {% if object.agenda.booking_form_url %}
-    <li><a href="{{ object.get_booking_form_url }}&ReturnURL={{ request.build_absolute_uri }}">{% trans "Booking form" %}</a></li>
+    {% if event.agenda.booking_form_url %}
+    <li><a href="{{ event.get_booking_form_url }}&ReturnURL={{ request.build_absolute_uri }}">{% trans "Booking form" %}</a></li>
+    {% endif %}
+    {% if not event.cancelled %}
+    <li><a href="{% url 'chrono-manager-event-timesheet' pk=agenda.pk event_pk=event.pk %}">{% trans "Timesheet" %}</a></li>
     {% endif %}
   </ul>
   {% endif %}
-  {% if object.is_day_past and not object.cancelled %}
-  <a href="{% url 'chrono-manager-event-check' pk=agenda.pk event_pk=object.pk %}">{% trans "Check" %}</a>
+  {% if event.is_day_past and not event.cancelled %}
+  <a href="{% url 'chrono-manager-event-check' pk=agenda.pk event_pk=event.pk %}">{% trans "Check" %}</a>
   {% endif %}
 </span>
 {% endblock %}
diff --git a/chrono/manager/templates/chrono/manager_event_timesheet.html b/chrono/manager/templates/chrono/manager_event_timesheet.html
new file mode 100644
index 00000000..a2aa56ee
--- /dev/null
+++ b/chrono/manager/templates/chrono/manager_event_timesheet.html
@@ -0,0 +1,13 @@
+{% extends "chrono/manager_event_detail.html" %}
+{% load i18n %}
+
+{% block breadcrumb %}
+{{ block.super }}
+<a href="{% url 'chrono-manager-event-timesheet' pk=agenda.pk event_pk=event.pk %}">{% trans "Timesheet" %}</a>
+{% endblock %}
+
+{% block appbar_actions %}{% endblock %}
+
+{% block content %}
+  {% include 'chrono/manager_events_timesheet_form_fragment.html' %}
+{% endblock %}
diff --git a/chrono/manager/templates/chrono/manager_events_timesheet.html b/chrono/manager/templates/chrono/manager_events_timesheet.html
index 6a16e157..33b6ef7a 100644
--- a/chrono/manager/templates/chrono/manager_events_timesheet.html
+++ b/chrono/manager/templates/chrono/manager_events_timesheet.html
@@ -9,42 +9,5 @@
 {% block appbar_actions %}{% endblock %}
 
 {% block content %}
-<div class="section">
-  <h3>{% trans "Timesheet configuration" %}</h3>
-  <div>
-    <form id="timesheet">
-      {{ form.as_p }}
-      <script>
-      $(function() {
-        $('#id_date_display').on('change', function() {
-          if ($(this).val() == 'custom') {
-            $('#id_custom_nb_dates_per_page').parent().show();
-          } else {
-            $('#id_custom_nb_dates_per_page').parent().hide();
-          }
-        });
-        $('#id_date_display').trigger('change');
-        $('#id_group_by').on('change', function() {
-          if ($(this).val()) {
-            $('#id_with_page_break').parent().show();
-          } else {
-            $('#id_with_page_break').parent().hide();
-          }
-        });
-        $('#id_group_by').trigger('change');
-      });
-      </script>
-      <button class="submit-button">{% trans "See timesheet" %}</button>
-      {% if request.GET and form.is_valid %}
-      <button class="submit-button" name="pdf">{% trans "Get PDF file" %}</button>
-      {% endif %}
-    </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>
-
-    {% include 'chrono/manager_events_timesheet_fragment.html' %}
-    {% endif %}
-  </div>
-</div>
+  {% include 'chrono/manager_events_timesheet_form_fragment.html' %}
 {% endblock %}
diff --git a/chrono/manager/templates/chrono/manager_events_timesheet_form_fragment.html b/chrono/manager/templates/chrono/manager_events_timesheet_form_fragment.html
new file mode 100644
index 00000000..a3f64c73
--- /dev/null
+++ b/chrono/manager/templates/chrono/manager_events_timesheet_form_fragment.html
@@ -0,0 +1,44 @@
+{% load i18n %}
+
+<div class="section">
+  <h3>{% trans "Timesheet configuration" %}</h3>
+  <div>
+    <form id="timesheet">
+      {{ form.as_p }}
+      <script>
+      $(function() {
+        $('#id_date_display').on('change', function() {
+          if ($(this).val() == 'custom') {
+            $('#id_custom_nb_dates_per_page').parent().show();
+          } else {
+            $('#id_custom_nb_dates_per_page').parent().hide();
+          }
+        });
+        $('#id_date_display').trigger('change');
+        $('#id_group_by').on('change', function() {
+          if ($(this).val()) {
+            $('#id_with_page_break').parent().show();
+          } else {
+            $('#id_with_page_break').parent().hide();
+          }
+        });
+        $('#id_group_by').trigger('change');
+      });
+      </script>
+      <button class="submit-button">{% trans "See timesheet" %}</button>
+      {% if request.GET and form.is_valid %}
+      <button class="submit-button" name="pdf">{% trans "Get PDF file" %}</button>
+      {% endif %}
+    </form>
+
+    {% if request.GET and form.is_valid %}
+    {% if event %}
+    <h4>{% blocktrans %}Timesheet{% endblocktrans %}</h4>
+    {% else %}
+    <h4>{% blocktrans with start=form.cleaned_data.date_start end=form.cleaned_data.date_end %}Timesheet from {{ start }} to {{ end }}{% endblocktrans %}</h4>
+    {% endif %}
+
+    {% include 'chrono/manager_events_timesheet_fragment.html' %}
+    {% endif %}
+  </div>
+</div>
diff --git a/chrono/manager/urls.py b/chrono/manager/urls.py
index 8e999ea5..6f96120d 100644
--- a/chrono/manager/urls.py
+++ b/chrono/manager/urls.py
@@ -265,6 +265,11 @@ urlpatterns = [
         views.event_checked,
         name='chrono-manager-event-checked',
     ),
+    url(
+        r'^agendas/(?P<pk>\d+)/events/(?P<event_pk>\d+)/timesheet$',
+        views.events_timesheet,
+        name='chrono-manager-event-timesheet',
+    ),
     url(
         r'^agendas/(?P<pk>\d+)/event_cancellation_report/(?P<report_pk>\d+)/$',
         views.event_cancellation_report,
diff --git a/chrono/manager/views.py b/chrono/manager/views.py
index 282abd49..58993343 100644
--- a/chrono/manager/views.py
+++ b/chrono/manager/views.py
@@ -2233,22 +2233,36 @@ 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')
+        self.event = None
+        if 'event_pk' in kwargs:
+            self.event = get_object_or_404(
+                Event,
+                pk=kwargs.get('event_pk'),
+                agenda=self.agenda,
+                recurrence_days__isnull=True,
+                cancelled=False,
+            )
 
     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)
+        form = EventsTimesheetForm(agenda=self.agenda, event=self.event, data=self.request.GET or None)
         if self.request.GET:
             form.is_valid()
         context['form'] = form
+        context['event'] = self.event
         return context
 
+    def get_template_names(self):
+        if self.event is not None:
+            return ['chrono/manager_event_timesheet.html']
+        return ['chrono/manager_events_timesheet.html']
+
     def get(self, request, *args, **kwargs):
         self.object = self.get_object()
         context = self.get_context_data(object=self.object)
@@ -2263,11 +2277,17 @@ class EventsTimesheetView(ViewableAgendaMixin, DetailView):
         )
         pdf = html.write_pdf()
         response = HttpResponse(pdf, content_type='application/pdf')
-        response['Content-Disposition'] = 'attachment; filename="timesheet_{}_{}_{}.pdf"'.format(
-            self.agenda.slug,
-            context['form'].cleaned_data['date_start'].strftime('%Y-%m-%d'),
-            context['form'].cleaned_data['date_end'].strftime('%Y-%m-%d'),
-        )
+        if self.event is not None:
+            response['Content-Disposition'] = 'attachment; filename="timesheet_{}_{}.pdf"'.format(
+                self.agenda.slug,
+                self.event.slug,
+            )
+        else:
+            response['Content-Disposition'] = 'attachment; filename="timesheet_{}_{}_{}.pdf"'.format(
+                self.agenda.slug,
+                context['form'].cleaned_data['date_start'].strftime('%Y-%m-%d'),
+                context['form'].cleaned_data['date_end'].strftime('%Y-%m-%d'),
+            )
         return response
 
 
diff --git a/tests/manager/test_event_timesheet.py b/tests/manager/test_event_timesheet.py
index 40f1ed22..eb82737d 100644
--- a/tests/manager/test_event_timesheet.py
+++ b/tests/manager/test_event_timesheet.py
@@ -896,3 +896,174 @@ def test_events_timesheet_pdf(app, admin_user):
         % agenda.pk
     )
     assert resp.context['form'].errors['orientation'] == ['This field is required.']
+
+
+def test_event_timesheet_wrong_kind(app, admin_user):
+    agenda = Agenda.objects.create(label='Foo bar', kind='meetings')
+    event = Event.objects.create(
+        start_datetime=make_aware(datetime.datetime(2022, 2, 15, 17, 0)), places=10, agenda=agenda
+    )
+
+    app = login(app)
+    app.get('/manage/agendas/%s/events/%s/timesheet' % (agenda.pk, event.pk), status=404)
+    agenda.kind = 'virtual'
+    agenda.save()
+    app.get('/manage/agendas/%s/events/%s/timesheet' % (agenda.pk, event.pk), status=404)
+
+
+def test_event_timesheet_wrong_event(app, admin_user):
+    agenda = Agenda.objects.create(label='Events', kind='events')
+    agenda2 = 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,
+        cancelled=True,
+    )
+
+    app = login(app)
+    app.get('/manage/agendas/%s/events/%s/timesheet' % (agenda.pk, event.pk), status=404)
+    event.cancelled = False
+    event.recurrence_days = [1]
+    event.save()
+    app.get('/manage/agendas/%s/events/%s/timesheet' % (agenda.pk, event.pk), status=404)
+
+    event.recurrence_days = None
+    event.save()
+    app.get('/manage/agendas/%s/events/%s/timesheet' % (agenda.pk, event.pk), status=200)
+
+    app.get('/manage/agendas/%s/events/%s/timesheet' % (agenda2.pk, event.pk), status=404)
+    app.get('/manage/agendas/%s/events/%s/timesheet' % (agenda.pk, 0), status=404)
+
+
+def test_event_timesheet_form(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
+    )
+
+    app = login(app)
+    resp = app.get('/manage/agendas/%s/events/%s/timesheet' % (agenda.pk, event.pk))
+    assert 'date_start' not in resp.context['form'].fields
+    assert 'date_end' not in resp.context['form'].fields
+    assert resp.context['form'].errors == {}
+
+
+def test_event_timesheet_slots(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
+    )
+    Subscription.objects.create(
+        agenda=agenda,
+        user_external_id='user:1',
+        user_first_name='Subscription',
+        user_last_name='42',
+        date_start=datetime.date(2022, 2, 15),
+        date_end=datetime.date(2022, 2, 16),
+    )
+
+    login(app)
+    resp = app.get('/manage/agendas/%s/events/%s/timesheet' % (agenda.pk, event.pk))
+    with CaptureQueriesContext(connection) as ctx:
+        resp = resp.form.submit()
+        assert len(ctx.captured_queries) == 7
+
+    slots = resp.context['form'].get_slots()
+    assert slots['dates'] == [
+        [
+            datetime.date(2022, 2, 15),
+        ]
+    ]
+    assert slots['events'] == [event]
+    assert slots['users'][0]['users'] == [
+        {
+            'user_id': 'user:1',
+            'user_first_name': 'Subscription',
+            'user_last_name': '42',
+            'extra_data': {},
+            'events': [
+                {
+                    'event': event,
+                    'dates': {datetime.date(2022, 2, 15): False},
+                },
+            ],
+        },
+    ]
+    assert slots['extra_data'] == []
+
+
+def test_event_timesheet_subscription_limits(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
+    )
+
+    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/%s/timesheet' % (agenda.pk, event.pk))
+    resp = resp.form.submit()
+
+    slots = resp.context['form'].get_slots()
+    assert slots['dates'] == [
+        [
+            datetime.date(2022, 2, 15),
+        ]
+    ]
+
+    assert slots['events'] == [
+        event,
+    ]
+    users = slots['users'][0]['users']
+    assert len(users) == 4
+    assert users[0]['user_id'] == 'user:2022-02-01-2022-02-16'
+    assert users[1]['user_id'] == 'user:2022-02-01-2022-03-01'
+    assert users[2]['user_id'] == 'user:2022-02-15-2022-02-28'
+    assert users[3]['user_id'] == 'user:2022-02-15-2022-03-01'
+
+
+def test_event_timesheet_pdf(app, admin_user):
+    agenda = Agenda.objects.create(label='Foo', kind='events')
+    event = Event.objects.create(
+        label='Bar',
+        start_datetime=make_aware(datetime.datetime(2022, 2, 15, 17, 0)),
+        places=10,
+        agenda=agenda,
+    )
+
+    login(app)
+    resp = app.get(
+        '/manage/agendas/%s/events/%s/timesheet?pdf=&sort=lastname,firstname&date_display=all&orientation=portrait'
+        % (agenda.pk, event.pk)
+    )
+    assert resp.headers['Content-Type'] == 'application/pdf'
+    assert resp.headers['Content-Disposition'] == 'attachment; filename="timesheet_foo_bar.pdf"'
+
+    # form invalid
+    resp = app.get(
+        '/manage/agendas/%s/events/%s/timesheet?pdf=&sort=lastname,firstname&date_display=all'
+        % (agenda.pk, event.pk)
+    )
+    assert resp.context['form'].errors['orientation'] == ['This field is required.']
-- 
2.35.1

