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 37s
CI / test-python-backend-compliance (push) Successful in 34s
CI / test-python-document-crawler (push) Successful in 22s
CI / test-python-dsms-gateway (push) Successful in 18s
Alle 7 Betrieb-Module von 30–75% auf 100% gebracht: **Gruppe 1 — UI-Ergänzungen (Backend bereits vorhanden):** - incidents/page.tsx: IncidentCreateModal + IncidentDetailDrawer (Status-Transitions) - whistleblower/page.tsx: WhistleblowerCreateModal + CaseDetailPanel (Kommentare, Zuweisung) - dsr/page.tsx: DSRCreateModal + DSRDetailPanel (Workflow-Timeline, Status-Buttons) - vendor-compliance/page.tsx: VendorCreateModal + "Neuer Vendor" Button **Gruppe 2 — Escalations Full Stack:** - Migration 011: compliance_escalations Tabelle - Backend: escalation_routes.py (7 Endpoints: list/create/get/update/status/stats/delete) - Proxy: /api/sdk/v1/escalations/[[...path]] → backend:8002 - Frontend: Mock-Array komplett ersetzt durch echte API + EscalationCreateModal + EscalationDetailDrawer **Gruppe 2 — Consent Templates:** - Migration 010: compliance_consent_email_templates + compliance_consent_gdpr_processes (7+7 Seed-Einträge) - Backend: consent_template_routes.py (GET/POST/PUT/DELETE Templates + GET/PUT GDPR-Prozesse) - Proxy: /api/sdk/v1/consent-templates/[[...path]] - Frontend: consent-management/page.tsx lädt Templates + Prozesse aus DB (ApiTemplateEditor, ApiGdprProcessEditor) **Gruppe 3 — Notfallplan:** - Migration 012: 4 Tabellen (contacts, scenarios, checklists, exercises) - Backend: notfallplan_routes.py (vollständiges CRUD + /stats) - Proxy: /api/sdk/v1/notfallplan/[[...path]] - Frontend: notfallplan/page.tsx — DB-backed Kontakte + Szenarien + Übungen, ContactCreateModal + ScenarioCreateModal **Infrastruktur:** - __init__.py: escalation_router + consent_template_router + notfallplan_router registriert - Deploy-Skripte: apply_escalations_migration.sh, apply_consent_templates_migration.sh, apply_notfallplan_migration.sh - Tests: 40 neue Tests (test_escalation_routes.py, test_consent_template_routes.py, test_notfallplan_routes.py) - flow-data.ts: Completion aller 7 Module auf 100% gesetzt Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
343 lines
10 KiB
Python
343 lines
10 KiB
Python
"""
|
|
FastAPI routes for Compliance Escalations.
|
|
|
|
Endpoints:
|
|
GET /escalations — list with filters (status, priority, limit, offset)
|
|
POST /escalations — create new escalation
|
|
GET /escalations/stats — counts per status and priority
|
|
GET /escalations/{id} — get single escalation
|
|
PUT /escalations/{id} — update escalation
|
|
PUT /escalations/{id}/status — update status only
|
|
DELETE /escalations/{id} — delete escalation
|
|
"""
|
|
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import Optional, List, Any, Dict
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Header
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import text
|
|
from sqlalchemy.orm import Session
|
|
|
|
from classroom_engine.database import get_db
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/escalations", tags=["escalations"])
|
|
|
|
|
|
# =============================================================================
|
|
# Pydantic Schemas
|
|
# =============================================================================
|
|
|
|
class EscalationCreate(BaseModel):
|
|
title: str
|
|
description: Optional[str] = None
|
|
priority: str = 'medium' # low|medium|high|critical
|
|
category: Optional[str] = None # dsgvo_breach|ai_act|vendor|internal|other
|
|
assignee: Optional[str] = None
|
|
reporter: Optional[str] = None
|
|
source_module: Optional[str] = None
|
|
source_id: Optional[str] = None
|
|
due_date: Optional[datetime] = None
|
|
|
|
|
|
class EscalationUpdate(BaseModel):
|
|
title: Optional[str] = None
|
|
description: Optional[str] = None
|
|
priority: Optional[str] = None
|
|
status: Optional[str] = None
|
|
category: Optional[str] = None
|
|
assignee: Optional[str] = None
|
|
due_date: Optional[datetime] = None
|
|
|
|
|
|
class EscalationStatusUpdate(BaseModel):
|
|
status: str
|
|
resolved_at: Optional[datetime] = None
|
|
|
|
|
|
def _row_to_dict(row) -> Dict[str, Any]:
|
|
"""Convert a SQLAlchemy row to a serialisable dict."""
|
|
result = dict(row._mapping)
|
|
for key, val in result.items():
|
|
if isinstance(val, datetime):
|
|
result[key] = val.isoformat()
|
|
elif hasattr(val, '__str__') and not isinstance(val, (str, int, float, bool, type(None))):
|
|
result[key] = str(val)
|
|
return result
|
|
|
|
|
|
# =============================================================================
|
|
# Routes
|
|
# =============================================================================
|
|
|
|
@router.get("")
|
|
async def list_escalations(
|
|
status: Optional[str] = Query(None),
|
|
priority: Optional[str] = Query(None),
|
|
limit: int = Query(50, ge=1, le=500),
|
|
offset: int = Query(0, ge=0),
|
|
tenant_id: Optional[str] = Header(None, alias="x-tenant-id"),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""List escalations with optional filters."""
|
|
tid = tenant_id or 'default'
|
|
|
|
where_clauses = ["tenant_id = :tenant_id"]
|
|
params: Dict[str, Any] = {"tenant_id": tid, "limit": limit, "offset": offset}
|
|
|
|
if status:
|
|
where_clauses.append("status = :status")
|
|
params["status"] = status
|
|
if priority:
|
|
where_clauses.append("priority = :priority")
|
|
params["priority"] = priority
|
|
|
|
where_sql = " AND ".join(where_clauses)
|
|
|
|
rows = db.execute(
|
|
text(
|
|
f"SELECT * FROM compliance_escalations WHERE {where_sql} "
|
|
f"ORDER BY created_at DESC LIMIT :limit OFFSET :offset"
|
|
),
|
|
params,
|
|
).fetchall()
|
|
|
|
total_row = db.execute(
|
|
text(f"SELECT COUNT(*) FROM compliance_escalations WHERE {where_sql}"),
|
|
{k: v for k, v in params.items() if k not in ("limit", "offset")},
|
|
).fetchone()
|
|
|
|
return {
|
|
"items": [_row_to_dict(r) for r in rows],
|
|
"total": total_row[0] if total_row else 0,
|
|
"limit": limit,
|
|
"offset": offset,
|
|
}
|
|
|
|
|
|
@router.post("", status_code=201)
|
|
async def create_escalation(
|
|
request: EscalationCreate,
|
|
tenant_id: Optional[str] = Header(None, alias="x-tenant-id"),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Create a new escalation."""
|
|
tid = tenant_id or 'default'
|
|
|
|
row = db.execute(
|
|
text(
|
|
"""
|
|
INSERT INTO compliance_escalations
|
|
(tenant_id, title, description, priority, status, category,
|
|
assignee, reporter, source_module, source_id, due_date)
|
|
VALUES
|
|
(:tenant_id, :title, :description, :priority, 'open', :category,
|
|
:assignee, :reporter, :source_module, :source_id, :due_date)
|
|
RETURNING *
|
|
"""
|
|
),
|
|
{
|
|
"tenant_id": tid,
|
|
"title": request.title,
|
|
"description": request.description,
|
|
"priority": request.priority,
|
|
"category": request.category,
|
|
"assignee": request.assignee,
|
|
"reporter": request.reporter,
|
|
"source_module": request.source_module,
|
|
"source_id": request.source_id,
|
|
"due_date": request.due_date,
|
|
},
|
|
).fetchone()
|
|
|
|
db.commit()
|
|
return _row_to_dict(row)
|
|
|
|
|
|
@router.get("/stats")
|
|
async def get_stats(
|
|
tenant_id: Optional[str] = Header(None, alias="x-tenant-id"),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Return counts per status and priority."""
|
|
tid = tenant_id or 'default'
|
|
|
|
status_rows = db.execute(
|
|
text(
|
|
"SELECT status, COUNT(*) as cnt FROM compliance_escalations "
|
|
"WHERE tenant_id = :tenant_id GROUP BY status"
|
|
),
|
|
{"tenant_id": tid},
|
|
).fetchall()
|
|
|
|
priority_rows = db.execute(
|
|
text(
|
|
"SELECT priority, COUNT(*) as cnt FROM compliance_escalations "
|
|
"WHERE tenant_id = :tenant_id GROUP BY priority"
|
|
),
|
|
{"tenant_id": tid},
|
|
).fetchall()
|
|
|
|
total_row = db.execute(
|
|
text("SELECT COUNT(*) FROM compliance_escalations WHERE tenant_id = :tenant_id"),
|
|
{"tenant_id": tid},
|
|
).fetchone()
|
|
|
|
active_row = db.execute(
|
|
text(
|
|
"SELECT COUNT(*) FROM compliance_escalations "
|
|
"WHERE tenant_id = :tenant_id AND status NOT IN ('resolved', 'closed')"
|
|
),
|
|
{"tenant_id": tid},
|
|
).fetchone()
|
|
|
|
by_status = {"open": 0, "in_progress": 0, "escalated": 0, "resolved": 0, "closed": 0}
|
|
for r in status_rows:
|
|
key = r[0] if r[0] in by_status else r[0]
|
|
by_status[key] = r[1]
|
|
|
|
by_priority = {"low": 0, "medium": 0, "high": 0, "critical": 0}
|
|
for r in priority_rows:
|
|
if r[0] in by_priority:
|
|
by_priority[r[0]] = r[1]
|
|
|
|
return {
|
|
"by_status": by_status,
|
|
"by_priority": by_priority,
|
|
"total": total_row[0] if total_row else 0,
|
|
"active": active_row[0] if active_row else 0,
|
|
}
|
|
|
|
|
|
@router.get("/{escalation_id}")
|
|
async def get_escalation(
|
|
escalation_id: str,
|
|
tenant_id: Optional[str] = Header(None, alias="x-tenant-id"),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Get a single escalation by ID."""
|
|
tid = tenant_id or 'default'
|
|
row = db.execute(
|
|
text(
|
|
"SELECT * FROM compliance_escalations "
|
|
"WHERE id = :id AND tenant_id = :tenant_id"
|
|
),
|
|
{"id": escalation_id, "tenant_id": tid},
|
|
).fetchone()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail=f"Escalation {escalation_id} not found")
|
|
return _row_to_dict(row)
|
|
|
|
|
|
@router.put("/{escalation_id}")
|
|
async def update_escalation(
|
|
escalation_id: str,
|
|
request: EscalationUpdate,
|
|
tenant_id: Optional[str] = Header(None, alias="x-tenant-id"),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Update an escalation's fields."""
|
|
tid = tenant_id or 'default'
|
|
|
|
existing = db.execute(
|
|
text(
|
|
"SELECT id FROM compliance_escalations "
|
|
"WHERE id = :id AND tenant_id = :tenant_id"
|
|
),
|
|
{"id": escalation_id, "tenant_id": tid},
|
|
).fetchone()
|
|
if not existing:
|
|
raise HTTPException(status_code=404, detail=f"Escalation {escalation_id} not found")
|
|
|
|
updates = request.dict(exclude_none=True)
|
|
if not updates:
|
|
row = db.execute(
|
|
text("SELECT * FROM compliance_escalations WHERE id = :id"),
|
|
{"id": escalation_id},
|
|
).fetchone()
|
|
return _row_to_dict(row)
|
|
|
|
set_clauses = ", ".join(f"{k} = :{k}" for k in updates.keys())
|
|
updates["id"] = escalation_id
|
|
updates["updated_at"] = datetime.utcnow()
|
|
|
|
row = db.execute(
|
|
text(
|
|
f"UPDATE compliance_escalations SET {set_clauses}, updated_at = :updated_at "
|
|
f"WHERE id = :id RETURNING *"
|
|
),
|
|
updates,
|
|
).fetchone()
|
|
db.commit()
|
|
return _row_to_dict(row)
|
|
|
|
|
|
@router.put("/{escalation_id}/status")
|
|
async def update_status(
|
|
escalation_id: str,
|
|
request: EscalationStatusUpdate,
|
|
tenant_id: Optional[str] = Header(None, alias="x-tenant-id"),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Update only the status of an escalation."""
|
|
tid = tenant_id or 'default'
|
|
|
|
existing = db.execute(
|
|
text(
|
|
"SELECT id FROM compliance_escalations "
|
|
"WHERE id = :id AND tenant_id = :tenant_id"
|
|
),
|
|
{"id": escalation_id, "tenant_id": tid},
|
|
).fetchone()
|
|
if not existing:
|
|
raise HTTPException(status_code=404, detail=f"Escalation {escalation_id} not found")
|
|
|
|
resolved_at = request.resolved_at
|
|
if request.status in ('resolved', 'closed') and resolved_at is None:
|
|
resolved_at = datetime.utcnow()
|
|
|
|
row = db.execute(
|
|
text(
|
|
"UPDATE compliance_escalations "
|
|
"SET status = :status, resolved_at = :resolved_at, updated_at = :updated_at "
|
|
"WHERE id = :id RETURNING *"
|
|
),
|
|
{
|
|
"status": request.status,
|
|
"resolved_at": resolved_at,
|
|
"updated_at": datetime.utcnow(),
|
|
"id": escalation_id,
|
|
},
|
|
).fetchone()
|
|
db.commit()
|
|
return _row_to_dict(row)
|
|
|
|
|
|
@router.delete("/{escalation_id}")
|
|
async def delete_escalation(
|
|
escalation_id: str,
|
|
tenant_id: Optional[str] = Header(None, alias="x-tenant-id"),
|
|
db: Session = Depends(get_db),
|
|
):
|
|
"""Delete an escalation."""
|
|
tid = tenant_id or 'default'
|
|
|
|
existing = db.execute(
|
|
text(
|
|
"SELECT id FROM compliance_escalations "
|
|
"WHERE id = :id AND tenant_id = :tenant_id"
|
|
),
|
|
{"id": escalation_id, "tenant_id": tid},
|
|
).fetchone()
|
|
if not existing:
|
|
raise HTTPException(status_code=404, detail=f"Escalation {escalation_id} not found")
|
|
|
|
db.execute(
|
|
text("DELETE FROM compliance_escalations WHERE id = :id"),
|
|
{"id": escalation_id},
|
|
)
|
|
db.commit()
|
|
return {"success": True, "message": f"Escalation {escalation_id} deleted"}
|