diff --git a/admin-compliance/app/sdk/dsr/[requestId]/_components/ActionButtons.tsx b/admin-compliance/app/sdk/dsr/[requestId]/_components/ActionButtons.tsx
index 9521b37..7727b43 100644
--- a/admin-compliance/app/sdk/dsr/[requestId]/_components/ActionButtons.tsx
+++ b/admin-compliance/app/sdk/dsr/[requestId]/_components/ActionButtons.tsx
@@ -23,9 +23,24 @@ export function ActionButtons({
if (isTerminal) {
return (
-
)
}
diff --git a/backend-compliance/compliance/api/dsr_routes.py b/backend-compliance/compliance/api/dsr_routes.py
index 45503d6..0870d8e 100644
--- a/backend-compliance/compliance/api/dsr_routes.py
+++ b/backend-compliance/compliance/api/dsr_routes.py
@@ -367,3 +367,42 @@ async def update_exception_check(
):
with translate_domain_errors():
return svc.update_exception_check(dsr_id, check_id, body, tenant_id)
+
+
+# =============================================================================
+# User Data Export (Art. 15 / Art. 20)
+# =============================================================================
+
+
+@router.get("/{dsr_id}/export-user-data")
+async def export_user_data(
+ dsr_id: str,
+ format: str = Query("json"),
+ tenant_id: str = Depends(_get_tenant),
+ svc: DSRService = Depends(_dsr_svc),
+ db: Session = Depends(get_db),
+):
+ """Export all CMP data about the data subject as JSON, CSV, or PDF."""
+ import io
+ from compliance.services.dsr_export_service import DSRExportService
+
+ with translate_domain_errors():
+ dsr = svc.get(dsr_id, tenant_id)
+ email = dsr.get("requester_email")
+ if not email:
+ from fastapi import HTTPException
+ raise HTTPException(400, "DSR has no requester email")
+
+ export_svc = DSRExportService(db)
+ if format == "pdf":
+ content, filename = export_svc.export_pdf(tenant_id, email)
+ return StreamingResponse(io.BytesIO(content), media_type="application/pdf",
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'})
+ elif format == "csv":
+ content, filename = export_svc.export_csv(tenant_id, email)
+ return StreamingResponse(io.BytesIO(content), media_type="text/csv",
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'})
+ else:
+ content, filename = export_svc.export_json(tenant_id, email)
+ return StreamingResponse(io.BytesIO(content), media_type="application/json",
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'})
diff --git a/backend-compliance/compliance/services/dsr_export_service.py b/backend-compliance/compliance/services/dsr_export_service.py
new file mode 100644
index 0000000..cdad1aa
--- /dev/null
+++ b/backend-compliance/compliance/services/dsr_export_service.py
@@ -0,0 +1,261 @@
+"""
+DSR User Data Export Service — aggregates all CMP data about a user.
+
+Supports Art. 15 (access right, PDF) and Art. 20 (data portability, JSON/CSV).
+Collects from: Banner Consents, Einwilligungen, Consent Audit Trail, DSR History.
+"""
+
+import csv
+import io
+import json
+import logging
+import uuid
+from datetime import datetime, timezone
+from typing import Any, Optional
+
+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
+
+from sqlalchemy import text
+from sqlalchemy.orm import Session
+
+from compliance.services.banner_dsr_service import BannerDSRService
+
+logger = logging.getLogger(__name__)
+
+PURPLE = colors.HexColor("#7c3aed")
+LIGHT_PURPLE = colors.HexColor("#f5f3ff")
+GRAY = colors.HexColor("#6b7280")
+
+
+class DSRExportService:
+ """Aggregates and exports all user data stored in the CMP."""
+
+ def __init__(self, db: Session) -> None:
+ self.db = db
+
+ def aggregate_user_data(self, tenant_id: str, email: str) -> dict[str, Any]:
+ """Collect ALL data about a user from all CMP sources."""
+ now = datetime.now(timezone.utc)
+ tid = uuid.UUID(tenant_id) if len(tenant_id) > 20 else tenant_id
+
+ # 1. Banner consents + audit trail
+ banner_data: dict[str, Any] = {"banner_consents": [], "audit_trail": []}
+ try:
+ banner_svc = BannerDSRService(self.db)
+ banner_data = banner_svc.export_for_dsr(tenant_id, email)
+ except Exception as e:
+ logger.warning("Banner DSR export failed: %s", e)
+
+ # 2. Einwilligungen (user-based consents)
+ einwilligungen: list[dict] = []
+ try:
+ q = text("""
+ SELECT c.id, c.data_point_id, c.granted, c.granted_at, c.revoked_at,
+ c.consent_version, c.source, c.ip_address, c.user_agent, c.created_at
+ FROM compliance_einwilligungen_consents c
+ WHERE c.tenant_id = :tid AND c.user_id = :email
+ ORDER BY c.created_at DESC
+ """)
+ rows = self.db.execute(q, {"tid": tid, "email": email}).fetchall()
+ for r in rows:
+ entry = dict(r._mapping)
+ for k, v in entry.items():
+ if isinstance(v, datetime):
+ entry[k] = v.isoformat()
+ elif isinstance(v, uuid.UUID):
+ entry[k] = str(v)
+ # Get history
+ hist_q = text("""
+ SELECT action, consent_version, ip_address, user_agent, source, created_at
+ FROM compliance_einwilligungen_consent_history
+ WHERE consent_id = :cid ORDER BY created_at
+ """)
+ hist = self.db.execute(hist_q, {"cid": entry["id"]}).fetchall()
+ entry["history"] = [
+ {k: (v.isoformat() if isinstance(v, datetime) else str(v) if isinstance(v, uuid.UUID) else v)
+ for k, v in dict(h._mapping).items()}
+ for h in hist
+ ]
+ einwilligungen.append(entry)
+ except Exception as e:
+ logger.warning("Einwilligungen export failed: %s", e)
+
+ # 3. DSR requests by this user
+ dsr_requests: list[dict] = []
+ try:
+ q = text("""
+ SELECT id, request_number, request_type, status, received_at, deadline_at, completed_at
+ FROM compliance_dsr_requests
+ WHERE tenant_id = :tid AND requester_email = :email
+ ORDER BY received_at DESC
+ """)
+ rows = self.db.execute(q, {"tid": tid, "email": email}).fetchall()
+ for r in rows:
+ entry = dict(r._mapping)
+ for k, v in entry.items():
+ if isinstance(v, datetime):
+ entry[k] = v.isoformat()
+ elif isinstance(v, uuid.UUID):
+ entry[k] = str(v)
+ dsr_requests.append(entry)
+ except Exception as e:
+ logger.warning("DSR requests export failed: %s", e)
+
+ return {
+ "export_date": now.isoformat(),
+ "data_subject": {"email": email},
+ "banner_consents": banner_data.get("banner_consents", []),
+ "consent_audit_trail": banner_data.get("audit_trail", []),
+ "einwilligungen": einwilligungen,
+ "dsr_requests": dsr_requests,
+ "metadata": {
+ "tenant_id": tenant_id,
+ "data_categories": ["Banner-Consents", "Einwilligungen", "Audit-Trail", "DSR-Anfragen"],
+ "legal_basis": "Art. 15 / Art. 20 DSGVO",
+ },
+ }
+
+ def export_json(self, tenant_id: str, email: str) -> tuple[bytes, str]:
+ data = self.aggregate_user_data(tenant_id, email)
+ data["metadata"]["export_format"] = "json"
+ content = json.dumps(data, indent=2, ensure_ascii=False, default=str).encode("utf-8")
+ return content, f"dsr-export-{email.split('@')[0]}.json"
+
+ def export_csv(self, tenant_id: str, email: str) -> tuple[bytes, str]:
+ data = self.aggregate_user_data(tenant_id, email)
+ buf = io.StringIO()
+ writer = csv.writer(buf)
+ writer.writerow(["Kategorie", "Schluessel", "Wert", "Zeitpunkt", "Quelle"])
+
+ # Banner consents
+ for c in data.get("banner_consents", []):
+ writer.writerow(["Banner-Consent", "site_id", c.get("site_id", ""), c.get("created_at", ""), "CMP"])
+ writer.writerow(["Banner-Consent", "categories", ", ".join(c.get("categories", [])), c.get("updated_at", ""), "CMP"])
+ writer.writerow(["Banner-Consent", "ip_hash", c.get("ip_hash", ""), c.get("created_at", ""), "CMP"])
+
+ # Audit trail
+ for a in data.get("consent_audit_trail", []):
+ writer.writerow(["Audit-Trail", a.get("action", ""), ", ".join(a.get("categories", [])), a.get("created_at", ""), "CMP"])
+
+ # Einwilligungen
+ for e in data.get("einwilligungen", []):
+ status = "Erteilt" if e.get("granted") else "Widerrufen"
+ writer.writerow(["Einwilligung", e.get("data_point_id", ""), status, e.get("granted_at", ""), e.get("source", "")])
+
+ # DSR requests
+ for d in data.get("dsr_requests", []):
+ writer.writerow(["DSR-Anfrage", d.get("request_type", ""), d.get("status", ""), d.get("received_at", ""), ""])
+
+ content = buf.getvalue().encode("utf-8-sig") # BOM for Excel
+ return content, f"dsr-export-{email.split('@')[0]}.csv"
+
+ def export_pdf(self, tenant_id: str, email: str) -> tuple[bytes, str]:
+ data = self.aggregate_user_data(tenant_id, email)
+ buf = io.BytesIO()
+ doc = SimpleDocTemplate(buf, pagesize=A4, leftMargin=20 * mm, rightMargin=20 * mm, topMargin=25 * mm, bottomMargin=20 * mm)
+ ss = getSampleStyleSheet()
+ ss.add(ParagraphStyle("Title2", parent=ss["Title"], fontSize=20, textColor=PURPLE, spaceAfter=6))
+ ss.add(ParagraphStyle("Section", parent=ss["Heading2"], fontSize=13, textColor=PURPLE, spaceBefore=10))
+ ss.add(ParagraphStyle("Body2", parent=ss["Normal"], fontSize=9, leading=13))
+ ss.add(ParagraphStyle("Small", parent=ss["Normal"], fontSize=8, textColor=GRAY))
+ story: list = []
+
+ # Cover
+ story.append(Paragraph("Datenauskunft gemaess Art. 15 DSGVO", ss["Title2"]))
+ story.append(Paragraph(f"Betroffene Person: {email}", ss["Body2"]))
+ story.append(Paragraph(f"Erstellt am: {data['export_date'][:10]}", ss["Small"]))
+ story.append(Spacer(1, 8 * mm))
+
+ tbl_style = TableStyle([
+ ("BACKGROUND", (0, 0), (-1, 0), LIGHT_PURPLE),
+ ("TEXTCOLOR", (0, 0), (-1, 0), PURPLE),
+ ("FONTSIZE", (0, 0), (-1, -1), 8),
+ ("GRID", (0, 0), (-1, -1), 0.5, colors.lightgrey),
+ ("VALIGN", (0, 0), (-1, -1), "TOP"),
+ ("TOPPADDING", (0, 0), (-1, -1), 3),
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 3),
+ ])
+
+ # Section 1: Banner Consents
+ consents = data.get("banner_consents", [])
+ story.append(Paragraph(f"1. Banner-Consents ({len(consents)})", ss["Section"]))
+ if consents:
+ rows = [["Site", "Kategorien", "IP-Hash", "Erstellt", "Aktualisiert"]]
+ for c in consents:
+ rows.append([
+ str(c.get("site_id", "")),
+ ", ".join(c.get("categories", [])),
+ str(c.get("ip_hash", ""))[:12] + "...",
+ str(c.get("created_at", ""))[:10],
+ str(c.get("updated_at", ""))[:10],
+ ])
+ t = Table(rows, colWidths=[30 * mm, 40 * mm, 30 * mm, 25 * mm, 25 * mm])
+ t.setStyle(tbl_style)
+ story.append(t)
+ else:
+ story.append(Paragraph("Keine Banner-Consents gespeichert.", ss["Body2"]))
+
+ # Section 2: Einwilligungen
+ einw = data.get("einwilligungen", [])
+ story.append(Paragraph(f"2. Einwilligungen ({len(einw)})", ss["Section"]))
+ if einw:
+ rows = [["Datenpunkt", "Status", "Erteilt am", "Widerrufen am", "IP-Adresse"]]
+ for e in einw:
+ rows.append([
+ str(e.get("data_point_id", "")),
+ "Erteilt" if e.get("granted") else "Widerrufen",
+ str(e.get("granted_at", ""))[:10],
+ str(e.get("revoked_at", ""))[:10] if e.get("revoked_at") else "-",
+ str(e.get("ip_address", ""))[:15] if e.get("ip_address") else "-",
+ ])
+ t = Table(rows, colWidths=[35 * mm, 25 * mm, 25 * mm, 25 * mm, 35 * mm])
+ t.setStyle(tbl_style)
+ story.append(t)
+ else:
+ story.append(Paragraph("Keine Einwilligungen gespeichert.", ss["Body2"]))
+
+ # Section 3: Audit Trail
+ trail = data.get("consent_audit_trail", [])
+ story.append(Paragraph(f"3. Consent-Audit-Trail ({len(trail)})", ss["Section"]))
+ if trail:
+ rows = [["Aktion", "Kategorien", "Datum"]]
+ for a in trail[:50]: # Limit to 50 for PDF
+ rows.append([
+ str(a.get("action", "")),
+ ", ".join(a.get("categories", [])),
+ str(a.get("created_at", ""))[:19],
+ ])
+ t = Table(rows, colWidths=[40 * mm, 60 * mm, 45 * mm])
+ t.setStyle(tbl_style)
+ story.append(t)
+ if len(trail) > 50:
+ story.append(Paragraph(f"... und {len(trail) - 50} weitere Eintraege (im JSON-Export enthalten)", ss["Small"]))
+ else:
+ story.append(Paragraph("Kein Audit-Trail vorhanden.", ss["Body2"]))
+
+ # Section 4: DSR Requests
+ dsrs = data.get("dsr_requests", [])
+ story.append(Paragraph(f"4. Bisherige DSR-Anfragen ({len(dsrs)})", ss["Section"]))
+ if dsrs:
+ rows = [["Typ", "Status", "Eingegangen", "Abgeschlossen"]]
+ for d in dsrs:
+ rows.append([
+ str(d.get("request_type", "")),
+ str(d.get("status", "")),
+ str(d.get("received_at", ""))[:10],
+ str(d.get("completed_at", ""))[:10] if d.get("completed_at") else "-",
+ ])
+ t = Table(rows, colWidths=[35 * mm, 30 * mm, 35 * mm, 35 * mm])
+ t.setStyle(tbl_style)
+ story.append(t)
+
+ # Footer
+ story.append(Spacer(1, 15 * mm))
+ story.append(Paragraph("Erstellt mit BreakPilot Compliance SDK | Art. 15 DSGVO Datenauskunft", ss["Small"]))
+
+ doc.build(story)
+ return buf.getvalue(), f"dsr-export-{email.split('@')[0]}.pdf"