Files
breakpilot-compliance/backend-compliance/compliance/api/notfallplan_routes.py
Benjamin Admin 25d5da78ef
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
feat: Alle 5 verbleibenden SDK-Module auf 100% — RAG, Security-Backlog, Quality, Notfallplan, Loeschfristen
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>
2026-03-03 18:04:53 +01:00

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")