diff --git a/.gitignore b/.gitignore
index c6dd879a..cbc8d812 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 fdef4a49..092ace40 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 00000000..751f5e34
--- /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 f4672134..cb7bcd51 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 @@
{% trans "Timesheet configuration" %}
-
{% if request.GET and form.is_valid %}
{% blocktrans with start=form.cleaned_data.date_start end=form.cleaned_data.date_end %}Timesheet from {{ start }} to {{ end }}{% endblocktrans %}
- {% with slots=form.get_slots %}
- {% with events_num=slots.events|length %}
-
-
-
- {% trans "First name" %} |
- {% trans "Last name" %} |
- {% for k in slots.extra_data %}{{ k }} | {% endfor %}
- {% if events_num > 1 %}{% trans "Activity" %} | {% endif %}
- {% for date in slots.dates %}{{ date|date:"D d/m" }} | {% endfor %}
-
-
-
- {% for user in slots.users %}{% for event in user.events %}
-
- {% if forloop.first %}
- 1 %}rowspan="{{ events_num }}"{% endif %}>{{ user.user_first_name }} |
- 1 %}rowspan="{{ events_num }}"{% endif %}>{{ user.user_last_name }} |
- {% for k in slots.extra_data %} 1 %}rowspan="{{ events_num }}"{% endif %}>{{ user.extra_data|get:k }} | {% endfor %}
- {% endif %}
- {% if events_num > 1 %}{{ event.event }} | {% endif %}
- {% for date in slots.dates %}
- {% with booked=event.dates|get:date %}{% if booked is True %}☐{% elif booked is None %}-{% endif %} | {% endwith %}
- {% endfor %}
-
- {% endfor %}{% endfor %}
-
-
- {% endwith %}
- {% endwith %}
+ {% include 'chrono/manager_events_timesheet_fragment.html' %}
{% endif %}
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 00000000..986698ab
--- /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 %}
+
+
+
+ {% trans "First name" %} |
+ {% trans "Last name" %} |
+ {% for k in slots.extra_data %}{{ k }} | {% endfor %}
+ {% if events_num > 1 %}{% trans "Activity" %} | {% endif %}
+ {% for date in slots.dates %}{{ date|date:"D d/m" }} | {% endfor %}
+
+
+
+ {% for user in slots.users %}{% for event in user.events %}
+
+ {% if forloop.first %}
+ 1 %}rowspan="{{ events_num }}"{% endif %}>{{ user.user_first_name }} |
+ 1 %}rowspan="{{ events_num }}"{% endif %}>{{ user.user_last_name }} |
+ {% for k in slots.extra_data %} 1 %}rowspan="{{ events_num }}"{% endif %}>{{ user.extra_data|get:k }} | {% endfor %}
+ {% endif %}
+ {% if events_num > 1 %}{{ event.event }} | {% endif %}
+ {% for date in slots.dates %}
+ {% with booked=event.dates|get:date %}{% if booked is True %}☐{% elif booked is None %}-{% endif %} | {% endwith %}
+ {% endfor %}
+
+ {% endfor %}{% endfor %}
+
+
+{% 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 00000000..e1194192
--- /dev/null
+++ b/chrono/manager/templates/chrono/manager_events_timesheet_pdf.html
@@ -0,0 +1,22 @@
+{% load static i18n %}
+
+
+
+
+ {% blocktrans with start=form.cleaned_data.date_start end=form.cleaned_data.date_end %}Timesheet from {{ start }} to {{ end }}{% endblocktrans %}
+
+
+
+
+
+
+ {% if request.GET and form.is_valid %}
+ {% include 'chrono/manager_events_timesheet_fragment.html' %}
+ {% endif %}
+
+
+
diff --git a/chrono/manager/views.py b/chrono/manager/views.py
index 2d08db74..35f7876e 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 0c73e199..d440d394 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 18a7f026..9b6f00ee 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 ce6266b0..a72d2ada 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&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'
+ % agenda.pk
+ )
+ assert resp.context['form'].errors['orientation'] == ['This field is required.']