""" 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 """ import json import logging from datetime import datetime, timezone from typing import Optional, List, Any from fastapi import APIRouter, Depends, HTTPException, Query, Header from pydantic import BaseModel from sqlalchemy.orm import Session from sqlalchemy import text from classroom_engine.database import get_db logger = logging.getLogger(__name__) router = APIRouter(prefix="/notfallplan", tags=["notfallplan"]) # ============================================================================ # Pydantic Schemas # ============================================================================ 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 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' # ============================================================================ # Contacts # ============================================================================ @router.get("/contacts") async def list_contacts( db: Session = Depends(get_db), 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 ] @router.post("/contacts", status_code=201) async def create_contact( request: ContactCreate, db: Session = Depends(get_db), 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, } @router.put("/contacts/{contact_id}") async def update_contact( contact_id: str, request: ContactUpdate, db: Session = Depends(get_db), 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, } @router.delete("/contacts/{contact_id}") async def delete_contact( contact_id: str, db: Session = Depends(get_db), 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"} # ============================================================================ # Scenarios # ============================================================================ @router.get("/scenarios") async def list_scenarios( db: Session = Depends(get_db), 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 ] @router.post("/scenarios", status_code=201) async def create_scenario( request: ScenarioCreate, db: Session = Depends(get_db), 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, } @router.put("/scenarios/{scenario_id}") async def update_scenario( scenario_id: str, request: ScenarioUpdate, db: Session = Depends(get_db), 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, } @router.delete("/scenarios/{scenario_id}") async def delete_scenario( scenario_id: str, db: Session = Depends(get_db), 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"} # ============================================================================ # Checklists # ============================================================================ @router.get("/checklists") async def list_checklists( scenario_id: Optional[str] = Query(None), db: Session = Depends(get_db), 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 ] @router.post("/checklists", status_code=201) async def create_checklist( request: ChecklistCreate, db: Session = Depends(get_db), 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, } @router.put("/checklists/{checklist_id}") async def update_checklist( checklist_id: str, request: ChecklistUpdate, db: Session = Depends(get_db), 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, } @router.delete("/checklists/{checklist_id}") async def delete_checklist( checklist_id: str, db: Session = Depends(get_db), 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"} # ============================================================================ # Exercises # ============================================================================ @router.get("/exercises") async def list_exercises( db: Session = Depends(get_db), 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 ] @router.post("/exercises", status_code=201) async def create_exercise( request: ExerciseCreate, db: Session = Depends(get_db), 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, } # ============================================================================ # Stats # ============================================================================ @router.get("/stats") async def get_stats( db: Session = Depends(get_db), 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, } # ============================================================================ # 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), 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] @router.post("/incidents", status_code=201) async def create_incident( request: IncidentCreate, db: Session = Depends(get_db), 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) @router.put("/incidents/{incident_id}") async def update_incident( incident_id: str, request: IncidentUpdate, db: Session = Depends(get_db), 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) @router.delete("/incidents/{incident_id}", status_code=204) async def delete_incident( incident_id: str, db: Session = Depends(get_db), 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") # ============================================================================ # 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), 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] @router.post("/templates", status_code=201) async def create_template( request: TemplateCreate, db: Session = Depends(get_db), 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) @router.put("/templates/{template_id}") async def update_template( template_id: str, request: TemplateUpdate, db: Session = Depends(get_db), 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) @router.delete("/templates/{template_id}", status_code=204) async def delete_template( template_id: str, db: Session = Depends(get_db), 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")