diff --git a/admin-compliance/app/api/vendor-compliance/assessments/[id]/route.ts b/admin-compliance/app/api/vendor-compliance/assessments/[id]/route.ts new file mode 100644 index 0000000..48e8a53 --- /dev/null +++ b/admin-compliance/app/api/vendor-compliance/assessments/[id]/route.ts @@ -0,0 +1,53 @@ +/** + * Vendor Assessment Status/Detail Proxy + */ + +import { NextRequest, NextResponse } from 'next/server' + +const BACKEND_URL = process.env.COMPLIANCE_BACKEND_URL || 'http://backend-compliance:8002' + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params + try { + const resp = await fetch( + `${BACKEND_URL}/api/vendor-compliance/assessments/${id}`, + { signal: AbortSignal.timeout(10000) }, + ) + const data = await resp.json() + return NextResponse.json(data, { status: resp.status }) + } catch (error) { + console.error('Assessment status proxy error:', error) + return NextResponse.json( + { error: 'Backend nicht erreichbar' }, + { status: 503 }, + ) + } +} + +export async function POST( + _request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params + try { + const resp = await fetch( + `${BACKEND_URL}/api/vendor-compliance/assessments/${id}/approve`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + signal: AbortSignal.timeout(10000), + }, + ) + const data = await resp.json() + return NextResponse.json(data, { status: resp.status }) + } catch (error) { + console.error('Assessment approve proxy error:', error) + return NextResponse.json( + { error: 'Backend nicht erreichbar' }, + { status: 503 }, + ) + } +} diff --git a/admin-compliance/app/api/vendor-compliance/assessments/route.ts b/admin-compliance/app/api/vendor-compliance/assessments/route.ts new file mode 100644 index 0000000..7d36aec --- /dev/null +++ b/admin-compliance/app/api/vendor-compliance/assessments/route.ts @@ -0,0 +1,41 @@ +/** + * Vendor Assessment API Proxy + * Proxies to backend-compliance (Python FastAPI) + */ + +import { NextRequest, NextResponse } from 'next/server' + +const BACKEND_URL = process.env.COMPLIANCE_BACKEND_URL || 'http://backend-compliance:8002' + +export async function POST(request: NextRequest) { + try { + const body = await request.text() + const resp = await fetch(`${BACKEND_URL}/api/vendor-compliance/assessments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + signal: AbortSignal.timeout(10000), + }) + const data = await resp.json() + return NextResponse.json(data, { status: resp.status }) + } catch (error) { + console.error('Vendor assessment proxy error:', error) + return NextResponse.json( + { error: 'Backend nicht erreichbar' }, + { status: 503 }, + ) + } +} + +export async function GET() { + try { + const resp = await fetch(`${BACKEND_URL}/api/vendor-compliance/assessments`, { + signal: AbortSignal.timeout(10000), + }) + const data = await resp.json() + return NextResponse.json(data) + } catch (error) { + console.error('Vendor assessment list proxy error:', error) + return NextResponse.json({ assessments: [] }) + } +} diff --git a/admin-compliance/app/sdk/vendor-assessment/_components/AssessmentProgress.tsx b/admin-compliance/app/sdk/vendor-assessment/_components/AssessmentProgress.tsx new file mode 100644 index 0000000..f0eed15 --- /dev/null +++ b/admin-compliance/app/sdk/vendor-assessment/_components/AssessmentProgress.tsx @@ -0,0 +1,93 @@ +'use client' + +import React, { useEffect, useRef, useState } from 'react' + +interface Props { + assessmentId: string + backendUrl: string + // eslint-disable-next-line @typescript-eslint/no-explicit-any + onComplete: (result: any) => void + onError: (msg: string) => void +} + +export function AssessmentProgress({ assessmentId, backendUrl, onComplete, onError }: Props) { + const [progress, setProgress] = useState('Initialisierung...') + const [dots, setDots] = useState(0) + const pollRef = useRef | null>(null) + + useEffect(() => { + const dotTimer = setInterval(() => setDots(d => (d + 1) % 4), 500) + + pollRef.current = setInterval(async () => { + try { + const res = await fetch( + `${backendUrl}/api/vendor-compliance/assessments/${assessmentId}`, + ) + if (!res.ok) return + + const data = await res.json() + + if (data.status === 'completed' && data.result) { + if (pollRef.current) clearInterval(pollRef.current) + clearInterval(dotTimer) + onComplete(data.result) + return + } + + if (data.status === 'failed') { + if (pollRef.current) clearInterval(pollRef.current) + clearInterval(dotTimer) + onError(data.error || 'Pruefung fehlgeschlagen') + return + } + + if (data.progress) { + setProgress(data.progress) + } + } catch { + // retry silently + } + }, 2000) + + return () => { + if (pollRef.current) clearInterval(pollRef.current) + clearInterval(dotTimer) + } + }, [assessmentId, backendUrl, onComplete, onError]) + + return ( +
+
+
+
+
+ +

+ Vertragspruefung laeuft +

+ +

+ {progress}{'.'.repeat(dots)} +

+ +
+
+ 1 + Text extrahieren +
+
+ 2 + Checklisten pruefen (L1/L2) +
+
+ 3 + Cross-Check zwischen Dokumenten +
+
+ 4 + Pruefprotokoll generieren +
+
+
+ ) +} diff --git a/admin-compliance/app/sdk/vendor-assessment/_components/DocumentUploader.tsx b/admin-compliance/app/sdk/vendor-assessment/_components/DocumentUploader.tsx new file mode 100644 index 0000000..6be3b94 --- /dev/null +++ b/admin-compliance/app/sdk/vendor-assessment/_components/DocumentUploader.tsx @@ -0,0 +1,154 @@ +'use client' + +import React, { useState } from 'react' + +interface DocumentEntry { + doc_type: string + label: string + url: string +} + +interface Props { + onStart: (vendorName: string, documents: DocumentEntry[]) => void +} + +const DOC_TYPES = [ + { value: 'auto', label: 'Automatisch erkennen' }, + { value: 'avv', label: 'AVV / Auftragsverarbeitungsvertrag' }, + { value: 'scc', label: 'SCC / Standardvertragsklauseln' }, + { value: 'tom_annex', label: 'TOM-Anlage (Art. 32)' }, + { value: 'sub_processor_list', label: 'Sub-Processor-Liste' }, + { value: 'agb', label: 'AGB / Nutzungsbedingungen' }, +] + +export function DocumentUploader({ onStart }: Props) { + const [vendorName, setVendorName] = useState('') + const [entries, setEntries] = useState([ + { doc_type: 'auto', label: '', url: '' }, + ]) + const [loading, setLoading] = useState(false) + + const updateEntry = (idx: number, field: keyof DocumentEntry, value: string) => { + setEntries(prev => { + const copy = [...prev] + copy[idx] = { ...copy[idx], [field]: value } + return copy + }) + } + + const addEntry = () => { + setEntries(prev => [...prev, { doc_type: 'auto', label: '', url: '' }]) + } + + const removeEntry = (idx: number) => { + if (entries.length <= 1) return + setEntries(prev => prev.filter((_, i) => i !== idx)) + } + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + const valid = entries.filter(d => d.url.trim()) + if (!vendorName.trim() || valid.length === 0) return + setLoading(true) + onStart(vendorName.trim(), valid.map(d => ({ + ...d, + label: d.label || `${DOC_TYPES.find(t => t.value === d.doc_type)?.label || d.doc_type}: ${vendorName}`, + }))) + } + + return ( +
+ {/* Vendor Name */} +
+ + setVendorName(e.target.value)} + placeholder="z.B. SysEleven GmbH, Amazon Web Services, Microsoft" + className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm" + required + /> +
+ + {/* Documents */} +
+

Dokumente

+

+ Fuegen Sie die URLs der Vertragsdokumente hinzu. Das System erkennt den Dokumenttyp automatisch. +

+ +
+ {entries.map((entry, idx) => ( +
+
+ +
+
+ updateEntry(idx, 'url', e.target.value)} + placeholder="https://example.com/avv.pdf" + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm" + required + /> +
+
+ updateEntry(idx, 'label', e.target.value)} + placeholder="Bezeichnung (optional)" + className="w-full px-3 py-2 border border-gray-200 rounded-lg text-sm" + /> +
+ {entries.length > 1 && ( + + )} +
+ ))} +
+ + +
+ + {/* Submit */} +
+ +

+ Dokumente werden automatisch gegen Art. 28 DSGVO, Art. 32, Art. 44-49 und weitere Anforderungen geprueft. +

+
+
+ ) +} diff --git a/admin-compliance/app/sdk/vendor-assessment/_components/PruefprotokollView.tsx b/admin-compliance/app/sdk/vendor-assessment/_components/PruefprotokollView.tsx new file mode 100644 index 0000000..986ffe5 --- /dev/null +++ b/admin-compliance/app/sdk/vendor-assessment/_components/PruefprotokollView.tsx @@ -0,0 +1,313 @@ +'use client' + +import React, { useState } from 'react' + +interface Props { + result: { + vendor_name: string + documents: DocumentResult[] + findings: Finding[] + overall_score: number + category_scores: Record + cross_check_findings: CrossCheckFinding[] + report_html: string + checked_at: string + } + onReset: () => void +} + +interface DocumentResult { + label: string + doc_type: string + completeness_pct: number + correctness_pct: number + checks: Check[] + findings_count: number + error: string +} + +interface Check { + id: string + label: string + passed: boolean + severity: string + level: number + parent: string | null + skipped: boolean + hint: string +} + +interface Finding { + id: string + category: string + severity: string + type: string + title: string + description: string + document_label: string + document_type: string +} + +interface CrossCheckFinding { + id: string + label: string + severity: string + hint: string +} + +const CAT_LABELS: Record = { + INSTRUCTION: 'Weisungsgebundenheit', + CONFIDENTIALITY: 'Vertraulichkeit', + TOM: 'TOM (Art. 32)', + SUBPROCESSOR: 'Unterauftragsverarbeitung', + DATA_SUBJECT_RIGHTS: 'Betroffenenrechte', + DELETION: 'Loeschung/Rueckgabe', + AUDIT_RIGHTS: 'Audit-/Inspektionsrechte', + INCIDENT: 'Datenschutzverletzungen', + TRANSFER: 'Drittlandtransfer', + LIABILITY: 'Haftung', + AVV_CONTENT: 'AVV Inhalt', +} + +const SEV_COLORS: Record = { + CRITICAL: 'bg-red-100 text-red-700 border-red-200', + HIGH: 'bg-orange-100 text-orange-700 border-orange-200', + MEDIUM: 'bg-yellow-100 text-yellow-700 border-yellow-200', + LOW: 'bg-green-100 text-green-700 border-green-200', +} + +function scoreColor(s: number) { + if (s >= 80) return 'text-green-600' + if (s >= 50) return 'text-yellow-600' + return 'text-red-600' +} + +function scoreBg(s: number) { + if (s >= 80) return 'bg-green-50 border-green-200' + if (s >= 50) return 'bg-yellow-50 border-yellow-200' + return 'bg-red-50 border-red-200' +} + +function verdict(s: number) { + if (s >= 80) return 'Bestanden' + if (s >= 50) return 'Bedingt bestanden' + return 'Nicht bestanden' +} + +export function PruefprotokollView({ result, onReset }: Props) { + const [expandedDoc, setExpandedDoc] = useState(null) + const [showHtml, setShowHtml] = useState(false) + + const criticalCount = result.findings.filter(f => f.severity === 'CRITICAL').length + + result.cross_check_findings.filter(f => f.severity === 'CRITICAL').length + const totalFindings = result.findings.length + result.cross_check_findings.length + + return ( +
+ {/* Score Overview */} +
+
Pruefprotokoll — {result.vendor_name}
+
+ {result.overall_score}% +
+
{verdict(result.overall_score)}
+
+ + {/* Summary Stats */} +
+
+
{result.documents.length}
+
Dokumente
+
+
+
{totalFindings}
+
Findings
+
+
+
{criticalCount}
+
Kritisch
+
+
+ + {/* Category Scores */} + {Object.keys(result.category_scores).length > 0 && ( +
+

Kategorie-Uebersicht

+
+ {Object.entries(result.category_scores) + .sort(([, a], [, b]) => a - b) + .map(([cat, score]) => ( +
+ {CAT_LABELS[cat] || cat} +
+
= 80 ? 'bg-green-500' : score >= 50 ? 'bg-yellow-500' : 'bg-red-500'}`} + style={{ width: `${score}%` }} + /> +
+ {score}% +
+ ))} +
+
+ )} + + {/* Cross-Check Findings */} + {result.cross_check_findings.length > 0 && ( +
+

+ Dokumenten-Cross-Check + {result.cross_check_findings.length} Findings +

+
+ {result.cross_check_findings.map(f => ( +
+
+ + {f.severity} + + {f.label} +
+

{f.hint}

+
+ ))} +
+
+ )} + + {/* Documents Detail */} +
+

