Phase 5 Batch 5 Task 5.18: Report Generator PDF-Support & Druck-Funktionen

- Installed WeasyPrint 69.0 for PDF generation
- Created 5 Jinja2 HTML templates: contact_list, calendar_week, calendar_month, company_list, audit_log
- All templates: A4 landscape, print-optimized CSS (@media print, page margins)
- Extended output_format in schemas.py: added pdf and print
- Created pdf_generator.py: Jinja2 + WeasyPrint pipeline with preset support
- Modified routes.py: PDF/print generation, preset endpoints (/presets, /presets/generate)
- Added RBAC (require_permission) to all report endpoints
- Generate endpoints return StreamingResponse (file download) directly
- 7 backend tests: presets listing, PDF/CSV generation, template CRUD, RBAC
This commit is contained in:
Agent Zero
2026-07-23 23:22:33 +02:00
parent 3c8e41b3f8
commit 15f1a57c0f
11 changed files with 1248 additions and 43 deletions
@@ -0,0 +1,106 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<style>
@page {
size: A4 landscape;
margin: 1.5cm 2cm;
@bottom-center {
content: "Seite " counter(page) " von " counter(pages);
font-size: 9px;
color: #666;
}
}
body {
font-family: 'Helvetica', 'Arial', sans-serif;
font-size: 10px;
color: #222;
margin: 0;
padding: 0;
}
h1 {
font-size: 20px;
color: #1a365d;
margin-bottom: 4px;
}
.subtitle {
font-size: 10px;
color: #666;
margin-bottom: 20px;
}
table {
width: 100%;
border-collapse: collapse;
}
thead th {
background: #1a365d;
color: #fff;
padding: 8px 10px;
text-align: left;
font-size: 9px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
tbody td {
padding: 5px 10px;
border-bottom: 1px solid #e0e0e0;
}
tbody tr:nth-child(even) {
background: #f7f7f7;
}
.action-badge {
display: inline-block;
padding: 2px 6px;
border-radius: 3px;
font-size: 8px;
font-weight: 600;
}
.action-create { background: #e6f4ea; color: #1b7334; }
.action-update { background: #e8f0fe; color: #1a56c4; }
.action-delete { background: #fce8e6; color: #c5221f; }
.action-read { background: #f0f0f0; color: #555; }
@media print {
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
thead { display: table-header-group; }
tr { page-break-inside: avoid; }
}
</style>
</head>
<body>
<h1>{{ title|default('Audit-Log') }}</h1>
<div class="subtitle">Erstellt am: {{ generated_at|default('') }} &nbsp;|&nbsp; Einträge: {{ entries|length }}{% if date_from|default('') %} &nbsp;|&nbsp; Von: {{ date_from }}{% endif %}{% if date_to|default('') %} &nbsp;|&nbsp; Bis: {{ date_to }}{% endif %}</div>
<table>
<thead>
<tr>
<th>#</th>
<th>Zeitstempel</th>
<th>Benutzer</th>
<th>Aktion</th>
<th>Entität</th>
<th>Entität-ID</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{% for entry in entries %}
<tr>
<td>{{ loop.index }}</td>
<td>{{ entry.timestamp|default('') }}</td>
<td>{{ entry.user|default('—') }}</td>
<td>
{% set action = entry.action|default('read') %}
<span class="action-badge action-{{ action|lower }}">{{ action|upper }}</span>
</td>
<td>{{ entry.entity|default('—') }}</td>
<td>{{ entry.entity_id|default('—') }}</td>
<td>{{ entry.details|default('—') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if entries|length == 0 %}
<p style="text-align:center; padding:40px; color:#999;">Keine Audit-Log-Einträge gefunden.</p>
{% endif %}
</body>
</html>
@@ -0,0 +1,118 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<style>
@page {
size: A4 landscape;
margin: 1.5cm 1.5cm;
@bottom-center {
content: "Seite " counter(page) " von " counter(pages);
font-size: 9px;
color: #666;
}
}
body {
font-family: 'Helvetica', 'Arial', sans-serif;
font-size: 9px;
color: #222;
margin: 0;
padding: 0;
}
h1 {
font-size: 20px;
color: #1a365d;
margin-bottom: 2px;
}
.subtitle {
font-size: 10px;
color: #666;
margin-bottom: 15px;
}
.month-grid {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.month-grid th {
background: #1a365d;
color: #fff;
padding: 6px 4px;
text-align: center;
font-size: 10px;
font-weight: 600;
border: 1px solid #1a365d;
}
.month-grid td {
border: 1px solid #ccc;
vertical-align: top;
width: 14.28%;
height: 70px;
padding: 2px 4px;
}
.day-num {
font-weight: 600;
font-size: 11px;
color: #333;
margin-bottom: 2px;
}
.day-num.today {
background: #3b82f6;
color: #fff;
border-radius: 50%;
display: inline-block;
width: 18px;
height: 18px;
line-height: 18px;
text-align: center;
}
.day-num.other-month { color: #ccc; }
.weekend { background: #fafafa; }
.event {
background: #3b82f6;
color: #fff;
border-radius: 2px;
padding: 1px 3px;
font-size: 8px;
margin: 1px 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.event.all-day { background: #10b981; }
.event.task { background: #f59e0b; }
@media print {
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
thead { display: table-header-group; }
tr { page-break-inside: avoid; }
}
</style>
</head>
<body>
<h1>{{ title|default('Monatskalender') }}</h1>
<div class="subtitle">{{ month_name|default('') }} {{ year|default('') }} &nbsp;|&nbsp; Erstellt am: {{ generated_at|default('') }}</div>
<table class="month-grid">
<thead>
<tr>
{% for day_name in weekday_names|default(['Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag','Sonntag']) %}
<th>{{ day_name }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for week in weeks %}
<tr>
{% for day in week %}
<td class="{{ day.is_weekend|default(False) and 'weekend' or '' }}">
<div class="day-num {{ day.is_today|default(False) and 'today' or '' }} {{ day.is_other_month|default(False) and 'other-month' or '' }}">{{ day.day }}</div>
{% for event in day.events|default([]) %}
<div class="event {{ event.type|default('') }} {{ event.all_day|default(False) and 'all-day' or '' }}">{{ event.title }}</div>
{% endfor %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</body>
</html>
@@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<style>
@page {
size: A4 landscape;
margin: 1.5cm 1.5cm;
@bottom-center {
content: "Seite " counter(page) " von " counter(pages);
font-size: 9px;
color: #666;
}
}
body {
font-family: 'Helvetica', 'Arial', sans-serif;
font-size: 9px;
color: #222;
margin: 0;
padding: 0;
}
h1 {
font-size: 18px;
color: #1a365d;
margin-bottom: 2px;
}
.subtitle {
font-size: 10px;
color: #666;
margin-bottom: 15px;
}
.calendar-grid {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.calendar-grid th, .calendar-grid td {
border: 1px solid #ccc;
vertical-align: top;
}
.time-col {
width: 50px;
background: #f5f5f5;
font-weight: 600;
text-align: center;
padding: 2px;
}
.day-header {
background: #1a365d;
color: #fff;
padding: 6px 4px;
text-align: center;
font-size: 10px;
font-weight: 600;
}
.day-header .date {
font-size: 14px;
display: block;
}
.slot {
height: 28px;
padding: 1px 2px;
overflow: hidden;
}
.event {
background: #3b82f6;
color: #fff;
border-radius: 2px;
padding: 1px 4px;
font-size: 8px;
margin: 1px 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.event.all-day {
background: #10b981;
}
.event.task {
background: #f59e0b;
}
.weekend .day-header { background: #6b7280; }
@media print {
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
thead { display: table-header-group; }
tr { page-break-inside: avoid; }
}
</style>
</head>
<body>
<h1>{{ title|default('Wochenkalender') }}</h1>
<div class="subtitle">Woche {{ week_number|default('') }} &nbsp;|&nbsp; {{ week_start|default('') }} {{ week_end|default('') }} &nbsp;|&nbsp; Erstellt am: {{ generated_at|default('') }}</div>
<table class="calendar-grid">
<thead>
<tr>
<th class="time-col">Zeit</th>
{% for day in days %}
<th class="day-header {{ day.is_weekend|default(False) and 'weekend' or '' }}">
{{ day.name }}
<span class="date">{{ day.date }}</span>
</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for hour in hours %}
<tr>
<td class="time-col">{{ hour.label }}</td>
{% for day in days %}
<td class="slot">
{% for event in day.events|default([]) %}
{% if event.hour == hour.value %}
<div class="event {{ event.type|default('') }} {{ event.all_day|default(False) and 'all-day' or '' }}">
{{ event.title }}
</div>
{% endif %}
{% endfor %}
</td>
{% endfor %}
</tr>
{% endfor %}
</tbody>
</table>
</body>
</html>
@@ -0,0 +1,92 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<style>
@page {
size: A4 landscape;
margin: 1.5cm 2cm;
@bottom-center {
content: "Seite " counter(page) " von " counter(pages);
font-size: 9px;
color: #666;
}
}
body {
font-family: 'Helvetica', 'Arial', sans-serif;
font-size: 11px;
color: #222;
margin: 0;
padding: 0;
}
h1 {
font-size: 20px;
color: #1a365d;
margin-bottom: 4px;
}
.subtitle {
font-size: 10px;
color: #666;
margin-bottom: 20px;
}
table {
width: 100%;
border-collapse: collapse;
}
thead th {
background: #1a365d;
color: #fff;
padding: 8px 10px;
text-align: left;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
tbody td {
padding: 6px 10px;
border-bottom: 1px solid #e0e0e0;
}
tbody tr:nth-child(even) {
background: #f7f7f7;
}
@media print {
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
thead { display: table-header-group; }
tr { page-break-inside: avoid; }
}
</style>
</head>
<body>
<h1>{{ title|default('Firmenliste') }}</h1>
<div class="subtitle">Erstellt am: {{ generated_at|default('') }} &nbsp;|&nbsp; Anzahl: {{ companies|length }}</div>
<table>
<thead>
<tr>
<th>#</th>
<th>Firmenname</th>
<th>Adresse</th>
<th>PLZ / Ort</th>
<th>Telefon</th>
<th>E-Mail</th>
<th>Ansprechpartner</th>
</tr>
</thead>
<tbody>
{% for company in companies %}
<tr>
<td>{{ loop.index }}</td>
<td>{{ company.name|default('') }}</td>
<td>{{ company.address|default('—') }}</td>
<td>{{ company.zip|default('') }} {{ company.city|default('') }}</td>
<td>{{ company.phone|default('—') }}</td>
<td>{{ company.email|default('—') }}</td>
<td>{{ company.contact_person|default('—') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if companies|length == 0 %}
<p style="text-align:center; padding:40px; color:#999;">Keine Firmen gefunden.</p>
{% endif %}
</body>
</html>
@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<style>
@page {
size: A4 landscape;
margin: 1.5cm 2cm;
@bottom-center {
content: "Seite " counter(page) " von " counter(pages);
font-size: 9px;
color: #666;
}
}
body {
font-family: 'Helvetica', 'Arial', sans-serif;
font-size: 11px;
color: #222;
margin: 0;
padding: 0;
}
h1 {
font-size: 20px;
color: #1a365d;
margin-bottom: 4px;
}
.subtitle {
font-size: 10px;
color: #666;
margin-bottom: 20px;
}
table {
width: 100%;
border-collapse: collapse;
}
thead th {
background: #1a365d;
color: #fff;
padding: 8px 10px;
text-align: left;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
tbody td {
padding: 6px 10px;
border-bottom: 1px solid #e0e0e0;
}
tbody tr:nth-child(even) {
background: #f7f7f7;
}
.contact-type {
display: inline-block;
padding: 2px 8px;
border-radius: 3px;
font-size: 9px;
font-weight: 600;
}
.type-customer { background: #e6f4ea; color: #1b7334; }
.type-supplier { background: #fef7e0; color: #8a6d00; }
.type-partner { background: #e8f0fe; color: #1a56c4; }
.type-other { background: #f0f0f0; color: #555; }
@media print {
body { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
thead { display: table-header-group; }
tr { page-break-inside: avoid; }
}
</style>
</head>
<body>
<h1>{{ title|default('Kontaktliste') }}</h1>
<div class="subtitle">Erstellt am: {{ generated_at|default('') }} &nbsp;|&nbsp; Anzahl: {{ contacts|length }}</div>
<table>
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>E-Mail</th>
<th>Telefon</th>
<th>Typ</th>
<th>Firma</th>
</tr>
</thead>
<tbody>
{% for contact in contacts %}
<tr>
<td>{{ loop.index }}</td>
<td>{{ contact.name|default('') }}</td>
<td>{{ contact.email|default('—') }}</td>
<td>{{ contact.phone|default('—') }}</td>
<td>
{% set ctype = contact.type|default('other') %}
<span class="contact-type type-{{ ctype }}">{{ ctype|capitalize }}</span>
</td>
<td>{{ contact.company|default('—') }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if contacts|length == 0 %}
<p style="text-align:center; padding:40px; color:#999;">Keine Kontakte gefunden.</p>
{% endif %}
</body>
</html>