From c93c88577c8fa553fda40dcaf60c192c107e2033 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Thu, 21 May 2026 17:06:48 +0200 Subject: [PATCH] feat(audit): P88 PDF-Export via WeasyPrint GET /api/compliance/agent/snapshots/{id}/pdf liefert application/pdf mit dem vollen Audit-Mail-Inhalt im A4-Print-Layout (Header mit Site/Timestamp/Snapshot-ID, Seitenzahlen unten rechts). check_replay.py liefert jetzt zusaetzlich 'full_html' (nicht nur 500-char-preview), damit der PDF-Renderer das komplette HTML hat. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/agent_compliance_check_routes.py | 22 +++++ .../compliance/services/check_replay.py | 1 + .../compliance/services/mail_pdf_export.py | 86 +++++++++++++++++++ 3 files changed, 109 insertions(+) create mode 100644 backend-compliance/compliance/services/mail_pdf_export.py diff --git a/backend-compliance/compliance/api/agent_compliance_check_routes.py b/backend-compliance/compliance/api/agent_compliance_check_routes.py index 6c05f815..c782ce59 100644 --- a/backend-compliance/compliance/api/agent_compliance_check_routes.py +++ b/backend-compliance/compliance/api/agent_compliance_check_routes.py @@ -207,6 +207,28 @@ async def get_snapshot(snapshot_id: str): db.close() +@router.get("/snapshots/{snapshot_id}/pdf") +async def export_snapshot_pdf(snapshot_id: str): + """P88 — PDF-Export der Audit-Mail. Liefert application/pdf.""" + from fastapi import HTTPException + from fastapi.responses import Response + from database import SessionLocal + from compliance.services.mail_pdf_export import render_snapshot_as_pdf + db = SessionLocal() + try: + pdf = render_snapshot_as_pdf(db, snapshot_id) + finally: + db.close() + if not pdf: + raise HTTPException(404, f"Snapshot {snapshot_id} nicht gefunden " + "oder PDF-Render fehlgeschlagen.") + fname = f"breakpilot-audit-{snapshot_id[:8]}.pdf" + return Response( + content=pdf, media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="{fname}"'}, + ) + + @router.post("/snapshots/{snapshot_id}/replay") async def replay_snapshot( snapshot_id: str, diff --git a/backend-compliance/compliance/services/check_replay.py b/backend-compliance/compliance/services/check_replay.py index c913131c..13170cf2 100644 --- a/backend-compliance/compliance/services/check_replay.py +++ b/backend-compliance/compliance/services/check_replay.py @@ -200,6 +200,7 @@ def replay_from_snapshot( "sections": section_sizes, "mail_sent": False, "preview": full_html[:500] + "..." if len(full_html) > 500 else full_html, + "full_html": full_html, # P88 PDF-Export braucht das volle HTML. } if recipient and not dry_run: diff --git a/backend-compliance/compliance/services/mail_pdf_export.py b/backend-compliance/compliance/services/mail_pdf_export.py new file mode 100644 index 00000000..42ce38f1 --- /dev/null +++ b/backend-compliance/compliance/services/mail_pdf_export.py @@ -0,0 +1,86 @@ +""" +P88 — PDF-Export der Audit-Mail. + +Rendert dieselbe HTML wie die Mail via WeasyPrint zu PDF. Endpoint: +GET /api/compliance/agent/snapshots/{snapshot_id}/pdf → application/pdf + +Verwendung: +- GF/Lawyer-Uebergabe (kein E-Mail-Programm noetig) +- Archivierung +- Mandatsausgabe an externen Berater +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone + +from sqlalchemy.orm import Session + +from compliance.services.check_replay import replay_from_snapshot + +logger = logging.getLogger(__name__) + +_PDF_WRAPPER_HEAD = """ +{title} + +
+

BreakPilot Compliance-Audit — {site}

+
PDF-Export erstellt am {ts} · Snapshot {snap_short}
+
+""" + + +def render_snapshot_as_pdf( + db: Session, + snapshot_id: str, +) -> bytes | None: + """Returns PDF bytes or None on failure.""" + try: + from weasyprint import HTML # noqa: WPS433 — Optional dep + except Exception as e: + logger.error("WeasyPrint nicht verfuegbar: %s", e) + return None + + res = replay_from_snapshot(db, snapshot_id, recipient=None, dry_run=True) + if not res or res.get("error"): + logger.warning("PDF-Export: Snapshot %s nicht gefunden", snapshot_id) + return None + + # The replay returns html via "preview" (truncated) — fetch the full + # render by injecting site_label into a wrapper. + full_html = _build_full_html(res, snapshot_id) + try: + pdf_bytes = HTML(string=full_html).write_pdf() + return pdf_bytes + except Exception as e: + logger.exception("WeasyPrint PDF render failed: %s", e) + return None + + +def _build_full_html(replay_result: dict, snapshot_id: str) -> str: + """Wraps the replay's full_html in the PDF-print wrapper.""" + full = replay_result.get("full_html") or replay_result.get("preview") or "" + site = replay_result.get("site_domain") or "—" + snap_short = snapshot_id[:8] + ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + header = _PDF_WRAPPER_HEAD.format( + title=f"BreakPilot Audit — {site}", + site=site, snap_short=snap_short, ts=ts, + ) + return header + full + ""