Files
breakpilot-compliance/backend-compliance/compliance/services/incident_workflow_service.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

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"}