Files
breakpilot-compliance/backend-compliance/compliance/api/dsfa_routes.py
Benjamin Admin 05aa0ee2c6
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 35s
CI / test-python-backend-compliance (push) Successful in 32s
CI / test-python-document-crawler (push) Successful in 22s
CI / test-python-dsms-gateway (push) Successful in 21s
fix: DSFA Router-Prefix und Proxy-Pfad korrigiert
Router-Prefix /v1/dsfa → /dsfa (konsistent mit allen anderen Routes)
Proxy-Pfad /api/v1/dsfa → /api/compliance/dsfa

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 23:22:17 +01:00

438 lines
14 KiB
Python

"""
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
"""
import logging
from datetime import datetime
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.orm import Session
from classroom_engine.database import get_db
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/dsfa", tags=["compliance-dsfa"])
DEFAULT_TENANT_ID = "default"
VALID_STATUSES = {"draft", "in-review", "approved", "needs-update"}
VALID_RISK_LEVELS = {"low", "medium", "high", "critical"}
# =============================================================================
# Pydantic Schemas
# =============================================================================
class DSFACreate(BaseModel):
title: str
description: str = ""
status: str = "draft"
risk_level: str = "low"
processing_activity: str = ""
data_categories: List[str] = []
recipients: List[str] = []
measures: List[str] = []
created_by: str = "system"
class DSFAUpdate(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
status: Optional[str] = None
risk_level: Optional[str] = None
processing_activity: Optional[str] = None
data_categories: Optional[List[str]] = None
recipients: Optional[List[str]] = None
measures: Optional[List[str]] = None
approved_by: Optional[str] = None
class DSFAStatusUpdate(BaseModel):
status: str
approved_by: Optional[str] = None
# =============================================================================
# Helpers
# =============================================================================
def _get_tenant_id(tenant_id: Optional[str]) -> str:
return tenant_id or DEFAULT_TENANT_ID
def _dsfa_to_response(row) -> dict:
"""Convert a DB row to a JSON-serializable dict."""
import json
def parse_json(val):
if val is None:
return []
if isinstance(val, list):
return val
if isinstance(val, str):
try:
return json.loads(val)
except Exception:
return []
return val
return {
"id": str(row["id"]),
"tenant_id": row["tenant_id"],
"title": row["title"],
"description": row["description"] or "",
"status": row["status"] or "draft",
"risk_level": row["risk_level"] or "low",
"processing_activity": row["processing_activity"] or "",
"data_categories": parse_json(row["data_categories"]),
"recipients": parse_json(row["recipients"]),
"measures": parse_json(row["measures"]),
"approved_by": row["approved_by"],
"approved_at": row["approved_at"].isoformat() if row["approved_at"] else None,
"created_by": row["created_by"] or "system",
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
"updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
}
def _log_audit(
db: Session,
tenant_id: str,
dsfa_id,
action: str,
changed_by: str = "system",
old_values=None,
new_values=None,
):
import json
db.execute(
text("""
INSERT INTO compliance_dsfa_audit_log
(tenant_id, dsfa_id, action, changed_by, old_values, new_values)
VALUES
(:tenant_id, :dsfa_id, :action, :changed_by,
CAST(:old_values AS jsonb), CAST(:new_values AS jsonb))
"""),
{
"tenant_id": tenant_id,
"dsfa_id": str(dsfa_id) if dsfa_id else None,
"action": action,
"changed_by": changed_by,
"old_values": json.dumps(old_values) if old_values else None,
"new_values": json.dumps(new_values) if new_values else None,
},
)
# =============================================================================
# Stats (must be before /{id} to avoid route conflict)
# =============================================================================
@router.get("/stats")
async def get_stats(
tenant_id: Optional[str] = Query(None),
db: Session = Depends(get_db),
):
"""Zähler nach Status und Risiko-Level."""
tid = _get_tenant_id(tenant_id)
rows = db.execute(
text("SELECT status, risk_level FROM compliance_dsfas WHERE tenant_id = :tid"),
{"tid": tid},
).fetchall()
by_status: dict = {}
by_risk: dict = {}
for row in rows:
s = row["status"] or "draft"
r = row["risk_level"] or "low"
by_status[s] = by_status.get(s, 0) + 1
by_risk[r] = by_risk.get(r, 0) + 1
return {
"total": len(rows),
"by_status": by_status,
"by_risk_level": by_risk,
"draft_count": by_status.get("draft", 0),
"in_review_count": by_status.get("in-review", 0),
"approved_count": by_status.get("approved", 0),
"needs_update_count": by_status.get("needs-update", 0),
}
# =============================================================================
# Audit Log (must be before /{id} to avoid route conflict)
# =============================================================================
@router.get("/audit-log")
async def get_audit_log(
tenant_id: Optional[str] = Query(None),
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
db: Session = Depends(get_db),
):
"""DSFA Audit-Trail."""
tid = _get_tenant_id(tenant_id)
rows = db.execute(
text("""
SELECT id, tenant_id, dsfa_id, action, changed_by, old_values, new_values, created_at
FROM compliance_dsfa_audit_log
WHERE tenant_id = :tid
ORDER BY created_at DESC
LIMIT :limit OFFSET :offset
"""),
{"tid": tid, "limit": limit, "offset": offset},
).fetchall()
return [
{
"id": str(r["id"]),
"tenant_id": r["tenant_id"],
"dsfa_id": str(r["dsfa_id"]) if r["dsfa_id"] else None,
"action": r["action"],
"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,
}
for r in rows
]
# =============================================================================
# List + Create
# =============================================================================
@router.get("")
async def list_dsfas(
tenant_id: Optional[str] = Query(None),
status: Optional[str] = Query(None),
risk_level: Optional[str] = Query(None),
skip: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=500),
db: Session = Depends(get_db),
):
"""Liste aller DSFAs für einen Tenant."""
tid = _get_tenant_id(tenant_id)
sql = "SELECT * FROM compliance_dsfas WHERE tenant_id = :tid"
params: dict = {"tid": tid}
if status:
sql += " AND status = :status"
params["status"] = status
if risk_level:
sql += " AND risk_level = :risk_level"
params["risk_level"] = risk_level
sql += " ORDER BY created_at DESC LIMIT :limit OFFSET :skip"
params["limit"] = limit
params["skip"] = skip
rows = db.execute(text(sql), params).fetchall()
return [_dsfa_to_response(r) for r in rows]
@router.post("", status_code=201)
async def create_dsfa(
request: DSFACreate,
tenant_id: Optional[str] = Query(None),
db: Session = Depends(get_db),
):
"""Neue DSFA erstellen."""
import json
if request.status not in VALID_STATUSES:
raise HTTPException(status_code=422, detail=f"Ungültiger Status: {request.status}")
if request.risk_level not in VALID_RISK_LEVELS:
raise HTTPException(status_code=422, detail=f"Ungültiges Risiko-Level: {request.risk_level}")
tid = _get_tenant_id(tenant_id)
row = db.execute(
text("""
INSERT INTO compliance_dsfas
(tenant_id, title, description, status, risk_level,
processing_activity, data_categories, recipients, measures, created_by)
VALUES
(:tenant_id, :title, :description, :status, :risk_level,
:processing_activity,
CAST(:data_categories AS jsonb),
CAST(:recipients AS jsonb),
CAST(:measures AS jsonb),
:created_by)
RETURNING *
"""),
{
"tenant_id": tid,
"title": request.title,
"description": request.description,
"status": request.status,
"risk_level": request.risk_level,
"processing_activity": request.processing_activity,
"data_categories": json.dumps(request.data_categories),
"recipients": json.dumps(request.recipients),
"measures": json.dumps(request.measures),
"created_by": request.created_by,
},
).fetchone()
db.flush()
_log_audit(
db, tid, row["id"], "CREATE", request.created_by,
new_values={"title": request.title, "status": request.status},
)
db.commit()
return _dsfa_to_response(row)
# =============================================================================
# Single Item (GET / PUT / DELETE / PATCH status)
# =============================================================================
@router.get("/{dsfa_id}")
async def get_dsfa(
dsfa_id: str,
tenant_id: Optional[str] = Query(None),
db: Session = Depends(get_db),
):
"""Einzelne DSFA abrufen."""
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")
return _dsfa_to_response(row)
@router.put("/{dsfa_id}")
async def update_dsfa(
dsfa_id: str,
request: DSFAUpdate,
tenant_id: Optional[str] = Query(None),
db: Session = Depends(get_db),
):
"""DSFA aktualisieren."""
import json
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")
updates = request.model_dump(exclude_none=True)
if "status" in updates and updates["status"] not in VALID_STATUSES:
raise HTTPException(status_code=422, detail=f"Ungültiger Status: {updates['status']}")
if "risk_level" in updates and updates["risk_level"] not in VALID_RISK_LEVELS:
raise HTTPException(status_code=422, detail=f"Ungültiges Risiko-Level: {updates['risk_level']}")
if not updates:
return _dsfa_to_response(existing)
set_clauses = []
params: dict = {"id": dsfa_id, "tid": tid}
jsonb_fields = {"data_categories", "recipients", "measures"}
for field, value in updates.items():
if field in jsonb_fields:
set_clauses.append(f"{field} = CAST(:{field} AS jsonb)")
params[field] = json.dumps(value)
else:
set_clauses.append(f"{field} = :{field}")
params[field] = value
set_clauses.append("updated_at = NOW()")
sql = f"UPDATE compliance_dsfas SET {', '.join(set_clauses)} WHERE id = :id AND tenant_id = :tid RETURNING *"
old_values = {"title": existing["title"], "status": existing["status"]}
row = db.execute(text(sql), params).fetchone()
_log_audit(db, tid, dsfa_id, "UPDATE", new_values=updates, old_values=old_values)
db.commit()
return _dsfa_to_response(row)
@router.delete("/{dsfa_id}")
async def delete_dsfa(
dsfa_id: str,
tenant_id: Optional[str] = Query(None),
db: Session = Depends(get_db),
):
"""DSFA löschen (Art. 17 DSGVO)."""
tid = _get_tenant_id(tenant_id)
existing = db.execute(
text("SELECT id, title 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")
_log_audit(db, tid, dsfa_id, "DELETE", old_values={"title": existing["title"]})
db.execute(
text("DELETE FROM compliance_dsfas WHERE id = :id AND tenant_id = :tid"),
{"id": dsfa_id, "tid": tid},
)
db.commit()
return {"success": True, "message": f"DSFA {dsfa_id} gelöscht"}
@router.patch("/{dsfa_id}/status")
async def update_dsfa_status(
dsfa_id: str,
request: DSFAStatusUpdate,
tenant_id: Optional[str] = Query(None),
db: Session = Depends(get_db),
):
"""Schnell-Statuswechsel."""
if request.status not in VALID_STATUSES:
raise HTTPException(status_code=422, detail=f"Ungültiger Status: {request.status}")
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")
params: dict = {
"id": dsfa_id, "tid": tid,
"status": request.status,
"approved_at": datetime.utcnow() if request.status == "approved" else None,
"approved_by": request.approved_by,
}
row = db.execute(
text("""
UPDATE compliance_dsfas
SET status = :status, approved_at = :approved_at, approved_by = :approved_by, updated_at = NOW()
WHERE id = :id AND tenant_id = :tid
RETURNING *
"""),
params,
).fetchone()
_log_audit(
db, tid, dsfa_id, "STATUS_CHANGE",
old_values={"status": existing["status"]},
new_values={"status": request.status},
)
db.commit()
return _dsfa_to_response(row)