Files
breakpilot-compliance/backend-compliance/compliance/services/compliance_pdf_generator.py
T
Benjamin Admin 07cc00da11 feat(licenses): Stufe 2 — auto-attribution footer in compliance PDF
Extends CompliancePDFGenerator with a "Quellen & Lizenzen" section
appended to every generated compliance PDF.

The footer is built from compliance.canonical_controls + control_parent_links
directly (no HTTP hop to /licenses/aggregate — same DB connection
already open in the generator). It groups by license_rule and lists
the top 8 source regulations per bucket.

For Rule-2 entries (CC-BY-SA, OECD-Public, Apache, etc.) it emits the
mandatory attribution paragraph required by the underlying licenses.
For Rule 1 a brief reference list satisfies the auditability goal
without legal obligation. Rule 3 is identifier-only by design.

Architecture decision: this is a PLATFORM-level footer (which sources
the platform draws on overall), not a per-export filter of "only the
sources actually cited in THIS document". The latter would require
control-uuid tracking across all sections (TOM/VVT/DSFA/etc.) which
the current PDF generator does not surface — that's a follow-up scope.
The platform-level footer fulfils the immediate legal mandate that
attribution be present on the work, not buried in AGB/Impressum.

Part of Attribution-Renderer Task #23. Stufe 1 (overview page) +
Stufe 3 (SourceBadge component) already shipped in commit dfac940.
Stufe 4 (tech-file appendix) remains for the IACE tech-file generator
in a separate iteration.
2026-05-21 21:30:02 +02:00

280 lines
13 KiB
Python

