feat(audit): P88 PDF-Export via WeasyPrint
CI / detect-changes (push) Successful in 9s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / test-python-backend (push) Successful in 42s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 14s
CI / loc-budget (push) Failing after 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
CI / detect-changes (push) Successful in 9s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / secret-scan (push) Has been skipped
CI / dep-audit (push) Has been skipped
CI / test-python-backend (push) Successful in 42s
CI / test-python-document-crawler (push) Has been skipped
CI / test-python-dsms-gateway (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / validate-canonical-controls (push) Successful in 14s
CI / loc-budget (push) Failing after 17s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / nodejs-build (push) Has been skipped
CI / test-go (push) Has been skipped
CI / iace-gt-coverage (push) Has been skipped
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 = """<!DOCTYPE html>
|
||||
<html lang="de"><head><meta charset="utf-8"><title>{title}</title>
|
||||
<style>
|
||||
@page {{ size: A4; margin: 18mm 14mm 18mm 14mm;
|
||||
@bottom-right {{ content: "Seite " counter(page) " / " counter(pages);
|
||||
color: #94a3b8; font-size: 9pt; }} }}
|
||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI',
|
||||
Roboto, sans-serif; font-size: 11pt;
|
||||
color: #1e293b; max-width: 760px; margin: 0 auto;
|
||||
line-height: 1.45; }}
|
||||
h1, h2, h3 {{ page-break-after: avoid; }}
|
||||
table {{ page-break-inside: auto; }}
|
||||
tr {{ page-break-inside: avoid; }}
|
||||
.header {{ border-bottom: 2px solid #0f172a; padding-bottom: 8mm;
|
||||
margin-bottom: 8mm; }}
|
||||
.header h1 {{ margin: 0; font-size: 16pt; color: #0f172a; }}
|
||||
.header .meta {{ font-size: 9pt; color: #64748b; margin-top: 2mm; }}
|
||||
</style></head><body>
|
||||
<div class="header">
|
||||
<h1>BreakPilot Compliance-Audit — {site}</h1>
|
||||
<div class="meta">PDF-Export erstellt am {ts} · Snapshot {snap_short}</div>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
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 + "</body></html>"
|
||||
Reference in New Issue
Block a user