Files
breakpilot-compliance/backend-compliance/compliance/api/incident_routes.py
Sharang Parnerkar cc1c61947d refactor(backend/api): extract Incident services (Step 4 — file 11 of 18)
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>
2026-04-09 08:35:57 +02:00

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",
]