From b42f6fa5df653ecbd90d0db09eaf2a484f3415cc Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Laur=C3=A9line=20Gu=C3=A9rin?= <zebuline@entrouvert.com>
Date: Fri, 18 Feb 2022 11:39:17 +0100
Subject: [PATCH 4/5] manager: timesheet to PDF (#61070)

---
 .gitignore                                    |  1 +
 chrono/manager/forms.py                       |  8 ++++
 chrono/manager/static/css/timesheet.scss      | 38 +++++++++++++++++++
 .../chrono/manager_events_timesheet.html      | 38 +++----------------
 .../manager_events_timesheet_fragment.html    | 32 ++++++++++++++++
 .../chrono/manager_events_timesheet_pdf.html  | 22 +++++++++++
 chrono/manager/views.py                       | 22 +++++++++++
 debian/control                                |  1 +
 setup.py                                      |  1 +
 tests/manager/test_event.py                   | 22 +++++++++++
 10 files changed, 153 insertions(+), 32 deletions(-)
 create mode 100644 chrono/manager/static/css/timesheet.scss
 create mode 100644 chrono/manager/templates/chrono/manager_events_timesheet_fragment.html
 create mode 100644 chrono/manager/templates/chrono/manager_events_timesheet_pdf.html

diff --git a/.gitignore b/.gitignore
index c6dd879..cbc8d81 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@ local_settings.py
 /dist
 /chrono.egg-info
 /chrono/manager/static/css/style.css
+/chrono/manager/static/css/timesheet.css
diff --git a/chrono/manager/forms.py b/chrono/manager/forms.py
index fdef4a4..092ace4 100644
--- a/chrono/manager/forms.py
+++ b/chrono/manager/forms.py
@@ -376,6 +376,14 @@ class EventsTimesheetForm(forms.Form):
         required=False,
         help_text=_('Comma separated list of keys defined in extra_data.'),
     )
+    orientation = forms.ChoiceField(
+        label=_('PDF orientation'),
+        choices=[
+            ('portrait', _('Portrait')),
+            ('landscape', _('Landscape')),
+        ],
+        initial='portrait',
+    )
 
     def __init__(self, *args, **kwargs):
         self.agenda = kwargs.pop('agenda')
diff --git a/chrono/manager/static/css/timesheet.scss b/chrono/manager/static/css/timesheet.scss
new file mode 100644
index 0000000..751f5e3
--- /dev/null
+++ b/chrono/manager/static/css/timesheet.scss
@@ -0,0 +1,38 @@
+@charset "UTF-8";
+@page {
+	margin: 0.5cm;
+	margin-bottom: 1cm;
+	@bottom-right {
+		font-size: 10pt;
+		content: counter(page) " / " counter(pages);
+		height: 1cm;
+		text-align: right;
+		width: 2cm;
+	}
+}
+
+body {
+	font-family: sans-serif;
+	font-size: 8pt;
+}
+
+table.timesheet {
+	border-collapse: collapse;
+	th {
+		padding: 0.5em 0.5ex;
+		border: 0.5px solid black;
+		&.date {
+			width: 30px;
+			text-align: center;
+		}
+	}
+	td {
+		border: 0.5px solid black;
+		padding: 0.5em 0.5ex;
+		&.date {
+			text-align: center;
+			padding: 0px;
+			max-width: 2em;
+		}
+	}
+}
diff --git a/chrono/manager/templates/chrono/manager_events_timesheet.html b/chrono/manager/templates/chrono/manager_events_timesheet.html
index f467213..cb7bcd5 100644
--- a/chrono/manager/templates/chrono/manager_events_timesheet.html
+++ b/chrono/manager/templates/chrono/manager_events_timesheet.html
@@ -1,5 +1,5 @@
 {% extends "chrono/manager_agenda_view.html" %}
-{% load i18n chrono %}
+{% load i18n %}
 
 {% block breadcrumb %}
 {{ block.super }}
@@ -12,44 +12,18 @@
 <div class="section">
   <h3>{% trans "Timesheet configuration" %}</h3>
   <div>
-    <form>
+    <form id="timesheet">
       {{ form.as_p }}
       <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>
 
