""" 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, timezone from typing import Any, Dict, List, Optional 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.now(timezone.utc), } 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