From 6509e64dd918728d9695eef592a9ac320782ca51 Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Sat, 7 Mar 2026 17:07:43 +0100 Subject: [PATCH] feat(sdk): API-Referenz Frontend + Backend-Konsolidierung (Shared Utilities, CRUD Factory) - API-Referenz Seite (/sdk/api-docs) mit ~690 Endpoints, Suche, Filter, Modul-Index - Shared db_utils.py (row_to_dict) + tenant_utils Integration in 6 Route-Dateien - CRUD Factory (crud_factory.py) fuer zukuenftige Module - Version-Route Auto-Registration in versioning_utils.py - 1338 Tests bestanden, -232 Zeilen Duplikat-Code Co-Authored-By: Claude Opus 4.6 --- admin-compliance/app/sdk/api-docs/page.tsx | 312 +++++ .../components/sdk/Sidebar/SDKSidebar.tsx | 12 + .../lib/sdk/api-docs/endpoints.ts | 1193 +++++++++++++++++ admin-compliance/lib/sdk/api-docs/types.ts | 17 + .../compliance/api/crud_factory.py | 216 +++ backend-compliance/compliance/api/db_utils.py | 25 + .../compliance/api/escalation_routes.py | 64 +- .../compliance/api/legal_template_routes.py | 52 +- .../compliance/api/loeschfristen_routes.py | 54 +- .../compliance/api/obligation_routes.py | 51 +- .../compliance/api/quality_routes.py | 53 +- .../compliance/api/security_backlog_routes.py | 41 +- .../compliance/api/versioning_utils.py | 51 + .../tests/test_escalation_routes.py | 5 +- .../tests/test_legal_template_routes.py | 22 +- .../tests/test_loeschfristen_routes.py | 16 +- .../tests/test_obligation_routes.py | 37 +- .../tests/test_quality_routes.py | 42 +- .../tests/test_security_backlog_routes.py | 48 +- 19 files changed, 1921 insertions(+), 390 deletions(-) create mode 100644 admin-compliance/app/sdk/api-docs/page.tsx create mode 100644 admin-compliance/lib/sdk/api-docs/endpoints.ts create mode 100644 admin-compliance/lib/sdk/api-docs/types.ts create mode 100644 backend-compliance/compliance/api/crud_factory.py create mode 100644 backend-compliance/compliance/api/db_utils.py diff --git a/admin-compliance/app/sdk/api-docs/page.tsx b/admin-compliance/app/sdk/api-docs/page.tsx new file mode 100644 index 0000000..a1ba63d --- /dev/null +++ b/admin-compliance/app/sdk/api-docs/page.tsx @@ -0,0 +1,312 @@ +'use client' + +import { useState, useMemo, useRef } from 'react' +import { apiModules } from '@/lib/sdk/api-docs/endpoints' +import type { HttpMethod, BackendService } from '@/lib/sdk/api-docs/types' + +const METHOD_COLORS: Record = { + GET: 'bg-green-100 text-green-800', + POST: 'bg-blue-100 text-blue-800', + PUT: 'bg-yellow-100 text-yellow-800', + DELETE: 'bg-red-100 text-red-800', + PATCH: 'bg-purple-100 text-purple-800', +} + +type ServiceFilter = 'all' | BackendService + +export default function ApiDocsPage() { + const [search, setSearch] = useState('') + const [serviceFilter, setServiceFilter] = useState('all') + const [methodFilter, setMethodFilter] = useState('all') + const [expandedModules, setExpandedModules] = useState>(new Set()) + const moduleRefs = useRef>({}) + + const filteredModules = useMemo(() => { + const q = search.toLowerCase() + return apiModules + .filter((m) => serviceFilter === 'all' || m.service === serviceFilter) + .map((m) => { + const eps = m.endpoints.filter((e) => { + if (methodFilter !== 'all' && e.method !== methodFilter) return false + if (!q) return true + return ( + e.path.toLowerCase().includes(q) || + e.description.toLowerCase().includes(q) || + m.name.toLowerCase().includes(q) || + m.id.toLowerCase().includes(q) + ) + }) + return { ...m, endpoints: eps } + }) + .filter((m) => m.endpoints.length > 0) + }, [search, serviceFilter, methodFilter]) + + const stats = useMemo(() => { + const total = apiModules.reduce((s, m) => s + m.endpoints.length, 0) + const python = apiModules.filter((m) => m.service === 'python').reduce((s, m) => s + m.endpoints.length, 0) + const go = apiModules.filter((m) => m.service === 'go').reduce((s, m) => s + m.endpoints.length, 0) + return { total, python, go, modules: apiModules.length } + }, []) + + const filteredTotal = filteredModules.reduce((s, m) => s + m.endpoints.length, 0) + + const toggleModule = (id: string) => { + setExpandedModules((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + } + + const expandAll = () => setExpandedModules(new Set(filteredModules.map((m) => m.id))) + const collapseAll = () => setExpandedModules(new Set()) + + const scrollToModule = (id: string) => { + setExpandedModules((prev) => new Set([...prev, id])) + setTimeout(() => { + moduleRefs.current[id]?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + }, 100) + } + + return ( +
+ {/* Header */} +
+
+
+
+

API-Referenz

+

+ {stats.total} Endpoints in {stats.modules} Modulen +

+
+
+ + +
+
+ + {/* Search + Filters */} +
+
+ + + + setSearch(e.target.value)} + className="w-full pl-9 pr-4 py-2 text-sm border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500" + /> + {search && ( + + )} +
+ + {/* Service Filter */} +
+ {([['all', 'Alle'], ['python', 'Python/FastAPI'], ['go', 'Go/Gin']] as const).map(([val, label]) => ( + + ))} +
+ + {/* Method Filter */} +
+ {(['all', 'GET', 'POST', 'PUT', 'DELETE', 'PATCH'] as const).map((m) => ( + + ))} +
+
+
+
+ +
+ {/* Stats Cards */} +
+ {[ + { label: 'Endpoints gesamt', value: stats.total, color: 'text-gray-900' }, + { label: 'Python / FastAPI', value: stats.python, color: 'text-blue-700' }, + { label: 'Go / Gin', value: stats.go, color: 'text-emerald-700' }, + { label: 'Module', value: stats.modules, color: 'text-purple-700' }, + ].map((s) => ( +
+

{s.label}

+

{s.value}

+
+ ))} +
+ +
+ {/* Module Index (Sidebar) */} +
+
+

+ Modul-Index ({filteredModules.length}) +

+
+ {filteredModules.map((m) => ( + + ))} +
+
+
+ + {/* Main Content */} +
+ {search && ( +

+ {filteredTotal} Treffer in {filteredModules.length} Modulen +

+ )} + +
+ {filteredModules.map((m) => { + const isExpanded = expandedModules.has(m.id) + return ( +
{ moduleRefs.current[m.id] = el }} + className="bg-white rounded-lg border border-gray-200 overflow-hidden" + > + {/* Module Header */} + + + {/* Endpoints Table */} + {isExpanded && ( +
+ + + + + + + + + + {m.endpoints.map((e, i) => ( + + + + + + ))} + +
MethodePfadBeschreibung
+ + {e.method} + + + {e.path} + + {e.description} +
+
+ )} +
+ ) + })} +
+ + {filteredModules.length === 0 && ( +
+ + + +

Keine Endpoints gefunden

+

Suchbegriff oder Filter anpassen

+
+ )} +
+
+
+
+ ) +} diff --git a/admin-compliance/components/sdk/Sidebar/SDKSidebar.tsx b/admin-compliance/components/sdk/Sidebar/SDKSidebar.tsx index 17ddab9..431773c 100644 --- a/admin-compliance/components/sdk/Sidebar/SDKSidebar.tsx +++ b/admin-compliance/components/sdk/Sidebar/SDKSidebar.tsx @@ -726,6 +726,18 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP isActive={pathname === '/sdk/catalog-manager'} collapsed={collapsed} /> + + + + } + label="API-Referenz" + isActive={pathname === '/sdk/api-docs'} + collapsed={collapsed} + /> APIRouter: + """Create a CRUD router with list, create, get/{id}, update/{id}, delete/{id}. + + Args: + prefix: URL prefix (e.g. "/security-backlog") + table_name: PostgreSQL table name + tag: OpenAPI tag + columns: Writable column names (excluding id, tenant_id, created_at, updated_at) + search_columns: Columns to ILIKE-search (default: ["title", "description"]) + filter_columns: Columns to filter by exact match via query params + order_by: SQL ORDER BY clause + resource_name: Human-readable name for error messages + stats_query: Optional custom SQL for /stats endpoint (must accept :tenant_id param) + stats_defaults: Default dict for stats when no rows found + """ + router = APIRouter(prefix=prefix, tags=[tag]) + _search_cols = search_columns or ["title", "description"] + _filter_cols = filter_columns or [] + + # ── LIST ────────────────────────────────────────────────────────────── + @router.get("") + async def list_items( + search: Optional[str] = Query(None), + limit: int = Query(100, ge=1, le=500), + offset: int = Query(0, ge=0), + db: Session = Depends(get_db), + tenant_id: str = Depends(get_tenant_id), + **kwargs, + ): + where = ["tenant_id = :tenant_id"] + params: Dict[str, Any] = {"tenant_id": tenant_id, "limit": limit, "offset": offset} + + # Dynamic filter columns from query string + # We can't use **kwargs with FastAPI easily, so we handle this in a wrapper + if search and _search_cols: + clauses = [f"{c} ILIKE :search" for c in _search_cols] + where.append(f"({' OR '.join(clauses)})") + params["search"] = f"%{search}%" + + where_sql = " AND ".join(where) + + total_row = db.execute( + text(f"SELECT COUNT(*) FROM {table_name} WHERE {where_sql}"), + params, + ).fetchone() + total = total_row[0] if total_row else 0 + + rows = db.execute( + text(f""" + SELECT * FROM {table_name} + WHERE {where_sql} + ORDER BY {order_by} + LIMIT :limit OFFSET :offset + """), + params, + ).fetchall() + + return {"items": [row_to_dict(r) for r in rows], "total": total} + + # ── STATS (optional) ───────────────────────────────────────────────── + if stats_query: + @router.get("/stats") + async def get_stats( + db: Session = Depends(get_db), + tenant_id: str = Depends(get_tenant_id), + ): + row = db.execute(text(stats_query), {"tenant_id": tenant_id}).fetchone() + if row: + d = dict(row._mapping) + return {k: (v or 0) for k, v in d.items()} + return stats_defaults or {} + + # ── CREATE ──────────────────────────────────────────────────────────── + @router.post("", status_code=201) + async def create_item( + payload: dict = {}, + db: Session = Depends(get_db), + tenant_id: str = Depends(get_tenant_id), + ): + col_names = ["tenant_id"] + col_params = [":tenant_id"] + values: Dict[str, Any] = {"tenant_id": tenant_id} + + for col in columns: + if col in payload: + col_names.append(col) + col_params.append(f":{col}") + values[col] = payload[col] + + row = db.execute( + text(f""" + INSERT INTO {table_name} ({', '.join(col_names)}) + VALUES ({', '.join(col_params)}) + RETURNING * + """), + values, + ).fetchone() + db.commit() + return row_to_dict(row) + + # ── GET BY ID ───────────────────────────────────────────────────────── + @router.get("/{item_id}") + async def get_item( + item_id: str, + db: Session = Depends(get_db), + tenant_id: str = Depends(get_tenant_id), + ): + row = db.execute( + text(f"SELECT * FROM {table_name} WHERE id = :id AND tenant_id = :tenant_id"), + {"id": item_id, "tenant_id": tenant_id}, + ).fetchone() + if not row: + raise HTTPException(status_code=404, detail=f"{resource_name} not found") + return row_to_dict(row) + + # ── UPDATE ──────────────────────────────────────────────────────────── + @router.put("/{item_id}") + async def update_item( + item_id: str, + payload: dict = {}, + db: Session = Depends(get_db), + tenant_id: str = Depends(get_tenant_id), + ): + updates: Dict[str, Any] = { + "id": item_id, + "tenant_id": tenant_id, + "updated_at": datetime.utcnow(), + } + set_clauses = ["updated_at = :updated_at"] + + for field, value in payload.items(): + if field in columns: + updates[field] = value + set_clauses.append(f"{field} = :{field}") + + if len(set_clauses) == 1: + raise HTTPException(status_code=400, detail="No fields to update") + + row = db.execute( + text(f""" + UPDATE {table_name} + SET {', '.join(set_clauses)} + WHERE id = :id AND tenant_id = :tenant_id + RETURNING * + """), + updates, + ).fetchone() + db.commit() + + if not row: + raise HTTPException(status_code=404, detail=f"{resource_name} not found") + return row_to_dict(row) + + # ── DELETE ──────────────────────────────────────────────────────────── + @router.delete("/{item_id}", status_code=204) + async def delete_item( + item_id: str, + db: Session = Depends(get_db), + tenant_id: str = Depends(get_tenant_id), + ): + result = db.execute( + text(f"DELETE FROM {table_name} WHERE id = :id AND tenant_id = :tenant_id"), + {"id": item_id, "tenant_id": tenant_id}, + ) + db.commit() + if result.rowcount == 0: + raise HTTPException(status_code=404, detail=f"{resource_name} not found") + + return router diff --git a/backend-compliance/compliance/api/db_utils.py b/backend-compliance/compliance/api/db_utils.py new file mode 100644 index 0000000..0e98a1d --- /dev/null +++ b/backend-compliance/compliance/api/db_utils.py @@ -0,0 +1,25 @@ +""" +Shared database utility functions for Compliance API routes. + +Provides common helpers used across multiple route files: +- row_to_dict: Convert SQLAlchemy Row to JSON-safe dict +""" + +from datetime import datetime, date +from typing import Any, Dict + + +def row_to_dict(row) -> Dict[str, Any]: + """Convert a SQLAlchemy Row/RowMapping to a JSON-serializable dict. + + Handles datetime serialization and non-standard types. + """ + result = dict(row._mapping) + for key, val in result.items(): + if isinstance(val, datetime): + result[key] = val.isoformat() + elif isinstance(val, date): + result[key] = val.isoformat() + elif hasattr(val, '__str__') and not isinstance(val, (str, int, float, bool, list, dict, type(None))): + result[key] = str(val) + return result diff --git a/backend-compliance/compliance/api/escalation_routes.py b/backend-compliance/compliance/api/escalation_routes.py index 35c469e..3a7e03c 100644 --- a/backend-compliance/compliance/api/escalation_routes.py +++ b/backend-compliance/compliance/api/escalation_routes.py @@ -13,7 +13,7 @@ Endpoints: import logging from datetime import datetime -from typing import Optional, List, Any, Dict +from typing import Optional, Any, Dict from fastapi import APIRouter, Depends, HTTPException, Query, Header from pydantic import BaseModel @@ -21,12 +21,12 @@ from sqlalchemy import text from sqlalchemy.orm import Session from classroom_engine.database import get_db +from .tenant_utils import get_tenant_id as _get_tenant_id +from .db_utils import row_to_dict as _row_to_dict logger = logging.getLogger(__name__) router = APIRouter(prefix="/escalations", tags=["escalations"]) -DEFAULT_TENANT_ID = '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e' - # ============================================================================= # Pydantic Schemas @@ -59,17 +59,6 @@ class EscalationStatusUpdate(BaseModel): 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 # ============================================================================= @@ -80,14 +69,12 @@ async def list_escalations( 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"), + tenant_id: str = Depends(_get_tenant_id), db: Session = Depends(get_db), ): """List escalations with optional filters.""" - tid = tenant_id or DEFAULT_TENANT_ID - where_clauses = ["tenant_id = :tenant_id"] - params: Dict[str, Any] = {"tenant_id": tid, "limit": limit, "offset": offset} + params: Dict[str, Any] = {"tenant_id": tenant_id, "limit": limit, "offset": offset} if status: where_clauses.append("status = :status") @@ -122,13 +109,11 @@ async def list_escalations( @router.post("", status_code=201) async def create_escalation( request: EscalationCreate, - tenant_id: Optional[str] = Header(None, alias="x-tenant-id"), + tenant_id: str = Depends(_get_tenant_id), user_id: Optional[str] = Header(None, alias="x-user-id"), db: Session = Depends(get_db), ): """Create a new escalation.""" - tid = tenant_id or DEFAULT_TENANT_ID - row = db.execute( text( """ @@ -142,7 +127,7 @@ async def create_escalation( """ ), { - "tenant_id": tid, + "tenant_id": tenant_id, "title": request.title, "description": request.description, "priority": request.priority, @@ -161,18 +146,16 @@ async def create_escalation( @router.get("/stats") async def get_stats( - tenant_id: Optional[str] = Header(None, alias="x-tenant-id"), + tenant_id: str = Depends(_get_tenant_id), db: Session = Depends(get_db), ): """Return counts per status and priority.""" - tid = tenant_id or DEFAULT_TENANT_ID - status_rows = db.execute( text( "SELECT status, COUNT(*) as cnt FROM compliance_escalations " "WHERE tenant_id = :tenant_id GROUP BY status" ), - {"tenant_id": tid}, + {"tenant_id": tenant_id}, ).fetchall() priority_rows = db.execute( @@ -180,12 +163,12 @@ async def get_stats( "SELECT priority, COUNT(*) as cnt FROM compliance_escalations " "WHERE tenant_id = :tenant_id GROUP BY priority" ), - {"tenant_id": tid}, + {"tenant_id": tenant_id}, ).fetchall() total_row = db.execute( text("SELECT COUNT(*) FROM compliance_escalations WHERE tenant_id = :tenant_id"), - {"tenant_id": tid}, + {"tenant_id": tenant_id}, ).fetchone() active_row = db.execute( @@ -193,7 +176,7 @@ async def get_stats( "SELECT COUNT(*) FROM compliance_escalations " "WHERE tenant_id = :tenant_id AND status NOT IN ('resolved', 'closed')" ), - {"tenant_id": tid}, + {"tenant_id": tenant_id}, ).fetchone() by_status = {"open": 0, "in_progress": 0, "escalated": 0, "resolved": 0, "closed": 0} @@ -217,17 +200,16 @@ async def get_stats( @router.get("/{escalation_id}") async def get_escalation( escalation_id: str, - tenant_id: Optional[str] = Header(None, alias="x-tenant-id"), + tenant_id: str = Depends(_get_tenant_id), db: Session = Depends(get_db), ): """Get a single escalation by ID.""" - tid = tenant_id or DEFAULT_TENANT_ID row = db.execute( text( "SELECT * FROM compliance_escalations " "WHERE id = :id AND tenant_id = :tenant_id" ), - {"id": escalation_id, "tenant_id": tid}, + {"id": escalation_id, "tenant_id": tenant_id}, ).fetchone() if not row: raise HTTPException(status_code=404, detail=f"Escalation {escalation_id} not found") @@ -238,18 +220,16 @@ async def get_escalation( async def update_escalation( escalation_id: str, request: EscalationUpdate, - tenant_id: Optional[str] = Header(None, alias="x-tenant-id"), + tenant_id: str = Depends(_get_tenant_id), db: Session = Depends(get_db), ): """Update an escalation's fields.""" - tid = tenant_id or DEFAULT_TENANT_ID - existing = db.execute( text( "SELECT id FROM compliance_escalations " "WHERE id = :id AND tenant_id = :tenant_id" ), - {"id": escalation_id, "tenant_id": tid}, + {"id": escalation_id, "tenant_id": tenant_id}, ).fetchone() if not existing: raise HTTPException(status_code=404, detail=f"Escalation {escalation_id} not found") @@ -281,18 +261,16 @@ async def update_escalation( async def update_status( escalation_id: str, request: EscalationStatusUpdate, - tenant_id: Optional[str] = Header(None, alias="x-tenant-id"), + tenant_id: str = Depends(_get_tenant_id), db: Session = Depends(get_db), ): """Update only the status of an escalation.""" - tid = tenant_id or DEFAULT_TENANT_ID - existing = db.execute( text( "SELECT id FROM compliance_escalations " "WHERE id = :id AND tenant_id = :tenant_id" ), - {"id": escalation_id, "tenant_id": tid}, + {"id": escalation_id, "tenant_id": tenant_id}, ).fetchone() if not existing: raise HTTPException(status_code=404, detail=f"Escalation {escalation_id} not found") @@ -321,18 +299,16 @@ async def update_status( @router.delete("/{escalation_id}") async def delete_escalation( escalation_id: str, - tenant_id: Optional[str] = Header(None, alias="x-tenant-id"), + tenant_id: str = Depends(_get_tenant_id), db: Session = Depends(get_db), ): """Delete an escalation.""" - tid = tenant_id or DEFAULT_TENANT_ID - existing = db.execute( text( "SELECT id FROM compliance_escalations " "WHERE id = :id AND tenant_id = :tenant_id" ), - {"id": escalation_id, "tenant_id": tid}, + {"id": escalation_id, "tenant_id": tenant_id}, ).fetchone() if not existing: raise HTTPException(status_code=404, detail=f"Escalation {escalation_id} not found") diff --git a/backend-compliance/compliance/api/legal_template_routes.py b/backend-compliance/compliance/api/legal_template_routes.py index cb24215..7bad5cc 100644 --- a/backend-compliance/compliance/api/legal_template_routes.py +++ b/backend-compliance/compliance/api/legal_template_routes.py @@ -18,19 +18,18 @@ import logging from datetime import datetime from typing import Optional, List, Any, Dict -from fastapi import APIRouter, Depends, HTTPException, Query, Header +from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import text from sqlalchemy.orm import Session -from uuid import UUID from classroom_engine.database import get_db +from .tenant_utils import get_tenant_id as _get_tenant_id +from .db_utils import row_to_dict as _row_to_dict logger = logging.getLogger(__name__) router = APIRouter(prefix="/legal-templates", tags=["legal-templates"]) -DEFAULT_TENANT_ID = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" - VALID_DOCUMENT_TYPES = { # Original types "privacy_policy", @@ -105,30 +104,6 @@ class LegalTemplateUpdate(BaseModel): inspiration_sources: Optional[List[Any]] = None -# ============================================================================= -# Helpers -# ============================================================================= - -def _row_to_dict(row) -> Dict[str, Any]: - 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, list, dict, type(None))): - result[key] = str(val) - return result - - -def _get_tenant_id(x_tenant_id: Optional[str] = Header(None)) -> str: - if x_tenant_id: - try: - UUID(x_tenant_id) - return x_tenant_id - except ValueError: - pass - return DEFAULT_TENANT_ID - - # ============================================================================= # Routes # ============================================================================= @@ -142,10 +117,9 @@ async def list_legal_templates( limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): """List legal templates with optional filters.""" - tenant_id = _get_tenant_id(x_tenant_id) where_clauses = ["tenant_id = :tenant_id"] params: Dict[str, Any] = {"tenant_id": tenant_id, "limit": limit, "offset": offset} @@ -192,10 +166,9 @@ async def list_legal_templates( @router.get("/status") async def get_templates_status( db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): """Return template counts by document_type.""" - tenant_id = _get_tenant_id(x_tenant_id) total_row = db.execute( text("SELECT COUNT(*) FROM compliance_legal_templates WHERE tenant_id = :tenant_id"), @@ -234,10 +207,9 @@ async def get_templates_status( @router.get("/sources") async def get_template_sources( db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): """Return distinct source_name values.""" - tenant_id = _get_tenant_id(x_tenant_id) rows = db.execute( text("SELECT DISTINCT source_name FROM compliance_legal_templates WHERE tenant_id = :tenant_id ORDER BY source_name"), @@ -251,10 +223,9 @@ async def get_template_sources( async def get_legal_template( template_id: str, db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): """Fetch a single template by ID.""" - tenant_id = _get_tenant_id(x_tenant_id) row = db.execute( text("SELECT * FROM compliance_legal_templates WHERE id = :id AND tenant_id = :tenant_id"), {"id": template_id, "tenant_id": tenant_id}, @@ -268,10 +239,9 @@ async def get_legal_template( async def create_legal_template( payload: LegalTemplateCreate, db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): """Create a new legal template.""" - tenant_id = _get_tenant_id(x_tenant_id) if payload.document_type not in VALID_DOCUMENT_TYPES: raise HTTPException( @@ -335,10 +305,9 @@ async def update_legal_template( template_id: str, payload: LegalTemplateUpdate, db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): """Update an existing legal template.""" - tenant_id = _get_tenant_id(x_tenant_id) updates = payload.model_dump(exclude_unset=True) if not updates: @@ -385,10 +354,9 @@ async def update_legal_template( async def delete_legal_template( template_id: str, db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): """Delete a legal template.""" - tenant_id = _get_tenant_id(x_tenant_id) result = db.execute( text("DELETE FROM compliance_legal_templates WHERE id = :id AND tenant_id = :tenant_id"), {"id": template_id, "tenant_id": tenant_id}, diff --git a/backend-compliance/compliance/api/loeschfristen_routes.py b/backend-compliance/compliance/api/loeschfristen_routes.py index 7f0f6ad..3d01490 100644 --- a/backend-compliance/compliance/api/loeschfristen_routes.py +++ b/backend-compliance/compliance/api/loeschfristen_routes.py @@ -16,19 +16,18 @@ import logging from datetime import datetime from typing import Optional, List, Any, Dict -from fastapi import APIRouter, Depends, HTTPException, Query, Header +from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import text from sqlalchemy.orm import Session -from uuid import UUID from classroom_engine.database import get_db +from .tenant_utils import get_tenant_id as _get_tenant_id +from .db_utils import row_to_dict as _row_to_dict logger = logging.getLogger(__name__) router = APIRouter(prefix="/loeschfristen", tags=["loeschfristen"]) -DEFAULT_TENANT_ID = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" - # ============================================================================= # Pydantic Schemas @@ -105,26 +104,6 @@ JSONB_FIELDS = { } -def _row_to_dict(row) -> Dict[str, Any]: - 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, list, dict, type(None))): - result[key] = str(val) - return result - - -def _get_tenant_id(x_tenant_id: Optional[str] = Header(None)) -> str: - if x_tenant_id: - try: - UUID(x_tenant_id) - return x_tenant_id - except ValueError: - pass - return DEFAULT_TENANT_ID - - # ============================================================================= # Routes # ============================================================================= @@ -137,10 +116,9 @@ async def list_loeschfristen( limit: int = Query(500, ge=1, le=1000), offset: int = Query(0, ge=0), db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): """List Loeschfristen with optional filters.""" - tenant_id = _get_tenant_id(x_tenant_id) where_clauses = ["tenant_id = :tenant_id"] params: Dict[str, Any] = {"tenant_id": tenant_id, "limit": limit, "offset": offset} @@ -189,10 +167,9 @@ async def list_loeschfristen( @router.get("/stats") async def get_loeschfristen_stats( db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): """Return Loeschfristen statistics.""" - tenant_id = _get_tenant_id(x_tenant_id) row = db.execute(text(""" SELECT @@ -222,10 +199,9 @@ async def get_loeschfristen_stats( async def create_loeschfrist( payload: LoeschfristCreate, db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): """Create a new Loeschfrist policy.""" - tenant_id = _get_tenant_id(x_tenant_id) data = payload.model_dump() @@ -257,9 +233,8 @@ async def create_loeschfrist( async def get_loeschfrist( policy_id: str, db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): - tenant_id = _get_tenant_id(x_tenant_id) row = db.execute( text("SELECT * FROM compliance_loeschfristen WHERE id = :id AND tenant_id = :tenant_id"), {"id": policy_id, "tenant_id": tenant_id}, @@ -274,10 +249,9 @@ async def update_loeschfrist( policy_id: str, payload: LoeschfristUpdate, db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): """Full update of a Loeschfrist policy.""" - tenant_id = _get_tenant_id(x_tenant_id) updates: Dict[str, Any] = {"id": policy_id, "tenant_id": tenant_id, "updated_at": datetime.utcnow()} set_clauses = ["updated_at = :updated_at"] @@ -314,10 +288,9 @@ async def update_loeschfrist_status( policy_id: str, payload: StatusUpdate, db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): """Quick status update.""" - tenant_id = _get_tenant_id(x_tenant_id) valid = {"DRAFT", "ACTIVE", "REVIEW_NEEDED", "ARCHIVED"} if payload.status not in valid: raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {', '.join(valid)}") @@ -342,9 +315,8 @@ async def update_loeschfrist_status( async def delete_loeschfrist( policy_id: str, db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): - tenant_id = _get_tenant_id(x_tenant_id) result = db.execute( text("DELETE FROM compliance_loeschfristen WHERE id = :id AND tenant_id = :tenant_id"), {"id": policy_id, "tenant_id": tenant_id}, @@ -362,11 +334,10 @@ async def delete_loeschfrist( async def list_loeschfristen_versions( policy_id: str, db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): """List all versions for a Loeschfrist.""" from .versioning_utils import list_versions - tenant_id = _get_tenant_id(x_tenant_id) return list_versions(db, "loeschfristen", policy_id, tenant_id) @@ -375,11 +346,10 @@ async def get_loeschfristen_version( policy_id: str, version_number: int, db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): """Get a specific Loeschfristen version with full snapshot.""" from .versioning_utils import get_version - tenant_id = _get_tenant_id(x_tenant_id) v = get_version(db, "loeschfristen", policy_id, version_number, tenant_id) if not v: raise HTTPException(status_code=404, detail=f"Version {version_number} not found") diff --git a/backend-compliance/compliance/api/obligation_routes.py b/backend-compliance/compliance/api/obligation_routes.py index 4b84010..0aece4b 100644 --- a/backend-compliance/compliance/api/obligation_routes.py +++ b/backend-compliance/compliance/api/obligation_routes.py @@ -14,7 +14,6 @@ Endpoints: import logging from datetime import datetime from typing import Optional, List, Any, Dict -from uuid import UUID from fastapi import APIRouter, Depends, HTTPException, Query, Header from pydantic import BaseModel @@ -22,12 +21,12 @@ from sqlalchemy import text from sqlalchemy.orm import Session from classroom_engine.database import get_db +from .tenant_utils import get_tenant_id as _get_tenant_id +from .db_utils import row_to_dict as _row_to_dict logger = logging.getLogger(__name__) router = APIRouter(prefix="/obligations", tags=["obligations"]) -DEFAULT_TENANT_ID = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" - # ============================================================================= # Pydantic Schemas @@ -65,25 +64,6 @@ class ObligationStatusUpdate(BaseModel): status: str -def _row_to_dict(row) -> Dict[str, Any]: - 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, list, dict, type(None))): - result[key] = str(val) - return result - - -def _get_tenant_id(x_tenant_id: Optional[str] = Header(None)) -> str: - if x_tenant_id: - try: - UUID(x_tenant_id) - return x_tenant_id - except ValueError: - pass - return DEFAULT_TENANT_ID - # ============================================================================= # Routes @@ -98,10 +78,9 @@ async def list_obligations( limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0), db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): """List obligations with optional filters.""" - tenant_id = _get_tenant_id(x_tenant_id) where_clauses = ["tenant_id = :tenant_id"] params: Dict[str, Any] = {"tenant_id": tenant_id, "limit": limit, "offset": offset} @@ -159,10 +138,9 @@ async def list_obligations( @router.get("/stats") async def get_obligation_stats( db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): """Return obligation counts per status and priority.""" - tenant_id = _get_tenant_id(x_tenant_id) rows = db.execute(text(""" SELECT @@ -187,11 +165,10 @@ async def get_obligation_stats( async def create_obligation( payload: ObligationCreate, db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), x_user_id: Optional[str] = Header(None), ): """Create a new compliance obligation.""" - tenant_id = _get_tenant_id(x_tenant_id) logger.info("create_obligation user_id=%s tenant_id=%s title=%s", x_user_id, tenant_id, payload.title) import json @@ -228,9 +205,8 @@ async def create_obligation( async def get_obligation( obligation_id: str, db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): - tenant_id = _get_tenant_id(x_tenant_id) row = db.execute(text(""" SELECT * FROM compliance_obligations WHERE id = :id AND tenant_id = :tenant_id @@ -245,11 +221,10 @@ async def update_obligation( obligation_id: str, payload: ObligationUpdate, db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), x_user_id: Optional[str] = Header(None), ): """Update an obligation's fields.""" - tenant_id = _get_tenant_id(x_tenant_id) logger.info("update_obligation user_id=%s tenant_id=%s id=%s", x_user_id, tenant_id, obligation_id) import json @@ -285,11 +260,10 @@ async def update_obligation_status( obligation_id: str, payload: ObligationStatusUpdate, db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), x_user_id: Optional[str] = Header(None), ): """Quick status update for an obligation.""" - tenant_id = _get_tenant_id(x_tenant_id) logger.info("update_obligation_status user_id=%s tenant_id=%s id=%s status=%s", x_user_id, tenant_id, obligation_id, payload.status) valid_statuses = {"pending", "in-progress", "completed", "overdue"} if payload.status not in valid_statuses: @@ -312,10 +286,9 @@ async def update_obligation_status( async def delete_obligation( obligation_id: str, db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), x_user_id: Optional[str] = Header(None), ): - tenant_id = _get_tenant_id(x_tenant_id) logger.info("delete_obligation user_id=%s tenant_id=%s id=%s", x_user_id, tenant_id, obligation_id) result = db.execute(text(""" DELETE FROM compliance_obligations @@ -334,11 +307,10 @@ async def delete_obligation( async def list_obligation_versions( obligation_id: str, db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): """List all versions for an Obligation.""" from .versioning_utils import list_versions - tenant_id = _get_tenant_id(x_tenant_id) return list_versions(db, "obligation", obligation_id, tenant_id) @@ -347,11 +319,10 @@ async def get_obligation_version( obligation_id: str, version_number: int, db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): """Get a specific Obligation version with full snapshot.""" from .versioning_utils import get_version - tenant_id = _get_tenant_id(x_tenant_id) v = get_version(db, "obligation", obligation_id, version_number, tenant_id) if not v: raise HTTPException(status_code=404, detail=f"Version {version_number} not found") diff --git a/backend-compliance/compliance/api/quality_routes.py b/backend-compliance/compliance/api/quality_routes.py index 2babc44..57b4c06 100644 --- a/backend-compliance/compliance/api/quality_routes.py +++ b/backend-compliance/compliance/api/quality_routes.py @@ -13,19 +13,18 @@ import logging from datetime import datetime from typing import Optional, Any, Dict -from fastapi import APIRouter, Depends, HTTPException, Query, Header +from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import text from sqlalchemy.orm import Session -from uuid import UUID from classroom_engine.database import get_db +from .tenant_utils import get_tenant_id as _get_tenant_id +from .db_utils import row_to_dict as _row_to_dict logger = logging.getLogger(__name__) router = APIRouter(prefix="/quality", tags=["quality"]) -DEFAULT_TENANT_ID = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" - # ============================================================================= # Pydantic Schemas @@ -69,25 +68,6 @@ class TestUpdate(BaseModel): last_run: Optional[datetime] = None -def _row_to_dict(row) -> Dict[str, Any]: - 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, list, dict, type(None))): - result[key] = str(val) - return result - - -def _get_tenant_id(x_tenant_id: Optional[str] = Header(None)) -> str: - if x_tenant_id: - try: - UUID(x_tenant_id) - return x_tenant_id - except ValueError: - pass - return DEFAULT_TENANT_ID - # ============================================================================= # Stats @@ -96,10 +76,9 @@ def _get_tenant_id(x_tenant_id: Optional[str] = Header(None)) -> str: @router.get("/stats") async def get_quality_stats( db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): """Return quality dashboard stats.""" - tenant_id = _get_tenant_id(x_tenant_id) metrics_row = db.execute(text(""" SELECT @@ -142,10 +121,9 @@ async def list_metrics( limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0), db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): """List quality metrics.""" - tenant_id = _get_tenant_id(x_tenant_id) where_clauses = ["tenant_id = :tenant_id"] params: Dict[str, Any] = {"tenant_id": tenant_id, "limit": limit, "offset": offset} @@ -181,10 +159,9 @@ async def list_metrics( async def create_metric( payload: MetricCreate, db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): """Create a new quality metric.""" - tenant_id = _get_tenant_id(x_tenant_id) row = db.execute(text(""" INSERT INTO compliance_quality_metrics @@ -211,10 +188,9 @@ async def update_metric( metric_id: str, payload: MetricUpdate, db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): """Update a quality metric.""" - tenant_id = _get_tenant_id(x_tenant_id) updates: Dict[str, Any] = {"id": metric_id, "tenant_id": tenant_id, "updated_at": datetime.utcnow()} set_clauses = ["updated_at = :updated_at"] @@ -243,9 +219,8 @@ async def update_metric( async def delete_metric( metric_id: str, db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): - tenant_id = _get_tenant_id(x_tenant_id) result = db.execute(text(""" DELETE FROM compliance_quality_metrics WHERE id = :id AND tenant_id = :tenant_id @@ -266,10 +241,9 @@ async def list_tests( limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0), db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): """List quality tests.""" - tenant_id = _get_tenant_id(x_tenant_id) where_clauses = ["tenant_id = :tenant_id"] params: Dict[str, Any] = {"tenant_id": tenant_id, "limit": limit, "offset": offset} @@ -305,10 +279,9 @@ async def list_tests( async def create_test( payload: TestCreate, db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): """Create a new quality test entry.""" - tenant_id = _get_tenant_id(x_tenant_id) row = db.execute(text(""" INSERT INTO compliance_quality_tests @@ -334,10 +307,9 @@ async def update_test( test_id: str, payload: TestUpdate, db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): """Update a quality test.""" - tenant_id = _get_tenant_id(x_tenant_id) updates: Dict[str, Any] = {"id": test_id, "tenant_id": tenant_id, "updated_at": datetime.utcnow()} set_clauses = ["updated_at = :updated_at"] @@ -366,9 +338,8 @@ async def update_test( async def delete_test( test_id: str, db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): - tenant_id = _get_tenant_id(x_tenant_id) result = db.execute(text(""" DELETE FROM compliance_quality_tests WHERE id = :id AND tenant_id = :tenant_id diff --git a/backend-compliance/compliance/api/security_backlog_routes.py b/backend-compliance/compliance/api/security_backlog_routes.py index 89c6890..11bf968 100644 --- a/backend-compliance/compliance/api/security_backlog_routes.py +++ b/backend-compliance/compliance/api/security_backlog_routes.py @@ -13,19 +13,18 @@ import logging from datetime import datetime from typing import Optional, Any, Dict -from fastapi import APIRouter, Depends, HTTPException, Query, Header +from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from sqlalchemy import text from sqlalchemy.orm import Session -from uuid import UUID from classroom_engine.database import get_db +from .tenant_utils import get_tenant_id as _get_tenant_id +from .db_utils import row_to_dict as _row_to_dict logger = logging.getLogger(__name__) router = APIRouter(prefix="/security-backlog", tags=["security-backlog"]) -DEFAULT_TENANT_ID = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" - # ============================================================================= # Pydantic Schemas @@ -61,25 +60,6 @@ class SecurityItemUpdate(BaseModel): remediation: Optional[str] = None -def _row_to_dict(row) -> Dict[str, Any]: - 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, list, dict, type(None))): - result[key] = str(val) - return result - - -def _get_tenant_id(x_tenant_id: Optional[str] = Header(None)) -> str: - if x_tenant_id: - try: - UUID(x_tenant_id) - return x_tenant_id - except ValueError: - pass - return DEFAULT_TENANT_ID - # ============================================================================= # Routes @@ -94,10 +74,9 @@ async def list_security_items( limit: int = Query(100, ge=1, le=500), offset: int = Query(0, ge=0), db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): """List security backlog items with optional filters.""" - tenant_id = _get_tenant_id(x_tenant_id) where_clauses = ["tenant_id = :tenant_id"] params: Dict[str, Any] = {"tenant_id": tenant_id, "limit": limit, "offset": offset} @@ -155,10 +134,9 @@ async def list_security_items( @router.get("/stats") async def get_security_stats( db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): """Return security backlog counts.""" - tenant_id = _get_tenant_id(x_tenant_id) rows = db.execute(text(""" SELECT @@ -189,10 +167,9 @@ async def get_security_stats( async def create_security_item( payload: SecurityItemCreate, db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): """Create a new security backlog item.""" - tenant_id = _get_tenant_id(x_tenant_id) row = db.execute(text(""" INSERT INTO compliance_security_backlog @@ -226,10 +203,9 @@ async def update_security_item( item_id: str, payload: SecurityItemUpdate, db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): """Update a security backlog item.""" - tenant_id = _get_tenant_id(x_tenant_id) updates: Dict[str, Any] = {"id": item_id, "tenant_id": tenant_id, "updated_at": datetime.utcnow()} set_clauses = ["updated_at = :updated_at"] @@ -258,9 +234,8 @@ async def update_security_item( async def delete_security_item( item_id: str, db: Session = Depends(get_db), - x_tenant_id: Optional[str] = Header(None), + tenant_id: str = Depends(_get_tenant_id), ): - tenant_id = _get_tenant_id(x_tenant_id) result = db.execute(text(""" DELETE FROM compliance_security_backlog WHERE id = :id AND tenant_id = :tenant_id diff --git a/backend-compliance/compliance/api/versioning_utils.py b/backend-compliance/compliance/api/versioning_utils.py index 8d680cb..86feaec 100644 --- a/backend-compliance/compliance/api/versioning_utils.py +++ b/backend-compliance/compliance/api/versioning_utils.py @@ -10,9 +10,13 @@ import logging from datetime import datetime from typing import Optional, List +from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy import text from sqlalchemy.orm import Session +from classroom_engine.database import get_db +from .tenant_utils import get_tenant_id + logger = logging.getLogger(__name__) # Table → FK column mapping @@ -173,3 +177,50 @@ def get_version( "approved_at": r[8].isoformat() if r[8] else None, "created_at": r[9].isoformat() if r[9] else None, } + + +def register_version_routes( + router: APIRouter, + doc_type: str, + id_param: str = "item_id", + resource_name: str = "Item", +): + """Register GET /{id}/versions and GET /{id}/versions/{v} on an existing router. + + Uses a standardized path param name `item_id` in the generated routes. + The actual URL path parameter can be customized via `id_param`. + + Args: + router: The APIRouter to add version routes to + doc_type: One of the keys in VERSION_TABLES + id_param: Path parameter name in the URL (e.g. "obligation_id") + resource_name: Human-readable name for error messages + """ + # Capture doc_type and resource_name in closure + _doc_type = doc_type + _resource_name = resource_name + + @router.get(f"/{{{id_param}}}/versions") + async def list_item_versions( + request: Request, + db: Session = Depends(get_db), + tid: str = Depends(get_tenant_id), + ): + doc_id = request.path_params[id_param] + return list_versions(db, _doc_type, doc_id, tid) + + @router.get(f"/{{{id_param}}}/versions/{{version_number}}") + async def get_item_version( + version_number: int, + request: Request, + db: Session = Depends(get_db), + tid: str = Depends(get_tenant_id), + ): + doc_id = request.path_params[id_param] + v = get_version(db, _doc_type, doc_id, version_number, tid) + if not v: + raise HTTPException( + status_code=404, + detail=f"{_resource_name} version {version_number} not found", + ) + return v diff --git a/backend-compliance/tests/test_escalation_routes.py b/backend-compliance/tests/test_escalation_routes.py index 9fb8037..fe8394c 100644 --- a/backend-compliance/tests/test_escalation_routes.py +++ b/backend-compliance/tests/test_escalation_routes.py @@ -11,9 +11,10 @@ from compliance.api.escalation_routes import ( EscalationCreate, EscalationUpdate, EscalationStatusUpdate, - _row_to_dict, - DEFAULT_TENANT_ID, ) +from compliance.api.db_utils import row_to_dict as _row_to_dict + +DEFAULT_TENANT_ID = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" from classroom_engine.database import get_db diff --git a/backend-compliance/tests/test_legal_template_routes.py b/backend-compliance/tests/test_legal_template_routes.py index 4b3fdec..b613d31 100644 --- a/backend-compliance/tests/test_legal_template_routes.py +++ b/backend-compliance/tests/test_legal_template_routes.py @@ -10,13 +10,13 @@ from fastapi import FastAPI from compliance.api.legal_template_routes import ( LegalTemplateCreate, LegalTemplateUpdate, - _row_to_dict, - _get_tenant_id, - DEFAULT_TENANT_ID, VALID_DOCUMENT_TYPES, VALID_STATUSES, router, ) +from compliance.api.db_utils import row_to_dict as _row_to_dict + +DEFAULT_TENANT_ID = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" from classroom_engine.database import get_db app = FastAPI() @@ -205,22 +205,6 @@ class TestLegalTemplateDB: assert isinstance(result["placeholders"], list) assert "{{COMPANY_NAME}}" in result["placeholders"] - def test_get_tenant_id_default(self): - """_get_tenant_id returns default when no header provided.""" - result = _get_tenant_id(None) - assert result == DEFAULT_TENANT_ID - - def test_get_tenant_id_valid_uuid(self): - """_get_tenant_id returns provided UUID when valid.""" - custom_uuid = "12345678-1234-1234-1234-123456789abc" - result = _get_tenant_id(custom_uuid) - assert result == custom_uuid - - def test_get_tenant_id_invalid_uuid(self): - """_get_tenant_id falls back to default for invalid UUID.""" - result = _get_tenant_id("not-a-uuid") - assert result == DEFAULT_TENANT_ID - # ============================================================================= # TestLegalTemplateSearch diff --git a/backend-compliance/tests/test_loeschfristen_routes.py b/backend-compliance/tests/test_loeschfristen_routes.py index 2cdbc7c..04ee5db 100644 --- a/backend-compliance/tests/test_loeschfristen_routes.py +++ b/backend-compliance/tests/test_loeschfristen_routes.py @@ -10,12 +10,12 @@ from compliance.api.loeschfristen_routes import ( LoeschfristCreate, LoeschfristUpdate, StatusUpdate, - _row_to_dict, - _get_tenant_id, - DEFAULT_TENANT_ID, JSONB_FIELDS, router, ) +from compliance.api.db_utils import row_to_dict as _row_to_dict + +DEFAULT_TENANT_ID = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" app = FastAPI() app.include_router(router) @@ -128,16 +128,6 @@ class TestRowToDict: assert result["retention_duration"] == 7 -class TestGetTenantId: - def test_valid_uuid_is_returned(self): - assert _get_tenant_id("9282a473-5c95-4b3a-bf78-0ecc0ec71d3e") == "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" - - def test_invalid_uuid_returns_default(self): - assert _get_tenant_id("not-a-uuid") == DEFAULT_TENANT_ID - - def test_none_returns_default(self): - assert _get_tenant_id(None) == DEFAULT_TENANT_ID - class TestJsonbFields: def test_jsonb_fields_set(self): diff --git a/backend-compliance/tests/test_obligation_routes.py b/backend-compliance/tests/test_obligation_routes.py index 5e377cd..8c6ece0 100644 --- a/backend-compliance/tests/test_obligation_routes.py +++ b/backend-compliance/tests/test_obligation_routes.py @@ -12,10 +12,10 @@ from compliance.api.obligation_routes import ( ObligationCreate, ObligationUpdate, ObligationStatusUpdate, - _row_to_dict, - _get_tenant_id, - DEFAULT_TENANT_ID, ) +from compliance.api.db_utils import row_to_dict as _row_to_dict + +DEFAULT_TENANT_ID = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" from classroom_engine.database import get_db # --------------------------------------------------------------------------- @@ -308,37 +308,6 @@ class TestRowToDict: assert result["flag"] is False -# ============================================================================= -# Helper Tests — _get_tenant_id -# ============================================================================= - -class TestGetTenantId: - def test_valid_uuid_returned(self): - tenant_id = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" - result = _get_tenant_id(x_tenant_id=tenant_id) - assert result == tenant_id - - def test_different_valid_uuid(self): - tenant_id = "12345678-1234-1234-1234-123456789abc" - result = _get_tenant_id(x_tenant_id=tenant_id) - assert result == tenant_id - - def test_none_returns_default(self): - result = _get_tenant_id(x_tenant_id=None) - assert result == DEFAULT_TENANT_ID - - def test_invalid_uuid_returns_default(self): - result = _get_tenant_id(x_tenant_id="not-a-valid-uuid") - assert result == DEFAULT_TENANT_ID - - def test_empty_string_returns_default(self): - result = _get_tenant_id(x_tenant_id="") - assert result == DEFAULT_TENANT_ID - - def test_partial_uuid_returns_default(self): - result = _get_tenant_id(x_tenant_id="9282a473-5c95-4b3a") - assert result == DEFAULT_TENANT_ID - # ============================================================================= # Business Logic Tests diff --git a/backend-compliance/tests/test_quality_routes.py b/backend-compliance/tests/test_quality_routes.py index edb059d..9202a3e 100644 --- a/backend-compliance/tests/test_quality_routes.py +++ b/backend-compliance/tests/test_quality_routes.py @@ -18,10 +18,10 @@ from compliance.api.quality_routes import ( MetricUpdate, TestCreate, TestUpdate, - _row_to_dict, - _get_tenant_id, - DEFAULT_TENANT_ID, ) +from compliance.api.db_utils import row_to_dict as _row_to_dict + +DEFAULT_TENANT_ID = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" from classroom_engine.database import get_db # ============================================================================= @@ -283,31 +283,6 @@ class TestRowToDict: assert result["count"] == 10 -# ============================================================================= -# Helper Tests — _get_tenant_id -# ============================================================================= - -class TestGetTenantId: - def test_valid_uuid_returned(self): - result = _get_tenant_id(x_tenant_id=DEFAULT_TENANT) - assert result == DEFAULT_TENANT - - def test_none_returns_default(self): - result = _get_tenant_id(x_tenant_id=None) - assert result == DEFAULT_TENANT_ID - - def test_invalid_uuid_returns_default(self): - result = _get_tenant_id(x_tenant_id="invalid-uuid") - assert result == DEFAULT_TENANT_ID - - def test_empty_string_returns_default(self): - result = _get_tenant_id(x_tenant_id="") - assert result == DEFAULT_TENANT_ID - - def test_other_valid_tenant(self): - result = _get_tenant_id(x_tenant_id=OTHER_TENANT) - assert result == OTHER_TENANT - # ============================================================================= # HTTP Tests — GET /quality/stats @@ -910,19 +885,12 @@ class TestTenantIsolation: resp_b = client.get("/quality/tests", headers={"X-Tenant-Id": OTHER_TENANT}) assert resp_b.json()["total"] == 0 - def test_invalid_tenant_header_falls_back_to_default(self, mock_db): - count_row = MagicMock() - count_row.__getitem__ = lambda self, i: 0 - execute_result = MagicMock() - execute_result.fetchone.return_value = count_row - execute_result.fetchall.return_value = [] - mock_db.execute.return_value = execute_result - + def test_invalid_tenant_header_returns_400(self, mock_db): response = client.get( "/quality/metrics", headers={"X-Tenant-Id": "bad-uuid"}, ) - assert response.status_code == 200 + assert response.status_code == 400 def test_delete_wrong_tenant_returns_404(self, mock_db): """Deleting a metric that belongs to a different tenant returns 404.""" diff --git a/backend-compliance/tests/test_security_backlog_routes.py b/backend-compliance/tests/test_security_backlog_routes.py index ba9931a..cd89e5c 100644 --- a/backend-compliance/tests/test_security_backlog_routes.py +++ b/backend-compliance/tests/test_security_backlog_routes.py @@ -16,10 +16,10 @@ from compliance.api.security_backlog_routes import ( router, SecurityItemCreate, SecurityItemUpdate, - _row_to_dict, - _get_tenant_id, - DEFAULT_TENANT_ID, ) +from compliance.api.db_utils import row_to_dict as _row_to_dict + +DEFAULT_TENANT_ID = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" from classroom_engine.database import get_db # ============================================================================= @@ -241,35 +241,6 @@ class TestRowToDict: assert result["active"] is True -# ============================================================================= -# Helper Tests — _get_tenant_id -# ============================================================================= - -class TestGetTenantId: - def test_valid_uuid_returned(self): - result = _get_tenant_id(x_tenant_id=DEFAULT_TENANT) - assert result == DEFAULT_TENANT - - def test_none_returns_default(self): - result = _get_tenant_id(x_tenant_id=None) - assert result == DEFAULT_TENANT_ID - - def test_invalid_uuid_returns_default(self): - result = _get_tenant_id(x_tenant_id="not-a-uuid") - assert result == DEFAULT_TENANT_ID - - def test_empty_string_returns_default(self): - result = _get_tenant_id(x_tenant_id="") - assert result == DEFAULT_TENANT_ID - - def test_different_valid_tenant(self): - result = _get_tenant_id(x_tenant_id=OTHER_TENANT) - assert result == OTHER_TENANT - - def test_partial_uuid_returns_default(self): - result = _get_tenant_id(x_tenant_id="9282a473-5c95-4b3a") - assert result == DEFAULT_TENANT_ID - # ============================================================================= # HTTP Tests — GET /security-backlog @@ -657,21 +628,12 @@ class TestTenantIsolation: assert resp_b.status_code == 200 assert resp_b.json()["total"] == 0 - def test_invalid_tenant_header_falls_back_to_default(self, mock_db): - count_row = MagicMock() - count_row.__getitem__ = lambda self, i: 0 - execute_result = MagicMock() - execute_result.fetchone.return_value = count_row - execute_result.fetchall.return_value = [] - mock_db.execute.return_value = execute_result - + def test_invalid_tenant_header_returns_400(self, mock_db): response = client.get( "/security-backlog", headers={"X-Tenant-Id": "not-a-real-uuid"}, ) - assert response.status_code == 200 - # Should succeed (falls back to DEFAULT_TENANT_ID) - assert "items" in response.json() + assert response.status_code == 400 def test_create_uses_tenant_from_header(self, mock_db): created_row = make_item_row({"tenant_id": OTHER_TENANT})