"""
Compliance Report PDF Generator — generates a comprehensive A4 PDF
covering all compliance modules for a project.
Uses reportlab (same as audit_pdf_generator.py).
"""
import io
import logging
from datetime import datetime, timezone
from typing import Any
from reportlab.lib import colors
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import mm
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak,
)
from sqlalchemy import text
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
# Colors
PURPLE = colors.HexColor("#7c3aed")
LIGHT_PURPLE = colors.HexColor("#f5f3ff")
GRAY = colors.HexColor("#6b7280")
GREEN = colors.HexColor("#16a34a")
RED = colors.HexColor("#dc2626")
YELLOW = colors.HexColor("#ca8a04")
def _styles():
ss = getSampleStyleSheet()
ss.add(ParagraphStyle("Title2", parent=ss["Title"], fontSize=24, textColor=PURPLE, spaceAfter=6))
ss.add(ParagraphStyle("Section", parent=ss["Heading2"], fontSize=14, textColor=PURPLE, spaceBefore=12, spaceAfter=6))
ss.add(ParagraphStyle("Body2", parent=ss["Normal"], fontSize=10, leading=14, spaceAfter=4))
ss.add(ParagraphStyle("Small", parent=ss["Normal"], fontSize=8, textColor=GRAY))
return ss
class CompliancePDFGenerator:
"""Generates a full compliance status report as PDF."""
def __init__(self, db: Session) -> None:
self.db = db
def generate(self, tenant_id: str, project_id: str | None = None, language: str = "de") -> tuple[bytes, str]:
buf = io.BytesIO()
doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=20 * mm, rightMargin=20 * mm, topMargin=25 * mm, bottomMargin=20 * mm)
ss = _styles()
story: list = []
now = datetime.now(timezone.utc)
story.append(Paragraph("Compliance-Report", ss["Title2"]))
story.append(Paragraph(f"Stand: {now.strftime('%d.%m.%Y %H:%M')} UTC", ss["Small"]))
story.append(Spacer(1, 10 * mm))
# Company Profile
self._add_company_section(story, ss, tenant_id, project_id)
# TOM
self._add_count_section(story, ss, "TOM (Technisch-Organisatorische Massnahmen)",
"compliance_toms", tenant_id)
# VVT
self._add_count_section(story, ss, "VVT (Verarbeitungstaetigkeiten)",
"compliance_vvt_activities", tenant_id)
# DSFA
self._add_count_section(story, ss, "Datenschutz-Folgenabschaetzungen",
"compliance_dsfa_assessments", tenant_id)
# Risks
self._add_risk_section(story, ss, tenant_id)
# Vendors
self._add_count_section(story, ss, "Auftragsverarbeiter",
"compliance_vendor_assessments", tenant_id)
# Incidents
self._add_count_section(story, ss, "Datenschutz-Vorfaelle",
"compliance_notfallplan_incidents", tenant_id)
# Document Reviews
self._add_review_section(story, ss, tenant_id)
# Banner Consents
self._add_consent_section(story, ss, tenant_id)
# Org Roles
self._add_role_section(story, ss, tenant_id, project_id)
# Stufe 2 — Quellen- und Lizenz-Footer (Attribution-Renderer Task #23)
self._add_attribution_footer(story, ss)
# Footer
story.append(Spacer(1, 15 * mm))
story.append(Paragraph("Erstellt mit BreakPilot Compliance SDK", ss["Small"]))
doc.build(story)
filename = f"compliance-report-{now.strftime('%Y%m%d')}.pdf"
return buf.getvalue(), filename
def _add_company_section(self, story, ss, tid, pid):
story.append(Paragraph("Unternehmensprofil", ss["Section"]))
try:
where = "tenant_id = :tid"
params: dict[str, Any] = {"tid": tid}
if pid:
where += " AND project_id = :pid"
params["pid"] = pid
row = self.db.execute(text(f"SELECT * FROM compliance_company_profiles WHERE {where} LIMIT 1"), params).fetchone()
if row:
d = dict(row._mapping)
data = [
["Feld", "Wert"],
["Firma", d.get("company_name", "-")],
["Branche", d.get("industry", "-")],
["Rechtsform", d.get("legal_form", "-")],
["Mitarbeiter", str(d.get("employee_count", "-"))],
]
t = Table(data, colWidths=[60 * mm, 100 * mm])
t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), LIGHT_PURPLE),
("TEXTCOLOR", (0, 0), (-1, 0), PURPLE),
("FONTSIZE", (0, 0), (-1, -1), 9),
("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey),
("VALIGN", (0, 0), (-1, -1), "TOP"),
]))
story.append(t)
else:
story.append(Paragraph("Kein Unternehmensprofil hinterlegt.", ss["Body2"]))
except Exception as e:
story.append(Paragraph(f"Fehler beim Laden: {e}", ss["Small"]))
story.append(Spacer(1, 5 * mm))
def _add_count_section(self, story, ss, title, table_name, tid):
story.append(Paragraph(title, ss["Section"]))
try:
count = self.db.execute(text(f"SELECT COUNT(*) FROM {table_name} WHERE tenant_id = :tid"), {"tid": tid}).scalar()
story.append(Paragraph(f"Eintraege: <b>{count or 0}</b>", ss["Body2"]))
except Exception:
story.append(Paragraph("Tabelle nicht vorhanden oder leer.", ss["Small"]))
story.append(Spacer(1, 3 * mm))
def _add_risk_section(self, story, ss, tid):
story.append(Paragraph("Risikobewertung", ss["Section"]))
try:
q = text("""
SELECT severity, COUNT(*) as cnt FROM compliance_risks
WHERE tenant_id = :tid GROUP BY severity ORDER BY severity
""")
rows = self.db.execute(q, {"tid": tid}).fetchall()
if rows:
data = [["Schweregrad", "Anzahl"]]
for r in rows:
data.append([r.severity or "UNKNOWN", str(r.cnt)])
t = Table(data, colWidths=[80 * mm, 40 * mm])
t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), LIGHT_PURPLE),
("TEXTCOLOR", (0, 0), (-1, 0), PURPLE),
("FONTSIZE", (0, 0), (-1, -1), 9),
("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey),
]))
story.append(t)
else:
story.append(Paragraph("Keine Risiken erfasst.", ss["Body2"]))
except Exception:
story.append(Paragraph("Risiko-Tabelle nicht vorhanden.", ss["Small"]))
story.append(Spacer(1, 3 * mm))
def _add_review_section(self, story, ss, tid):
story.append(Paragraph("Dokumenten-Reviews", ss["Section"]))
try:
q = text("SELECT status, COUNT(*) as cnt FROM compliance_document_reviews WHERE tenant_id = :tid GROUP BY status")
rows = self.db.execute(q, {"tid": tid}).fetchall()
if rows:
data = [["Status", "Anzahl"]]
for r in rows:
data.append([r.status, str(r.cnt)])
t = Table(data, colWidths=[80 * mm, 40 * mm])
t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), LIGHT_PURPLE),
("FONTSIZE", (0, 0), (-1, -1), 9),
("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey),
]))
story.append(t)
else:
story.append(Paragraph("Keine Reviews vorhanden.", ss["Body2"]))
except Exception:
story.append(Paragraph("Review-Tabelle nicht vorhanden.", ss["Small"]))
story.append(Spacer(1, 3 * mm))
def _add_consent_section(self, story, ss, tid):
story.append(Paragraph("Banner-Consents", ss["Section"]))
try:
count = self.db.execute(text("SELECT COUNT(*) FROM compliance_banner_consents WHERE tenant_id = :tid"), {"tid": tid}).scalar()
story.append(Paragraph(f"Gesamte Consents: <b>{count or 0}</b>", ss["Body2"]))
except Exception:
story.append(Paragraph("Banner-Tabelle nicht vorhanden.", ss["Small"]))
story.append(Spacer(1, 3 * mm))
def _add_role_section(self, story, ss, tid, pid):
story.append(Paragraph("Rollenkonzept", ss["Section"]))
try:
where = "tenant_id = :tid"
params: dict[str, Any] = {"tid": tid}
if pid:
where += " AND (project_id = :pid OR project_id IS NULL)"
params["pid"] = pid
rows = self.db.execute(text(f"SELECT role_key, role_label, person_name, person_email FROM compliance_org_roles WHERE {where} ORDER BY role_key"), params).fetchall()
if rows:
data = [["Rolle", "Name", "E-Mail"]]
for r in rows:
data.append([r.role_label or r.role_key, r.person_name or "-", r.person_email or "-"])
t = Table(data, colWidths=[60 * mm, 50 * mm, 50 * mm])
t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), LIGHT_PURPLE),
("TEXTCOLOR", (0, 0), (-1, 0), PURPLE),
("FONTSIZE", (0, 0), (-1, -1), 9),
("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey),
]))
story.append(t)
else:
story.append(Paragraph("Keine Rollen zugewiesen.", ss["Body2"]))
except Exception:
story.append(Paragraph("Rollen-Tabelle nicht vorhanden.", ss["Small"]))
def _add_attribution_footer(self, story, ss) -> None:
"""Stufe 2 of the attribution renderer (Task #23).
Adds a "Quellen und Lizenzen" section listing the platform's
license-rule distribution and, crucially, the mandatory
attribution lines for Rule-2 sources (CC-BY-SA, OECD, Apache).
For Rule 1 sources the attribution is optional but rendered as
a brief reference list for auditability.
The section is added to every generated compliance PDF so each
export carries its own provenance footer — pauschale Hinweise
in AGB/Impressum reichen rechtlich nicht (siehe
project_attribution_strategy.md).
"""
try:
rows = self.db.execute(text("""
SELECT cc.license_rule, COUNT(*) AS n,
array_agg(DISTINCT cpl.source_regulation ORDER BY cpl.source_regulation)
FILTER (WHERE cpl.source_regulation IS NOT NULL) AS sources
FROM compliance.canonical_controls cc
LEFT JOIN compliance.control_parent_links cpl ON cpl.control_uuid = cc.id
WHERE cc.license_rule IS NOT NULL
GROUP BY cc.license_rule
ORDER BY cc.license_rule
""")).fetchall()
except Exception as e:
logger.warning("attribution footer skipped: %s", e)
return
if not rows:
return
rule_labels = {1: "Hoheitsrecht/Public Domain (woertlich)",
2: "Mit Attribution (CC-BY u.ae.)",
3: "Nur Identifier-Verweis"}
story.append(Spacer(1, 8 * mm))
story.append(Paragraph("Quellen &amp; Lizenzen", ss["Section"]))
story.append(Paragraph(
"Dieser Bericht stuetzt sich auf klassifizierte Compliance-Controls "
"aus den folgenden Quellen. Jede Quelle ist deterministisch in eine "
"der drei Lizenzregeln (R1-R3) eingeordnet.", ss["Body2"]))
for r in rows:
rule = int(r.license_rule)
sources = (r.sources or [])[:8]
label = rule_labels.get(rule, f"Regel {rule}")
head = f"<b>R{rule}{label}</b> &nbsp; ({r.n} Controls)"
story.append(Paragraph(head, ss["Body2"]))
if sources:
src_text = "; ".join(sources)
if len(r.sources or []) > 8:
src_text += f" und {len(r.sources) - 8} weitere"
story.append(Paragraph(src_text, ss["Small"]))
if rule == 2:
story.append(Paragraph(
"Pflicht-Attribution: Inhalte aus den oben genannten Quellen sind "
"unter den jeweiligen freien Lizenzen (z.B. CC-BY-SA, OECD-Public, "
"Apache-2.0) wiedergegeben. Original-Urheber bleibt in jeder "
"Weiterverwendung zu nennen.", ss["Small"]))
story.append(Spacer(1, 2 * mm))