# Copyright (c) 2025 Patrick Motsch # All rights reserved. """ SysAdmin API for database table statistics and FK orphan detection/cleanup. """ import logging from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException, Request, status from pydantic import BaseModel, Field from modules.auth import limiter from modules.auth.authentication import requireSysAdmin from modules.datamodels.datamodelUam import User from modules.system.databaseHealth import ( OrphanCleanupRefused, _cleanAllOrphans, _cleanOrphans, _getTableStats, _isUserIdFk, _listOrphans, _scanOrphans, ) logger = logging.getLogger(__name__) router = APIRouter( prefix="/api/admin/database-health", tags=["Admin Database Health"], ) class OrphanCleanRequest(BaseModel): """Body for deleting orphans for one FK relationship.""" db: str = Field(..., description="Source database name (e.g. poweron_app)") table: str = Field(..., description="Source table (Pydantic model class name)") column: str = Field(..., description="FK column on the source table") force: bool = Field( False, description="Override safety guards (empty target / >50%% of source). Use with care.", ) class OrphanCleanAllRequest(BaseModel): """Body for cleaning all detected orphans.""" force: bool = Field( False, description="Override safety guards on every relationship. Use with extreme care.", ) excludeUserFks: bool = Field( False, description=( "Skip FK relationships pointing at UserInDB.id. Deleted-user remnants " "(audit / billing / membership rows) are handled by a dedicated purge " "workflow and should not be touched by generic FK cleanup." ), ) @router.get("/stats") @limiter.limit("30/minute") def getDatabaseTableStats( request: Request, db: Optional[str] = None, currentUser: User = Depends(requireSysAdmin), ) -> Dict[str, Any]: """Table statistics from pg_stat_user_tables (optional filter by database name).""" rows = _getTableStats(dbFilter=db) return {"stats": rows} @router.get("/orphans") @limiter.limit("10/minute") def getDatabaseOrphans( request: Request, db: Optional[str] = None, excludeUserFks: bool = False, currentUser: User = Depends(requireSysAdmin), ) -> Dict[str, Any]: """FK orphan scan (optional filter by source database name). When ``excludeUserFks=true``, results targeting ``UserInDB.id`` are omitted from the response so the SysAdmin UI can keep deleted-user remnants visually separate from real FK drift. """ rows = _scanOrphans(dbFilter=db) if excludeUserFks: rows = [r for r in rows if not _isUserIdFk(r.get("targetTable", ""), r.get("targetColumn", ""))] return {"orphans": rows} @router.get("/orphans/list") @limiter.limit("30/minute") def getDatabaseOrphansList( request: Request, db: str, table: str, column: str, limit: int = 1000, currentUser: User = Depends(requireSysAdmin), ) -> Dict[str, Any]: """Return up to ``limit`` actual orphan source-rows for one FK relationship. Used by the SysAdmin UI's per-row download button: a human can review the orphan list (full source row + the unresolved FK value) before triggering the destructive clean operation. """ try: records = _listOrphans(db=db, table=table, column=column, limit=limit) except ValueError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e), ) from e return { "db": db, "table": table, "column": column, "count": len(records), "limit": limit, "records": records, } @router.post("/orphans/clean") @limiter.limit("10/minute") def postDatabaseOrphansClean( request: Request, body: OrphanCleanRequest, currentUser: User = Depends(requireSysAdmin), ) -> Dict[str, Any]: """Delete orphaned rows for a single FK relationship.""" try: deleted = _cleanOrphans(body.db, body.table, body.column, force=body.force) except OrphanCleanupRefused as e: logger.warning( "SysAdmin orphan clean REFUSED: user=%s db=%s table=%s column=%s reason=%s", currentUser.username, body.db, body.table, body.column, e, ) raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail={"refused": True, "reason": str(e)}, ) from e except ValueError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e), ) from e logger.info( "SysAdmin orphan clean: user=%s db=%s table=%s column=%s deleted=%s force=%s", currentUser.username, body.db, body.table, body.column, deleted, body.force, ) return {"deleted": deleted} @router.post("/orphans/clean-all") @limiter.limit("2/minute") def postDatabaseOrphansCleanAll( request: Request, body: Optional[OrphanCleanAllRequest] = None, currentUser: User = Depends(requireSysAdmin), ) -> Dict[str, Any]: """Run orphan cleanup for every relationship that currently has orphans. Returns per-relationship results. Each entry contains either `deleted` (success), `skipped` (safety guard triggered, no force), or `error` (other failure). """ force = bool(body.force) if body is not None else False excludeUserFks = bool(body.excludeUserFks) if body is not None else False results: List[dict] = _cleanAllOrphans(force=force, excludeUserFks=excludeUserFks) skipped = sum(1 for r in results if "skipped" in r) errored = sum(1 for r in results if "error" in r) deletedTotal = sum(int(r.get("deleted", 0)) for r in results) logger.info( "SysAdmin orphan clean-all: user=%s batches=%s deleted=%s skipped=%s errored=%s force=%s excludeUserFks=%s", currentUser.username, len(results), deletedTotal, skipped, errored, force, excludeUserFks, ) return {"results": results, "skipped": skipped, "errored": errored, "deleted": deletedTotal}