feat(sdk): API-Referenz Frontend + Backend-Konsolidierung (Shared Utilities, CRUD Factory)
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 32s
CI / test-python-backend-compliance (push) Successful in 30s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 18s
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 32s
CI / test-python-backend-compliance (push) Successful in 30s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 18s
- 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 <noreply@anthropic.com>
This commit is contained in:
312
admin-compliance/app/sdk/api-docs/page.tsx
Normal file
312
admin-compliance/app/sdk/api-docs/page.tsx
Normal file
@@ -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<HttpMethod, string> = {
|
||||
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<ServiceFilter>('all')
|
||||
const [methodFilter, setMethodFilter] = useState<HttpMethod | 'all'>('all')
|
||||
const [expandedModules, setExpandedModules] = useState<Set<string>>(new Set())
|
||||
const moduleRefs = useRef<Record<string, HTMLDivElement | null>>({})
|
||||
|
||||
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 (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Header */}
|
||||
<div className="bg-white border-b border-gray-200 sticky top-0 z-20">
|
||||
<div className="max-w-7xl mx-auto px-4 py-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900">API-Referenz</h1>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
{stats.total} Endpoints in {stats.modules} Modulen
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={expandAll}
|
||||
className="px-3 py-1.5 text-xs font-medium text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Alle aufklappen
|
||||
</button>
|
||||
<button
|
||||
onClick={collapseAll}
|
||||
className="px-3 py-1.5 text-xs font-medium text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
Alle zuklappen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search + Filters */}
|
||||
<div className="flex flex-wrap gap-3 items-center">
|
||||
<div className="relative flex-1 min-w-[240px]">
|
||||
<svg
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Endpoint, Beschreibung oder Modul suchen..."
|
||||
value={search}
|
||||
onChange={(e) => 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 && (
|
||||
<button
|
||||
onClick={() => setSearch('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Service Filter */}
|
||||
<div className="flex rounded-lg border border-gray-300 overflow-hidden">
|
||||
{([['all', 'Alle'], ['python', 'Python/FastAPI'], ['go', 'Go/Gin']] as const).map(([val, label]) => (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => setServiceFilter(val)}
|
||||
className={`px-3 py-2 text-xs font-medium transition-colors ${
|
||||
serviceFilter === val
|
||||
? 'bg-gray-900 text-white'
|
||||
: 'bg-white text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Method Filter */}
|
||||
<div className="flex gap-1.5">
|
||||
{(['all', 'GET', 'POST', 'PUT', 'DELETE', 'PATCH'] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMethodFilter(m)}
|
||||
className={`px-2.5 py-1.5 text-xs font-mono font-bold rounded-md transition-colors ${
|
||||
methodFilter === m
|
||||
? m === 'all'
|
||||
? 'bg-gray-900 text-white'
|
||||
: METHOD_COLORS[m] + ' ring-2 ring-offset-1 ring-gray-400'
|
||||
: 'bg-gray-100 text-gray-500 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{m === 'all' ? 'ALLE' : m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 py-6">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
{[
|
||||
{ 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) => (
|
||||
<div key={s.label} className="bg-white rounded-lg border border-gray-200 p-4">
|
||||
<p className="text-xs text-gray-500 mb-1">{s.label}</p>
|
||||
<p className={`text-2xl font-bold ${s.color}`}>{s.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6">
|
||||
{/* Module Index (Sidebar) */}
|
||||
<div className="hidden lg:block w-64 flex-shrink-0">
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-4 sticky top-[140px] max-h-[calc(100vh-180px)] overflow-y-auto">
|
||||
<h3 className="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">
|
||||
Modul-Index ({filteredModules.length})
|
||||
</h3>
|
||||
<div className="space-y-0.5">
|
||||
{filteredModules.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
onClick={() => scrollToModule(m.id)}
|
||||
className="w-full text-left px-2 py-1.5 text-xs rounded hover:bg-gray-100 transition-colors group flex items-center justify-between"
|
||||
>
|
||||
<span className="truncate text-gray-700 group-hover:text-gray-900">
|
||||
{m.id}
|
||||
</span>
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${
|
||||
m.service === 'python' ? 'bg-blue-50 text-blue-600' : 'bg-emerald-50 text-emerald-600'
|
||||
}`}>
|
||||
{m.endpoints.length}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{search && (
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
{filteredTotal} Treffer in {filteredModules.length} Modulen
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
{filteredModules.map((m) => {
|
||||
const isExpanded = expandedModules.has(m.id)
|
||||
return (
|
||||
<div
|
||||
key={m.id}
|
||||
ref={(el) => { moduleRefs.current[m.id] = el }}
|
||||
className="bg-white rounded-lg border border-gray-200 overflow-hidden"
|
||||
>
|
||||
{/* Module Header */}
|
||||
<button
|
||||
onClick={() => toggleModule(m.id)}
|
||||
className="w-full flex items-center justify-between px-4 py-3 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<svg
|
||||
className={`w-4 h-4 text-gray-400 flex-shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span className={`text-[10px] font-mono font-bold px-2 py-0.5 rounded ${
|
||||
m.service === 'python' ? 'bg-blue-50 text-blue-700' : 'bg-emerald-50 text-emerald-700'
|
||||
}`}>
|
||||
{m.service === 'python' ? 'PY' : 'GO'}
|
||||
</span>
|
||||
<span className="text-sm font-medium text-gray-900 truncate">{m.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0 ml-3">
|
||||
<span className="text-xs text-gray-400 font-mono">{m.basePath}</span>
|
||||
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-0.5 rounded-full">
|
||||
{m.endpoints.length}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Endpoints Table */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-gray-100">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 text-xs text-gray-500">
|
||||
<th className="text-left px-4 py-2 w-20">Methode</th>
|
||||
<th className="text-left px-4 py-2">Pfad</th>
|
||||
<th className="text-left px-4 py-2">Beschreibung</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{m.endpoints.map((e, i) => (
|
||||
<tr
|
||||
key={`${e.method}-${e.path}-${i}`}
|
||||
className="border-t border-gray-50 hover:bg-gray-50/50 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-2">
|
||||
<span className={`inline-block text-[11px] font-mono font-bold px-2 py-0.5 rounded ${METHOD_COLORS[e.method]}`}>
|
||||
{e.method}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 font-mono text-xs text-gray-800">
|
||||
{e.path}
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs text-gray-600">
|
||||
{e.description}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filteredModules.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<svg className="w-12 h-12 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<p className="text-sm">Keine Endpoints gefunden</p>
|
||||
<p className="text-xs mt-1">Suchbegriff oder Filter anpassen</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -726,6 +726,18 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
|
||||
isActive={pathname === '/sdk/catalog-manager'}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
<AdditionalModuleItem
|
||||
href="/sdk/api-docs"
|
||||
icon={
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
|
||||
</svg>
|
||||
}
|
||||
label="API-Referenz"
|
||||
isActive={pathname === '/sdk/api-docs'}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
<Link
|
||||
href="/sdk/change-requests"
|
||||
className={`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
|
||||
|
||||
1193
admin-compliance/lib/sdk/api-docs/endpoints.ts
Normal file
1193
admin-compliance/lib/sdk/api-docs/endpoints.ts
Normal file
File diff suppressed because it is too large
Load Diff
17
admin-compliance/lib/sdk/api-docs/types.ts
Normal file
17
admin-compliance/lib/sdk/api-docs/types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
|
||||
export type BackendService = 'python' | 'go'
|
||||
|
||||
export interface ApiEndpoint {
|
||||
method: HttpMethod
|
||||
path: string
|
||||
description: string
|
||||
service: BackendService
|
||||
}
|
||||
|
||||
export interface ApiModule {
|
||||
id: string
|
||||
name: string
|
||||
service: BackendService
|
||||
basePath: string
|
||||
endpoints: ApiEndpoint[]
|
||||
}
|
||||
216
backend-compliance/compliance/api/crud_factory.py
Normal file
216
backend-compliance/compliance/api/crud_factory.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
Generic CRUD Router Factory for Compliance API.
|
||||
|
||||
Creates standardized CRUD endpoints (list, create, get, update, delete)
|
||||
for simple resource tables that follow the tenant-isolated pattern:
|
||||
- Table has `id`, `tenant_id`, `created_at`, `updated_at` columns
|
||||
- All queries filtered by tenant_id
|
||||
|
||||
Usage:
|
||||
router = create_crud_router(
|
||||
prefix="/security-backlog",
|
||||
table_name="compliance_security_backlog",
|
||||
tag="security-backlog",
|
||||
columns=["title", "description", "type", "severity", "status", ...],
|
||||
search_columns=["title", "description"],
|
||||
filter_columns=["status", "severity", "type"],
|
||||
order_by="created_at DESC",
|
||||
resource_name="Security item",
|
||||
)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Callable
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from classroom_engine.database import get_db
|
||||
from .tenant_utils import get_tenant_id
|
||||
from .db_utils import row_to_dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_crud_router(
|
||||
prefix: str,
|
||||
table_name: str,
|
||||
tag: str,
|
||||
columns: List[str],
|
||||
search_columns: Optional[List[str]] = None,
|
||||
filter_columns: Optional[List[str]] = None,
|
||||
order_by: str = "created_at DESC",
|
||||
resource_name: str = "Item",
|
||||
stats_query: Optional[str] = None,
|
||||
stats_defaults: Optional[Dict[str, int]] = None,
|
||||
) -> 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
|
||||
25
backend-compliance/compliance/api/db_utils.py
Normal file
25
backend-compliance/compliance/api/db_utils.py
Normal file
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user