""" 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)