0004-manager-timesheet-to-PDF-61070.patch
.gitignore | ||
---|---|---|
4 | 4 |
/dist |
5 | 5 |
/chrono.egg-info |
6 | 6 |
/chrono/manager/static/css/style.css |
7 |
/chrono/manager/static/css/timesheet.css |
chrono/manager/forms.py | ||
---|---|---|
376 | 376 |
required=False, |
377 | 377 |
help_text=_('Comma separated list of keys defined in extra_data.'), |
378 | 378 |
) |
379 |
orientation = forms.ChoiceField( |
|
380 |
label=_('PDF orientation'), |
|
381 |
choices=[ |
|
382 |
('portrait', _('Portrait')), |
|
383 |
('landscape', _('Landscape')), |
|
384 |
], |
|
385 |
initial='portrait', |
|
386 |
) |
|
379 | 387 | |
380 | 388 |
def __init__(self, *args, **kwargs): |
381 | 389 |
self.agenda = kwargs.pop('agenda') |
chrono/manager/static/css/timesheet.scss | ||
---|---|---|
1 |
@charset "UTF-8"; |
|
2 |
@page { |
|
3 |
margin: 0.5cm; |
|
4 |
margin-bottom: 1cm; |
|
5 |
@bottom-right { |
|
6 |
font-size: 10pt; |
|
7 |
content: counter(page) " / " counter(pages); |
|
8 |
height: 1cm; |
|
9 |
text-align: right; |
|
10 |
width: 2cm; |
|
11 |
} |
|
12 |
} |
|
13 | ||
14 |
body { |
|
15 |
font-family: sans-serif; |
|
16 |
font-size: 8pt; |
|
17 |
} |
|
18 | ||
19 |
table.timesheet { |
|
20 |
border-collapse: collapse; |
|
21 |
th { |
|
22 |
padding: 0.5em 0.5ex; |
|
23 |
border: 0.5px solid black; |
|
24 |
&.date { |
|
25 |
width: 30px; |
|
26 |
text-align: center; |
|
27 |
} |
|
28 |
} |
|
29 |
td { |
|
30 |
border: 0.5px solid black; |
|
31 |
padding: 0.5em 0.5ex; |
|
32 |
&.date { |
|
33 |
text-align: center; |
|
34 |
padding: 0px; |
|
35 |
max-width: 2em; |
|
36 |
} |
|
37 |
} |
|
38 |
} |
chrono/manager/templates/chrono/manager_events_timesheet.html | ||
---|---|---|
1 | 1 |
{% extends "chrono/manager_agenda_view.html" %} |
2 |
{% load i18n chrono %}
|
|
2 |
{% load i18n %} |
|
3 | 3 | |
4 | 4 |
{% block breadcrumb %} |
5 | 5 |
{{ block.super }} |
... | ... | |
12 | 12 |
<div class="section"> |
13 | 13 |
<h3>{% trans "Timesheet configuration" %}</h3> |
14 | 14 |
<div> |
15 |
<form> |
|
15 |
<form id="timesheet">
|
|
16 | 16 |
{{ form.as_p }} |
17 | 17 |
<button class="submit-button">{% trans "See timesheet" %}</button> |
18 |
{% if request.GET and form.is_valid %} |
|
19 |
<button class="submit-button" name="pdf">{% trans "Get PDF file" %}</button> |
|
20 |
{% endif %} |
|
18 | 21 |
</form> |
19 | 22 | |
20 | 23 |
{% if request.GET and form.is_valid %} |
21 | 24 |
<h4>{% blocktrans with start=form.cleaned_data.date_start end=form.cleaned_data.date_end %}Timesheet from {{ start }} to {{ end }}{% endblocktrans %}</h4> |
22 | 25 | |
23 |
{% with slots=form.get_slots %} |
|
24 |
{% with events_num=slots.events|length %} |
|
25 |
<table class="main timesheet"> |
|
26 |
<thead> |
|
27 |
<tr> |
|
28 |
<th>{% trans "First name" %}</th> |
|
29 |
<th>{% trans "Last name" %}</th> |
|
30 |
{% for k in slots.extra_data %}<th>{{ k }}</th>{% endfor %} |
|
31 |
{% if events_num > 1 %}<th>{% trans "Activity" %}</th>{% endif %} |
|
32 |
{% for date in slots.dates %}<th class="date">{{ date|date:"D d/m" }}</th>{% endfor %} |
|
33 |
</tr> |
|
34 |
</thead> |
|
35 |
<tbody> |
|
36 |
{% for user in slots.users %}{% for event in user.events %} |
|
37 |
<tr> |
|
38 |
{% if forloop.first %} |
|
39 |
<td {% if events_num > 1 %}rowspan="{{ events_num }}"{% endif %}>{{ user.user_first_name }}</td> |
|
40 |
<td {% if events_num > 1 %}rowspan="{{ events_num }}"{% endif %}>{{ user.user_last_name }}</td> |
|
41 |
{% for k in slots.extra_data %}<td {% if events_num > 1 %}rowspan="{{ events_num }}"{% endif %}>{{ user.extra_data|get:k }}</td>{% endfor %} |
|
42 |
{% endif %} |
|
43 |
{% if events_num > 1 %}<td>{{ event.event }}</td>{% endif %} |
|
44 |
{% for date in slots.dates %} |
|
45 |
{% with booked=event.dates|get:date %}<td class="date">{% if booked is True %}☐{% elif booked is None %}-{% endif %}</td>{% endwith %} |
|
46 |
{% endfor %} |
|
47 |
</tr> |
|
48 |
{% endfor %}{% endfor %} |
|
49 |
</tbody> |
|
50 |
</table> |
|
51 |
{% endwith %} |
|
52 |
{% endwith %} |
|
26 |
{% include 'chrono/manager_events_timesheet_fragment.html' %} |
|
53 | 27 |
{% endif %} |
54 | 28 |
</div> |
55 | 29 |
</div> |
chrono/manager/templates/chrono/manager_events_timesheet_fragment.html | ||
---|---|---|
1 |
{% load i18n chrono %} |
|
2 | ||
3 |
{% with slots=form.get_slots %} |
|
4 |
{% with events_num=slots.events|length %} |
|
5 |
<table class="main timesheet"> |
|
6 |
<thead> |
|
7 |
<tr> |
|
8 |
<th>{% trans "First name" %}</th> |
|
9 |
<th>{% trans "Last name" %}</th> |
|
10 |
{% for k in slots.extra_data %}<th>{{ k }}</th>{% endfor %} |
|
11 |
{% if events_num > 1 %}<th>{% trans "Activity" %}</th>{% endif %} |
|
12 |
{% for date in slots.dates %}<th class="date">{{ date|date:"D d/m" }}</th>{% endfor %} |
|
13 |
</tr> |
|
14 |
</thead> |
|
15 |
<tbody> |
|
16 |
{% for user in slots.users %}{% for event in user.events %} |
|
17 |
<tr> |
|
18 |
{% if forloop.first %} |
|
19 |
<td {% if events_num > 1 %}rowspan="{{ events_num }}"{% endif %}>{{ user.user_first_name }}</td> |
|
20 |
<td {% if events_num > 1 %}rowspan="{{ events_num }}"{% endif %}>{{ user.user_last_name }}</td> |
|
21 |
{% for k in slots.extra_data %}<td {% if events_num > 1 %}rowspan="{{ events_num }}"{% endif %}>{{ user.extra_data|get:k }}</td>{% endfor %} |
|
22 |
{% endif %} |
|
23 |
{% if events_num > 1 %}<td>{{ event.event }}</td>{% endif %} |
|
24 |
{% for date in slots.dates %} |
|
25 |
{% with booked=event.dates|get:date %}<td class="date">{% if booked is True %}☐{% elif booked is None %}-{% endif %}</td>{% endwith %} |
|
26 |
{% endfor %} |
|
27 |
</tr> |
|
28 |
{% endfor %}{% endfor %} |
|
29 |
</tbody> |
|
30 |
</table> |
|
31 |
{% endwith %} |
|
32 |
{% endwith %} |
chrono/manager/templates/chrono/manager_events_timesheet_pdf.html | ||
---|---|---|
1 |
{% load static i18n %} |
|
2 |
<html> |
|
3 |
<head> |
|
4 |
<base href="{{ base_uri }}" /> |
|
5 |
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" /> |
|
6 |
<title>{% blocktrans with start=form.cleaned_data.date_start end=form.cleaned_data.date_end %}Timesheet from {{ start }} to {{ end }}{% endblocktrans %}</title> |
|
7 |
<meta name="author" content="Entr'ouvert" /> |
|
8 |
<link href="{% static 'css/timesheet.css' %}" media="print" rel="stylesheet" /> |
|
9 |
<style media="print"> |
|
10 |
@page { |
|
11 |
size: {{ form.cleaned_data.orientation|default:"portrait" }}; |
|
12 |
} |
|
13 |
</style> |
|
14 |
</head> |
|
15 |
<body> |
|
16 |
<div id="main"> |
|
17 |
{% if request.GET and form.is_valid %} |
|
18 |
{% include 'chrono/manager_events_timesheet_fragment.html' %} |
|
19 |
{% endif %} |
|
20 |
</div> |
|
21 |
</body> |
|
22 |
</html> |
chrono/manager/views.py | ||
---|---|---|
54 | 54 |
UpdateView, |
55 | 55 |
View, |
56 | 56 |
) |
57 |
from weasyprint import HTML |
|
57 | 58 | |
58 | 59 |
from chrono.agendas.models import ( |
59 | 60 |
AbsenceReason, |
... | ... | |
1972 | 1973 |
context['form'] = form |
1973 | 1974 |
return context |
1974 | 1975 | |
1976 |
def get(self, request, *args, **kwargs): |
|
1977 |
self.object = self.get_object() |
|
1978 |
context = self.get_context_data(object=self.object) |
|
1979 |
if 'pdf' in request.GET and context['form'].is_valid(): |
|
1980 |
return self.pdf(request, context) |
|
1981 |
return self.render_to_response(context) |
|
1982 | ||
1983 |
def pdf(self, request, context): |
|
1984 |
context['base_uri'] = request.build_absolute_uri('/') |
|
1985 |
html = HTML( |
|
1986 |
string=render_to_string('chrono/manager_events_timesheet_pdf.html', context, request=request) |
|
1987 |
) |
|
1988 |
pdf = html.write_pdf() |
|
1989 |
response = HttpResponse(pdf, content_type='application/pdf') |
|
1990 |
response['Content-Disposition'] = 'attachment; filename="timesheet_{}_{}_{}.pdf"'.format( |
|
1991 |
self.agenda.slug, |
|
1992 |
context['form'].cleaned_data['date_start'].strftime('%Y-%m-%d'), |
|
1993 |
context['form'].cleaned_data['date_end'].strftime('%Y-%m-%d'), |
|
1994 |
) |
|
1995 |
return response |
|
1996 | ||
1975 | 1997 | |
1976 | 1998 |
events_timesheet = EventsTimesheetView.as_view() |
1977 | 1999 |
debian/control | ||
---|---|---|
25 | 25 |
python3-psycopg2, |
26 | 26 |
python3-django-mellon, |
27 | 27 |
python3-dateutil, |
28 |
python3-weasyprint, |
|
28 | 29 |
uwsgi, |
29 | 30 |
uwsgi-plugin-python3 |
30 | 31 |
Recommends: nginx, |
setup.py | ||
---|---|---|
167 | 167 |
'python-dateutil', |
168 | 168 |
'requests', |
169 | 169 |
'workalendar', |
170 |
'weasyprint<0.43', |
|
170 | 171 |
], |
171 | 172 |
zip_safe=False, |
172 | 173 |
cmdclass={ |
tests/manager/test_event.py | ||
---|---|---|
2565 | 2565 |
assert slots['extra_data'] == ['foo', 'baz'] |
2566 | 2566 |
assert slots['users'][0]['extra_data']['foo'] == 'baz' |
2567 | 2567 |
assert slots['users'][0]['extra_data']['baz'] == '' |
2568 | ||
2569 | ||
2570 |
def test_events_timesheet_pdf(app, admin_user): |
|
2571 |
agenda = Agenda.objects.create(label='Events', kind='events') |
|
2572 | ||
2573 |
login(app) |
|
2574 |
resp = app.get( |
|
2575 |
'/manage/agendas/%s/events/timesheet?pdf=&date_start=2022-02-01&date_end=2022-02-28&extra_data=&orientation=portrait' |
|
2576 |
% agenda.pk |
|
2577 |
) |
|
2578 |
assert resp.headers['Content-Type'] == 'application/pdf' |
|
2579 |
assert ( |
|
2580 |
resp.headers['Content-Disposition'] |
|
2581 |
== 'attachment; filename="timesheet_events_2022-02-01_2022-02-28.pdf"' |
|
2582 |
) |
|
2583 | ||
2584 |
# form invalid |
|
2585 |
resp = app.get( |
|
2586 |
'/manage/agendas/%s/events/timesheet?pdf=&date_start=2022-02-01&date_end=2022-02-28&extra_data=' |
|
2587 |
% agenda.pk |
|
2588 |
) |
|
2589 |
assert resp.context['form'].errors['orientation'] == ['This field is required.'] |
|
2568 |
- |