Gepruefte Dokumente

+
+ {result.documents.map((doc, i) => ( +
+ + + {expandedDoc === i && ( +
+ {doc.error ? ( +

{doc.error}

+ ) : ( +
+ {doc.checks.filter(c => c.level === 1).map(c => { + const l2s = doc.checks.filter(l => l.level === 2 && l.parent === c.id) + return ( +
+
+ {c.passed ? '✓' : c.skipped ? '—' : '✗'} + {c.label} + + {c.severity} + +
+ {!c.passed && !c.skipped && c.hint && ( +
+ {c.hint} +
+ )} + {l2s.map(l2 => ( +
+ {l2.passed ? '✓' : l2.skipped ? '—' : '✗'} + {l2.label} +
+ ))} +
+ ) + })} +
+ )} +
+ )} +
+ ))} +
+
+ + {/* Findings Detail */} + {result.findings.length > 0 && ( +
+

+ Alle Findings + {result.findings.length} +

+
+ {[...result.findings] + .sort((a, b) => { + const order: Record = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 } + return (order[a.severity] ?? 4) - (order[b.severity] ?? 4) + }) + .map(f => ( +
+
+ + {f.severity} + + {f.title} +
+
+ {CAT_LABELS[f.category] || f.category} | {f.document_label} +
+ {f.description && ( +

{f.description}

+ )} +
+ ))} +
+
+ )} + + {/* Actions */} +
+ + {result.report_html && ( + + )} +
+ + {/* Print-ready HTML report */} + {showHtml && result.report_html && ( +
+
+
+ )} +
+ ) +} diff --git a/admin-compliance/app/sdk/vendor-assessment/page.tsx b/admin-compliance/app/sdk/vendor-assessment/page.tsx new file mode 100644 index 0000000..4b92d50 --- /dev/null +++ b/admin-compliance/app/sdk/vendor-assessment/page.tsx @@ -0,0 +1,143 @@ +'use client' + +import React, { useState, useCallback } from 'react' +import { DocumentUploader } from './_components/DocumentUploader' +import { AssessmentProgress } from './_components/AssessmentProgress' +import { PruefprotokollView } from './_components/PruefprotokollView' + +type View = 'upload' | 'progress' | 'result' + +interface AssessmentResult { + vendor_name: string + documents: DocumentResult[] + findings: Finding[] + overall_score: number + category_scores: Record + cross_check_findings: CrossCheckFinding[] + report_html: string + checked_at: string +} + +interface DocumentResult { + label: string + url: string + doc_type: string + word_count: number + completeness_pct: number + correctness_pct: number + checks: Check[] + findings_count: number + error: string +} + +interface Check { + id: string + label: string + passed: boolean + severity: string + level: number + parent: string | null + skipped: boolean + hint: string + matched_text: string +} + +interface Finding { + id: string + category: string + severity: string + type: string + title: string + description: string + recommendation: string + document_label: string + document_type: string +} + +interface CrossCheckFinding { + id: string + label: string + severity: string + hint: string +} + +const BACKEND_URL = process.env.NEXT_PUBLIC_COMPLIANCE_API_URL || '' + +export default function VendorAssessmentPage() { + const [view, setView] = useState('upload') + const [assessmentId, setAssessmentId] = useState('') + const [result, setResult] = useState(null) + const [error, setError] = useState('') + + const handleStartAssessment = useCallback(async ( + vendorName: string, + documents: Array<{ doc_type: string; label: string; url: string }>, + ) => { + setError('') + try { + const res = await fetch(`${BACKEND_URL}/api/vendor-compliance/assessments`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ vendor_name: vendorName, documents }), + }) + if (!res.ok) throw new Error(await res.text()) + const data = await res.json() + setAssessmentId(data.assessment_id) + setView('progress') + } catch (e) { + setError(e instanceof Error ? e.message : 'Fehler beim Starten') + } + }, []) + + const handleComplete = useCallback((data: AssessmentResult) => { + setResult(data) + setView('result') + }, []) + + const handleReset = useCallback(() => { + setView('upload') + setAssessmentId('') + setResult(null) + setError('') + }, []) + + return ( +
+
+ {/* Header */} +
+

Vertragspruefung

+

+ Automatisierte Pruefung von Auftragsverarbeitungsvertraegen gem. Art. 28 DSGVO +

+
+ + {error && ( +
+

{error}

+ +
+ )} + + {view === 'upload' && ( + + )} + + {view === 'progress' && assessmentId && ( + { setError(msg); setView('upload') }} + /> + )} + + {view === 'result' && result && ( + + )} +
+
+ ) +} diff --git a/admin-compliance/components/sdk/Sidebar/SidebarModuleList.tsx b/admin-compliance/components/sdk/Sidebar/SidebarModuleList.tsx index dde2413..47c4af5 100644 --- a/admin-compliance/components/sdk/Sidebar/SidebarModuleList.tsx +++ b/admin-compliance/components/sdk/Sidebar/SidebarModuleList.tsx @@ -96,15 +96,15 @@ export function SidebarModuleList({ collapsed, projectId, pendingCRCount }: Side projectId={projectId} /> } - label="Use-Case Audits" - isActive={pathname?.startsWith('/sdk/use-case-audit') ?? false} + label="Vertragspruefung" + isActive={pathname?.startsWith('/sdk/vendor-assessment') ?? false} collapsed={collapsed} projectId={projectId} /> diff --git a/backend-compliance/compliance/api/vendor_assessment_routes.py b/backend-compliance/compliance/api/vendor_assessment_routes.py index 61c380c..f3069ed 100644 --- a/backend-compliance/compliance/api/vendor_assessment_routes.py +++ b/backend-compliance/compliance/api/vendor_assessment_routes.py @@ -26,6 +26,9 @@ from compliance.services.dsi_document_checker import ( from compliance.services.vendor_assessment_cross_check import ( cross_check_documents, ) +from compliance.services.vendor_assessment_report import ( + build_pruefprotokoll, +) logger = logging.getLogger(__name__) @@ -84,6 +87,7 @@ class AssessmentResult(BaseModel): overall_score: int = 0 category_scores: dict[str, int] = {} cross_check_findings: list[dict] = [] + report_html: str = "" checked_at: str = "" @@ -320,6 +324,12 @@ async def _run_assessment(assessment_id: str, req: AssessmentRequest): checked_at=datetime.now(timezone.utc).isoformat(), ) + # 8. Generate Pruefprotokoll HTML + try: + result.report_html = build_pruefprotokoll(result.model_dump()) + except Exception as e: + logger.warning("Report generation failed: %s", e) + job["status"] = "completed" job["progress"] = "" job["result"] = result diff --git a/backend-compliance/compliance/services/vendor_assessment_report.py b/backend-compliance/compliance/services/vendor_assessment_report.py new file mode 100644 index 0000000..141b258 --- /dev/null +++ b/backend-compliance/compliance/services/vendor_assessment_report.py @@ -0,0 +1,309 @@ +""" +Vendor Assessment Pruefprotokoll — HTML report builder. + +Generates a professional assessment report styled like a real DSB +Pruefprotokoll for vendor contract analysis (Art. 28 DSGVO). +""" + +from datetime import datetime, timezone + + +def build_pruefprotokoll(result: dict) -> str: + """Build HTML Pruefprotokoll from assessment result.""" + vendor = result.get("vendor_name", "Unbekannt") + docs = result.get("documents", []) + findings = result.get("findings", []) + cross = result.get("cross_check_findings", []) + cat_scores = result.get("category_scores", {}) + overall = result.get("overall_score", 0) + checked_at = result.get("checked_at", datetime.now(timezone.utc).isoformat()) + + verdict = _verdict(overall) + now_str = _format_date(checked_at) + protocol_nr = f"VP-{datetime.now().strftime('%Y')}-{abs(hash(vendor)) % 10000:04d}" + + html = [_style(), '
'] + + # ── 1. Kopfdaten ──────────────────────────────────────────────── + html.append(f''' +
+

