- Create compliance/schemas/dsfa.py (161 LOC) — extract DSFACreate, DSFAUpdate, DSFAStatusUpdate, DSFASectionUpdate, DSFAApproveRequest - Create compliance/services/dsfa_service.py (386 LOC) — CRUD + helpers + stats + audit-log + CSV export; uses domain errors - Create compliance/services/dsfa_workflow_service.py (347 LOC) — status update, section update, submit-for-review, approve, export JSON, versions - Rewrite compliance/api/dsfa_routes.py (339 LOC) as thin handlers with Depends + translate_domain_errors(); re-export legacy symbols via __all__ - Add [mypy-compliance.api.dsfa_routes] ignore_errors = False to mypy.ini - Update tests: 422 -> 400 for domain ValidationError (6 assertions) - Regenerate OpenAPI baseline (360 paths / 484 operations — unchanged) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
340 lines
11 KiB
Python
340 lines
11 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 — Zaehler 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} — Loeschen (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
|
|
|
|
Phase 1 Step 4 refactor: handlers delegate to DSFAService (CRUD/stats/
|
|
audit/csv) and DSFAWorkflowService (status/section/submit/approve/export/
|
|
versions). Module-level helpers re-exported for legacy tests.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Any, Optional
|
|
|
|
from fastapi import APIRouter, Depends, Query
|
|
from fastapi.responses import Response
|
|
from sqlalchemy.orm import Session
|
|
|
|
from classroom_engine.database import get_db
|
|
from compliance.api._http_errors import translate_domain_errors
|
|
from compliance.schemas.dsfa import (
|
|
DSFAApproveRequest,
|
|
DSFACreate,
|
|
DSFASectionUpdate,
|
|
DSFAStatusUpdate,
|
|
DSFAUpdate,
|
|
)
|
|
from compliance.services.dsfa_service import (
|
|
DEFAULT_TENANT_ID,
|
|
VALID_RISK_LEVELS,
|
|
VALID_STATUSES,
|
|
DSFAService,
|
|
_dsfa_to_response, # re-exported for legacy test imports
|
|
_get_tenant_id, # re-exported for legacy test imports
|
|
)
|
|
from compliance.services.dsfa_workflow_service import (
|
|
SECTION_FIELD_MAP, # noqa: F401 — re-export
|
|
DSFAWorkflowService,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/dsfa", tags=["compliance-dsfa"])
|
|
|
|
|
|
def get_dsfa_service(db: Session = Depends(get_db)) -> DSFAService:
|
|
return DSFAService(db)
|
|
|
|
|
|
def get_workflow_service(
|
|
db: Session = Depends(get_db),
|
|
) -> DSFAWorkflowService:
|
|
return DSFAWorkflowService(db)
|
|
|
|
|
|
# =============================================================================
|
|
# Stats (must be before /{id} to avoid route conflict)
|
|
# =============================================================================
|
|
|
|
|
|
@router.get("/stats")
|
|
async def get_stats(
|
|
tenant_id: Optional[str] = Query(None),
|
|
service: DSFAService = Depends(get_dsfa_service),
|
|
) -> dict[str, Any]:
|
|
"""Zaehler nach Status und Risiko-Level."""
|
|
with translate_domain_errors():
|
|
return service.stats(tenant_id)
|
|
|
|
|
|
# =============================================================================
|
|
# 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),
|
|
service: DSFAService = Depends(get_dsfa_service),
|
|
) -> list[dict[str, Any]]:
|
|
"""DSFA Audit-Trail."""
|
|
with translate_domain_errors():
|
|
return service.audit_log(tenant_id, limit, offset)
|
|
|
|
|
|
# =============================================================================
|
|
# 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),
|
|
service: DSFAService = Depends(get_dsfa_service),
|
|
) -> Response:
|
|
"""Export all DSFAs as CSV."""
|
|
with translate_domain_errors():
|
|
csv_content = service.export_csv(tenant_id)
|
|
return Response(
|
|
content=csv_content,
|
|
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,
|
|
) -> dict[str, str]:
|
|
"""Stub: Create DSFA from UCCA assessment."""
|
|
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,
|
|
) -> dict[str, str]:
|
|
"""Stub: Get DSFA by linked UCCA assessment ID."""
|
|
return {
|
|
"detail": (
|
|
"Not implemented — requires cross-service "
|
|
"integration with ai-compliance-sdk"
|
|
)
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# 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),
|
|
service: DSFAService = Depends(get_dsfa_service),
|
|
) -> list[dict[str, Any]]:
|
|
"""Liste aller DSFAs fuer einen Tenant."""
|
|
with translate_domain_errors():
|
|
return service.list_dsfas(tenant_id, status, risk_level, skip, limit)
|
|
|
|
|
|
@router.post("", status_code=201)
|
|
async def create_dsfa(
|
|
request: DSFACreate,
|
|
tenant_id: Optional[str] = Query(None),
|
|
service: DSFAService = Depends(get_dsfa_service),
|
|
) -> dict[str, Any]:
|
|
"""Neue DSFA erstellen."""
|
|
with translate_domain_errors():
|
|
return service.create(tenant_id, request)
|
|
|
|
|
|
# =============================================================================
|
|
# Single Item (GET / PUT / DELETE / PATCH status)
|
|
# =============================================================================
|
|
|
|
|
|
@router.get("/{dsfa_id}")
|
|
async def get_dsfa(
|
|
dsfa_id: str,
|
|
tenant_id: Optional[str] = Query(None),
|
|
service: DSFAService = Depends(get_dsfa_service),
|
|
) -> dict[str, Any]:
|
|
"""Einzelne DSFA abrufen."""
|
|
with translate_domain_errors():
|
|
return service.get(dsfa_id, tenant_id)
|
|
|
|
|
|
@router.put("/{dsfa_id}")
|
|
async def update_dsfa(
|
|
dsfa_id: str,
|
|
request: DSFAUpdate,
|
|
tenant_id: Optional[str] = Query(None),
|
|
service: DSFAService = Depends(get_dsfa_service),
|
|
) -> dict[str, Any]:
|
|
"""DSFA aktualisieren."""
|
|
with translate_domain_errors():
|
|
return service.update(dsfa_id, tenant_id, request)
|
|
|
|
|
|
@router.delete("/{dsfa_id}")
|
|
async def delete_dsfa(
|
|
dsfa_id: str,
|
|
tenant_id: Optional[str] = Query(None),
|
|
service: DSFAService = Depends(get_dsfa_service),
|
|
) -> dict[str, Any]:
|
|
"""DSFA loeschen (Art. 17 DSGVO)."""
|
|
with translate_domain_errors():
|
|
return service.delete(dsfa_id, tenant_id)
|
|
|
|
|
|
@router.patch("/{dsfa_id}/status")
|
|
async def update_dsfa_status(
|
|
dsfa_id: str,
|
|
request: DSFAStatusUpdate,
|
|
tenant_id: Optional[str] = Query(None),
|
|
wf: DSFAWorkflowService = Depends(get_workflow_service),
|
|
) -> dict[str, Any]:
|
|
"""Schnell-Statuswechsel."""
|
|
with translate_domain_errors():
|
|
return wf.update_status(dsfa_id, tenant_id, request)
|
|
|
|
|
|
# =============================================================================
|
|
# Section Update
|
|
# =============================================================================
|
|
|
|
|
|
@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),
|
|
wf: DSFAWorkflowService = Depends(get_workflow_service),
|
|
) -> dict[str, Any]:
|
|
"""Update a specific DSFA section (1-8)."""
|
|
with translate_domain_errors():
|
|
return wf.update_section(dsfa_id, section_number, tenant_id, request)
|
|
|
|
|
|
# =============================================================================
|
|
# 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),
|
|
wf: DSFAWorkflowService = Depends(get_workflow_service),
|
|
) -> dict[str, Any]:
|
|
"""Submit a DSFA for DPO review (draft -> in-review)."""
|
|
with translate_domain_errors():
|
|
return wf.submit_for_review(dsfa_id, tenant_id)
|
|
|
|
|
|
@router.post("/{dsfa_id}/approve")
|
|
async def approve_dsfa(
|
|
dsfa_id: str,
|
|
request: DSFAApproveRequest,
|
|
tenant_id: Optional[str] = Query(None),
|
|
wf: DSFAWorkflowService = Depends(get_workflow_service),
|
|
) -> dict[str, Any]:
|
|
"""Approve or reject a DSFA (DPO/CISO action)."""
|
|
with translate_domain_errors():
|
|
return wf.approve(dsfa_id, tenant_id, request)
|
|
|
|
|
|
# =============================================================================
|
|
# Export
|
|
# =============================================================================
|
|
|
|
|
|
@router.get("/{dsfa_id}/export")
|
|
async def export_dsfa_json(
|
|
dsfa_id: str,
|
|
format: str = Query("json"),
|
|
tenant_id: Optional[str] = Query(None),
|
|
wf: DSFAWorkflowService = Depends(get_workflow_service),
|
|
) -> dict[str, Any]:
|
|
"""Export a single DSFA as JSON."""
|
|
with translate_domain_errors():
|
|
return wf.export_json(dsfa_id, tenant_id, format)
|
|
|
|
|
|
# =============================================================================
|
|
# Versioning
|
|
# =============================================================================
|
|
|
|
|
|
@router.get("/{dsfa_id}/versions")
|
|
async def list_dsfa_versions(
|
|
dsfa_id: str,
|
|
tenant_id: Optional[str] = Query(None),
|
|
wf: DSFAWorkflowService = Depends(get_workflow_service),
|
|
) -> Any:
|
|
"""List all versions for a DSFA."""
|
|
with translate_domain_errors():
|
|
return wf.list_versions(dsfa_id, tenant_id)
|
|
|
|
|
|
@router.get("/{dsfa_id}/versions/{version_number}")
|
|
async def get_dsfa_version(
|
|
dsfa_id: str,
|
|
version_number: int,
|
|
tenant_id: Optional[str] = Query(None),
|
|
wf: DSFAWorkflowService = Depends(get_workflow_service),
|
|
) -> Any:
|
|
"""Get a specific DSFA version with full snapshot."""
|
|
with translate_domain_errors():
|
|
return wf.get_version(dsfa_id, version_number, tenant_id)
|
|
|
|
|
|
# Legacy re-exports
|
|
__all__ = [
|
|
"router",
|
|
"DSFACreate",
|
|
"DSFAUpdate",
|
|
"DSFAStatusUpdate",
|
|
"DSFASectionUpdate",
|
|
"DSFAApproveRequest",
|
|
"_dsfa_to_response",
|
|
"_get_tenant_id",
|
|
"DEFAULT_TENANT_ID",
|
|
"VALID_STATUSES",
|
|
"VALID_RISK_LEVELS",
|
|
]
|