-    {% 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>
-          {% for k in slots.extra_data %}<th>{{ k }}</th>{% endfor %}
-          {% 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>
-          {% for k in slots.extra_data %}<td {% if events_num > 1 %}rowspan="{{ events_num }}"{% endif %}>{{ user.extra_data|get:k }}</td>{% endfor %}
-          {% 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 %}
+    {% include 'chrono/manager_events_timesheet_fragment.html' %}
     {% endif %}
   </div>
 </div>
diff --git a/chrono/manager/templates/chrono/manager_events_timesheet_fragment.html b/chrono/manager/templates/chrono/manager_events_timesheet_fragment.html
new file mode 100644
index 0000000..986698a
--- /dev/null
+++ b/chrono/manager/templates/chrono/manager_events_timesheet_fragment.html
@@ -0,0 +1,32 @@
+{% load i18n chrono %}
+
+{% 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>
+      {% for k in slots.extra_data %}<th>{{ k }}</th>{% endfor %}
+      {% 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>
+      {% for k in slots.extra_data %}<td {% if events_num > 1 %}rowspan="{{ events_num }}"{% endif %}>{{ user.extra_data|get:k }}</td>{% endfor %}
+      {% 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 %}
diff --git a/chrono/manager/templates/chrono/manager_events_timesheet_pdf.html b/chrono/manager/templates/chrono/manager_events_timesheet_pdf.html
new file mode 100644
index 0000000..e119419
--- /dev/null
+++ b/chrono/manager/templates/chrono/manager_events_timesheet_pdf.html
@@ -0,0 +1,22 @@
+{% load static i18n %}
+<html>
+<head>
+  <base href="{{ base_uri }}" />
+  <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
+  <title>{% blocktrans with start=form.cleaned_data.date_start end=form.cleaned_data.date_end %}Timesheet from {{ start }} to {{ end }}{% endblocktrans %}</title>
+  <meta name="author" content="Entr'ouvert" />
+  <link href="{% static 'css/timesheet.css' %}" media="print" rel="stylesheet" />
+  <style media="print">
+    @page {
+        size: {{ form.cleaned_data.orientation|default:"portrait" }};
+    }
+  </style>
+</head>
+<body>
+  <div id="main">
+  {% if request.GET and form.is_valid %}
+  {% include 'chrono/manager_events_timesheet_fragment.html' %}
+  {% endif %}
+  </div>
+</body>
+</html>
diff --git a/chrono/manager/views.py b/chrono/manager/views.py
index 2d08db7..35f7876 100644
--- a/chrono/manager/views.py
+++ b/chrono/manager/views.py
@@ -54,6 +54,7 @@ from django.views.generic import (
     UpdateView,
     View,
 )
+from weasyprint import HTML
 
 from chrono.agendas.models import (
     AbsenceReason,
@@ -1972,6 +1973,27 @@ class EventsTimesheetView(ViewableAgendaMixin, DetailView):
         context['form'] = form
         return context
 
+    def get(self, request, *args, **kwargs):
+        self.object = self.get_object()
+        context = self.get_context_data(object=self.object)
+        if 'pdf' in request.GET and context['form'].is_valid():
+            return self.pdf(request, context)
+        return self.render_to_response(context)
+
+    def pdf(self, request, context):
+        context['base_uri'] = request.build_absolute_uri('/')
+        html = HTML(
+            string=render_to_string('chrono/manager_events_timesheet_pdf.html', context, request=request)
+        )
+        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'),
+        )
+        return response
+
 
 events_timesheet = EventsTimesheetView.as_view()
 
diff --git a/debian/control b/debian/control
index 01ff93a..bc53426 100644
--- a/debian/control
+++ b/debian/control
@@ -25,6 +25,7 @@ Depends: ${misc:Depends},
     python3-psycopg2,
     python3-django-mellon,
     python3-dateutil,
+    python3-weasyprint,
     uwsgi,
     uwsgi-plugin-python3
 Recommends: nginx,
diff --git a/setup.py b/setup.py
index 18a7f02..9b6f00e 100644
--- a/setup.py
+++ b/setup.py
@@ -167,6 +167,7 @@ setup(
         'python-dateutil',
         'requests',
         'workalendar',
+        'weasyprint<0.43',
     ],
     zip_safe=False,
     cmdclass={
diff --git a/tests/manager/test_event.py b/tests/manager/test_event.py
index ce6266b..d880b2b 100644
--- a/tests/manager/test_event.py
+++ b/tests/manager/test_event.py
@@ -2565,3 +2565,25 @@ def test_events_timesheet_extra_data(app, admin_user):
     assert slots['extra_data'] == ['foo', 'baz']
     assert slots['users'][0]['extra_data']['foo'] == 'baz'
     assert slots['users'][0]['extra_data']['baz'] == ''
+
+
+def test_events_timesheet_pdf(app, admin_user):
+    agenda = Agenda.objects.create(label='Events', kind='events')
+
+    login(app)
+    resp = app.get(
+        '/manage/agendas/%s/events/timesheet?pdf=&date_start=2022-02-01&date_end=2022-02-28&extra_data=&orientation=portrait'
+        % agenda.pk
+    )
+    assert resp.headers['Content-Type'] == 'application/pdf'
+    assert (
+        resp.headers['Content-Disposition']
+        == 'attachment; filename="timesheet_events_2022-02-01_2022-02-28.pdf"'
+    )
+
+    # form invalid
+    resp = app.get(
+        '/manage/agendas/%s/events/timesheet?pdf=&date_start=2022-02-01&date_end=2022-02-28&extra_data='
+        % agenda.pk
+    )
+    assert resp.context['form'].errors['orientation'] == ['This field is required.']
-- 
2.34.1

