# Copyright (c) 2026 PowerOn AG # All rights reserved. """ SysAdmin API for database table statistics, FK orphan detection/cleanup, and database migration (backup / restore). """ import json import logging import os import tempfile import threading import uuid from datetime import datetime, timezone from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status from fastapi.responses import StreamingResponse 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, _discoverLegacyTables, _dropLegacyTable, _getTableStats, _isUserIdFk, _listOrphans, _scanOrphans, ) from modules.system.databaseMigration import ( _exportDatabases, _exportSingleDb, _getAvailableDatabases, _getInstanceLabel, _importDatabases, _importSingleDb, _importSingleDbFromFiles, _streamSplitToFiles, _streamValidate, _validateImportPayload, streamExportGenerator, ) 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} # --------------------------------------------------------------------------- # Legacy Tables (tables without Pydantic model) # --------------------------------------------------------------------------- class LegacyTableDropRequest(BaseModel): """Body for dropping a legacy table.""" db: str = Field(..., description="Database name") table: str = Field(..., description="Table name to drop") @router.get("/legacy-tables") @limiter.limit("10/minute") def getLegacyTables( request: Request, db: Optional[str] = None, currentUser: User = Depends(requireSysAdmin), ) -> Dict[str, Any]: """List tables that exist in the database but have no Pydantic model. Optional ``db`` filter to scope to a single database. """ tables = _discoverLegacyTables(dbFilter=db) totalRows = sum(t["rowCount"] for t in tables) totalSize = sum(t["sizeBytes"] for t in tables) return { "legacyTables": tables, "totalCount": len(tables), "totalRows": totalRows, "totalSizeBytes": totalSize, } @router.post("/legacy-tables/drop") @limiter.limit("10/minute") def postLegacyTableDrop( request: Request, body: LegacyTableDropRequest, currentUser: User = Depends(requireSysAdmin), ) -> Dict[str, Any]: """Drop a legacy table (CASCADE). Refuses if the table is model-backed.""" try: result = _dropLegacyTable(body.db, body.table) except ValueError as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=str(e), ) from e except Exception as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Drop failed: {e}", ) from e logger.info( "SysAdmin legacy-table drop: user=%s db=%s table=%s rows=%s", currentUser.username, body.db, body.table, result.get("rowCount"), ) return result # --------------------------------------------------------------------------- # Migration (Backup / Restore) # --------------------------------------------------------------------------- class MigrationImportRequest(BaseModel): """Body for the import endpoint.""" payload: dict = Field(..., description="The full export JSON payload") mode: str = Field( ..., description="'replace' (clear + insert) or 'merge' (insert missing only)", ) @router.get("/migration/databases") @limiter.limit("30/minute") def getMigrationDatabases( request: Request, currentUser: User = Depends(requireSysAdmin), ) -> Dict[str, Any]: """List registered databases with table/record counts for the migration UI.""" databases = _getAvailableDatabases() return {"databases": databases, "instanceLabel": _getInstanceLabel()} @router.get("/migration/export") @limiter.limit("2/minute") def getMigrationExport( request: Request, databases: str = "all", currentUser: User = Depends(requireSysAdmin), ) -> StreamingResponse: """Export selected databases as a downloadable JSON file. ``databases`` is a comma-separated list of database names, or ``"all"``. """ if databases == "all": available = _getAvailableDatabases() dbList = [db["name"] for db in available] else: dbList = [d.strip() for d in databases.split(",") if d.strip()] if not dbList: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="No databases selected for export.", ) logger.info( "SysAdmin migration export: user=%s databases=%s", currentUser.username, dbList, ) try: exportData = _exportDatabases(dbList) except Exception as e: logger.error("Migration export failed: %s", e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Export failed: {e}", ) from e ts = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H-%M") filename = f"migration_backup_{ts}.json" content = json.dumps(exportData, ensure_ascii=False, default=str) return StreamingResponse( iter([content]), media_type="application/json", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) @router.post("/migration/validate") @limiter.limit("5/minute") async def postMigrationValidate( request: Request, file: UploadFile = File(...), currentUser: User = Depends(requireSysAdmin), ) -> Dict[str, Any]: """Validate an uploaded migration JSON file without writing anything.""" try: rawBytes = await file.read() payload = json.loads(rawBytes.decode("utf-8")) except (json.JSONDecodeError, UnicodeDecodeError) as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid JSON file: {e}", ) from e result = _validateImportPayload(payload) logger.info( "SysAdmin migration validate: user=%s valid=%s", currentUser.username, result.get("valid"), ) return result @router.post("/migration/import") @limiter.limit("2/minute") async def postMigrationImport( request: Request, file: UploadFile = File(...), mode: str = "merge", currentUser: User = Depends(requireSysAdmin), ) -> Dict[str, Any]: """Import a migration JSON file. ``mode`` is passed as a form field: - ``replace``: clear all tables (except system objects) and insert. - ``merge``: insert only records whose ID does not yet exist. """ if mode not in ("replace", "merge"): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid mode: '{mode}'. Must be 'replace' or 'merge'.", ) try: rawBytes = await file.read() payload = json.loads(rawBytes.decode("utf-8")) except (json.JSONDecodeError, UnicodeDecodeError) as e: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid JSON file: {e}", ) from e validation = _validateImportPayload(payload) if not validation.get("valid"): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={"message": "Payload validation failed", "warnings": validation.get("warnings", [])}, ) logger.info( "SysAdmin migration import: user=%s mode=%s", currentUser.username, mode, ) try: result = _importDatabases(payload, mode) except Exception as e: logger.error("Migration import failed: %s", e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Import failed: {e}", ) from e logger.info( "SysAdmin migration import complete: user=%s mode=%s totalRecords=%s warnings=%s", currentUser.username, mode, result.get("totalRecords"), len(result.get("warnings", [])), ) return result # --------------------------------------------------------------------------- # Per-DB endpoints (progress-friendly) # --------------------------------------------------------------------------- @router.get("/migration/export-single") @limiter.limit("60/minute") def getMigrationExportSingle( request: Request, database: str, currentUser: User = Depends(requireSysAdmin), ) -> Dict[str, Any]: """Export a single database. Returns full payload so the frontend can assemble the final JSON client-side (no server-side state needed).""" from modules.dbHelpers.dbRegistry import getRegisteredDatabases if database not in getRegisteredDatabases(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Database '{database}' is not registered.", ) logger.info("SysAdmin migration export-single: user=%s db=%s", currentUser.username, database) try: dbPayload = _exportSingleDb(database) except Exception as e: logger.error("Export-single failed for %s: %s", database, e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Export failed for '{database}': {e}", ) from e logger.info("SysAdmin migration export-single done: user=%s db=%s tables=%s records=%s", currentUser.username, database, dbPayload.get("tableCount", 0), dbPayload.get("totalRecords", 0)) return { "database": database, "tableCount": dbPayload.get("tableCount", 0), "totalRecords": dbPayload.get("totalRecords", 0), "payload": dbPayload, } @router.get("/migration/export-stream") @limiter.limit("2/minute") def getMigrationExportStream( request: Request, databases: str = "all", currentUser: User = Depends(requireSysAdmin), ): """Stream a full database export as a single JSON file download. Uses server-side cursors and row-by-row serialization so that neither backend memory nor browser JS heap is exhausted — works for any DB size. """ from modules.dbHelpers.dbRegistry import getRegisteredDatabases registeredDbs = getRegisteredDatabases() if databases == "all": dbList = sorted(registeredDbs.keys()) else: dbList = [db.strip() for db in databases.split(",") if db.strip()] invalid = [db for db in dbList if db not in registeredDbs] if invalid: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Unknown databases: {', '.join(invalid)}", ) if not dbList: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="No databases selected for export.", ) instanceLabel = _getInstanceLabel() timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%SZ") filename = f"export_{instanceLabel}_{timestamp}.json" if instanceLabel else f"export_{timestamp}.json" logger.info("SysAdmin stream export: user=%s databases=%s", currentUser.username, dbList) return StreamingResponse( streamExportGenerator(dbList, instanceLabel), media_type="application/json", headers={ "Content-Disposition": f'attachment; filename="{filename}"', "X-Export-Databases": ",".join(dbList), }, ) @router.post("/migration/upload-import") @limiter.limit("5/minute") async def postMigrationUploadImport( request: Request, file: UploadFile = File(...), currentUser: User = Depends(requireSysAdmin), ) -> Dict[str, Any]: """Upload a backup file to disk (chunked). Returns a token that the frontend passes to ``/process-import-stream`` for streaming validation. """ token = str(uuid.uuid4()) tmpDir = tempfile.gettempdir() filePath = os.path.join(tmpDir, f"poweron_import_{token}.json") logger.info("SysAdmin migration upload-import: user=%s streaming to %s", currentUser.username, filePath) totalBytes = 0 chunkSize = 1024 * 1024 try: with open(filePath, "wb") as f: while True: chunk = await file.read(chunkSize) if not chunk: break f.write(chunk) totalBytes += len(chunk) except Exception as e: logger.error("Upload-import write failed: %s", e) if os.path.exists(filePath): os.remove(filePath) raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Upload failed: {e}") from e fileSizeMb = round(totalBytes / (1024 * 1024), 1) logger.info("SysAdmin migration upload-import: %s bytes on disk (%.1f MB)", totalBytes, fileSizeMb) _writeTokenMeta(token, "processing", {"filePath": filePath, "tmpDir": tmpDir}) return {"token": token, "fileSizeMb": fileSizeMb} def _tokenMetaPath(token: str, kind: str) -> str: return os.path.join(tempfile.gettempdir(), f"poweron_{kind}_{token}.meta.json") def _writeTokenMeta(token: str, kind: str, data: dict): path = _tokenMetaPath(token, kind) with open(path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False) def _readTokenMeta(token: str, kind: str, pop: bool = False) -> dict | None: path = _tokenMetaPath(token, kind) if not os.path.exists(path): return None with open(path, "r", encoding="utf-8") as f: data = json.load(f) if pop: try: os.remove(path) except OSError: pass return data @router.get("/migration/process-import-stream") @limiter.limit("5/minute") def getProcessImportStream( request: Request, token: str, currentUser: User = Depends(requireSysAdmin), ): """Stream validation + split progress as newline-delimited JSON. Each line is a JSON object: - ``{"phase":"validate","db":"...","table":"...","rows":N}`` - ``{"phase":"split","db":"...","table":"...","rows":N}`` - ``{"phase":"done","result":{valid, databases, warnings, ...}}`` - ``{"phase":"error","detail":"..."}`` """ import queue pending = _readTokenMeta(token, "processing", pop=True) if not pending: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired processing token.") filePath = pending["filePath"] tmpDir = pending["tmpDir"] if not os.path.exists(filePath): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Upload file not found.") q: queue.Queue = queue.Queue() def _progressCb(info: dict): q.put(info) def _worker(): try: result = _streamValidate(filePath, progressCb=_progressCb) if not result.get("valid"): try: os.remove(filePath) except OSError: pass q.put({"phase": "done", "result": { "valid": False, "databases": [], "warnings": result.get("warnings", []), "systemObjectsFound": result.get("systemObjectsFound", []), }}) q.put(None) return remap = result.get("remap", {}) protectedIds = result.get("protectedIds", []) dbFiles = _streamSplitToFiles(filePath, tmpDir, token, remap, progressCb=_progressCb) try: os.remove(filePath) except OSError: pass _writeTokenMeta(token, "import", { "dbFiles": dbFiles, "protectedIds": protectedIds, }) q.put({"phase": "done", "result": { "token": token, "valid": True, "databases": result.get("databases", []), "warnings": result.get("warnings", []), "systemObjectsFound": result.get("systemObjectsFound", []), }}) except Exception as e: logger.exception("Processing import stream failed: %s", e) try: os.remove(filePath) except OSError: pass q.put({"phase": "error", "detail": str(e)}) finally: q.put(None) def _generate(): thread = threading.Thread(target=_worker, daemon=True) thread.start() while True: item = q.get() if item is None: break yield json.dumps(item, ensure_ascii=False) + "\n" thread.join(timeout=5) return StreamingResponse( _generate(), media_type="text/x-ndjson", ) @router.post("/migration/import-single") @limiter.limit("60/minute") def postMigrationImportSingle( request: Request, body: dict, currentUser: User = Depends(requireSysAdmin), ) -> Dict[str, Any]: """Import a single database from previously uploaded + prepared payload. Supports both the new per-table JSONL format (``{tableName: filePath}``) and the legacy single-JSON-per-DB format (plain file path string). Body: ``{token, database, mode}`` """ token = body.get("token", "") database = body.get("database", "") mode = body.get("mode", "merge") if mode not in ("replace", "merge"): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid mode: '{mode}'.") pending = _readTokenMeta(token, "import") if not pending: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired import token.") dbEntry = pending.get("dbFiles", {}).get(database) if not dbEntry: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"No data for database '{database}'.") logger.info("SysAdmin migration import-single: user=%s db=%s mode=%s", currentUser.username, database, mode) try: if isinstance(dbEntry, dict): result = _importSingleDbFromFiles(dbEntry, database, mode, pending["protectedIds"]) else: dbFilePath = dbEntry if not os.path.exists(dbFilePath): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"No data for database '{database}'.") with open(dbFilePath, "r", encoding="utf-8") as f: dbData = json.load(f) payload = {"databases": {database: dbData}} result = _importSingleDb(payload, database, mode, pending["protectedIds"]) except HTTPException: raise except Exception as e: logger.error("Import-single failed for %s: %s", database, e) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Import failed for '{database}': {e}", ) from e return result @router.post("/migration/import-done") @limiter.limit("10/minute") def postMigrationImportDone( request: Request, body: dict, currentUser: User = Depends(requireSysAdmin), ) -> Dict[str, Any]: """Clean up the per-DB / per-table temp files.""" token = body.get("token", "") pending = _readTokenMeta(token, "import", pop=True) if pending: for dbEntry in pending.get("dbFiles", {}).values(): if isinstance(dbEntry, str): try: os.remove(dbEntry) except OSError: pass elif isinstance(dbEntry, dict): for tblPath in dbEntry.values(): try: os.remove(tblPath) except OSError: pass return {"ok": True}