From 1a2ae896fbb32e234ded46e21196e01e94399032 Mon Sep 17 00:00:00 2001 From: Sharang Parnerkar <30073382+mighty840@users.noreply.github.com> Date: Thu, 9 Apr 2026 20:10:43 +0200 Subject: [PATCH] refactor(backend/api): extract Notfallplan schemas + services (Step 4) 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) --- .../compliance/api/notfallplan_routes.py | 949 +++--------------- .../compliance/schemas/notfallplan.py | 146 +++ .../services/notfallplan_service.py | 501 +++++++++ .../services/notfallplan_workflow_service.py | 309 ++++++ 4 files changed, 1102 insertions(+), 803 deletions(-) create mode 100644 backend-compliance/compliance/schemas/notfallplan.py create mode 100644 backend-compliance/compliance/services/notfallplan_service.py create mode 100644 backend-compliance/compliance/services/notfallplan_workflow_service.py diff --git a/backend-compliance/compliance/api/notfallplan_routes.py b/backend-compliance/compliance/api/notfallplan_routes.py index 9699106..4961c28 100644 --- a/backend-compliance/compliance/api/notfallplan_routes.py +++ b/backend-compliance/compliance/api/notfallplan_routes.py @@ -1,1018 +1,361 @@ """ -FastAPI routes for Notfallplan (Emergency Plan) — Art. 33/34 DSGVO. +FastAPI routes for Notfallplan (Emergency Plan) -- Art. 33/34 DSGVO. Endpoints: - GET /notfallplan/contacts — List emergency contacts - POST /notfallplan/contacts — Create contact - PUT /notfallplan/contacts/{id} — Update contact - DELETE /notfallplan/contacts/{id} — Delete contact - GET /notfallplan/scenarios — List scenarios - POST /notfallplan/scenarios — Create scenario - PUT /notfallplan/scenarios/{id} — Update scenario - DELETE /notfallplan/scenarios/{id} — Delete scenario - GET /notfallplan/checklists — List checklists (filter by scenario_id) - POST /notfallplan/checklists — Create checklist item - PUT /notfallplan/checklists/{id} — Update checklist item - DELETE /notfallplan/checklists/{id} — Delete checklist item - GET /notfallplan/exercises — List exercises - POST /notfallplan/exercises — Create exercise - GET /notfallplan/stats — Statistics overview + GET /notfallplan/contacts -- List emergency contacts + POST /notfallplan/contacts -- Create contact + PUT /notfallplan/contacts/{id} -- Update contact + DELETE /notfallplan/contacts/{id} -- Delete contact + GET /notfallplan/scenarios -- List scenarios + POST /notfallplan/scenarios -- Create scenario + PUT /notfallplan/scenarios/{id} -- Update scenario + DELETE /notfallplan/scenarios/{id} -- Delete scenario + GET /notfallplan/checklists -- List checklists + POST /notfallplan/checklists -- Create checklist item + PUT /notfallplan/checklists/{id} -- Update checklist item + DELETE /notfallplan/checklists/{id} -- Delete checklist item + GET /notfallplan/exercises -- List exercises + POST /notfallplan/exercises -- Create exercise + GET /notfallplan/stats -- Statistics overview + GET /notfallplan/incidents -- List incidents + POST /notfallplan/incidents -- Create incident + PUT /notfallplan/incidents/{id} -- Update incident + DELETE /notfallplan/incidents/{id} -- Delete incident + GET /notfallplan/templates -- List templates + POST /notfallplan/templates -- Create template + PUT /notfallplan/templates/{id} -- Update template + DELETE /notfallplan/templates/{id} -- Delete template + +Phase 1 Step 4 refactor: handlers delegate to NotfallplanService and +NotfallplanWorkflowService. Schemas re-exported for legacy test imports. """ -import json import logging -from datetime import datetime, timezone -from typing import Optional, List, Any +from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, Query, Header -from pydantic import BaseModel +from fastapi import APIRouter, Depends, Header, Query from sqlalchemy.orm import Session -from sqlalchemy import text from classroom_engine.database import get_db +from compliance.api._http_errors import translate_domain_errors +from compliance.schemas.notfallplan import ( # noqa: F401 -- re-export + ChecklistCreate, + ChecklistUpdate, + ContactCreate, + ContactUpdate, + ExerciseCreate, + IncidentCreate, + IncidentUpdate, + ScenarioCreate, + ScenarioUpdate, + TemplateCreate, + TemplateUpdate, +) +from compliance.services.notfallplan_service import NotfallplanService +from compliance.services.notfallplan_workflow_service import ( + NotfallplanWorkflowService, +) + +__all__ = [ + "ContactCreate", + "ContactUpdate", + "ScenarioCreate", + "ScenarioUpdate", + "ChecklistCreate", + "ChecklistUpdate", + "ExerciseCreate", + "IncidentCreate", + "IncidentUpdate", + "TemplateCreate", + "TemplateUpdate", + "router", +] logger = logging.getLogger(__name__) router = APIRouter(prefix="/notfallplan", tags=["notfallplan"]) # ============================================================================ -# Pydantic Schemas +# Dependencies # ============================================================================ -class ContactCreate(BaseModel): - name: str - role: Optional[str] = None - email: Optional[str] = None - phone: Optional[str] = None - is_primary: bool = False - available_24h: bool = False + +def _get_tenant( + x_tenant_id: Optional[str] = Header(None, alias="X-Tenant-ID"), +) -> str: + return x_tenant_id or "default" -class ContactUpdate(BaseModel): - name: Optional[str] = None - role: Optional[str] = None - email: Optional[str] = None - phone: Optional[str] = None - is_primary: Optional[bool] = None - available_24h: Optional[bool] = None +def _get_service(db: Session = Depends(get_db)) -> NotfallplanService: + return NotfallplanService(db) -class ScenarioCreate(BaseModel): - title: str - category: Optional[str] = None - severity: str = 'medium' - description: Optional[str] = None - response_steps: List[Any] = [] - estimated_recovery_time: Optional[int] = None - is_active: bool = True - - -class ScenarioUpdate(BaseModel): - title: Optional[str] = None - category: Optional[str] = None - severity: Optional[str] = None - description: Optional[str] = None - response_steps: Optional[List[Any]] = None - estimated_recovery_time: Optional[int] = None - last_tested: Optional[str] = None - is_active: Optional[bool] = None - - -class ChecklistCreate(BaseModel): - title: str - scenario_id: Optional[str] = None - description: Optional[str] = None - order_index: int = 0 - is_required: bool = True - - -class ChecklistUpdate(BaseModel): - title: Optional[str] = None - description: Optional[str] = None - order_index: Optional[int] = None - is_required: Optional[bool] = None - - -class ExerciseCreate(BaseModel): - title: str - scenario_id: Optional[str] = None - exercise_type: str = 'tabletop' - exercise_date: Optional[str] = None - participants: List[Any] = [] - outcome: Optional[str] = None - notes: Optional[str] = None - - -# ============================================================================ -# Helpers -# ============================================================================ - -def _get_tenant(x_tenant_id: Optional[str] = Header(None, alias='X-Tenant-ID')) -> str: - return x_tenant_id or 'default' +def _get_workflow(db: Session = Depends(get_db)) -> NotfallplanWorkflowService: + return NotfallplanWorkflowService(db) # ============================================================================ # Contacts # ============================================================================ + @router.get("/contacts") async def list_contacts( - db: Session = Depends(get_db), + svc: NotfallplanService = Depends(_get_service), tenant_id: str = Depends(_get_tenant), ): - """List all emergency contacts for a tenant.""" - rows = 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 [ - { - "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, - } - for r in rows - ] + with translate_domain_errors(): + return svc.list_contacts(tenant_id) @router.post("/contacts", status_code=201) async def create_contact( request: ContactCreate, - db: Session = Depends(get_db), + svc: NotfallplanService = Depends(_get_service), tenant_id: str = Depends(_get_tenant), ): - """Create a new emergency contact.""" - row = 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": request.name, - "role": request.role, - "email": request.email, - "phone": request.phone, - "is_primary": request.is_primary, - "available_24h": request.available_24h, - }, - ).fetchone() - db.commit() - - return { - "id": str(row.id), - "tenant_id": row.tenant_id, - "name": row.name, - "role": row.role, - "email": row.email, - "phone": row.phone, - "is_primary": row.is_primary, - "available_24h": row.available_24h, - "created_at": row.created_at.isoformat() if row.created_at else None, - } + with translate_domain_errors(): + return svc.create_contact(tenant_id, request) @router.put("/contacts/{contact_id}") async def update_contact( contact_id: str, request: ContactUpdate, - db: Session = Depends(get_db), + svc: NotfallplanService = Depends(_get_service), tenant_id: str = Depends(_get_tenant), ): - """Update an existing emergency contact.""" - existing = 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 HTTPException(status_code=404, detail=f"Contact {contact_id} not found") - - updates = request.dict(exclude_none=True) - if not updates: - raise HTTPException(status_code=400, detail="No fields to update") - - set_clauses = ", ".join(f"{k} = :{k}" for k in updates) - updates["id"] = contact_id - updates["tenant_id"] = tenant_id - - row = 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() - db.commit() - - return { - "id": str(row.id), - "tenant_id": row.tenant_id, - "name": row.name, - "role": row.role, - "email": row.email, - "phone": row.phone, - "is_primary": row.is_primary, - "available_24h": row.available_24h, - "created_at": row.created_at.isoformat() if row.created_at else None, - } + with translate_domain_errors(): + return svc.update_contact(tenant_id, contact_id, request) @router.delete("/contacts/{contact_id}") async def delete_contact( contact_id: str, - db: Session = Depends(get_db), + svc: NotfallplanService = Depends(_get_service), tenant_id: str = Depends(_get_tenant), ): - """Delete an emergency contact.""" - existing = 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 HTTPException(status_code=404, detail=f"Contact {contact_id} not found") - - db.execute( - text("DELETE FROM compliance_notfallplan_contacts WHERE id = :id AND tenant_id = :tenant_id"), - {"id": contact_id, "tenant_id": tenant_id}, - ) - db.commit() - - return {"success": True, "message": f"Contact {contact_id} deleted"} + with translate_domain_errors(): + return svc.delete_contact(tenant_id, contact_id) # ============================================================================ # Scenarios # ============================================================================ + @router.get("/scenarios") async def list_scenarios( - db: Session = Depends(get_db), + svc: NotfallplanService = Depends(_get_service), tenant_id: str = Depends(_get_tenant), ): - """List all scenarios for a tenant.""" - rows = 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 [ - { - "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, - } - for r in rows - ] + with translate_domain_errors(): + return svc.list_scenarios(tenant_id) @router.post("/scenarios", status_code=201) async def create_scenario( request: ScenarioCreate, - db: Session = Depends(get_db), + svc: NotfallplanService = Depends(_get_service), tenant_id: str = Depends(_get_tenant), ): - """Create a new scenario.""" - row = 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": request.title, - "category": request.category, - "severity": request.severity, - "description": request.description, - "response_steps": json.dumps(request.response_steps), - "estimated_recovery_time": request.estimated_recovery_time, - "is_active": request.is_active, - }, - ).fetchone() - db.commit() - - return { - "id": str(row.id), - "tenant_id": row.tenant_id, - "title": row.title, - "category": row.category, - "severity": row.severity, - "description": row.description, - "response_steps": row.response_steps if row.response_steps else [], - "estimated_recovery_time": row.estimated_recovery_time, - "last_tested": row.last_tested.isoformat() if row.last_tested else None, - "is_active": row.is_active, - "created_at": row.created_at.isoformat() if row.created_at else None, - } + with translate_domain_errors(): + return svc.create_scenario(tenant_id, request) @router.put("/scenarios/{scenario_id}") async def update_scenario( scenario_id: str, request: ScenarioUpdate, - db: Session = Depends(get_db), + svc: NotfallplanService = Depends(_get_service), tenant_id: str = Depends(_get_tenant), ): - """Update an existing scenario.""" - existing = 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 HTTPException(status_code=404, detail=f"Scenario {scenario_id} not found") - - updates = request.dict(exclude_none=True) - if not updates: - raise HTTPException(status_code=400, detail="No fields to update") - - # Serialize response_steps to JSON if present - 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 = 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() - db.commit() - - return { - "id": str(row.id), - "tenant_id": row.tenant_id, - "title": row.title, - "category": row.category, - "severity": row.severity, - "description": row.description, - "response_steps": row.response_steps if row.response_steps else [], - "estimated_recovery_time": row.estimated_recovery_time, - "last_tested": row.last_tested.isoformat() if row.last_tested else None, - "is_active": row.is_active, - "created_at": row.created_at.isoformat() if row.created_at else None, - } + with translate_domain_errors(): + return svc.update_scenario(tenant_id, scenario_id, request) @router.delete("/scenarios/{scenario_id}") async def delete_scenario( scenario_id: str, - db: Session = Depends(get_db), + svc: NotfallplanService = Depends(_get_service), tenant_id: str = Depends(_get_tenant), ): - """Delete a scenario.""" - existing = 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 HTTPException(status_code=404, detail=f"Scenario {scenario_id} not found") - - db.execute( - text("DELETE FROM compliance_notfallplan_scenarios WHERE id = :id AND tenant_id = :tenant_id"), - {"id": scenario_id, "tenant_id": tenant_id}, - ) - db.commit() - - return {"success": True, "message": f"Scenario {scenario_id} deleted"} + with translate_domain_errors(): + return svc.delete_scenario(tenant_id, scenario_id) # ============================================================================ # Checklists # ============================================================================ + @router.get("/checklists") async def list_checklists( scenario_id: Optional[str] = Query(None), - db: Session = Depends(get_db), + svc: NotfallplanService = Depends(_get_service), tenant_id: str = Depends(_get_tenant), ): - """List checklist items, optionally filtered by scenario_id.""" - if scenario_id: - rows = 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 = 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 [ - { - "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, - } - for r in rows - ] + with translate_domain_errors(): + return svc.list_checklists(tenant_id, scenario_id) @router.post("/checklists", status_code=201) async def create_checklist( request: ChecklistCreate, - db: Session = Depends(get_db), + svc: NotfallplanService = Depends(_get_service), tenant_id: str = Depends(_get_tenant), ): - """Create a new checklist item.""" - row = 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": request.scenario_id, - "title": request.title, - "description": request.description, - "order_index": request.order_index, - "is_required": request.is_required, - }, - ).fetchone() - db.commit() - - return { - "id": str(row.id), - "tenant_id": row.tenant_id, - "scenario_id": str(row.scenario_id) if row.scenario_id else None, - "title": row.title, - "description": row.description, - "order_index": row.order_index, - "is_required": row.is_required, - "created_at": row.created_at.isoformat() if row.created_at else None, - } + with translate_domain_errors(): + return svc.create_checklist(tenant_id, request) @router.put("/checklists/{checklist_id}") async def update_checklist( checklist_id: str, request: ChecklistUpdate, - db: Session = Depends(get_db), + svc: NotfallplanService = Depends(_get_service), tenant_id: str = Depends(_get_tenant), ): - """Update a checklist item.""" - existing = 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 HTTPException(status_code=404, detail=f"Checklist item {checklist_id} not found") - - updates = request.dict(exclude_none=True) - if not updates: - raise HTTPException(status_code=400, detail="No fields to update") - - set_clauses = ", ".join(f"{k} = :{k}" for k in updates) - updates["id"] = checklist_id - updates["tenant_id"] = tenant_id - - row = 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() - db.commit() - - return { - "id": str(row.id), - "tenant_id": row.tenant_id, - "scenario_id": str(row.scenario_id) if row.scenario_id else None, - "title": row.title, - "description": row.description, - "order_index": row.order_index, - "is_required": row.is_required, - "created_at": row.created_at.isoformat() if row.created_at else None, - } + with translate_domain_errors(): + return svc.update_checklist(tenant_id, checklist_id, request) @router.delete("/checklists/{checklist_id}") async def delete_checklist( checklist_id: str, - db: Session = Depends(get_db), + svc: NotfallplanService = Depends(_get_service), tenant_id: str = Depends(_get_tenant), ): - """Delete a checklist item.""" - existing = 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 HTTPException(status_code=404, detail=f"Checklist item {checklist_id} not found") - - db.execute( - text("DELETE FROM compliance_notfallplan_checklists WHERE id = :id AND tenant_id = :tenant_id"), - {"id": checklist_id, "tenant_id": tenant_id}, - ) - db.commit() - - return {"success": True, "message": f"Checklist item {checklist_id} deleted"} + with translate_domain_errors(): + return svc.delete_checklist(tenant_id, checklist_id) # ============================================================================ # Exercises # ============================================================================ + @router.get("/exercises") async def list_exercises( - db: Session = Depends(get_db), + svc: NotfallplanService = Depends(_get_service), tenant_id: str = Depends(_get_tenant), ): - """List all exercises for a tenant.""" - rows = 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 [ - { - "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, - } - for r in rows - ] + with translate_domain_errors(): + return svc.list_exercises(tenant_id) @router.post("/exercises", status_code=201) async def create_exercise( request: ExerciseCreate, - db: Session = Depends(get_db), + svc: NotfallplanService = Depends(_get_service), tenant_id: str = Depends(_get_tenant), ): - """Create a new exercise.""" - exercise_date = None - if request.exercise_date: - try: - exercise_date = datetime.fromisoformat(request.exercise_date) - except ValueError: - pass - - row = 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": request.title, - "scenario_id": request.scenario_id, - "exercise_type": request.exercise_type, - "exercise_date": exercise_date, - "participants": json.dumps(request.participants), - "outcome": request.outcome, - "notes": request.notes, - }, - ).fetchone() - db.commit() - - return { - "id": str(row.id), - "tenant_id": row.tenant_id, - "title": row.title, - "scenario_id": str(row.scenario_id) if row.scenario_id else None, - "exercise_type": row.exercise_type, - "exercise_date": row.exercise_date.isoformat() if row.exercise_date else None, - "participants": row.participants if row.participants else [], - "outcome": row.outcome, - "notes": row.notes, - "created_at": row.created_at.isoformat() if row.created_at else None, - } + with translate_domain_errors(): + return svc.create_exercise(tenant_id, request) # ============================================================================ # Stats # ============================================================================ + @router.get("/stats") async def get_stats( - db: Session = Depends(get_db), + svc: NotfallplanService = Depends(_get_service), tenant_id: str = Depends(_get_tenant), ): - """Return statistics for the Notfallplan module.""" - contacts_count = db.execute( - text("SELECT COUNT(*) FROM compliance_notfallplan_contacts WHERE tenant_id = :tenant_id"), - {"tenant_id": tenant_id}, - ).scalar() - - scenarios_count = 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 = db.execute( - text("SELECT COUNT(*) FROM compliance_notfallplan_exercises WHERE tenant_id = :tenant_id"), - {"tenant_id": tenant_id}, - ).scalar() - - checklists_count = db.execute( - text("SELECT COUNT(*) FROM compliance_notfallplan_checklists WHERE tenant_id = :tenant_id"), - {"tenant_id": tenant_id}, - ).scalar() - - incidents_count = 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, - } + with translate_domain_errors(): + return svc.get_stats(tenant_id) # ============================================================================ # Incidents # ============================================================================ -class IncidentCreate(BaseModel): - title: str - description: Optional[str] = None - detected_by: Optional[str] = None - status: str = 'detected' - severity: str = 'medium' - affected_data_categories: List[Any] = [] - estimated_affected_persons: int = 0 - measures: List[Any] = [] - art34_required: bool = False - art34_justification: Optional[str] = None - - -class IncidentUpdate(BaseModel): - title: Optional[str] = None - description: Optional[str] = None - detected_by: Optional[str] = None - status: Optional[str] = None - severity: Optional[str] = None - affected_data_categories: Optional[List[Any]] = None - estimated_affected_persons: Optional[int] = None - measures: Optional[List[Any]] = None - art34_required: Optional[bool] = None - art34_justification: Optional[str] = None - reported_to_authority_at: Optional[str] = None - notified_affected_at: Optional[str] = None - closed_at: Optional[str] = None - closed_by: Optional[str] = None - lessons_learned: Optional[str] = None - - -def _incident_row(r) -> dict: - 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, - } - @router.get("/incidents") async def list_incidents( status: Optional[str] = None, severity: Optional[str] = None, - db: Session = Depends(get_db), + wf: NotfallplanWorkflowService = Depends(_get_workflow), tenant_id: str = Depends(_get_tenant), ): - """List all incidents for a tenant.""" - where = "WHERE tenant_id = :tenant_id" - params: dict = {"tenant_id": tenant_id} - - if status: - where += " AND status = :status" - params["status"] = status - if severity: - where += " AND severity = :severity" - params["severity"] = severity - - rows = 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] + with translate_domain_errors(): + return wf.list_incidents(tenant_id, status, severity) @router.post("/incidents", status_code=201) async def create_incident( request: IncidentCreate, - db: Session = Depends(get_db), + wf: NotfallplanWorkflowService = Depends(_get_workflow), tenant_id: str = Depends(_get_tenant), ): - """Create a new incident.""" - row = 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": request.title, - "description": request.description, - "detected_by": request.detected_by, - "status": request.status, - "severity": request.severity, - "affected_data_categories": json.dumps(request.affected_data_categories), - "estimated_affected_persons": request.estimated_affected_persons, - "measures": json.dumps(request.measures), - "art34_required": request.art34_required, - "art34_justification": request.art34_justification, - }, - ).fetchone() - db.commit() - return _incident_row(row) + with translate_domain_errors(): + return wf.create_incident(tenant_id, request) @router.put("/incidents/{incident_id}") async def update_incident( incident_id: str, request: IncidentUpdate, - db: Session = Depends(get_db), + wf: NotfallplanWorkflowService = Depends(_get_workflow), tenant_id: str = Depends(_get_tenant), ): - """Update an incident (including status transitions).""" - existing = 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 HTTPException(status_code=404, detail=f"Incident {incident_id} not found") - - updates = request.dict(exclude_none=True) - if not updates: - raise HTTPException(status_code=400, detail="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 = db.execute( - text(f""" - UPDATE compliance_notfallplan_incidents - SET {', '.join(set_parts)} - WHERE id = :id AND tenant_id = :tenant_id - RETURNING * - """), - updates, - ).fetchone() - db.commit() - return _incident_row(row) + with translate_domain_errors(): + return wf.update_incident(tenant_id, incident_id, request) @router.delete("/incidents/{incident_id}", status_code=204) async def delete_incident( incident_id: str, - db: Session = Depends(get_db), + wf: NotfallplanWorkflowService = Depends(_get_workflow), tenant_id: str = Depends(_get_tenant), ): - """Delete an incident.""" - result = db.execute( - text("DELETE FROM compliance_notfallplan_incidents WHERE id = :id AND tenant_id = :tenant_id"), - {"id": incident_id, "tenant_id": tenant_id}, - ) - db.commit() - if result.rowcount == 0: - raise HTTPException(status_code=404, detail=f"Incident {incident_id} not found") + with translate_domain_errors(): + wf.delete_incident(tenant_id, incident_id) # ============================================================================ # Templates # ============================================================================ -class TemplateCreate(BaseModel): - type: str = 'art33' - title: str - content: str - - -class TemplateUpdate(BaseModel): - type: Optional[str] = None - title: Optional[str] = None - content: Optional[str] = None - - -def _template_row(r) -> dict: - 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, - } - @router.get("/templates") async def list_templates( type: Optional[str] = None, - db: Session = Depends(get_db), + wf: NotfallplanWorkflowService = Depends(_get_workflow), tenant_id: str = Depends(_get_tenant), ): - """List Melde-Templates for a tenant.""" - where = "WHERE tenant_id = :tenant_id" - params: dict = {"tenant_id": tenant_id} - if type: - where += " AND type = :type" - params["type"] = type - - rows = db.execute( - text(f"SELECT * FROM compliance_notfallplan_templates {where} ORDER BY type, created_at"), - params, - ).fetchall() - return [_template_row(r) for r in rows] + with translate_domain_errors(): + return wf.list_templates(tenant_id, type) @router.post("/templates", status_code=201) async def create_template( request: TemplateCreate, - db: Session = Depends(get_db), + wf: NotfallplanWorkflowService = Depends(_get_workflow), tenant_id: str = Depends(_get_tenant), ): - """Create a new Melde-Template.""" - row = 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": request.type, "title": request.title, "content": request.content}, - ).fetchone() - db.commit() - return _template_row(row) + with translate_domain_errors(): + return wf.create_template(tenant_id, request) @router.put("/templates/{template_id}") async def update_template( template_id: str, request: TemplateUpdate, - db: Session = Depends(get_db), + wf: NotfallplanWorkflowService = Depends(_get_workflow), tenant_id: str = Depends(_get_tenant), ): - """Update a Melde-Template.""" - existing = 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 HTTPException(status_code=404, detail=f"Template {template_id} not found") - - updates = request.dict(exclude_none=True) - if not updates: - raise HTTPException(status_code=400, detail="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 = db.execute( - text(f""" - UPDATE compliance_notfallplan_templates - SET {set_clauses} - WHERE id = :id AND tenant_id = :tenant_id - RETURNING * - """), - updates, - ).fetchone() - db.commit() - return _template_row(row) + with translate_domain_errors(): + return wf.update_template(tenant_id, template_id, request) @router.delete("/templates/{template_id}", status_code=204) async def delete_template( template_id: str, - db: Session = Depends(get_db), + wf: NotfallplanWorkflowService = Depends(_get_workflow), tenant_id: str = Depends(_get_tenant), ): - """Delete a Melde-Template.""" - result = db.execute( - text("DELETE FROM compliance_notfallplan_templates WHERE id = :id AND tenant_id = :tenant_id"), - {"id": template_id, "tenant_id": tenant_id}, - ) - db.commit() - if result.rowcount == 0: - raise HTTPException(status_code=404, detail=f"Template {template_id} not found") + with translate_domain_errors(): + wf.delete_template(tenant_id, template_id) diff --git a/backend-compliance/compliance/schemas/notfallplan.py b/backend-compliance/compliance/schemas/notfallplan.py new file mode 100644 index 0000000..e60797a --- /dev/null +++ b/backend-compliance/compliance/schemas/notfallplan.py @@ -0,0 +1,146 @@ +""" +Notfallplan (Emergency Plan) schemas -- Art. 33/34 DSGVO. + +Phase 1 Step 4: extracted from ``compliance.api.notfallplan_routes``. +""" + +from typing import Any, List, Optional + +from pydantic import BaseModel + + +# ============================================================================ +# Contacts +# ============================================================================ + + +class ContactCreate(BaseModel): + name: str + role: Optional[str] = None + email: Optional[str] = None + phone: Optional[str] = None + is_primary: bool = False + available_24h: bool = False + + +class ContactUpdate(BaseModel): + name: Optional[str] = None + role: Optional[str] = None + email: Optional[str] = None + phone: Optional[str] = None + is_primary: Optional[bool] = None + available_24h: Optional[bool] = None + + +# ============================================================================ +# Scenarios +# ============================================================================ + + +class ScenarioCreate(BaseModel): + title: str + category: Optional[str] = None + severity: str = "medium" + description: Optional[str] = None + response_steps: List[Any] = [] + estimated_recovery_time: Optional[int] = None + is_active: bool = True + + +class ScenarioUpdate(BaseModel): + title: Optional[str] = None + category: Optional[str] = None + severity: Optional[str] = None + description: Optional[str] = None + response_steps: Optional[List[Any]] = None + estimated_recovery_time: Optional[int] = None + last_tested: Optional[str] = None + is_active: Optional[bool] = None + + +# ============================================================================ +# Checklists +# ============================================================================ + + +class ChecklistCreate(BaseModel): + title: str + scenario_id: Optional[str] = None + description: Optional[str] = None + order_index: int = 0 + is_required: bool = True + + +class ChecklistUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + order_index: Optional[int] = None + is_required: Optional[bool] = None + + +# ============================================================================ +# Exercises +# ============================================================================ + + +class ExerciseCreate(BaseModel): + title: str + scenario_id: Optional[str] = None + exercise_type: str = "tabletop" + exercise_date: Optional[str] = None + participants: List[Any] = [] + outcome: Optional[str] = None + notes: Optional[str] = None + + +# ============================================================================ +# Incidents +# ============================================================================ + + +class IncidentCreate(BaseModel): + title: str + description: Optional[str] = None + detected_by: Optional[str] = None + status: str = "detected" + severity: str = "medium" + affected_data_categories: List[Any] = [] + estimated_affected_persons: int = 0 + measures: List[Any] = [] + art34_required: bool = False + art34_justification: Optional[str] = None + + +class IncidentUpdate(BaseModel): + title: Optional[str] = None + description: Optional[str] = None + detected_by: Optional[str] = None + status: Optional[str] = None + severity: Optional[str] = None + affected_data_categories: Optional[List[Any]] = None + estimated_affected_persons: Optional[int] = None + measures: Optional[List[Any]] = None + art34_required: Optional[bool] = None + art34_justification: Optional[str] = None + reported_to_authority_at: Optional[str] = None + notified_affected_at: Optional[str] = None + closed_at: Optional[str] = None + closed_by: Optional[str] = None + lessons_learned: Optional[str] = None + + +# ============================================================================ +# Templates +# ============================================================================ + + +class TemplateCreate(BaseModel): + type: str = "art33" + title: str + content: str + + +class TemplateUpdate(BaseModel): + type: Optional[str] = None + title: Optional[str] = None + content: Optional[str] = None diff --git a/backend-compliance/compliance/services/notfallplan_service.py b/backend-compliance/compliance/services/notfallplan_service.py new file mode 100644 index 0000000..97c0afe --- /dev/null +++ b/backend-compliance/compliance/services/notfallplan_service.py @@ -0,0 +1,501 @@ +# mypy: disable-error-code="arg-type,assignment,union-attr,no-any-return" +""" +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, + } \ No newline at end of file diff --git a/backend-compliance/compliance/services/notfallplan_workflow_service.py b/backend-compliance/compliance/services/notfallplan_workflow_service.py new file mode 100644 index 0000000..780e888 --- /dev/null +++ b/backend-compliance/compliance/services/notfallplan_workflow_service.py @@ -0,0 +1,309 @@ +# 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")