manager: timesheet to PDF (#61070)
This commit is contained in:
parent
1f255d7ab9
commit
8ebbbfdfb7
|
@ -4,3 +4,4 @@ local_settings.py
|
||||||
/dist
|
/dist
|
||||||
/chrono.egg-info
|
/chrono.egg-info
|
||||||
/chrono/manager/static/css/style.css
|
/chrono/manager/static/css/style.css
|
||||||
|
/chrono/manager/static/css/timesheet.css
|
||||||
|
|
|
@ -376,6 +376,14 @@ class EventsTimesheetForm(forms.Form):
|
||||||
required=False,
|
required=False,
|
||||||
help_text=_('Comma separated list of keys defined in extra_data.'),
|
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):
|
def __init__(self, *args, **kwargs):
|
||||||
self.agenda = kwargs.pop('agenda')
|
self.agenda = kwargs.pop('agenda')
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
{% extends "chrono/manager_agenda_view.html" %}
|
{% extends "chrono/manager_agenda_view.html" %}
|
||||||
{% load i18n chrono %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block breadcrumb %}
|
{% block breadcrumb %}
|
||||||
{{ block.super }}
|
{{ block.super }}
|
||||||
|
@ -12,44 +12,18 @@
|
||||||
<div class="section">
|
<div class="section">
|
||||||
<h3>{% trans "Timesheet configuration" %}</h3>
|
<h3>{% trans "Timesheet configuration" %}</h3>
|
||||||
<div>
|
<div>
|
||||||
<form>
|
<form id="timesheet">
|
||||||
{{ form.as_p }}
|
{{ form.as_p }}
|
||||||
<button class="submit-button">{% trans "See timesheet" %}</button>
|
<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>
|
</form>
|
||||||
|
|
||||||
{% if request.GET and form.is_valid %}
|
{% 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>
|
<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 %}
|
{% include 'chrono/manager_events_timesheet_fragment.html' %}
|
||||||
{% 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 %}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -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 %}
|
|
@ -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>
|
|
@ -54,6 +54,7 @@ from django.views.generic import (
|
||||||
UpdateView,
|
UpdateView,
|
||||||
View,
|
View,
|
||||||
)
|
)
|
||||||
|
from weasyprint import HTML
|
||||||
|
|
||||||
from chrono.agendas.models import (
|
from chrono.agendas.models import (
|
||||||
AbsenceReason,
|
AbsenceReason,
|
||||||
|
@ -1972,6 +1973,27 @@ class EventsTimesheetView(ViewableAgendaMixin, DetailView):
|
||||||
context['form'] = form
|
context['form'] = form
|
||||||
return context
|
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()
|
events_timesheet = EventsTimesheetView.as_view()
|
||||||
|
|
||||||
|
|
|
@ -25,6 +25,7 @@ Depends: ${misc:Depends},
|
||||||
python3-psycopg2,
|
python3-psycopg2,
|
||||||
python3-django-mellon,
|
python3-django-mellon,
|
||||||
python3-dateutil,
|
python3-dateutil,
|
||||||
|
python3-weasyprint,
|
||||||
uwsgi,
|
uwsgi,
|
||||||
uwsgi-plugin-python3
|
uwsgi-plugin-python3
|
||||||
Recommends: nginx,
|
Recommends: nginx,
|
||||||
|
|
1
setup.py
1
setup.py
|
@ -167,6 +167,7 @@ setup(
|
||||||
'python-dateutil',
|
'python-dateutil',
|
||||||
'requests',
|
'requests',
|
||||||
'workalendar',
|
'workalendar',
|
||||||
|
'weasyprint<0.43',
|
||||||
],
|
],
|
||||||
zip_safe=False,
|
zip_safe=False,
|
||||||
cmdclass={
|
cmdclass={
|
||||||
|
|
|
@ -2565,3 +2565,25 @@ def test_events_timesheet_extra_data(app, admin_user):
|
||||||
assert slots['extra_data'] == ['foo', 'baz']
|
assert slots['extra_data'] == ['foo', 'baz']
|
||||||
assert slots['users'][0]['extra_data']['foo'] == 'baz'
|
assert slots['users'][0]['extra_data']['foo'] == 'baz'
|
||||||
assert slots['users'][0]['extra_data']['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.']
|
||||||
|
|
Loading…
Reference in New Issue