feat(vendor-assessment): Pruefprotokoll + Frontend + Sidebar
Build + Deploy / build-admin-compliance (push) Successful in 2m16s
Build + Deploy / build-ai-sdk (push) Successful in 58s
Build + Deploy / build-developer-portal (push) Successful in 1m13s
Build + Deploy / build-tts (push) Successful in 1m43s
CI / loc-budget (push) Failing after 17s
CI / secret-scan (push) Has been skipped
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
Build + Deploy / build-backend-compliance (push) Successful in 3m27s
Build + Deploy / build-document-crawler (push) Successful in 45s
Build + Deploy / build-dsms-gateway (push) Successful in 30s
Build + Deploy / build-dsms-node (push) Successful in 19s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / nodejs-build (push) Successful in 2m35s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Successful in 43s
CI / test-python-backend (push) Successful in 37s
CI / test-python-document-crawler (push) Successful in 26s
CI / test-python-dsms-gateway (push) Successful in 21s
CI / validate-canonical-controls (push) Successful in 14s
Build + Deploy / trigger-orca (push) Successful in 3m33s
Build + Deploy / build-admin-compliance (push) Successful in 2m16s
Build + Deploy / build-ai-sdk (push) Successful in 58s
Build + Deploy / build-developer-portal (push) Successful in 1m13s
Build + Deploy / build-tts (push) Successful in 1m43s
CI / loc-budget (push) Failing after 17s
CI / secret-scan (push) Has been skipped
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
Build + Deploy / build-backend-compliance (push) Successful in 3m27s
Build + Deploy / build-document-crawler (push) Successful in 45s
Build + Deploy / build-dsms-gateway (push) Successful in 30s
Build + Deploy / build-dsms-node (push) Successful in 19s
CI / branch-name (push) Has been skipped
CI / guardrail-integrity (push) Has been skipped
CI / nodejs-build (push) Successful in 2m35s
CI / dep-audit (push) Has been skipped
CI / sbom-scan (push) Has been skipped
CI / test-go (push) Successful in 43s
CI / test-python-backend (push) Successful in 37s
CI / test-python-document-crawler (push) Successful in 26s
CI / test-python-dsms-gateway (push) Successful in 21s
CI / validate-canonical-controls (push) Successful in 14s
Build + Deploy / trigger-orca (push) Successful in 3m33s
Phase 4-5: Professional Pruefprotokoll report builder with styled HTML output (Kopfdaten, Kategorie-Scores, L1/L2 Check-Hierarchie, Findings, Freigabe-Block). Frontend at /sdk/vendor-assessment with 3-step flow: DocumentUploader → AssessmentProgress → PruefprotokollView. Sidebar: "Use-Case Audits" → "Vertragspruefung" renamed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(), '<div class="report">']
|
||||
|
||||
# ── 1. Kopfdaten ────────────────────────────────────────────────
|
||||
html.append(f'''
|
||||
<div class="header">
|
||||
<h1>Pruefprotokoll</h1>
|
||||
<h2>Auftragsverarbeitung gem. Art. 28 DSGVO</h2>
|
||||
</div>
|
||||
<table class="meta">
|
||||
<tr><td class="label">Protokoll-Nr.</td><td>{protocol_nr}</td></tr>
|
||||
<tr><td class="label">Pruefungsdatum</td><td>{now_str}</td></tr>
|
||||
<tr><td class="label">Auftragsverarbeiter</td><td><strong>{vendor}</strong></td></tr>
|
||||
<tr><td class="label">Pruefungsumfang</td><td>{len(docs)} Dokument(e)</td></tr>
|
||||
<tr><td class="label">Pruefer</td><td>Automatisierte Pruefung (BreakPilot Compliance SDK)</td></tr>
|
||||
<tr><td class="label">Freigabe</td><td><em>Ausstehend</em></td></tr>
|
||||
</table>''')
|
||||
|
||||
# ── 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'''
|
||||
<div class="score-box {verdict["class"]}">
|
||||
<div class="score-value">{overall}%</div>
|
||||
<div class="score-label">{verdict["label"]}</div>
|
||||
</div>
|
||||
<div class="summary-row">
|
||||
<div class="stat"><span class="stat-value">{len(docs)}</span><span class="stat-label">Dokumente</span></div>
|
||||
<div class="stat"><span class="stat-value">{total_findings}</span><span class="stat-label">Findings</span></div>
|
||||
<div class="stat"><span class="stat-value red">{critical_count}</span><span class="stat-label">Kritisch</span></div>
|
||||
</div>''')
|
||||
|
||||
# ── Kategorie-Scores ────────────────────────────────────────────
|
||||
if cat_scores:
|
||||
html.append('<h3>Kategorie-Uebersicht</h3><table class="cat-table">')
|
||||
html.append('<tr><th>Kategorie</th><th>Score</th><th>Status</th></tr>')
|
||||
for cat, score in sorted(cat_scores.items(), key=lambda x: x[1]):
|
||||
status = _cat_status(score)
|
||||
html.append(f'''<tr>
|
||||
<td>{_cat_label(cat)}</td>
|
||||
<td>{_bar(score)}</td>
|
||||
<td><span class="badge {status["class"]}">{status["label"]}</span></td>
|
||||
</tr>''')
|
||||
html.append('</table>')
|
||||
|
||||
# ── 3. Gepruefte Dokumente ──────────────────────────────────────
|
||||
html.append('<h3>Gepruefte Dokumente</h3>')
|
||||
for i, doc in enumerate(docs):
|
||||
_render_document(html, doc, i + 1)
|
||||
|
||||
# ── 4. Cross-Check Findings ─────────────────────────────────────
|
||||
if cross:
|
||||
html.append('<h3>Dokumenten-Cross-Check</h3>')
|
||||
for f in cross:
|
||||
sev = f.get("severity", "MEDIUM")
|
||||
html.append(f'''<div class="finding {sev.lower()}">
|
||||
<span class="sev-badge {sev.lower()}">{sev}</span>
|
||||
<strong>{f.get("label", "")}</strong>
|
||||
<p class="hint">{f.get("hint", "")}</p>
|
||||
</div>''')
|
||||
|
||||
# ── 5. Findings ─────────────────────────────────────────────────
|
||||
if findings:
|
||||
html.append('<h3>Findings (sortiert nach Schweregrad)</h3>')
|
||||
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'''<div class="finding {sev.lower()}">
|
||||
<span class="sev-badge {sev.lower()}">{sev}</span>
|
||||
<strong>{_get(f, "title")}</strong>
|
||||
<div class="finding-meta">{_get(f, "category")} | {_get(f, "document_label")}</div>
|
||||
<p class="hint">{_get(f, "description")}</p>
|
||||
</div>''')
|
||||
|
||||
# ── 6. Freigabe-Block ───────────────────────────────────────────
|
||||
html.append(f'''
|
||||
<div class="approval-block">
|
||||
<h3>Freigabe</h3>
|
||||
<div class="approval-options">
|
||||
<label><input type="checkbox" disabled> Pruefprotokoll wird freigegeben</label>
|
||||
<label><input type="checkbox" disabled> Pruefprotokoll wird mit Auflagen freigegeben</label>
|
||||
<label><input type="checkbox" disabled> Pruefprotokoll wird abgelehnt</label>
|
||||
</div>
|
||||
<div class="signature">
|
||||
<div>Datum: _______________</div>
|
||||
<div>Unterschrift DSB: _______________</div>
|
||||
</div>
|
||||
</div>''')
|
||||
|
||||
html.append('</div>')
|
||||
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'''<div class="doc-section">
|
||||
<h4>{num}. {label} <span class="doc-type">{dtype}</span></h4>''')
|
||||
|
||||
if error:
|
||||
html.append(f'<div class="error">{error}</div></div>')
|
||||
return
|
||||
|
||||
html.append(f'''
|
||||
<div class="doc-scores">
|
||||
<span>Vollstaendigkeit: {_bar(comp)}</span>
|
||||
<span>Korrektheit: {_bar(corr)}</span>
|
||||
</div>''')
|
||||
|
||||
# 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('<table class="check-table">')
|
||||
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'''<tr class="l1 {'pass' if passed else 'fail' if not skipped else 'skip'}">
|
||||
<td class="icon">{icon}</td>
|
||||
<td>{c.get("label", "")}</td>
|
||||
<td><span class="sev-badge {sev.lower()}">{sev}</span></td>
|
||||
</tr>''')
|
||||
|
||||
if not passed and not skipped and c.get("hint"):
|
||||
html.append(f'<tr><td></td><td colspan="2" class="hint-cell">{c["hint"]}</td></tr>')
|
||||
|
||||
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'''<tr class="l2 {'pass' if l2_passed else 'fail'}">
|
||||
<td class="icon">{l2_icon}</td>
|
||||
<td style="padding-left:24px">{l2.get("label", "")}</td>
|
||||
<td></td>
|
||||
</tr>''')
|
||||
|
||||
html.append('</table>')
|
||||
|
||||
html.append('</div>')
|
||||
|
||||
|
||||
# ── 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'<div style="display:inline-block;width:100px;height:8px;background:#e5e7eb;'
|
||||
f'border-radius:4px;overflow:hidden;vertical-align:middle;margin-right:6px">'
|
||||
f'<div style="width:{pct}%;height:100%;background:{color};border-radius:4px"></div>'
|
||||
f'</div><span style="font-weight:600;color:{color}">{pct}%</span>'
|
||||
)
|
||||
|
||||
|
||||
def _icon(passed: bool, skipped: bool = False) -> str:
|
||||
if skipped:
|
||||
return '<span style="color:#d1d5db">—</span>'
|
||||
if passed:
|
||||
return '<span style="color:#22c55e;font-weight:bold">✓</span>'
|
||||
return '<span style="color:#ef4444;font-weight:bold">✗</span>'
|
||||
|
||||
|
||||
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 '''<style>
|
||||
.report { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 800px; margin: 0 auto; color: #1f2937; }
|
||||
.header { text-align: center; border-bottom: 2px solid #1f2937; padding-bottom: 12px; margin-bottom: 20px; }
|
||||
.header h1 { margin: 0; font-size: 24px; }
|
||||
.header h2 { margin: 4px 0 0; font-size: 14px; color: #6b7280; font-weight: normal; }
|
||||
.meta { width: 100%; margin-bottom: 24px; border-collapse: collapse; }
|
||||
.meta td { padding: 4px 12px; font-size: 13px; border-bottom: 1px solid #f3f4f6; }
|
||||
.meta .label { font-weight: 600; color: #6b7280; width: 180px; }
|
||||
.score-box { text-align: center; padding: 20px; border-radius: 12px; margin: 16px 0; }
|
||||
.score-box.green { background: #f0fdf4; border: 2px solid #86efac; }
|
||||
.score-box.yellow { background: #fefce8; border: 2px solid #fde047; }
|
||||
.score-box.red { background: #fef2f2; border: 2px solid #fca5a5; }
|
||||
.score-value { font-size: 48px; font-weight: 800; }
|
||||
.green .score-value { color: #16a34a; }
|
||||
.yellow .score-value { color: #ca8a04; }
|
||||
.red .score-value { color: #dc2626; }
|
||||
.score-label { font-size: 14px; color: #6b7280; margin-top: 4px; }
|
||||
.summary-row { display: flex; gap: 16px; margin: 16px 0 24px; }
|
||||
.stat { flex: 1; text-align: center; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; }
|
||||
.stat-value { display: block; font-size: 28px; font-weight: 700; color: #1f2937; }
|
||||
.stat-value.red { color: #dc2626; }
|
||||
.stat-label { display: block; font-size: 11px; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
h3 { font-size: 16px; margin: 28px 0 12px; padding-bottom: 6px; border-bottom: 1px solid #e5e7eb; }
|
||||
.cat-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.cat-table th { text-align: left; padding: 6px 8px; background: #f9fafb; border-bottom: 1px solid #e5e7eb; font-weight: 600; color: #6b7280; }
|
||||
.cat-table td { padding: 8px; border-bottom: 1px solid #f3f4f6; }
|
||||
.badge { padding: 2px 8px; border-radius: 9999px; font-size: 11px; font-weight: 600; }
|
||||
.badge.green { background: #dcfce7; color: #16a34a; }
|
||||
.badge.yellow { background: #fef9c3; color: #ca8a04; }
|
||||
.badge.red { background: #fee2e2; color: #dc2626; }
|
||||
.doc-section { background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 16px; margin: 12px 0; }
|
||||
.doc-section h4 { margin: 0 0 8px; font-size: 14px; }
|
||||
.doc-type { background: #e0e7ff; color: #4338ca; padding: 1px 6px; border-radius: 4px; font-size: 11px; font-weight: 600; }
|
||||
.doc-scores { display: flex; gap: 24px; margin-bottom: 12px; font-size: 13px; }
|
||||
.error { color: #dc2626; font-size: 13px; }
|
||||
.check-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.check-table td { padding: 4px 6px; }
|
||||
.check-table .icon { width: 20px; text-align: center; }
|
||||
.l1 { font-weight: 500; }
|
||||
.l1.fail td { background: #fef2f2; }
|
||||
.l2 { color: #6b7280; }
|
||||
.hint-cell { font-size: 11px; color: #dc2626; padding: 2px 6px 8px 26px !important; background: #fef2f2; border-left: 3px solid #fca5a5; }
|
||||
.sev-badge { padding: 1px 6px; border-radius: 4px; font-size: 10px; font-weight: 700; }
|
||||
.sev-badge.critical { background: #fee2e2; color: #dc2626; }
|
||||
.sev-badge.high { background: #ffedd5; color: #ea580c; }
|
||||
.sev-badge.medium { background: #fef9c3; color: #ca8a04; }
|
||||
.sev-badge.low { background: #dcfce7; color: #16a34a; }
|
||||
.finding { border-left: 4px solid; padding: 10px 14px; margin: 8px 0; border-radius: 0 8px 8px 0; background: #fff; }
|
||||
.finding.critical { border-color: #dc2626; background: #fef2f2; }
|
||||
.finding.high { border-color: #ea580c; background: #fff7ed; }
|
||||
.finding.medium { border-color: #ca8a04; background: #fefce8; }
|
||||
.finding.low { border-color: #16a34a; background: #f0fdf4; }
|
||||
.finding-meta { font-size: 11px; color: #9ca3af; margin: 2px 0; }
|
||||
.hint { font-size: 12px; color: #4b5563; margin: 6px 0 0; line-height: 1.5; }
|
||||
.approval-block { margin-top: 32px; padding: 20px; border: 2px dashed #d1d5db; border-radius: 8px; }
|
||||
.approval-options label { display: block; margin: 8px 0; font-size: 13px; }
|
||||
.signature { display: flex; gap: 40px; margin-top: 24px; font-size: 13px; color: #6b7280; }
|
||||
</style>'''
|
||||
Reference in New Issue
Block a user