compliance/api/incident_routes.py (916 LOC) -> 280 LOC thin routes +
two services + 95-line schemas file.
Two-service split for DSGVO Art. 33/34 Datenpannen-Management:
incident_service.py (460 LOC):
- CRUD (create, list, get, update, delete)
- Stats, status update, timeline append, close
- Module-level helpers: _calculate_risk_level, _is_notification_required,
_calculate_72h_deadline, _incident_to_response, _measure_to_response,
_parse_jsonb, _append_timeline, DEFAULT_TENANT_ID
incident_workflow_service.py (329 LOC):
- Risk assessment (likelihood x impact -> risk_level)
- Art. 33 authority notification (with 72h deadline tracking)
- Art. 34 data subject notification
- Corrective measures CRUD
Both services use raw SQL via sqlalchemy.text() — no ORM models for
incident_incidents / incident_measures tables. Migrated from the Go
ai-compliance-sdk; Python backend is Source of Truth.
Legacy test compat: tests/test_incident_routes.py imports
_calculate_risk_level, _is_notification_required, _calculate_72h_deadline,
_incident_to_response, _measure_to_response, _parse_jsonb,
DEFAULT_TENANT_ID directly from compliance.api.incident_routes — all
re-exported via __all__.
Verified:
- 223/223 pytest pass (173 core + 50 incident)
- OpenAPI 360/484 unchanged
- mypy compliance/ -> Success on 141 source files
- incident_routes.py 916 -> 280 LOC
- Hard-cap violations: 8 -> 7
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
281 lines
9.5 KiB
Python
281 lines
9.5 KiB
Python
"""
|
|
FastAPI routes for Incidents / Datenpannen-Management (DSGVO Art. 33/34).
|
|
|
|
Endpoints:
|
|
POST /incidents — create incident
|
|
GET /incidents — list (filter: status, severity, category)
|
|
GET /incidents/stats — statistics
|
|
GET /incidents/{id} — detail + measures + deadline_info
|
|
PUT /incidents/{id} — update
|
|
DELETE /incidents/{id} — delete
|
|
PUT /incidents/{id}/status — quick status change
|
|
POST /incidents/{id}/assess-risk — risk assessment
|
|
POST /incidents/{id}/notify-authority — Art. 33 authority notification
|
|
POST /incidents/{id}/notify-subjects — Art. 34 data subject notification
|
|
POST /incidents/{id}/measures — add measure
|
|
PUT /incidents/{id}/measures/{mid} — update measure
|
|
POST /incidents/{id}/measures/{mid}/complete — complete measure
|
|
POST /incidents/{id}/timeline — add timeline entry
|
|
POST /incidents/{id}/close — close incident
|
|
|
|
Phase 1 Step 4 refactor: handlers delegate to IncidentService (CRUD/
|
|
stats/status/timeline/close) and IncidentWorkflowService (risk/
|
|
notifications/measures). Module-level helpers re-exported for legacy tests.
|
|
"""
|
|
|
|
import logging
|
|
from typing import Any, Optional
|
|
from uuid import UUID
|
|
|
|
from fastapi import APIRouter, Depends, Header, Query
|
|
from sqlalchemy.orm import Session
|
|
|
|
from classroom_engine.database import get_db
|
|
from compliance.api._http_errors import translate_domain_errors
|
|
from compliance.schemas.incident import (
|
|
AuthorityNotificationRequest,
|
|
CloseIncidentRequest,
|
|
DataSubjectNotificationRequest,
|
|
IncidentCreate,
|
|
IncidentUpdate,
|
|
MeasureCreate,
|
|
MeasureUpdate,
|
|
RiskAssessmentRequest,
|
|
StatusUpdate,
|
|
TimelineEntryRequest,
|
|
)
|
|
from compliance.services.incident_service import (
|
|
DEFAULT_TENANT_ID,
|
|
IncidentService,
|
|
_calculate_72h_deadline, # re-exported for legacy test imports
|
|
_calculate_risk_level, # re-exported for legacy test imports
|
|
_incident_to_response, # re-exported for legacy test imports
|
|
_is_notification_required, # re-exported for legacy test imports
|
|
_measure_to_response, # re-exported for legacy test imports
|
|
_parse_jsonb, # re-exported for legacy test imports
|
|
)
|
|
from compliance.services.incident_workflow_service import IncidentWorkflowService
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/incidents", tags=["incidents"])
|
|
|
|
|
|
def get_incident_service(db: Session = Depends(get_db)) -> IncidentService:
|
|
return IncidentService(db)
|
|
|
|
|
|
def get_workflow_service(db: Session = Depends(get_db)) -> IncidentWorkflowService:
|
|
return IncidentWorkflowService(db)
|
|
|
|
|
|
# =============================================================================
|
|
# CRUD Endpoints
|
|
# =============================================================================
|
|
|
|
@router.post("")
|
|
def create_incident(
|
|
body: IncidentCreate,
|
|
x_tenant_id: Optional[str] = Header(None),
|
|
x_user_id: Optional[str] = Header(None),
|
|
service: IncidentService = Depends(get_incident_service),
|
|
) -> dict[str, Any]:
|
|
with translate_domain_errors():
|
|
return service.create(
|
|
x_tenant_id or DEFAULT_TENANT_ID,
|
|
x_user_id or "system",
|
|
body,
|
|
)
|
|
|
|
|
|
@router.get("")
|
|
def list_incidents(
|
|
x_tenant_id: Optional[str] = Header(None),
|
|
status: Optional[str] = Query(None),
|
|
severity: Optional[str] = Query(None),
|
|
category: Optional[str] = Query(None),
|
|
limit: int = Query(50),
|
|
offset: int = Query(0),
|
|
service: IncidentService = Depends(get_incident_service),
|
|
) -> dict[str, Any]:
|
|
with translate_domain_errors():
|
|
return service.list_incidents(
|
|
x_tenant_id or DEFAULT_TENANT_ID,
|
|
status, severity, category, limit, offset,
|
|
)
|
|
|
|
|
|
@router.get("/stats")
|
|
def get_stats(
|
|
x_tenant_id: Optional[str] = Header(None),
|
|
service: IncidentService = Depends(get_incident_service),
|
|
) -> dict[str, Any]:
|
|
with translate_domain_errors():
|
|
return service.stats(x_tenant_id or DEFAULT_TENANT_ID)
|
|
|
|
|
|
@router.get("/{incident_id}")
|
|
def get_incident(
|
|
incident_id: UUID,
|
|
service: IncidentService = Depends(get_incident_service),
|
|
) -> dict[str, Any]:
|
|
with translate_domain_errors():
|
|
return service.get(str(incident_id))
|
|
|
|
|
|
@router.put("/{incident_id}")
|
|
def update_incident(
|
|
incident_id: UUID,
|
|
body: IncidentUpdate,
|
|
service: IncidentService = Depends(get_incident_service),
|
|
) -> dict[str, Any]:
|
|
with translate_domain_errors():
|
|
return service.update(str(incident_id), body)
|
|
|
|
|
|
@router.delete("/{incident_id}")
|
|
def delete_incident(
|
|
incident_id: UUID,
|
|
service: IncidentService = Depends(get_incident_service),
|
|
) -> dict[str, Any]:
|
|
with translate_domain_errors():
|
|
return service.delete(str(incident_id))
|
|
|
|
|
|
# =============================================================================
|
|
# Status Update
|
|
# =============================================================================
|
|
|
|
@router.put("/{incident_id}/status")
|
|
def update_status(
|
|
incident_id: UUID,
|
|
body: StatusUpdate,
|
|
x_user_id: Optional[str] = Header(None),
|
|
service: IncidentService = Depends(get_incident_service),
|
|
) -> dict[str, Any]:
|
|
with translate_domain_errors():
|
|
return service.update_status(str(incident_id), x_user_id or "system", body)
|
|
|
|
|
|
# =============================================================================
|
|
# Risk Assessment
|
|
# =============================================================================
|
|
|
|
@router.post("/{incident_id}/assess-risk")
|
|
def assess_risk(
|
|
incident_id: UUID,
|
|
body: RiskAssessmentRequest,
|
|
x_user_id: Optional[str] = Header(None),
|
|
service: IncidentWorkflowService = Depends(get_workflow_service),
|
|
) -> dict[str, Any]:
|
|
with translate_domain_errors():
|
|
return service.assess_risk(str(incident_id), x_user_id or "system", body)
|
|
|
|
|
|
# =============================================================================
|
|
# Authority Notification (Art. 33)
|
|
# =============================================================================
|
|
|
|
@router.post("/{incident_id}/notify-authority")
|
|
def notify_authority(
|
|
incident_id: UUID,
|
|
body: AuthorityNotificationRequest,
|
|
x_user_id: Optional[str] = Header(None),
|
|
service: IncidentWorkflowService = Depends(get_workflow_service),
|
|
) -> dict[str, Any]:
|
|
with translate_domain_errors():
|
|
return service.notify_authority(str(incident_id), x_user_id or "system", body)
|
|
|
|
|
|
# =============================================================================
|
|
# Data Subject Notification (Art. 34)
|
|
# =============================================================================
|
|
|
|
@router.post("/{incident_id}/notify-subjects")
|
|
def notify_subjects(
|
|
incident_id: UUID,
|
|
body: DataSubjectNotificationRequest,
|
|
x_user_id: Optional[str] = Header(None),
|
|
service: IncidentWorkflowService = Depends(get_workflow_service),
|
|
) -> dict[str, Any]:
|
|
with translate_domain_errors():
|
|
return service.notify_subjects(str(incident_id), x_user_id or "system", body)
|
|
|
|
|
|
# =============================================================================
|
|
# Measures
|
|
# =============================================================================
|
|
|
|
@router.post("/{incident_id}/measures")
|
|
def add_measure(
|
|
incident_id: UUID,
|
|
body: MeasureCreate,
|
|
x_user_id: Optional[str] = Header(None),
|
|
service: IncidentWorkflowService = Depends(get_workflow_service),
|
|
) -> dict[str, Any]:
|
|
with translate_domain_errors():
|
|
return service.add_measure(str(incident_id), x_user_id or "system", body)
|
|
|
|
|
|
@router.put("/{incident_id}/measures/{measure_id}")
|
|
def update_measure(
|
|
incident_id: UUID,
|
|
measure_id: UUID,
|
|
body: MeasureUpdate,
|
|
service: IncidentWorkflowService = Depends(get_workflow_service),
|
|
) -> dict[str, Any]:
|
|
with translate_domain_errors():
|
|
return service.update_measure(str(measure_id), body)
|
|
|
|
|
|
@router.post("/{incident_id}/measures/{measure_id}/complete")
|
|
def complete_measure(
|
|
incident_id: UUID,
|
|
measure_id: UUID,
|
|
service: IncidentWorkflowService = Depends(get_workflow_service),
|
|
) -> dict[str, Any]:
|
|
with translate_domain_errors():
|
|
return service.complete_measure(str(measure_id))
|
|
|
|
|
|
# =============================================================================
|
|
# Timeline
|
|
# =============================================================================
|
|
|
|
@router.post("/{incident_id}/timeline")
|
|
def add_timeline_entry(
|
|
incident_id: UUID,
|
|
body: TimelineEntryRequest,
|
|
x_user_id: Optional[str] = Header(None),
|
|
service: IncidentService = Depends(get_incident_service),
|
|
) -> dict[str, Any]:
|
|
with translate_domain_errors():
|
|
return service.add_timeline(str(incident_id), x_user_id or "system", body)
|
|
|
|
|
|
# =============================================================================
|
|
# Close Incident
|
|
# =============================================================================
|
|
|
|
@router.post("/{incident_id}/close")
|
|
def close_incident(
|
|
incident_id: UUID,
|
|
body: CloseIncidentRequest,
|
|
x_user_id: Optional[str] = Header(None),
|
|
service: IncidentService = Depends(get_incident_service),
|
|
) -> dict[str, Any]:
|
|
with translate_domain_errors():
|
|
return service.close(str(incident_id), x_user_id or "system", body)
|
|
|
|
|
|
# Legacy re-exports
|
|
__all__ = [
|
|
"router",
|
|
"DEFAULT_TENANT_ID",
|
|
"_calculate_risk_level",
|
|
"_is_notification_required",
|
|
"_calculate_72h_deadline",
|
|
"_incident_to_response",
|
|
"_measure_to_response",
|
|
"_parse_jsonb",
|
|
]
|