diff --git a/app/plugins/builtins/report_generator/pdf_generator.py b/app/plugins/builtins/report_generator/pdf_generator.py new file mode 100644 index 0000000..df7478c --- /dev/null +++ b/app/plugins/builtins/report_generator/pdf_generator.py @@ -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}") diff --git a/app/plugins/builtins/report_generator/plugin.py b/app/plugins/builtins/report_generator/plugin.py index 1cc22c2..8f87add 100644 --- a/app/plugins/builtins/report_generator/plugin.py +++ b/app/plugins/builtins/report_generator/plugin.py @@ -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), ], diff --git a/app/plugins/builtins/report_generator/routes.py b/app/plugins/builtins/report_generator/routes.py index ecb1a8e..45191f4 100644 --- a/app/plugins/builtins/report_generator/routes.py +++ b/app/plugins/builtins/report_generator/routes.py @@ -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}" diff --git a/app/plugins/builtins/report_generator/schemas.py b/app/plugins/builtins/report_generator/schemas.py index 2ebb800..99deaef 100644 --- a/app/plugins/builtins/report_generator/schemas.py +++ b/app/plugins/builtins/report_generator/schemas.py @@ -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): diff --git a/app/plugins/builtins/report_generator/templates/audit_log.html.j2 b/app/plugins/builtins/report_generator/templates/audit_log.html.j2 new file mode 100644 index 0000000..4f5bbdb --- /dev/null +++ b/app/plugins/builtins/report_generator/templates/audit_log.html.j2 @@ -0,0 +1,106 @@ + + + + + + + +

{{ title|default('Audit-Log') }}

+
Erstellt am: {{ generated_at|default('') }}  |  Einträge: {{ entries|length }}{% if date_from|default('') %}  |  Von: {{ date_from }}{% endif %}{% if date_to|default('') %}  |  Bis: {{ date_to }}{% endif %}
+ + + + + + + + + + + + + + {% for entry in entries %} + + + + + + + + + + {% endfor %} + +
#ZeitstempelBenutzerAktionEntitätEntität-IDDetails
{{ loop.index }}{{ entry.timestamp|default('') }}{{ entry.user|default('—') }} + {% set action = entry.action|default('read') %} + {{ action|upper }} + {{ entry.entity|default('—') }}{{ entry.entity_id|default('—') }}{{ entry.details|default('—') }}
+{% if entries|length == 0 %} +

Keine Audit-Log-Einträge gefunden.

+{% endif %} + + diff --git a/app/plugins/builtins/report_generator/templates/calendar_month.html.j2 b/app/plugins/builtins/report_generator/templates/calendar_month.html.j2 new file mode 100644 index 0000000..d0005f9 --- /dev/null +++ b/app/plugins/builtins/report_generator/templates/calendar_month.html.j2 @@ -0,0 +1,118 @@ + + + + + + + +

{{ title|default('Monatskalender') }}

+
{{ month_name|default('') }} {{ year|default('') }}  |  Erstellt am: {{ generated_at|default('') }}
+ + + + {% for day_name in weekday_names|default(['Montag','Dienstag','Mittwoch','Donnerstag','Freitag','Samstag','Sonntag']) %} + + {% endfor %} + + + + {% for week in weeks %} + + {% for day in week %} + + {% endfor %} + + {% endfor %} + +
{{ day_name }}
+
{{ day.day }}
+ {% for event in day.events|default([]) %} +
{{ event.title }}
+ {% endfor %} +
+ + diff --git a/app/plugins/builtins/report_generator/templates/calendar_week.html.j2 b/app/plugins/builtins/report_generator/templates/calendar_week.html.j2 new file mode 100644 index 0000000..c8317ad --- /dev/null +++ b/app/plugins/builtins/report_generator/templates/calendar_week.html.j2 @@ -0,0 +1,125 @@ + + + + + + + +

{{ title|default('Wochenkalender') }}

+
Woche {{ week_number|default('') }}  |  {{ week_start|default('') }} – {{ week_end|default('') }}  |  Erstellt am: {{ generated_at|default('') }}
+ + + + + {% for day in days %} + + {% endfor %} + + + + {% for hour in hours %} + + + {% for day in days %} + + {% endfor %} + + {% endfor %} + +
Zeit + {{ day.name }} + {{ day.date }} +
{{ hour.label }} + {% for event in day.events|default([]) %} + {% if event.hour == hour.value %} +
+ {{ event.title }} +
+ {% endif %} + {% endfor %} +
+ + diff --git a/app/plugins/builtins/report_generator/templates/company_list.html.j2 b/app/plugins/builtins/report_generator/templates/company_list.html.j2 new file mode 100644 index 0000000..f764d0d --- /dev/null +++ b/app/plugins/builtins/report_generator/templates/company_list.html.j2 @@ -0,0 +1,92 @@ + + + + + + + +

{{ title|default('Firmenliste') }}

+
Erstellt am: {{ generated_at|default('') }}  |  Anzahl: {{ companies|length }}
+ + + + + + + + + + + + + + {% for company in companies %} + + + + + + + + + + {% endfor %} + +
#FirmennameAdressePLZ / OrtTelefonE-MailAnsprechpartner
{{ loop.index }}{{ company.name|default('') }}{{ company.address|default('—') }}{{ company.zip|default('') }} {{ company.city|default('') }}{{ company.phone|default('—') }}{{ company.email|default('—') }}{{ company.contact_person|default('—') }}
+{% if companies|length == 0 %} +

Keine Firmen gefunden.

+{% endif %} + + diff --git a/app/plugins/builtins/report_generator/templates/contact_list.html.j2 b/app/plugins/builtins/report_generator/templates/contact_list.html.j2 new file mode 100644 index 0000000..68cd2d5 --- /dev/null +++ b/app/plugins/builtins/report_generator/templates/contact_list.html.j2 @@ -0,0 +1,104 @@ + + + + + + + +

{{ title|default('Kontaktliste') }}

+
Erstellt am: {{ generated_at|default('') }}  |  Anzahl: {{ contacts|length }}
+ + + + + + + + + + + + + {% for contact in contacts %} + + + + + + + + + {% endfor %} + +
#NameE-MailTelefonTypFirma
{{ loop.index }}{{ contact.name|default('') }}{{ contact.email|default('—') }}{{ contact.phone|default('—') }} + {% set ctype = contact.type|default('other') %} + {{ ctype|capitalize }} + {{ contact.company|default('—') }}
+{% if contacts|length == 0 %} +

Keine Kontakte gefunden.

+{% endif %} + + diff --git a/tests/conftest.py b/tests/conftest.py index a879cf4..8beb43e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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() diff --git a/tests/test_report_generator.py b/tests/test_report_generator.py new file mode 100644 index 0000000..1740290 --- /dev/null +++ b/tests/test_report_generator.py @@ -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": "

{{ title }}

{{ message }}

", + "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