Pruefprotokoll

+

Auftragsverarbeitung gem. Art. 28 DSGVO

+
+ + + + + + + +
Protokoll-Nr.{protocol_nr}
Pruefungsdatum{now_str}
Auftragsverarbeiter{vendor}
Pruefungsumfang{len(docs)} Dokument(e)
PrueferAutomatisierte Pruefung (BreakPilot Compliance SDK)
FreigabeAusstehend
''') + + # ── 2. Zusammenfassung ────────────────────────────────────────── + critical_count = sum(1 for f in findings if _get(f, "severity") == "CRITICAL") + critical_count += sum(1 for f in cross if f.get("severity") == "CRITICAL") + total_findings = len(findings) + len(cross) + + html.append(f''' +
+
{overall}%
+
{verdict["label"]}
+
+
+
{len(docs)}Dokumente
+
{total_findings}Findings
+
{critical_count}Kritisch
+
''') + + # ── Kategorie-Scores ──────────────────────────────────────────── + if cat_scores: + html.append('

Kategorie-Uebersicht

') + html.append('') + for cat, score in sorted(cat_scores.items(), key=lambda x: x[1]): + status = _cat_status(score) + html.append(f''' + + + + ''') + html.append('
KategorieScoreStatus
{_cat_label(cat)}{_bar(score)}{status["label"]}
') + + # ── 3. Gepruefte Dokumente ────────────────────────────────────── + html.append('

Gepruefte Dokumente

') + for i, doc in enumerate(docs): + _render_document(html, doc, i + 1) + + # ── 4. Cross-Check Findings ───────────────────────────────────── + if cross: + html.append('

Dokumenten-Cross-Check

') + for f in cross: + sev = f.get("severity", "MEDIUM") + html.append(f'''
+ {sev} + {f.get("label", "")} +

