# mypy: disable-error-code="arg-type,assignment,union-attr,no-any-return,attr-defined,index,call-overload,type-arg,var-annotated,misc,call-arg,return-value" """ Notfallplan service -- contacts, scenarios, checklists, exercises, stats. Phase 1 Step 4: extracted from ``compliance.api.notfallplan_routes``. Incident and template operations live in ``compliance.services.notfallplan_workflow_service``. """ import json import logging from datetime import datetime 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 ( ChecklistCreate, ChecklistUpdate, ContactCreate, ContactUpdate, ExerciseCreate, ScenarioCreate, ScenarioUpdate, ) logger = logging.getLogger(__name__) def _contact_row(r: Any) -> Dict[str, Any]: return { "id": str(r.id), "tenant_id": r.tenant_id, "name": r.name, "role": r.role, "email": r.email, "phone": r.phone, "is_primary": r.is_primary, "available_24h": r.available_24h, "created_at": r.created_at.isoformat() if r.created_at else None, } def _scenario_row(r: Any) -> Dict[str, Any]: return { "id": str(r.id), "tenant_id": r.tenant_id, "title": r.title, "category": r.category, "severity": r.severity, "description": r.description, "response_steps": r.response_steps if r.response_steps else [], "estimated_recovery_time": r.estimated_recovery_time, "last_tested": r.last_tested.isoformat() if r.last_tested else None, "is_active": r.is_active, "created_at": r.created_at.isoformat() if r.created_at else None, } def _checklist_row(r: Any) -> Dict[str, Any]: return { "id": str(r.id), "tenant_id": r.tenant_id, "scenario_id": str(r.scenario_id) if r.scenario_id else None, "title": r.title, "description": r.description, "order_index": r.order_index, "is_required": r.is_required, "created_at": r.created_at.isoformat() if r.created_at else None, } def _exercise_row(r: Any) -> Dict[str, Any]: return { "id": str(r.id), "tenant_id": r.tenant_id, "title": r.title, "scenario_id": str(r.scenario_id) if r.scenario_id else None, "exercise_type": r.exercise_type, "exercise_date": r.exercise_date.isoformat() if r.exercise_date else None, "participants": r.participants if r.participants else [], "outcome": r.outcome, "notes": r.notes, "created_at": r.created_at.isoformat() if r.created_at else None, } class NotfallplanService: """Contacts, scenarios, checklists, exercises, stats.""" def __init__(self, db: Session) -> None: self.db = db # ------------------------------------------------------------------ contacts def list_contacts(self, tenant_id: str) -> List[Dict[str, Any]]: rows = self.db.execute( text(""" SELECT id, tenant_id, name, role, email, phone, is_primary, available_24h, created_at FROM compliance_notfallplan_contacts WHERE tenant_id = :tenant_id ORDER BY is_primary DESC, name """), {"tenant_id": tenant_id}, ).fetchall() return [_contact_row(r) for r in rows] def create_contact( self, tenant_id: str, req: ContactCreate, ) -> Dict[str, Any]: row = self.db.execute( text(""" INSERT INTO compliance_notfallplan_contacts (tenant_id, name, role, email, phone, is_primary, available_24h) VALUES (:tenant_id, :name, :role, :email, :phone, :is_primary, :available_24h) RETURNING id, tenant_id, name, role, email, phone, is_primary, available_24h, created_at """), { "tenant_id": tenant_id, "name": req.name, "role": req.role, "email": req.email, "phone": req.phone, "is_primary": req.is_primary, "available_24h": req.available_24h, }, ).fetchone() self.db.commit() return _contact_row(row) def update_contact( self, tenant_id: str, contact_id: str, req: ContactUpdate, ) -> Dict[str, Any]: existing = self.db.execute( text( "SELECT id FROM compliance_notfallplan_contacts" " WHERE id = :id AND tenant_id = :tenant_id" ), {"id": contact_id, "tenant_id": tenant_id}, ).fetchone() if not existing: raise NotFoundError(f"Contact {contact_id} not found") updates = req.dict(exclude_none=True) if not updates: raise ValidationError("No fields to update") set_clauses = ", ".join(f"{k} = :{k}" for k in updates) updates["id"] = contact_id updates["tenant_id"] = tenant_id row = self.db.execute( text(f""" UPDATE compliance_notfallplan_contacts SET {set_clauses} WHERE id = :id AND tenant_id = :tenant_id RETURNING id, tenant_id, name, role, email, phone, is_primary, available_24h, created_at """), updates, ).fetchone() self.db.commit() return _contact_row(row) def delete_contact(self, tenant_id: str, contact_id: str) -> Dict[str, Any]: existing = self.db.execute( text( "SELECT id FROM compliance_notfallplan_contacts" " WHERE id = :id AND tenant_id = :tenant_id" ), {"id": contact_id, "tenant_id": tenant_id}, ).fetchone() if not existing: raise NotFoundError(f"Contact {contact_id} not found") self.db.execute( text( "DELETE FROM compliance_notfallplan_contacts" " WHERE id = :id AND tenant_id = :tenant_id" ), {"id": contact_id, "tenant_id": tenant_id}, ) self.db.commit() return {"success": True, "message": f"Contact {contact_id} deleted"} # ---------------------------------------------------------------- scenarios def list_scenarios(self, tenant_id: str) -> List[Dict[str, Any]]: rows = self.db.execute( text(""" SELECT id, tenant_id, title, category, severity, description, response_steps, estimated_recovery_time, last_tested, is_active, created_at FROM compliance_notfallplan_scenarios WHERE tenant_id = :tenant_id ORDER BY created_at DESC """), {"tenant_id": tenant_id}, ).fetchall() return [_scenario_row(r) for r in rows] def create_scenario( self, tenant_id: str, req: ScenarioCreate, ) -> Dict[str, Any]: row = self.db.execute( text(""" INSERT INTO compliance_notfallplan_scenarios (tenant_id, title, category, severity, description, response_steps, estimated_recovery_time, is_active) VALUES (:tenant_id, :title, :category, :severity, :description, :response_steps, :estimated_recovery_time, :is_active) RETURNING id, tenant_id, title, category, severity, description, response_steps, estimated_recovery_time, last_tested, is_active, created_at """), { "tenant_id": tenant_id, "title": req.title, "category": req.category, "severity": req.severity, "description": req.description, "response_steps": json.dumps(req.response_steps), "estimated_recovery_time": req.estimated_recovery_time, "is_active": req.is_active, }, ).fetchone() self.db.commit() return _scenario_row(row) def update_scenario( self, tenant_id: str, scenario_id: str, req: ScenarioUpdate, ) -> Dict[str, Any]: existing = self.db.execute( text( "SELECT id FROM compliance_notfallplan_scenarios" " WHERE id = :id AND tenant_id = :tenant_id" ), {"id": scenario_id, "tenant_id": tenant_id}, ).fetchone() if not existing: raise NotFoundError(f"Scenario {scenario_id} not found") updates = req.dict(exclude_none=True) if not updates: raise ValidationError("No fields to update") if "response_steps" in updates: updates["response_steps"] = json.dumps(updates["response_steps"]) set_clauses = ", ".join(f"{k} = :{k}" for k in updates) updates["id"] = scenario_id updates["tenant_id"] = tenant_id row = self.db.execute( text(f""" UPDATE compliance_notfallplan_scenarios SET {set_clauses} WHERE id = :id AND tenant_id = :tenant_id RETURNING id, tenant_id, title, category, severity, description, response_steps, estimated_recovery_time, last_tested, is_active, created_at """), updates, ).fetchone() self.db.commit() return _scenario_row(row) def delete_scenario(self, tenant_id: str, scenario_id: str) -> Dict[str, Any]: existing = self.db.execute( text( "SELECT id FROM compliance_notfallplan_scenarios" " WHERE id = :id AND tenant_id = :tenant_id" ), {"id": scenario_id, "tenant_id": tenant_id}, ).fetchone() if not existing: raise NotFoundError(f"Scenario {scenario_id} not found") self.db.execute( text( "DELETE FROM compliance_notfallplan_scenarios" " WHERE id = :id AND tenant_id = :tenant_id" ), {"id": scenario_id, "tenant_id": tenant_id}, ) self.db.commit() return {"success": True, "message": f"Scenario {scenario_id} deleted"} # -------------------------------------------------------------- checklists def list_checklists( self, tenant_id: str, scenario_id: Optional[str] = None, ) -> List[Dict[str, Any]]: if scenario_id: rows = self.db.execute( text(""" SELECT id, tenant_id, scenario_id, title, description, order_index, is_required, created_at FROM compliance_notfallplan_checklists WHERE tenant_id = :tenant_id AND scenario_id = :scenario_id ORDER BY order_index, created_at """), {"tenant_id": tenant_id, "scenario_id": scenario_id}, ).fetchall() else: rows = self.db.execute( text(""" SELECT id, tenant_id, scenario_id, title, description, order_index, is_required, created_at FROM compliance_notfallplan_checklists WHERE tenant_id = :tenant_id ORDER BY order_index, created_at """), {"tenant_id": tenant_id}, ).fetchall() return [_checklist_row(r) for r in rows] def create_checklist( self, tenant_id: str, req: ChecklistCreate, ) -> Dict[str, Any]: row = self.db.execute( text(""" INSERT INTO compliance_notfallplan_checklists (tenant_id, scenario_id, title, description, order_index, is_required) VALUES (:tenant_id, :scenario_id, :title, :description, :order_index, :is_required) RETURNING id, tenant_id, scenario_id, title, description, order_index, is_required, created_at """), { "tenant_id": tenant_id, "scenario_id": req.scenario_id, "title": req.title, "description": req.description, "order_index": req.order_index, "is_required": req.is_required, }, ).fetchone() self.db.commit() return _checklist_row(row) def update_checklist( self, tenant_id: str, checklist_id: str, req: ChecklistUpdate, ) -> Dict[str, Any]: existing = self.db.execute( text( "SELECT id FROM compliance_notfallplan_checklists" " WHERE id = :id AND tenant_id = :tenant_id" ), {"id": checklist_id, "tenant_id": tenant_id}, ).fetchone() if not existing: raise NotFoundError(f"Checklist item {checklist_id} not found") updates = req.dict(exclude_none=True) if not updates: raise ValidationError("No fields to update") set_clauses = ", ".join(f"{k} = :{k}" for k in updates) updates["id"] = checklist_id updates["tenant_id"] = tenant_id row = self.db.execute( text(f""" UPDATE compliance_notfallplan_checklists SET {set_clauses} WHERE id = :id AND tenant_id = :tenant_id RETURNING id, tenant_id, scenario_id, title, description, order_index, is_required, created_at """), updates, ).fetchone() self.db.commit() return _checklist_row(row) def delete_checklist(self, tenant_id: str, checklist_id: str) -> Dict[str, Any]: existing = self.db.execute( text( "SELECT id FROM compliance_notfallplan_checklists" " WHERE id = :id AND tenant_id = :tenant_id" ), {"id": checklist_id, "tenant_id": tenant_id}, ).fetchone() if not existing: raise NotFoundError(f"Checklist item {checklist_id} not found") self.db.execute( text( "DELETE FROM compliance_notfallplan_checklists" " WHERE id = :id AND tenant_id = :tenant_id" ), {"id": checklist_id, "tenant_id": tenant_id}, ) self.db.commit() return {"success": True, "message": f"Checklist item {checklist_id} deleted"} # --------------------------------------------------------------- exercises def list_exercises(self, tenant_id: str) -> List[Dict[str, Any]]: rows = self.db.execute( text(""" SELECT id, tenant_id, title, scenario_id, exercise_type, exercise_date, participants, outcome, notes, created_at FROM compliance_notfallplan_exercises WHERE tenant_id = :tenant_id ORDER BY created_at DESC """), {"tenant_id": tenant_id}, ).fetchall() return [_exercise_row(r) for r in rows] def create_exercise( self, tenant_id: str, req: ExerciseCreate, ) -> Dict[str, Any]: exercise_date = None if req.exercise_date: try: exercise_date = datetime.fromisoformat(req.exercise_date) except ValueError: pass row = self.db.execute( text(""" INSERT INTO compliance_notfallplan_exercises (tenant_id, title, scenario_id, exercise_type, exercise_date, participants, outcome, notes) VALUES (:tenant_id, :title, :scenario_id, :exercise_type, :exercise_date, :participants, :outcome, :notes) RETURNING id, tenant_id, title, scenario_id, exercise_type, exercise_date, participants, outcome, notes, created_at """), { "tenant_id": tenant_id, "title": req.title, "scenario_id": req.scenario_id, "exercise_type": req.exercise_type, "exercise_date": exercise_date, "participants": json.dumps(req.participants), "outcome": req.outcome, "notes": req.notes, }, ).fetchone() self.db.commit() return _exercise_row(row) # ------------------------------------------------------------------- stats def get_stats(self, tenant_id: str) -> Dict[str, int]: contacts_count = self.db.execute( text( "SELECT COUNT(*) FROM compliance_notfallplan_contacts" " WHERE tenant_id = :tenant_id" ), {"tenant_id": tenant_id}, ).scalar() scenarios_count = self.db.execute( text( "SELECT COUNT(*) FROM compliance_notfallplan_scenarios" " WHERE tenant_id = :tenant_id AND is_active = TRUE" ), {"tenant_id": tenant_id}, ).scalar() exercises_count = self.db.execute( text( "SELECT COUNT(*) FROM compliance_notfallplan_exercises" " WHERE tenant_id = :tenant_id" ), {"tenant_id": tenant_id}, ).scalar() checklists_count = self.db.execute( text( "SELECT COUNT(*) FROM compliance_notfallplan_checklists" " WHERE tenant_id = :tenant_id" ), {"tenant_id": tenant_id}, ).scalar() incidents_count = self.db.execute( text( "SELECT COUNT(*) FROM compliance_notfallplan_incidents" " WHERE tenant_id = :tenant_id AND status != 'closed'" ), {"tenant_id": tenant_id}, ).scalar() return { "contacts": contacts_count or 0, "active_scenarios": scenarios_count or 0, "exercises": exercises_count or 0, "checklist_items": checklists_count or 0, "open_incidents": incidents_count or 0, }