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