{f.get("hint", "")}

+
''') + + # ── 5. Findings ───────────────────────────────────────────────── + if findings: + html.append('

Findings (sortiert nach Schweregrad)

') + sorted_findings = sorted(findings, + key=lambda f: {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3}.get( + _get(f, "severity"), 4)) + for f in sorted_findings: + sev = _get(f, "severity") + html.append(f'''
+ {sev} + {_get(f, "title")} +
{_get(f, "category")} | {_get(f, "document_label")}
+

{_get(f, "description")}

+
''') + + # ── 6. Freigabe-Block ─────────────────────────────────────────── + html.append(f''' +
+

Freigabe

+
+ + + +
+
+
Datum: _______________
+
Unterschrift DSB: _______________
+
+
''') + + html.append('
') + return "\n".join(html) + + +def _render_document(html: list[str], doc: dict, num: int): + """Render a single document section with L1/L2 checks.""" + label = doc.get("label", "Dokument") + dtype = doc.get("doc_type", "").upper() + comp = doc.get("completeness_pct", 0) + corr = doc.get("correctness_pct", 0) + error = doc.get("error", "") + checks = doc.get("checks", []) + + html.append(f'''
+

{num}. {label} {dtype}

''') + + if error: + html.append(f'
{error}
') + return + + html.append(f''' +
+ Vollstaendigkeit: {_bar(comp)} + Korrektheit: {_bar(corr)} +
''') + + # L1/L2 hierarchy + l1_checks = [c for c in checks if c.get("level") == 1] + l2_by_parent = {} + for c in checks: + if c.get("level") == 2 and c.get("parent"): + l2_by_parent.setdefault(c["parent"], []).append(c) + + if l1_checks: + html.append('') + for c in l1_checks: + passed = c.get("passed", False) + skipped = c.get("skipped", False) + icon = _icon(passed, skipped) + sev = c.get("severity", "") + html.append(f''' + + + + ''') + + if not passed and not skipped and c.get("hint"): + html.append(f'') + + for l2 in l2_by_parent.get(c.get("id", ""), []): + l2_passed = l2.get("passed", False) + l2_icon = _icon(l2_passed, l2.get("skipped", False)) + html.append(f''' + + + + ''') + + html.append('
{icon}{c.get("label", "")}{sev}
{c["hint"]}
{l2_icon}{l2.get("label", "")}
') + + html.append('
') + + +# ── Helpers ───────────────────────────────────────────────────────── + +def _get(obj, key, default=""): + """Get from dict or Pydantic model.""" + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) + + +def _verdict(score: int) -> dict: + if score >= 80: + return {"label": "Bestanden", "class": "green"} + if score >= 50: + return {"label": "Bedingt bestanden — Nachbesserung erforderlich", "class": "yellow"} + return {"label": "Nicht bestanden", "class": "red"} + + +def _cat_status(score: int) -> dict: + if score >= 80: + return {"label": "Bestanden", "class": "green"} + if score >= 50: + return {"label": "Teilweise", "class": "yellow"} + return {"label": "Mangelhaft", "class": "red"} + + +_CAT_LABELS = { + "INSTRUCTION": "Weisungsgebundenheit (Art. 28(3)(a))", + "CONFIDENTIALITY": "Vertraulichkeit (Art. 28(3)(b))", + "TOM": "Technische/Org. Massnahmen (Art. 32)", + "SUBPROCESSOR": "Unterauftragsverarbeitung (Art. 28(3)(d))", + "DATA_SUBJECT_RIGHTS": "Betroffenenrechte (Art. 28(3)(e))", + "DELETION": "Loeschung/Rueckgabe (Art. 28(3)(g))", + "AUDIT_RIGHTS": "Audit-/Inspektionsrechte (Art. 28(3)(h))", + "INCIDENT": "Datenschutzverletzungen (Art. 33)", + "TRANSFER": "Drittlandtransfer (Art. 44-49)", + "LIABILITY": "Haftung (Art. 82)", + "AVV_CONTENT": "AVV Inhalt (Art. 28(3))", + "GENERAL": "Allgemein", +} + + +def _cat_label(cat: str) -> str: + return _CAT_LABELS.get(cat, cat) + + +def _bar(pct: int) -> str: + color = "#22c55e" if pct >= 80 else "#eab308" if pct >= 50 else "#ef4444" + return ( + f'
' + f'
' + f'
{pct}%' + ) + + +def _icon(passed: bool, skipped: bool = False) -> str: + if skipped: + return '' + if passed: + return '' + return '' + + +def _format_date(iso: str) -> str: + try: + dt = datetime.fromisoformat(iso.replace("Z", "+00:00")) + return dt.strftime("%d.%m.%Y %H:%M") + except (ValueError, AttributeError): + return datetime.now().strftime("%d.%m.%Y %H:%M") + + +def _style() -> str: + return ''''''