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
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:
@@ -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}/status — Schnell-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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user