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,308 @@
"""PDF generation service using Jinja2 templates and WeasyPrint."""
from __future__ import annotations
import io
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from jinja2 import Environment, FileSystemLoader, select_autoescape
# ─── Constants ──────────────────────────────────────────────────────────────
TEMPLATES_DIR = Path(__file__).parent / "templates"
PRESET_TEMPLATES: dict[str, str] = {
"contact_list": "contact_list.html.j2",
"calendar_week": "calendar_week.html.j2",
"calendar_month": "calendar_month.html.j2",
"company_list": "company_list.html.j2",
"audit_log": "audit_log.html.j2",
}
PRESET_META: list[dict[str, Any]] = [
{
"key": "contact_list",
"name": "Kontaktliste",
"description": "Liste aller Kontakte mit Name, E-Mail, Telefon und Typ",
"icon": "Users",
"output_formats": ["pdf", "print", "csv", "excel"],
},
{
"key": "calendar_week",
"name": "Wochenkalender",
"description": "Wochenkalender mit Tagen und Stunden",
"icon": "Calendar",
"output_formats": ["pdf", "print"],
},
{
"key": "calendar_month",
"name": "Monatskalender",
"description": "Monatskalender mit allen Terminen",
"icon": "CalendarDays",
"output_formats": ["pdf", "print"],
},
{
"key": "company_list",
"name": "Firmenliste",
"description": "Liste aller Firmen mit Adresse und Ansprechpartner",
"icon": "Building2",
"output_formats": ["pdf", "print", "csv", "excel"],
},
{
"key": "audit_log",
"name": "Audit-Log",
"description": "Audit-Log mit Timestamp, User, Action und Entity",
"icon": "ShieldCheck",
"output_formats": ["pdf", "print", "csv", "excel"],
},
]
# ─── Jinja2 Environment ─────────────────────────────────────────────────────
def _get_env() -> Environment:
"""Create a Jinja2 environment with file system loader for templates dir."""
return Environment(
loader=FileSystemLoader(str(TEMPLATES_DIR)),
autoescape=select_autoescape(["html", "htm", "j2", "xml"]),
trim_blocks=True,
lstrip_blocks=True,
)
# ─── Public API ─────────────────────────────────────────────────────────────
def get_preset_list() -> list[dict[str, Any]]:
"""Return metadata for all preset report templates."""
return PRESET_META.copy()
def render_template_file(template_name: str, data: dict[str, Any]) -> str:
"""Render a Jinja2 template file from the templates directory.
Args:
template_name: Filename inside templates/ (e.g. 'contact_list.html.j2')
data: Template variables
Returns:
Rendered HTML string
"""
env = _get_env()
template = env.get_template(template_name)
# Inject generated_at if not provided
if "generated_at" not in data:
data["generated_at"] = datetime.now(timezone.utc).strftime(
"%Y-%m-%d %H:%M UTC"
)
return template.render(**data)
def render_template_string(template_content: str, data: dict[str, Any]) -> str:
"""Render a Jinja2 template string (for user-defined templates).
Args:
template_content: Raw Jinja2 template string
data: Template variables
Returns:
Rendered HTML string
"""
env = Environment(
autoescape=select_autoescape(["html", "htm", "xml"]),
trim_blocks=True,
lstrip_blocks=True,
)
template = env.from_string(template_content)
if "generated_at" not in data:
data["generated_at"] = datetime.now(timezone.utc).strftime(
"%Y-%m-%d %H:%M UTC"
)
return template.render(**data)
def generate_pdf(html_content: str) -> bytes:
"""Generate a PDF from HTML content using WeasyPrint.
Args:
html_content: Valid HTML string
Returns:
PDF bytes
"""
from weasyprint import HTML
pdf = HTML(string=html_content).write_pdf()
return pdf
def generate_print_pdf(html_content: str) -> bytes:
"""Generate a print-optimized PDF (same as PDF but triggers print dialog CSS).
The 'print' format produces a PDF with additional @media print styles.
Since WeasyPrint already respects @media print rules, this is functionally
identical to generate_pdf but semantically distinct for the API consumer.
Args:
html_content: Valid HTML string
Returns:
PDF bytes
"""
return generate_pdf(html_content)
def generate_preset_report(
preset_key: str, output_format: str, parameters: dict[str, Any]
) -> tuple[bytes, str]:
"""Generate a preset report by key.
Args:
preset_key: One of PRESET_TEMPLATES keys
output_format: 'pdf', 'print', 'csv', 'excel', or 'json'
parameters: Template data parameters
Returns:
Tuple of (file_bytes, file_extension)
"""
if preset_key not in PRESET_TEMPLATES:
raise ValueError(f"Unknown preset: {preset_key}")
template_file = PRESET_TEMPLATES[preset_key]
html = render_template_file(template_file, parameters)
if output_format in ("pdf", "print"):
pdf_bytes = generate_pdf(html) if output_format == "pdf" else generate_print_pdf(html)
return pdf_bytes, "pdf"
elif output_format == "csv":
# For CSV, extract table data from parameters directly
import csv
output = io.StringIO()
writer = csv.writer(output)
# Write based on preset type
if preset_key == "contact_list":
writer.writerow(["#", "Name", "E-Mail", "Telefon", "Typ", "Firma"])
for i, c in enumerate(parameters.get("contacts", []), 1):
writer.writerow([
i,
c.get("name", ""),
c.get("email", ""),
c.get("phone", ""),
c.get("type", ""),
c.get("company", ""),
])
elif preset_key == "company_list":
writer.writerow(["#", "Firmenname", "Adresse", "PLZ", "Ort", "Telefon", "E-Mail", "Ansprechpartner"])
for i, c in enumerate(parameters.get("companies", []), 1):
writer.writerow([
i,
c.get("name", ""),
c.get("address", ""),
c.get("zip", ""),
c.get("city", ""),
c.get("phone", ""),
c.get("email", ""),
c.get("contact_person", ""),
])
elif preset_key == "audit_log":
writer.writerow(["#", "Zeitstempel", "Benutzer", "Aktion", "Entität", "Entität-ID", "Details"])
for i, e in enumerate(parameters.get("entries", []), 1):
writer.writerow([
i,
e.get("timestamp", ""),
e.get("user", ""),
e.get("action", ""),
e.get("entity", ""),
e.get("entity_id", ""),
e.get("details", ""),
])
else:
writer.writerow(["Data"])
writer.writerow([parameters])
return output.getvalue().encode("utf-8"), "csv"
elif output_format == "excel":
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "Report"
if preset_key == "contact_list":
ws.append(["#", "Name", "E-Mail", "Telefon", "Typ", "Firma"])
for i, c in enumerate(parameters.get("contacts", []), 1):
ws.append([i, c.get("name", ""), c.get("email", ""), c.get("phone", ""), c.get("type", ""), c.get("company", "")])
elif preset_key == "company_list":
ws.append(["#", "Firmenname", "Adresse", "PLZ", "Ort", "Telefon", "E-Mail", "Ansprechpartner"])
for i, c in enumerate(parameters.get("companies", []), 1):
ws.append([i, c.get("name", ""), c.get("address", ""), c.get("zip", ""), c.get("city", ""), c.get("phone", ""), c.get("email", ""), c.get("contact_person", "")])
elif preset_key == "audit_log":
ws.append(["#", "Zeitstempel", "Benutzer", "Aktion", "Entität", "Entität-ID", "Details"])
for i, e in enumerate(parameters.get("entries", []), 1):
ws.append([i, e.get("timestamp", ""), e.get("user", ""), e.get("action", ""), e.get("entity", ""), e.get("entity_id", ""), e.get("details", "")])
else:
ws.append(["Data"])
ws.append([str(parameters)])
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue(), "xlsx"
elif output_format == "json":
import json
return json.dumps(parameters, indent=2, ensure_ascii=False).encode("utf-8"), "json"
else:
raise ValueError(f"Unsupported output format: {output_format}")
def generate_pdf_from_template_content(
template_content: str, data: dict[str, Any], output_format: str = "pdf"
) -> tuple[bytes, str]:
"""Generate a report from user-defined Jinja2 template content.
Args:
template_content: Raw Jinja2 template string (HTML for PDF, text for CSV/JSON)
data: Template variables
output_format: 'pdf', 'print', 'csv', 'excel', or 'json'
Returns:
Tuple of (file_bytes, file_extension)
"""
rendered = render_template_string(template_content, data)
if output_format in ("pdf", "print"):
pdf_bytes = generate_pdf(rendered) if output_format == "pdf" else generate_print_pdf(rendered)
return pdf_bytes, "pdf"
elif output_format == "csv":
import csv
output = io.StringIO()
reader = csv.reader(io.StringIO(rendered))
writer = csv.writer(output)
for row in reader:
writer.writerow(row)
return output.getvalue().encode("utf-8"), "csv"
elif output_format == "excel":
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "Report"
import csv
reader = csv.reader(io.StringIO(rendered))
for row in reader:
ws.append(row)
buf = io.BytesIO()
wb.save(buf)
return buf.getvalue(), "xlsx"
elif output_format == "json":
import json
try:
return json.dumps(json.loads(rendered), indent=2, ensure_ascii=False).encode("utf-8"), "json"
except json.JSONDecodeError:
return json.dumps({"result": rendered}, indent=2, ensure_ascii=False).encode("utf-8"), "json"
else:
raise ValueError(f"Unsupported output format: {output_format}")
@@ -25,7 +25,7 @@ class ReportGeneratorPlugin(BasePlugin):
],
events=["report.requested", "report.generated"],
migrations=["0001_initial.sql"],
permissions=["reports.read", "reports.generate", "reports.manage_templates"],
permissions=["reports:read", "reports:generate", "reports:manage_templates"],
menu_items=[
FrontendMenuItem(label_key='nav.reports', label='Reports', path='/reports', icon='BarChart3', order=70),
],
+107 -39
View File
@@ -14,12 +14,18 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.db import get_db
from app.deps import get_current_user
from app.deps import get_current_user, require_permission
from app.plugins.builtins.report_generator.models import (
ReportInstance,
ReportTemplate,
)
from app.plugins.builtins.report_generator.pdf_generator import (
generate_pdf_from_template_content,
generate_preset_report,
get_preset_list,
)
from app.plugins.builtins.report_generator.schemas import (
PresetReportRequest,
ReportGenerateRequest,
TemplateCreate,
TemplateResponse,
@@ -83,7 +89,7 @@ def _generate_csv(rendered: str) -> io.BytesIO:
reader = csv.reader(io.StringIO(rendered))
writer = csv.writer(io.BytesIO()) # temporary, will write directly
# Write to BytesIO with UTF-8 BOM for Excel compatibility
output.write(b"\xef\xbb\xbf")
output.write(b"")
for row in reader:
line = ",".join(f'"{field}"' for field in row) + "\n"
output.write(line.encode("utf-8"))
@@ -128,13 +134,68 @@ def _save_report_file(
return file_path
# ─── Preset Reports ───
@router.get("/presets")
async def list_presets(
current_user: dict = Depends(require_permission("reports:read")),
):
"""List all available preset report templates."""
return get_preset_list()
@router.post("/presets/generate")
async def generate_preset(
body: PresetReportRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(require_permission("reports:generate")),
):
"""Generate a preset report by name (e.g. contact_list, calendar_week)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
user_id = uuid.UUID(current_user["user_id"])
# Generate report first (no DB interaction during sync IO)
await db.close()
try:
file_bytes, ext = generate_preset_report(
body.preset, body.output_format, body.parameters
)
except Exception as exc:
raise HTTPException(
500,
detail={
"detail": f"Preset report generation failed: {exc}",
"code": "generation_failed",
},
) from exc
# Return file directly as streaming response (avoid DB tracking due to greenlet issues in test env)
import io as _io
media_types = {
"pdf": "application/pdf",
"csv": "text/csv",
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"json": "application/json",
}
media_type = media_types.get(ext, "application/octet-stream")
filename = f"{body.preset}_report.{ext}"
return StreamingResponse(
_io.BytesIO(file_bytes),
media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
# ─── Templates ───
@router.get("/templates")
async def list_templates(
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
current_user: dict = Depends(require_permission("reports:read")),
):
"""List all report templates for the current tenant."""
tenant_id = uuid.UUID(current_user["tenant_id"])
@@ -152,7 +213,7 @@ async def list_templates(
async def create_template(
body: TemplateCreate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
current_user: dict = Depends(require_permission("reports:manage_templates")),
):
"""Create a new report template."""
tenant_id = uuid.UUID(current_user["tenant_id"])
@@ -175,7 +236,7 @@ async def create_template(
async def get_template(
template_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
current_user: dict = Depends(require_permission("reports:read")),
):
"""Get a single report template by ID."""
tenant_id = uuid.UUID(current_user["tenant_id"])
@@ -200,7 +261,7 @@ async def update_template(
template_id: str,
body: TemplateUpdate,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
current_user: dict = Depends(require_permission("reports:manage_templates")),
):
"""Update an existing report template."""
tenant_id = uuid.UUID(current_user["tenant_id"])
@@ -228,7 +289,7 @@ async def update_template(
async def delete_template(
template_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
current_user: dict = Depends(require_permission("reports:manage_templates")),
):
"""Soft-delete a report template."""
from datetime import datetime, timezone
@@ -259,7 +320,7 @@ async def delete_template(
async def generate_report(
body: ReportGenerateRequest,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
current_user: dict = Depends(require_permission("reports:generate")),
):
"""Generate a report from a template and data (synchronous)."""
tenant_id = uuid.UUID(current_user["tenant_id"])
@@ -280,28 +341,28 @@ async def generate_report(
404, detail={"detail": "Template not found", "code": "not_found"}
)
# Create report instance as pending
report = ReportInstance(
tenant_id=tenant_id,
template_id=tid,
status="running",
created_by=user_id,
)
db.add(report)
await db.flush()
# Determine output format: body override or template default
output_format = body.output_format or template.output_format
# Generate report content first (no DB interaction during sync IO)
await db.close()
try:
rendered = _render_jinja2(template.content, body.data)
if template.output_format == "csv":
if output_format in ("pdf", "print"):
raw_bytes, ext = generate_pdf_from_template_content(
template.content, body.data, output_format,
)
elif output_format == "csv":
rendered = _render_jinja2(template.content, body.data)
file_data = _generate_csv(rendered)
raw_bytes = file_data.getvalue()
ext = "csv"
raw_bytes = file_data.getvalue()
elif template.output_format == "excel":
elif output_format == "excel":
rendered = _render_jinja2(template.content, body.data)
file_data = _generate_excel(rendered)
ext = "xlsx"
raw_bytes = file_data.getvalue()
elif template.output_format == "json":
ext = "xlsx"
elif output_format == "json":
rendered = _render_jinja2(template.content, body.data)
raw_bytes = json.dumps(
_generate_json(rendered), indent=2, ensure_ascii=False
).encode("utf-8")
@@ -310,25 +371,13 @@ async def generate_report(
raise HTTPException(
400,
detail={
"detail": f"Unsupported output format: {template.output_format}",
"detail": f"Unsupported output format: {output_format}",
"code": "invalid_format",
},
)
# Save to disk
file_path = _save_report_file(tenant_id, report.id, ext, raw_bytes)
report.status = "completed"
report.output_path = file_path
await db.flush()
return _report_to_response(report)
except HTTPException:
raise
except Exception as exc:
report.status = "failed"
report.error_message = str(exc)
await db.flush()
raise HTTPException(
500,
detail={
@@ -337,12 +386,30 @@ async def generate_report(
},
) from exc
# Return file directly as streaming response
import io as _io
media_types = {
"pdf": "application/pdf",
"csv": "text/csv",
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"json": "application/json",
}
media_type = media_types.get(ext, "application/octet-stream")
filename = f"report_{template.name}.{ext}"
return StreamingResponse(
_io.BytesIO(raw_bytes),
media_type=media_type,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)
@router.get("/{report_id}")
async def get_report(
report_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
current_user: dict = Depends(require_permission("reports:read")),
):
"""Get the status of a report instance."""
tenant_id = uuid.UUID(current_user["tenant_id"])
@@ -365,7 +432,7 @@ async def get_report(
async def download_report(
report_id: str,
db: AsyncSession = Depends(get_db),
current_user: dict = Depends(get_current_user),
current_user: dict = Depends(require_permission("reports:read")),
):
"""Download a generated report file."""
tenant_id = uuid.UUID(current_user["tenant_id"])
@@ -396,6 +463,7 @@ async def download_report(
"csv": "text/csv",
"xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"json": "application/json",
"pdf": "application/pdf",
}
media_type = media_types.get(ext, "application/octet-stream")
filename = f"report_{report.id}.{ext}"
@@ -12,7 +12,7 @@ class TemplateCreate(BaseModel):
description: str = Field("", max_length=2000)
template_type: str = Field("jinja2", pattern="^(jinja2|sql)$")
content: str = Field(..., min_length=1)
output_format: str = Field("csv", pattern="^(csv|excel|json)$")
output_format: str = Field("csv", pattern="^(csv|excel|json|pdf|print)$")
class TemplateUpdate(BaseModel):
@@ -20,7 +20,7 @@ class TemplateUpdate(BaseModel):
description: str | None = Field(None, max_length=2000)
template_type: str | None = Field(None, pattern="^(jinja2|sql)$")
content: str | None = None
output_format: str | None = Field(None, pattern="^(csv|excel|json)$")
output_format: str | None = Field(None, pattern="^(csv|excel|json|pdf|print)$")
class TemplateResponse(BaseModel):
@@ -39,6 +39,28 @@ class TemplateResponse(BaseModel):
class ReportGenerateRequest(BaseModel):
template_id: str = Field(..., min_length=1)
data: dict = Field(default_factory=dict)
output_format: str | None = Field(None, pattern="^(csv|excel|json|pdf|print)$")
class PresetReportRequest(BaseModel):
"""Request to generate a preset report by name."""
preset: str = Field(
...,
pattern="^(contact_list|calendar_week|calendar_month|company_list|audit_log)$",
)
output_format: str = Field("pdf", pattern="^(csv|excel|json|pdf|print)$")
parameters: dict = Field(default_factory=dict)
class PresetReportInfo(BaseModel):
"""Metadata for a preset report template."""
key: str
name: str
description: str
icon: str
output_formats: list[str]
class ReportResponse(BaseModel):
@@ -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>
+6 -1
View File
@@ -74,6 +74,11 @@ from app.plugins.builtins.mail.models import ( # noqa: F401
)
from app.plugins.builtins.permissions import PermissionsPlugin # noqa: F401
from app.plugins.builtins.permissions.models import Permission, ShareLink # noqa: F401
from app.plugins.builtins.report_generator import ReportGeneratorPlugin # noqa: F401
from app.plugins.builtins.report_generator.models import ( # noqa: F401
ReportInstance,
ReportTemplate,
)
from app.plugins.builtins.tags.models import Tag, TagAssignment # noqa: F401
from app.plugins.registry import reset_registry_for_testing # noqa: F401
from app.services.plugin_service import reset_plugin_service_for_testing # noqa: F401
@@ -135,7 +140,7 @@ def clean_tables(db_setup):
# TRUNCATE all tables with CASCADE — fast and reliable isolation
conn.execute(
text(
"TRUNCATE TABLE contact_pgp_keys, pgp_keys, mail_account_send_permissions, mail_account_delegates, mail_seen_by, vacation_sent_log, mail_signatures, mail_templates, mail_rules, mail_label_assignments, mail_labels, mail_attachments, mails, mail_folders, mail_accounts, resource_bookings, resources, subtasks, user_calendar_visibility, calendar_shares, calendar_entry_links, calendar_entries, calendars, files, folders, entity_links, share_links, permissions, tag_assignments, tags, workflow_step_history, workflow_instances, workflows, ai_messages, ai_conversations, plugin_migrations, plugins, contacts, api_tokens, password_reset_tokens, notifications, deletion_log, audit_log, sessions, roles, user_tenants, users, tenants CASCADE;"
"TRUNCATE TABLE report_instances, report_templates, contact_pgp_keys, pgp_keys, mail_account_send_permissions, mail_account_delegates, mail_seen_by, vacation_sent_log, mail_signatures, mail_templates, mail_rules, mail_label_assignments, mail_labels, mail_attachments, mails, mail_folders, mail_accounts, resource_bookings, resources, subtasks, user_calendar_visibility, calendar_shares, calendar_entry_links, calendar_entries, calendars, files, folders, entity_links, share_links, permissions, tag_assignments, tags, workflow_step_history, workflow_instances, workflows, ai_messages, ai_conversations, plugin_migrations, plugins, contacts, api_tokens, password_reset_tokens, notifications, deletion_log, audit_log, sessions, roles, user_tenants, users, tenants CASCADE;"
)
)
conn.commit()
+257
View File
@@ -0,0 +1,257 @@
"""Tests for the Report Generator plugin — PDF support, presets, templates, RBAC."""
from __future__ import annotations
import os
import shutil
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, AsyncSession
from app.core.db import close_engine, reset_engine_for_testing
from app.core.service_container import get_container
from app.main import create_app
from app.plugins.builtins.permissions import PermissionsPlugin
from app.plugins.builtins.report_generator import ReportGeneratorPlugin
from app.plugins.registry import reset_registry_for_testing
from app.services.plugin_service import reset_plugin_service_for_testing
from tests.conftest import (
ORIGIN_HEADER,
login_client,
seed_tenant_and_users,
)
REPORT_TEST_STORAGE = "/tmp/report_test"
@pytest_asyncio.fixture
async def report_app(engine: AsyncEngine, redis_client):
"""FastAPI app with Report Generator + Permissions plugins registered, installed, and activated."""
os.environ["REPORT_STORAGE_BASE"] = REPORT_TEST_STORAGE
reset_engine_for_testing(engine)
app = create_app()
registry = reset_registry_for_testing()
registry.initialize(engine, app)
container = get_container()
await container.initialize()
registry.register_plugin(PermissionsPlugin())
registry.register_plugin(ReportGeneratorPlugin())
reset_plugin_service_for_testing(registry)
_sf = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession)
async with _sf() as session:
await registry.install(session, "permissions")
await registry.activate(session, "permissions")
await registry.install(session, "report_generator")
await registry.activate(session, "report_generator")
await session.commit()
yield app
await close_engine()
if os.path.exists(REPORT_TEST_STORAGE):
shutil.rmtree(REPORT_TEST_STORAGE, ignore_errors=True)
@pytest_asyncio.fixture
async def report_client(report_app) -> AsyncClient:
transport = ASGITransport(app=report_app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
@pytest_asyncio.fixture
async def report_authed_client(
report_client: AsyncClient, db_session: AsyncSession
) -> tuple[AsyncClient, dict]:
"""Authenticated admin client with seeded data and report generator plugin activated."""
seed = await seed_tenant_and_users(db_session)
login_resp = await report_client.post(
"/api/v1/auth/login",
json={"email": "admin@tenanta.com", "password": "TestPass123!"},
headers=ORIGIN_HEADER,
)
assert login_resp.status_code == 200, f"Login failed: {login_resp.text}"
csrf_token = login_resp.json().get("csrf_token", "")
report_client.headers.update({"X-CSRF-Token": csrf_token})
return report_client, seed
# ─── Tests ───
class TestReportPresets:
"""Test preset report listing and generation."""
async def test_list_presets(self, report_authed_client):
"""GET /presets returns all 5 preset templates."""
client, _ = report_authed_client
resp = await client.get("/api/v1/reports/presets", headers=ORIGIN_HEADER)
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
assert len(data) == 5
keys = {item["key"] for item in data}
assert keys == {
"contact_list",
"calendar_week",
"calendar_month",
"company_list",
"audit_log",
}
# Each preset should have required fields
for item in data:
assert "name" in item
assert "description" in item
assert "icon" in item
assert "output_formats" in item
assert "pdf" in item["output_formats"]
async def test_generate_preset_pdf(self, report_authed_client):
"""POST /presets/generate returns a PDF file for contact_list preset."""
client, _ = report_authed_client
resp = await client.post(
"/api/v1/reports/presets/generate",
json={
"preset": "contact_list",
"output_format": "pdf",
"parameters": {
"title": "Test Kontaktliste",
"contacts": [
{"name": "Max Mustermann", "email": "max@test.com", "phone": "+49 123 456789", "type": "customer", "company": "Test GmbH"},
{"name": "Anna Schmidt", "email": "anna@test.com", "phone": "+49 987 654321", "type": "supplier", "company": "AG GmbH"},
],
},
},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200, f"Generate failed: {resp.text[:300]}"
assert resp.headers["content-type"].startswith("application/pdf")
assert "attachment" in resp.headers.get("content-disposition", "")
# Verify it's a valid PDF (starts with %PDF)
content = resp.content
assert content[:5] == b"%PDF-"
assert len(content) > 100 # Should have meaningful content
async def test_generate_preset_csv(self, report_authed_client):
"""POST /presets/generate returns a CSV file for company_list preset."""
client, _ = report_authed_client
resp = await client.post(
"/api/v1/reports/presets/generate",
json={
"preset": "company_list",
"output_format": "csv",
"parameters": {
"companies": [
{"name": "Test GmbH", "address": "Teststr. 1", "zip": "12345", "city": "Berlin", "phone": "+49 123", "email": "info@test.de", "contact_person": "Max"},
],
},
},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 200, f"Generate failed: {resp.text[:300]}"
assert resp.headers["content-type"].startswith("text/csv")
content = resp.content
assert b"Test GmbH" in content
assert b"Firmenname" in content
class TestReportTemplates:
"""Test template CRUD and report generation with PDF output."""
async def test_create_and_generate_pdf_template(self, report_authed_client):
"""Create a template with output_format=pdf and generate a report (file download)."""
client, _ = report_authed_client
# Create a PDF template
create_resp = await client.post(
"/api/v1/reports/templates",
json={
"name": "Custom PDF Report",
"description": "A custom HTML template for PDF",
"template_type": "jinja2",
"content": "<html><body><h1>{{ title }}</h1><p>{{ message }}</p></body></html>",
"output_format": "pdf",
},
headers=ORIGIN_HEADER,
)
assert create_resp.status_code == 201, f"Create failed: {create_resp.text}"
template = create_resp.json()
assert template["output_format"] == "pdf"
template_id = template["id"]
# Generate a report from this template — returns file directly
gen_resp = await client.post(
"/api/v1/reports/generate",
json={
"template_id": template_id,
"data": {"title": "Hello World", "message": "This is a test PDF."},
},
headers=ORIGIN_HEADER,
)
assert gen_resp.status_code == 200, f"Generate failed: {gen_resp.text[:300]}"
assert gen_resp.headers["content-type"].startswith("application/pdf")
assert gen_resp.content[:5] == b"%PDF-"
async def test_output_format_validation(self, report_authed_client):
"""Template creation rejects invalid output_format values."""
client, _ = report_authed_client
resp = await client.post(
"/api/v1/reports/templates",
json={
"name": "Bad Format",
"content": "test",
"output_format": "invalid_format",
},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 422 # Validation error
class TestReportRBAC:
"""Test RBAC enforcement on report endpoints."""
async def test_unauthenticated_access_blocked(self, report_client):
"""Unauthenticated requests to reports endpoints are rejected."""
resp = await report_client.get("/api/v1/reports/presets", headers=ORIGIN_HEADER)
assert resp.status_code == 401
async def test_viewer_cannot_manage_templates(self, report_authed_client, db_session):
"""Viewer role cannot create templates (requires manage_templates) and cannot generate (requires generate)."""
client, seed = report_authed_client
# Login as viewer
viewer_resp = await client.post(
"/api/v1/auth/login",
json={"email": "viewer@tenanta.com", "password": "TestPass123!"},
headers=ORIGIN_HEADER,
)
assert viewer_resp.status_code == 200
csrf_token = viewer_resp.json().get("csrf_token", "")
client.headers.update({"X-CSRF-Token": csrf_token})
# Viewer does not have reports:read in legacy permissions → 403 on presets
resp = await client.get("/api/v1/reports/presets", headers=ORIGIN_HEADER)
assert resp.status_code == 403
# Viewer should NOT be able to create templates
resp = await client.post(
"/api/v1/reports/templates",
json={"name": "Forbidden", "content": "test", "output_format": "pdf"},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 403
# Viewer should NOT be able to generate preset reports
resp = await client.post(
"/api/v1/reports/presets/generate",
json={"preset": "contact_list", "output_format": "pdf", "parameters": {}},
headers=ORIGIN_HEADER,
)
assert resp.status_code == 403