""" 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))