"""
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: {count or 0}", 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: {count or 0}", 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 & 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"R{rule} — {label} ({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))