196 lines
6.2 KiB
Python
196 lines
6.2 KiB
Python
# 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}
|