From 07cc00da11012ac5f83da624023a851084c5845a Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Thu, 21 May 2026 21:30:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(licenses):=20Stufe=202=20=E2=80=94=20auto-?= =?UTF-8?q?attribution=20footer=20in=20compliance=20PDF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../services/compliance_pdf_generator.py | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/backend-compliance/compliance/services/compliance_pdf_generator.py b/backend-compliance/compliance/services/compliance_pdf_generator.py index 433394d6..6172d4c8 100644 --- a/backend-compliance/compliance/services/compliance_pdf_generator.py +++ b/backend-compliance/compliance/services/compliance_pdf_generator.py @@ -82,6 +82,8 @@ class CompliancePDFGenerator: 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"])) @@ -214,3 +216,64 @@ class CompliancePDFGenerator: 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))