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
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>
438 lines
14 KiB
Python
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)
|