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>
330 lines
11 KiB
Python
330 lines
11 KiB
Python
# mypy: disable-error-code="arg-type,assignment,union-attr,no-any-return"
|
|
"""
|
|
Incident workflow service — risk assessment + Art. 33/34 notifications + measures.
|
|
|
|
Phase 1 Step 4: extracted from ``compliance.api.incident_routes``. CRUD +
|
|
stats + status + timeline + close live in
|
|
``compliance.services.incident_service``.
|
|
"""
|
|
|
|
import json
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
from sqlalchemy import text
|
|
from sqlalchemy.orm import Session
|
|
|
|
from compliance.domain import NotFoundError, ValidationError
|
|
from compliance.schemas.incident import (
|
|
AuthorityNotificationRequest,
|
|
DataSubjectNotificationRequest,
|
|
MeasureCreate,
|
|
MeasureUpdate,
|
|
RiskAssessmentRequest,
|
|
)
|
|
from compliance.services.incident_service import (
|
|
_append_timeline,
|
|
_calculate_72h_deadline,
|
|
_calculate_risk_level,
|
|
_is_notification_required,
|
|
_measure_to_response,
|
|
_parse_jsonb,
|
|
)
|
|
|
|
|
|
class IncidentWorkflowService:
|
|
"""Business logic for incident risk assessment, notifications, measures."""
|
|
|
|
def __init__(self, db: Session) -> None:
|
|
self.db = db
|
|
|
|
def _incident_row_or_raise(self, incident_id: str, columns: str) -> Any:
|
|
row = (
|
|
self.db.execute(
|
|
text(f"SELECT {columns} FROM incident_incidents WHERE id = :id"),
|
|
{"id": incident_id},
|
|
)
|
|
.mappings()
|
|
.first()
|
|
)
|
|
if not row:
|
|
raise NotFoundError("incident not found")
|
|
return row
|
|
|
|
# ------------------------------------------------------------------
|
|
# Risk assessment
|
|
# ------------------------------------------------------------------
|
|
|
|
def assess_risk(
|
|
self, incident_id: str, user_id: str, body: RiskAssessmentRequest
|
|
) -> dict[str, Any]:
|
|
row = self._incident_row_or_raise(
|
|
incident_id, "id, status, authority_notification"
|
|
)
|
|
|
|
risk_level = _calculate_risk_level(body.likelihood, body.impact)
|
|
notification_required = _is_notification_required(risk_level)
|
|
now = datetime.now(timezone.utc)
|
|
|
|
assessment = {
|
|
"likelihood": body.likelihood,
|
|
"impact": body.impact,
|
|
"risk_level": risk_level,
|
|
"assessed_at": now.isoformat(),
|
|
"assessed_by": user_id,
|
|
"notes": body.notes or "",
|
|
}
|
|
|
|
new_status = "assessment"
|
|
if notification_required:
|
|
new_status = "notification_required"
|
|
auth = _parse_jsonb(row["authority_notification"]) or {}
|
|
auth["status"] = "pending"
|
|
self.db.execute(
|
|
text(
|
|
"UPDATE incident_incidents SET authority_notification = CAST(:an AS jsonb) "
|
|
"WHERE id = :id"
|
|
),
|
|
{"id": incident_id, "an": json.dumps(auth)},
|
|
)
|
|
|
|
self.db.execute(
|
|
text("""
|
|
UPDATE incident_incidents
|
|
SET risk_assessment = CAST(:ra AS jsonb),
|
|
status = :status,
|
|
updated_at = NOW()
|
|
WHERE id = :id
|
|
"""),
|
|
{"id": incident_id, "ra": json.dumps(assessment), "status": new_status},
|
|
)
|
|
_append_timeline(self.db, incident_id, {
|
|
"timestamp": now.isoformat(),
|
|
"action": "risk_assessed",
|
|
"user_id": user_id,
|
|
"details": f"Risk level: {risk_level} (likelihood={body.likelihood}, impact={body.impact})",
|
|
})
|
|
self.db.commit()
|
|
|
|
return {
|
|
"risk_assessment": assessment,
|
|
"notification_required": notification_required,
|
|
"incident_status": new_status,
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Art. 33 authority notification
|
|
# ------------------------------------------------------------------
|
|
|
|
def notify_authority(
|
|
self, incident_id: str, user_id: str, body: AuthorityNotificationRequest
|
|
) -> dict[str, Any]:
|
|
row = self._incident_row_or_raise(
|
|
incident_id, "id, detected_at, authority_notification"
|
|
)
|
|
|
|
now = datetime.now(timezone.utc)
|
|
auth_existing = _parse_jsonb(row["authority_notification"]) or {}
|
|
deadline_str = auth_existing.get("deadline")
|
|
if not deadline_str and row["detected_at"]:
|
|
detected = row["detected_at"]
|
|
if hasattr(detected, "isoformat"):
|
|
deadline_str = (detected + timedelta(hours=72)).isoformat()
|
|
else:
|
|
deadline_str = _calculate_72h_deadline(
|
|
datetime.fromisoformat(str(detected).replace("Z", "+00:00"))
|
|
)
|
|
|
|
notification = {
|
|
"status": "sent",
|
|
"deadline": deadline_str,
|
|
"submitted_at": now.isoformat(),
|
|
"authority_name": body.authority_name,
|
|
"reference_number": body.reference_number or "",
|
|
"contact_person": body.contact_person or "",
|
|
"notes": body.notes or "",
|
|
}
|
|
|
|
self.db.execute(
|
|
text("""
|
|
UPDATE incident_incidents
|
|
SET authority_notification = CAST(:an AS jsonb),
|
|
status = 'notification_sent',
|
|
updated_at = NOW()
|
|
WHERE id = :id
|
|
"""),
|
|
{"id": incident_id, "an": json.dumps(notification)},
|
|
)
|
|
_append_timeline(self.db, incident_id, {
|
|
"timestamp": now.isoformat(),
|
|
"action": "authority_notified",
|
|
"user_id": user_id,
|
|
"details": f"Authority notification submitted to {body.authority_name}",
|
|
})
|
|
self.db.commit()
|
|
|
|
submitted_within_72h = True
|
|
if deadline_str:
|
|
try:
|
|
deadline_dt = datetime.fromisoformat(deadline_str.replace("Z", "+00:00"))
|
|
submitted_within_72h = now < deadline_dt
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
return {
|
|
"authority_notification": notification,
|
|
"submitted_within_72h": submitted_within_72h,
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Art. 34 data subject notification
|
|
# ------------------------------------------------------------------
|
|
|
|
def notify_subjects(
|
|
self, incident_id: str, user_id: str, body: DataSubjectNotificationRequest
|
|
) -> dict[str, Any]:
|
|
row = self._incident_row_or_raise(
|
|
incident_id, "id, affected_data_subject_count"
|
|
)
|
|
|
|
now = datetime.now(timezone.utc)
|
|
affected_count = body.affected_count or row["affected_data_subject_count"] or 0
|
|
|
|
notification = {
|
|
"required": True,
|
|
"status": "sent",
|
|
"sent_at": now.isoformat(),
|
|
"affected_count": affected_count,
|
|
"notification_text": body.notification_text,
|
|
"channel": body.channel,
|
|
}
|
|
|
|
self.db.execute(
|
|
text("""
|
|
UPDATE incident_incidents
|
|
SET data_subject_notification = CAST(:dsn AS jsonb),
|
|
updated_at = NOW()
|
|
WHERE id = :id
|
|
"""),
|
|
{"id": incident_id, "dsn": json.dumps(notification)},
|
|
)
|
|
_append_timeline(self.db, incident_id, {
|
|
"timestamp": now.isoformat(),
|
|
"action": "data_subjects_notified",
|
|
"user_id": user_id,
|
|
"details": f"Data subjects notified via {body.channel} ({affected_count} affected)",
|
|
})
|
|
self.db.commit()
|
|
|
|
return {"data_subject_notification": notification}
|
|
|
|
# ------------------------------------------------------------------
|
|
# Measures
|
|
# ------------------------------------------------------------------
|
|
|
|
def add_measure(
|
|
self, incident_id: str, user_id: str, body: MeasureCreate
|
|
) -> dict[str, Any]:
|
|
self._incident_row_or_raise(incident_id, "id")
|
|
measure_id = str(uuid4())
|
|
now = datetime.now(timezone.utc)
|
|
|
|
self.db.execute(
|
|
text("""
|
|
INSERT INTO incident_measures (
|
|
id, incident_id, title, description, measure_type, status,
|
|
responsible, due_date, created_at, updated_at
|
|
) VALUES (
|
|
:id, :incident_id, :title, :description, :measure_type, 'planned',
|
|
:responsible, :due_date, :now, :now
|
|
)
|
|
"""),
|
|
{
|
|
"id": measure_id,
|
|
"incident_id": incident_id,
|
|
"title": body.title,
|
|
"description": body.description or "",
|
|
"measure_type": body.measure_type,
|
|
"responsible": body.responsible or "",
|
|
"due_date": body.due_date,
|
|
"now": now.isoformat(),
|
|
},
|
|
)
|
|
_append_timeline(self.db, incident_id, {
|
|
"timestamp": now.isoformat(),
|
|
"action": "measure_added",
|
|
"user_id": user_id,
|
|
"details": f"Measure added: {body.title} ({body.measure_type})",
|
|
})
|
|
self.db.commit()
|
|
|
|
measure = (
|
|
self.db.execute(
|
|
text("SELECT * FROM incident_measures WHERE id = :id"),
|
|
{"id": measure_id},
|
|
)
|
|
.mappings()
|
|
.first()
|
|
)
|
|
return {"measure": _measure_to_response(measure)}
|
|
|
|
def update_measure(
|
|
self, measure_id: str, body: MeasureUpdate
|
|
) -> dict[str, Any]:
|
|
check = self.db.execute(
|
|
text("SELECT id FROM incident_measures WHERE id = :id"),
|
|
{"id": measure_id},
|
|
).first()
|
|
if not check:
|
|
raise NotFoundError("measure not found")
|
|
|
|
updates: list[str] = []
|
|
params: dict[str, Any] = {"id": measure_id}
|
|
for field in (
|
|
"title", "description", "measure_type", "status", "responsible", "due_date",
|
|
):
|
|
val = getattr(body, field, None)
|
|
if val is not None:
|
|
updates.append(f"{field} = :{field}")
|
|
params[field] = val
|
|
|
|
if not updates:
|
|
raise ValidationError("no fields to update")
|
|
|
|
updates.append("updated_at = NOW()")
|
|
self.db.execute(
|
|
text(f"UPDATE incident_measures SET {', '.join(updates)} WHERE id = :id"),
|
|
params,
|
|
)
|
|
self.db.commit()
|
|
|
|
measure = (
|
|
self.db.execute(
|
|
text("SELECT * FROM incident_measures WHERE id = :id"),
|
|
{"id": measure_id},
|
|
)
|
|
.mappings()
|
|
.first()
|
|
)
|
|
return {"measure": _measure_to_response(measure)}
|
|
|
|
def complete_measure(self, measure_id: str) -> dict[str, Any]:
|
|
check = self.db.execute(
|
|
text("SELECT id FROM incident_measures WHERE id = :id"),
|
|
{"id": measure_id},
|
|
).first()
|
|
if not check:
|
|
raise NotFoundError("measure not found")
|
|
|
|
self.db.execute(
|
|
text(
|
|
"UPDATE incident_measures "
|
|
"SET status = 'completed', completed_at = NOW(), updated_at = NOW() "
|
|
"WHERE id = :id"
|
|
),
|
|
{"id": measure_id},
|
|
)
|
|
self.db.commit()
|
|
return {"message": "measure completed"}
|