feat(dsfa): Go DSFA deprecated, URL-Fix, fehlende Endpoints + 145 Tests
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 34s
CI / test-python-backend-compliance (push) Successful in 30s
CI / test-python-document-crawler (push) Successful in 27s
CI / test-python-dsms-gateway (push) Successful in 18s

- Go: DEPRECATED-Kommentare an allen 6 DSFA-Handlern + Route-Block
- api.ts: URL-Fix /dsgvo/dsfas → /dsfa (Detail-Seite war komplett kaputt)
- Python: Section-Update, Workflow (submit/approve), Export (JSON+CSV), UCCA-Stubs
- Tests: 145/145 bestanden (Schema + Route-Integration mit TestClient+SQLite)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-06 19:41:12 +01:00
parent 095eff26d9
commit 6a940344c2
5 changed files with 1005 additions and 97 deletions

View File

@@ -2,14 +2,21 @@
FastAPI routes for DSFA — Datenschutz-Folgenabschaetzung (Art. 35 DSGVO).
Endpoints:
GET /v1/dsfa — Liste (tenant_id + status-filter + skip/limit)
POST /v1/dsfa — Neu erstellen → 201
GET /v1/dsfa/stats — Zähler nach Status
GET /v1/dsfa/audit-log — Audit-Log
GET /v1/dsfa/{id} — Detail
PUT /v1/dsfa/{id} — Update
DELETE /v1/dsfa/{id}Löschen (Art. 17 DSGVO)
PATCH /v1/dsfa/{id}/statusSchnell-Statuswechsel
GET /v1/dsfa — Liste (tenant_id + status-filter + skip/limit)
POST /v1/dsfa — Neu erstellen → 201
GET /v1/dsfa/stats — Zähler nach Status
GET /v1/dsfa/audit-log — Audit-Log
GET /v1/dsfa/export/csv — CSV-Export aller DSFAs
POST /v1/dsfa/from-assessment/{id} — Stub: DSFA aus UCCA-Assessment
GET /v1/dsfa/by-assessment/{id}Stub: DSFA nach Assessment-ID
GET /v1/dsfa/{id} Detail
PUT /v1/dsfa/{id} — Update
DELETE /v1/dsfa/{id} — Löschen (Art. 17 DSGVO)
PATCH /v1/dsfa/{id}/status — Schnell-Statuswechsel
PUT /v1/dsfa/{id}/sections/{nr} — Section-Update (1-8)
POST /v1/dsfa/{id}/submit-for-review — Workflow: Einreichen
POST /v1/dsfa/{id}/approve — Workflow: Genehmigen/Ablehnen
GET /v1/dsfa/{id}/export — JSON-Export einer DSFA
"""
import logging
@@ -165,6 +172,20 @@ class DSFAStatusUpdate(BaseModel):
approved_by: Optional[str] = None
class DSFASectionUpdate(BaseModel):
"""Body for PUT /dsfa/{id}/sections/{section_number}."""
content: Optional[str] = None
# Allow arbitrary extra fields so the frontend can send any section-specific data
extra: Optional[dict] = None
class DSFAApproveRequest(BaseModel):
"""Body for POST /dsfa/{id}/approve."""
approved: bool
comments: Optional[str] = None
approved_by: Optional[str] = None
# =============================================================================
# Helpers
# =============================================================================
@@ -207,7 +228,11 @@ def _dsfa_to_response(row) -> dict:
def _ts(val):
"""Timestamp → ISO string or None."""
return val.isoformat() if val else None
if not val:
return None
if isinstance(val, str):
return val
return val.isoformat()
def _get(key, default=None):
"""Safe row access — returns default if key missing (handles old rows)."""
@@ -389,12 +414,68 @@ async def get_audit_log(
"changed_by": r["changed_by"],
"old_values": r["old_values"],
"new_values": r["new_values"],
"created_at": r["created_at"].isoformat() if r["created_at"] else None,
"created_at": r["created_at"] if isinstance(r["created_at"], str) else (r["created_at"].isoformat() if r["created_at"] else None),
}
for r in rows
]
# =============================================================================
# CSV Export (must be before /{id} to avoid route conflict)
# =============================================================================
@router.get("/export/csv", name="export_dsfas_csv")
async def export_dsfas_csv(
tenant_id: Optional[str] = Query(None),
db: Session = Depends(get_db),
):
"""Export all DSFAs as CSV."""
import csv
import io
tid = _get_tenant_id(tenant_id)
rows = db.execute(
text("SELECT * FROM compliance_dsfas WHERE tenant_id = :tid ORDER BY created_at DESC"),
{"tid": tid},
).fetchall()
output = io.StringIO()
writer = csv.writer(output, delimiter=";")
writer.writerow(["ID", "Titel", "Status", "Risiko-Level", "Erstellt", "Aktualisiert"])
for r in rows:
writer.writerow([
str(r["id"]),
r["title"],
r["status"] or "draft",
r["risk_level"] or "low",
r["created_at"] if isinstance(r["created_at"], str) else (r["created_at"].isoformat() if r["created_at"] else ""),
r["updated_at"] if isinstance(r["updated_at"], str) else (r["updated_at"].isoformat() if r["updated_at"] else ""),
])
from fastapi.responses import Response
return Response(
content=output.getvalue(),
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=dsfas_export.csv"},
)
# =============================================================================
# UCCA Integration Stubs (must be before /{id} to avoid route conflict)
# =============================================================================
@router.post("/from-assessment/{assessment_id}", status_code=501)
async def create_from_assessment(assessment_id: str):
"""Stub: Create DSFA from UCCA assessment. Requires cross-service communication."""
return {"detail": "Not implemented — requires cross-service integration with ai-compliance-sdk"}
@router.get("/by-assessment/{assessment_id}", status_code=501)
async def get_by_assessment(assessment_id: str):
"""Stub: Get DSFA by linked UCCA assessment ID."""
return {"detail": "Not implemented — requires cross-service integration with ai-compliance-sdk"}
# =============================================================================
# List + Create
# =============================================================================
@@ -627,3 +708,204 @@ async def update_dsfa_status(
)
db.commit()
return _dsfa_to_response(row)
# =============================================================================
# Section Update
# =============================================================================
SECTION_FIELD_MAP = {
1: "processing_description",
2: "necessity_assessment",
3: "risk_assessment", # maps to overall_risk_level + risk_score
4: "stakeholder_consultations", # JSONB
5: "measures", # JSONB array
6: "dpo_opinion", # consultation section
7: "conclusion", # documentation / conclusion
8: "ai_use_case_modules", # JSONB array Section 8 KI
}
@router.put("/{dsfa_id}/sections/{section_number}")
async def update_section(
dsfa_id: str,
section_number: int,
request: DSFASectionUpdate,
tenant_id: Optional[str] = Query(None),
db: Session = Depends(get_db),
):
"""Update a specific DSFA section (1-8)."""
import json
if section_number < 1 or section_number > 8:
raise HTTPException(status_code=422, detail=f"Section must be 1-8, got {section_number}")
tid = _get_tenant_id(tenant_id)
existing = db.execute(
text("SELECT * FROM compliance_dsfas WHERE id = :id AND tenant_id = :tid"),
{"id": dsfa_id, "tid": tid},
).fetchone()
if not existing:
raise HTTPException(status_code=404, detail=f"DSFA {dsfa_id} nicht gefunden")
field = SECTION_FIELD_MAP[section_number]
jsonb_sections = {4, 5, 8}
params: dict = {"id": dsfa_id, "tid": tid}
if section_number in jsonb_sections:
value = request.extra if request.extra is not None else ([] if section_number != 4 else [])
params["val"] = json.dumps(value)
set_clause = f"{field} = CAST(:val AS jsonb)"
else:
params["val"] = request.content or ""
set_clause = f"{field} = :val"
# Also update section_progress
progress = existing["section_progress"] if existing["section_progress"] else {}
if isinstance(progress, str):
progress = json.loads(progress)
progress[f"section_{section_number}"] = True
params["progress"] = json.dumps(progress)
row = db.execute(
text(f"""
UPDATE compliance_dsfas
SET {set_clause}, section_progress = CAST(:progress AS jsonb), updated_at = NOW()
WHERE id = :id AND tenant_id = :tid
RETURNING *
"""),
params,
).fetchone()
_log_audit(db, tid, dsfa_id, "SECTION_UPDATE", new_values={"section": section_number, "field": field})
db.commit()
return _dsfa_to_response(row)
# =============================================================================
# Workflow: Submit for Review + Approve
# =============================================================================
@router.post("/{dsfa_id}/submit-for-review")
async def submit_for_review(
dsfa_id: str,
tenant_id: Optional[str] = Query(None),
db: Session = Depends(get_db),
):
"""Submit a DSFA for DPO review (draft → in-review)."""
tid = _get_tenant_id(tenant_id)
existing = db.execute(
text("SELECT id, status FROM compliance_dsfas WHERE id = :id AND tenant_id = :tid"),
{"id": dsfa_id, "tid": tid},
).fetchone()
if not existing:
raise HTTPException(status_code=404, detail=f"DSFA {dsfa_id} nicht gefunden")
if existing["status"] not in ("draft", "needs-update"):
raise HTTPException(
status_code=422,
detail=f"Kann nur aus Status 'draft' oder 'needs-update' eingereicht werden, aktuell: {existing['status']}",
)
row = db.execute(
text("""
UPDATE compliance_dsfas
SET status = 'in-review', submitted_for_review_at = NOW(), updated_at = NOW()
WHERE id = :id AND tenant_id = :tid
RETURNING *
"""),
{"id": dsfa_id, "tid": tid},
).fetchone()
_log_audit(
db, tid, dsfa_id, "SUBMIT_FOR_REVIEW",
old_values={"status": existing["status"]},
new_values={"status": "in-review"},
)
db.commit()
return {"message": "DSFA zur Prüfung eingereicht", "status": "in-review", "dsfa": _dsfa_to_response(row)}
@router.post("/{dsfa_id}/approve")
async def approve_dsfa(
dsfa_id: str,
request: DSFAApproveRequest,
tenant_id: Optional[str] = Query(None),
db: Session = Depends(get_db),
):
"""Approve or reject a DSFA (DPO/CISO action)."""
tid = _get_tenant_id(tenant_id)
existing = db.execute(
text("SELECT id, status FROM compliance_dsfas WHERE id = :id AND tenant_id = :tid"),
{"id": dsfa_id, "tid": tid},
).fetchone()
if not existing:
raise HTTPException(status_code=404, detail=f"DSFA {dsfa_id} nicht gefunden")
if existing["status"] != "in-review":
raise HTTPException(
status_code=422,
detail=f"Nur DSFAs im Status 'in-review' können genehmigt werden, aktuell: {existing['status']}",
)
if request.approved:
new_status = "approved"
row = db.execute(
text("""
UPDATE compliance_dsfas
SET status = 'approved', approved_by = :approved_by, approved_at = NOW(), updated_at = NOW()
WHERE id = :id AND tenant_id = :tid
RETURNING *
"""),
{"id": dsfa_id, "tid": tid, "approved_by": request.approved_by or "system"},
).fetchone()
else:
new_status = "needs-update"
row = db.execute(
text("""
UPDATE compliance_dsfas
SET status = 'needs-update', updated_at = NOW()
WHERE id = :id AND tenant_id = :tid
RETURNING *
"""),
{"id": dsfa_id, "tid": tid},
).fetchone()
_log_audit(
db, tid, dsfa_id, "APPROVE" if request.approved else "REJECT",
old_values={"status": existing["status"]},
new_values={"status": new_status, "comments": request.comments},
)
db.commit()
return {"message": f"DSFA {'genehmigt' if request.approved else 'zurückgewiesen'}", "status": new_status}
# =============================================================================
# Export
# =============================================================================
@router.get("/{dsfa_id}/export")
async def export_dsfa_json(
dsfa_id: str,
format: str = Query("json"),
tenant_id: Optional[str] = Query(None),
db: Session = Depends(get_db),
):
"""Export a single DSFA as JSON."""
tid = _get_tenant_id(tenant_id)
row = db.execute(
text("SELECT * FROM compliance_dsfas WHERE id = :id AND tenant_id = :tid"),
{"id": dsfa_id, "tid": tid},
).fetchone()
if not row:
raise HTTPException(status_code=404, detail=f"DSFA {dsfa_id} nicht gefunden")
dsfa_data = _dsfa_to_response(row)
return {
"exported_at": datetime.utcnow().isoformat(),
"format": format,
"dsfa": dsfa_data,
}