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:
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
|
||||
Reference in New Issue
Block a user