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" %}

-
+ {{ form.as_p }} + {% if request.GET and form.is_valid %} + + {% endif %}
{% 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 %} - - - - - - {% for k in slots.extra_data %}{% endfor %} - {% if events_num > 1 %}{% endif %} - {% for date in slots.dates %}{% endfor %} - - - - {% for user in slots.users %}{% for event in user.events %} - - {% if forloop.first %} - - - {% for k in slots.extra_data %}{% endfor %} - {% endif %} - {% if events_num > 1 %}{% endif %} - {% for date in slots.dates %} - {% with booked=event.dates|get:date %}{% endwith %} - {% endfor %} - - {% endfor %}{% endfor %} - -
{% trans "First name" %}{% trans "Last name" %}{{ k }}{% trans "Activity" %}{{ date|date:"D d/m" }}
1 %}rowspan="{{ events_num }}"{% endif %}>{{ user.user_first_name }} 1 %}rowspan="{{ events_num }}"{% endif %}>{{ user.user_last_name }} 1 %}rowspan="{{ events_num }}"{% endif %}>{{ user.extra_data|get:k }}{{ event.event }}{% if booked is True %}☐{% elif booked is None %}-{% endif %}
- {% 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 %} + + + + + + {% for k in slots.extra_data %}{% endfor %} + {% if events_num > 1 %}{% endif %} + {% for date in slots.dates %}{% endfor %} + + + + {% for user in slots.users %}{% for event in user.events %} + + {% if forloop.first %} + + + {% for k in slots.extra_data %}{% endfor %} + {% endif %} + {% if events_num > 1 %}{% endif %} + {% for date in slots.dates %} + {% with booked=event.dates|get:date %}{% endwith %} + {% endfor %} + + {% endfor %}{% endfor %} + +
{% trans "First name" %}{% trans "Last name" %}{{ k }}{% trans "Activity" %}{{ date|date:"D d/m" }}
1 %}rowspan="{{ events_num }}"{% endif %}>{{ user.user_first_name }} 1 %}rowspan="{{ events_num }}"{% endif %}>{{ user.user_last_name }} 1 %}rowspan="{{ events_num }}"{% endif %}>{{ user.extra_data|get:k }}{{ event.event }}{% if booked is True %}☐{% elif booked is None %}-{% endif %}
+{% 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.']