Split notfallplan_routes.py (1018 LOC) into clean architecture layers: - compliance/schemas/notfallplan.py (146 LOC): all Pydantic models - compliance/services/notfallplan_service.py (500 LOC): contacts, scenarios, checklists, exercises, stats - compliance/services/notfallplan_workflow_service.py (309 LOC): incidents, templates - compliance/api/notfallplan_routes.py (361 LOC): thin handlers with domain error translation All 250 tests pass. Schemas re-exported via __all__ for legacy test imports. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
310 lines
10 KiB
Python
310 lines
10 KiB
Python
# mypy: disable-error-code="arg-type,assignment,union-attr,no-any-return"
|
|
"""
|
|
Notfallplan workflow service -- incidents and templates.
|
|
|
|
Phase 1 Step 4: extracted from ``compliance.api.notfallplan_routes``.
|
|
Core CRUD for contacts/scenarios/checklists/exercises/stats lives in
|
|
``compliance.services.notfallplan_service``.
|
|
"""
|
|
|
|
import json
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from sqlalchemy import text
|
|
from sqlalchemy.orm import Session
|
|
|
|
from compliance.domain import NotFoundError, ValidationError
|
|
from compliance.schemas.notfallplan import (
|
|
IncidentCreate,
|
|
IncidentUpdate,
|
|
TemplateCreate,
|
|
TemplateUpdate,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ============================================================================
|
|
# Row serializers
|
|
# ============================================================================
|
|
|
|
|
|
def _incident_row(r: Any) -> Dict[str, Any]:
|
|
return {
|
|
"id": str(r.id),
|
|
"tenant_id": r.tenant_id,
|
|
"title": r.title,
|
|
"description": r.description,
|
|
"detected_at": r.detected_at.isoformat() if r.detected_at else None,
|
|
"detected_by": r.detected_by,
|
|
"status": r.status,
|
|
"severity": r.severity,
|
|
"affected_data_categories": (
|
|
r.affected_data_categories if r.affected_data_categories else []
|
|
),
|
|
"estimated_affected_persons": r.estimated_affected_persons,
|
|
"measures": r.measures if r.measures else [],
|
|
"art34_required": r.art34_required,
|
|
"art34_justification": r.art34_justification,
|
|
"reported_to_authority_at": (
|
|
r.reported_to_authority_at.isoformat()
|
|
if r.reported_to_authority_at
|
|
else None
|
|
),
|
|
"notified_affected_at": (
|
|
r.notified_affected_at.isoformat()
|
|
if r.notified_affected_at
|
|
else None
|
|
),
|
|
"closed_at": r.closed_at.isoformat() if r.closed_at else None,
|
|
"closed_by": r.closed_by,
|
|
"lessons_learned": r.lessons_learned,
|
|
"created_at": r.created_at.isoformat() if r.created_at else None,
|
|
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
|
|
}
|
|
|
|
|
|
def _template_row(r: Any) -> Dict[str, Any]:
|
|
return {
|
|
"id": str(r.id),
|
|
"tenant_id": r.tenant_id,
|
|
"type": r.type,
|
|
"title": r.title,
|
|
"content": r.content,
|
|
"created_at": r.created_at.isoformat() if r.created_at else None,
|
|
"updated_at": r.updated_at.isoformat() if r.updated_at else None,
|
|
}
|
|
|
|
|
|
class NotfallplanWorkflowService:
|
|
"""Incident and template operations."""
|
|
|
|
def __init__(self, db: Session) -> None:
|
|
self.db = db
|
|
|
|
# --------------------------------------------------------------- incidents
|
|
|
|
def list_incidents(
|
|
self,
|
|
tenant_id: str,
|
|
status: Optional[str] = None,
|
|
severity: Optional[str] = None,
|
|
) -> List[Dict[str, Any]]:
|
|
where = "WHERE tenant_id = :tenant_id"
|
|
params: Dict[str, Any] = {"tenant_id": tenant_id}
|
|
|
|
if status:
|
|
where += " AND status = :status"
|
|
params["status"] = status
|
|
if severity:
|
|
where += " AND severity = :severity"
|
|
params["severity"] = severity
|
|
|
|
rows = self.db.execute(
|
|
text(f"""
|
|
SELECT * FROM compliance_notfallplan_incidents
|
|
{where}
|
|
ORDER BY created_at DESC
|
|
"""),
|
|
params,
|
|
).fetchall()
|
|
return [_incident_row(r) for r in rows]
|
|
|
|
def create_incident(
|
|
self, tenant_id: str, req: IncidentCreate,
|
|
) -> Dict[str, Any]:
|
|
row = self.db.execute(
|
|
text("""
|
|
INSERT INTO compliance_notfallplan_incidents
|
|
(tenant_id, title, description, detected_by, status,
|
|
severity, affected_data_categories,
|
|
estimated_affected_persons, measures,
|
|
art34_required, art34_justification)
|
|
VALUES
|
|
(:tenant_id, :title, :description, :detected_by,
|
|
:status, :severity,
|
|
CAST(:affected_data_categories AS jsonb),
|
|
:estimated_affected_persons,
|
|
CAST(:measures AS jsonb),
|
|
:art34_required, :art34_justification)
|
|
RETURNING *
|
|
"""),
|
|
{
|
|
"tenant_id": tenant_id,
|
|
"title": req.title,
|
|
"description": req.description,
|
|
"detected_by": req.detected_by,
|
|
"status": req.status,
|
|
"severity": req.severity,
|
|
"affected_data_categories": json.dumps(
|
|
req.affected_data_categories
|
|
),
|
|
"estimated_affected_persons": req.estimated_affected_persons,
|
|
"measures": json.dumps(req.measures),
|
|
"art34_required": req.art34_required,
|
|
"art34_justification": req.art34_justification,
|
|
},
|
|
).fetchone()
|
|
self.db.commit()
|
|
return _incident_row(row)
|
|
|
|
def update_incident(
|
|
self, tenant_id: str, incident_id: str, req: IncidentUpdate,
|
|
) -> Dict[str, Any]:
|
|
existing = self.db.execute(
|
|
text(
|
|
"SELECT id FROM compliance_notfallplan_incidents"
|
|
" WHERE id = :id AND tenant_id = :tenant_id"
|
|
),
|
|
{"id": incident_id, "tenant_id": tenant_id},
|
|
).fetchone()
|
|
if not existing:
|
|
raise NotFoundError(f"Incident {incident_id} not found")
|
|
|
|
updates = req.dict(exclude_none=True)
|
|
if not updates:
|
|
raise ValidationError("No fields to update")
|
|
|
|
# Auto-set timestamps based on status transitions
|
|
if (
|
|
updates.get("status") == "reported"
|
|
and not updates.get("reported_to_authority_at")
|
|
):
|
|
updates["reported_to_authority_at"] = (
|
|
datetime.now(timezone.utc).isoformat()
|
|
)
|
|
if (
|
|
updates.get("status") == "closed"
|
|
and not updates.get("closed_at")
|
|
):
|
|
updates["closed_at"] = datetime.now(timezone.utc).isoformat()
|
|
|
|
updates["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
|
|
set_parts = []
|
|
for k in updates:
|
|
if k in ("affected_data_categories", "measures"):
|
|
set_parts.append(f"{k} = CAST(:{k} AS jsonb)")
|
|
updates[k] = (
|
|
json.dumps(updates[k])
|
|
if isinstance(updates[k], list)
|
|
else updates[k]
|
|
)
|
|
else:
|
|
set_parts.append(f"{k} = :{k}")
|
|
|
|
updates["id"] = incident_id
|
|
updates["tenant_id"] = tenant_id
|
|
|
|
row = self.db.execute(
|
|
text(f"""
|
|
UPDATE compliance_notfallplan_incidents
|
|
SET {', '.join(set_parts)}
|
|
WHERE id = :id AND tenant_id = :tenant_id
|
|
RETURNING *
|
|
"""),
|
|
updates,
|
|
).fetchone()
|
|
self.db.commit()
|
|
return _incident_row(row)
|
|
|
|
def delete_incident(self, tenant_id: str, incident_id: str) -> None:
|
|
result = self.db.execute(
|
|
text(
|
|
"DELETE FROM compliance_notfallplan_incidents"
|
|
" WHERE id = :id AND tenant_id = :tenant_id"
|
|
),
|
|
{"id": incident_id, "tenant_id": tenant_id},
|
|
)
|
|
self.db.commit()
|
|
if result.rowcount == 0:
|
|
raise NotFoundError(f"Incident {incident_id} not found")
|
|
|
|
# -------------------------------------------------------------- templates
|
|
|
|
def list_templates(
|
|
self, tenant_id: str, type: Optional[str] = None,
|
|
) -> List[Dict[str, Any]]:
|
|
where = "WHERE tenant_id = :tenant_id"
|
|
params: Dict[str, Any] = {"tenant_id": tenant_id}
|
|
if type:
|
|
where += " AND type = :type"
|
|
params["type"] = type
|
|
|
|
rows = self.db.execute(
|
|
text(
|
|
f"SELECT * FROM compliance_notfallplan_templates"
|
|
f" {where} ORDER BY type, created_at"
|
|
),
|
|
params,
|
|
).fetchall()
|
|
return [_template_row(r) for r in rows]
|
|
|
|
def create_template(
|
|
self, tenant_id: str, req: TemplateCreate,
|
|
) -> Dict[str, Any]:
|
|
row = self.db.execute(
|
|
text("""
|
|
INSERT INTO compliance_notfallplan_templates
|
|
(tenant_id, type, title, content)
|
|
VALUES (:tenant_id, :type, :title, :content)
|
|
RETURNING *
|
|
"""),
|
|
{
|
|
"tenant_id": tenant_id,
|
|
"type": req.type,
|
|
"title": req.title,
|
|
"content": req.content,
|
|
},
|
|
).fetchone()
|
|
self.db.commit()
|
|
return _template_row(row)
|
|
|
|
def update_template(
|
|
self, tenant_id: str, template_id: str, req: TemplateUpdate,
|
|
) -> Dict[str, Any]:
|
|
existing = self.db.execute(
|
|
text(
|
|
"SELECT id FROM compliance_notfallplan_templates"
|
|
" WHERE id = :id AND tenant_id = :tenant_id"
|
|
),
|
|
{"id": template_id, "tenant_id": tenant_id},
|
|
).fetchone()
|
|
if not existing:
|
|
raise NotFoundError(f"Template {template_id} not found")
|
|
|
|
updates = req.dict(exclude_none=True)
|
|
if not updates:
|
|
raise ValidationError("No fields to update")
|
|
|
|
updates["updated_at"] = datetime.now(timezone.utc).isoformat()
|
|
set_clauses = ", ".join(f"{k} = :{k}" for k in updates)
|
|
updates["id"] = template_id
|
|
updates["tenant_id"] = tenant_id
|
|
|
|
row = self.db.execute(
|
|
text(f"""
|
|
UPDATE compliance_notfallplan_templates
|
|
SET {set_clauses}
|
|
WHERE id = :id AND tenant_id = :tenant_id
|
|
RETURNING *
|
|
"""),
|
|
updates,
|
|
).fetchone()
|
|
self.db.commit()
|
|
return _template_row(row)
|
|
|
|
def delete_template(self, tenant_id: str, template_id: str) -> None:
|
|
result = self.db.execute(
|
|
text(
|
|
"DELETE FROM compliance_notfallplan_templates"
|
|
" WHERE id = :id AND tenant_id = :tenant_id"
|
|
),
|
|
{"id": template_id, "tenant_id": tenant_id},
|
|
)
|
|
self.db.commit()
|
|
if result.rowcount == 0:
|
|
raise NotFoundError(f"Template {template_id} not found")
|