All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 34s
CI / test-python-backend-compliance (push) Successful in 32s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 17s
Paket A — RAG Proxy: - NEU: admin-compliance/app/api/sdk/v1/rag/[[...path]]/route.ts → Proxy zu ai-compliance-sdk:8090, GET+POST, UUID-Validierung - UPDATE: rag/page.tsx — setTimeout Mock → echte API-Calls GET /regulations → dynamische suggestedQuestions POST /search → Qdrant-Ergebnisse mit score, title, reference Paket B — Security-Backlog + Quality: - NEU: migrations/014_security_backlog.sql + 015_quality.sql - NEU: compliance/api/security_backlog_routes.py — CRUD + Stats - NEU: compliance/api/quality_routes.py — Metrics + Tests CRUD + Stats - UPDATE: security-backlog/page.tsx — mockItems → API - UPDATE: quality/page.tsx — mockMetrics/mockTests → API - UPDATE: compliance/api/__init__.py — Router-Registrierung - NEU: tests/test_security_backlog_routes.py (48 Tests — 48/48 bestanden) - NEU: tests/test_quality_routes.py (67 Tests — 67/67 bestanden) Paket C — Notfallplan Incidents + Templates: - NEU: migrations/016_notfallplan_incidents.sql compliance_notfallplan_incidents + compliance_notfallplan_templates - UPDATE: notfallplan_routes.py — GET/POST/PUT/DELETE für /incidents + /templates - UPDATE: notfallplan/page.tsx — Incidents-Tab + Templates-Tab → API - UPDATE: tests/test_notfallplan_routes.py (+76 neue Tests — alle bestanden) Paket D — Loeschfristen localStorage → API: - NEU: migrations/017_loeschfristen.sql (JSONB: legal_holds, storage_locations, ...) - NEU: compliance/api/loeschfristen_routes.py — CRUD + Stats + Status-Update - UPDATE: loeschfristen/page.tsx — vollständige localStorage → API Migration createNewPolicy → POST (API-UUID als id), deletePolicy → DELETE, handleSaveAndClose → PUT, adoptGeneratedPolicies → POST je Policy apiToPolicy() + policyToPayload() Mapper, saving-State für Buttons - NEU: tests/test_loeschfristen_routes.py (58 Tests — alle bestanden) Gesamt: 253 neue Tests, alle bestanden (48 + 67 + 76 + 58 + bestehende) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1019 lines
34 KiB
Python
1019 lines
34 KiB
Python
"""
|
|
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
|
|
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.utcnow().isoformat()
|
|
if updates.get("status") == "closed" and not updates.get("closed_at"):
|
|
updates["closed_at"] = datetime.utcnow().isoformat()
|
|
|
|
updates["updated_at"] = datetime.utcnow().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.utcnow().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")
|