Compare commits

..

No commits in common. "29cc6312d5907fff6c574cc49a0104993f3ef7ff" and "6ab51cf67ee56880ed53964b2c98e453f863bcca" have entirely different histories.

7 changed files with 59 additions and 204 deletions

View file

@ -46,4 +46,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD python -c "import requests; requests.get('http://localhost:8000/api/admin/health', timeout=5)" || exit 1 CMD python -c "import requests; requests.get('http://localhost:8000/api/admin/health', timeout=5)" || exit 1
# Run the application # Run the application
CMD exec gunicorn app:app --bind 0.0.0.0:${PORT:-8000} --timeout 600 --worker-class uvicorn.workers.UvicornWorker --workers 1 CMD exec uvicorn app:app --host 0.0.0.0 --port ${PORT:-8000} --workers 1 --timeout-graceful-shutdown 5

16
app.py
View file

@ -730,19 +730,7 @@ logger.info(f"Feature router load results: {featureLoadResults}")
if __name__ == "__main__": if __name__ == "__main__":
port = int(os.environ.get("PORT", 8000))
try:
from gunicorn.app.wsgiapp import WSGIApplication # noqa: F401
import subprocess
import sys
subprocess.run([
sys.executable, "-m", "gunicorn", "app:app",
"--bind", f"0.0.0.0:{port}",
"--timeout", "600",
"--worker-class", "uvicorn.workers.UvicornWorker",
"--workers", "1",
], check=True)
except ImportError:
import uvicorn import uvicorn
port = int(os.environ.get("PORT", 8000))
uvicorn.run("app:app", host="0.0.0.0", port=port, workers=1, timeout_graceful_shutdown=5) uvicorn.run("app:app", host="0.0.0.0", port=port, workers=1, timeout_graceful_shutdown=5)

View file

@ -4,7 +4,7 @@
APP_ENV_TYPE = dev APP_ENV_TYPE = dev
APP_ENV_LABEL = Development Instance Patrick APP_ENV_LABEL = Development Instance Patrick
APP_API_URL = http://localhost:8000 APP_API_URL = http://localhost:8000
APP_KEY_SYSVAR = D:/Athi/Local/Web/poweron-swiss/local/notes/key.txt APP_KEY_SYSVAR = D:/Athi/Local/Web/poweron/local/notes/key.txt
APP_INIT_PASS_ADMIN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEeFFtRGtQeVUtcjlrU3dab1ZxUm9WSks0MlJVYUtERFlqUElHemZrOGNENk1tcmJNX3Vxc01UMDhlNU40VzZZRVBpUGNmT3podzZrOGhOeEJIUEt4eVlSWG5UYXA3d09DVXlLT21Kb1JYSUU9 APP_INIT_PASS_ADMIN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEeFFtRGtQeVUtcjlrU3dab1ZxUm9WSks0MlJVYUtERFlqUElHemZrOGNENk1tcmJNX3Vxc01UMDhlNU40VzZZRVBpUGNmT3podzZrOGhOeEJIUEt4eVlSWG5UYXA3d09DVXlLT21Kb1JYSUU9
APP_INIT_PASS_EVENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERzZjNm56WGVBdjJTeG5Udjd6OGQwUVotYXUzQjJ1YVNyVXVBa3NZVml3ODU0MVNkZjhWWmJwNUFkc19BcHlHMTU1Q3BRcHU0cDBoZkFlR2l6UEZQU3d2U3MtMDh5UDZteGFoQ0EyMUE1ckE9 APP_INIT_PASS_EVENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERzZjNm56WGVBdjJTeG5Udjd6OGQwUVotYXUzQjJ1YVNyVXVBa3NZVml3ODU0MVNkZjhWWmJwNUFkc19BcHlHMTU1Q3BRcHU0cDBoZkFlR2l6UEZQU3d2U3MtMDh5UDZteGFoQ0EyMUE1ckE9
@ -23,7 +23,7 @@ APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://nyla.pow
# Logging configuration # Logging configuration
APP_LOGGING_LOG_LEVEL = DEBUG APP_LOGGING_LOG_LEVEL = DEBUG
APP_LOGGING_LOG_DIR = D:/Athi/Local/Web/poweron-swiss/local/logs APP_LOGGING_LOG_DIR = D:/Athi/Local/Web/poweron/local/logs
APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s APP_LOGGING_FORMAT = %(asctime)s - %(levelname)s - %(name)s - %(message)s
APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S APP_LOGGING_DATE_FORMAT = %Y-%m-%d %H:%M:%S
APP_LOGGING_CONSOLE_ENABLED = True APP_LOGGING_CONSOLE_ENABLED = True
@ -80,9 +80,9 @@ TEAMSBOT_BROWSER_BOT_URL = http://localhost:4100
# Debug Configuration # Debug Configuration
APP_DEBUG_CHAT_WORKFLOW_ENABLED = True APP_DEBUG_CHAT_WORKFLOW_ENABLED = True
APP_DEBUG_CHAT_WORKFLOW_DIR = D:/Athi/Local/Web/poweron-swiss/local/debug APP_DEBUG_CHAT_WORKFLOW_DIR = D:/Athi/Local/Web/poweron/local/debug
APP_DEBUG_ACCOUNTING_SYNC_ENABLED = True APP_DEBUG_ACCOUNTING_SYNC_ENABLED = True
APP_DEBUG_ACCOUNTING_SYNC_DIR = D:/Athi/Local/Web/poweron-swiss/local/debug/sync APP_DEBUG_ACCOUNTING_SYNC_DIR = D:/Athi/Local/Web/poweron/local/debug/sync
# Azure Communication Services Email Configuration # Azure Communication Services Email Configuration
MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt

View file

@ -36,7 +36,6 @@ from modules.system.databaseMigration import (
_importSingleDb, _importSingleDb,
_prepareImport, _prepareImport,
_validateImportPayload, _validateImportPayload,
streamExportGenerator,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -439,18 +438,38 @@ async def postMigrationImport(
# Per-DB endpoints (progress-friendly) # Per-DB endpoints (progress-friendly)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
_pendingExports: Dict[str, dict] = {}
@router.post("/migration/export-start")
@limiter.limit("10/minute")
def postMigrationExportStart(
request: Request,
currentUser: User = Depends(requireSysAdmin),
) -> Dict[str, Any]:
"""Start an export session. Returns a token for subsequent per-DB calls."""
import uuid
token = str(uuid.uuid4())
_pendingExports[token] = {"databases": {}}
logger.info("SysAdmin migration export-start: user=%s token=%s", currentUser.username, token)
return {"token": token}
@router.get("/migration/export-single") @router.get("/migration/export-single")
@limiter.limit("60/minute") @limiter.limit("60/minute")
def getMigrationExportSingle( def getMigrationExportSingle(
request: Request, request: Request,
token: str,
database: str, database: str,
currentUser: User = Depends(requireSysAdmin), currentUser: User = Depends(requireSysAdmin),
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Export a single database. Returns full payload so the frontend can """Export a single database and store it server-side. Returns only metadata."""
assemble the final JSON client-side (no server-side state needed)."""
from modules.shared.dbRegistry import getRegisteredDatabases from modules.shared.dbRegistry import getRegisteredDatabases
pending = _pendingExports.get(token)
if not pending:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid export token.")
if database not in getRegisteredDatabases(): if database not in getRegisteredDatabases():
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
@ -468,6 +487,7 @@ def getMigrationExportSingle(
detail=f"Export failed for '{database}': {e}", detail=f"Export failed for '{database}': {e}",
) from e ) from e
pending["databases"][database] = dbPayload
logger.info("SysAdmin migration export-single done: user=%s db=%s tables=%s records=%s", 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)) currentUser.username, database, dbPayload.get("tableCount", 0), dbPayload.get("totalRecords", 0))
@ -475,57 +495,48 @@ def getMigrationExportSingle(
"database": database, "database": database,
"tableCount": dbPayload.get("tableCount", 0), "tableCount": dbPayload.get("tableCount", 0),
"totalRecords": dbPayload.get("totalRecords", 0), "totalRecords": dbPayload.get("totalRecords", 0),
"payload": dbPayload,
} }
@router.get("/migration/export-stream") @router.get("/migration/export-download")
@limiter.limit("2/minute") @limiter.limit("5/minute")
def getMigrationExportStream( def getMigrationExportDownload(
request: Request, request: Request,
databases: str = "all", token: str,
filename: str = "backup.json",
currentUser: User = Depends(requireSysAdmin), currentUser: User = Depends(requireSysAdmin),
): ) -> StreamingResponse:
"""Stream a full database export as a single JSON file download. """Assemble and stream the final export file from server-side data."""
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 datetime import datetime, timezone from datetime import datetime, timezone
from modules.shared.dbRegistry import getRegisteredDatabases
registeredDbs = getRegisteredDatabases() pending = _pendingExports.pop(token, None)
if not pending or not pending.get("databases"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired export token.")
if databases == "all": databases = pending["databases"]
dbList = sorted(registeredDbs.keys()) totalTables = sum(d.get("tableCount", 0) for d in databases.values())
else: totalRecords = sum(d.get("totalRecords", 0) for d in databases.values())
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: exportData = {
raise HTTPException( "meta": {
status_code=status.HTTP_400_BAD_REQUEST, "exportedAt": datetime.now(timezone.utc).isoformat(),
detail="No databases selected for export.", "version": "1.0",
) "databaseCount": len(databases),
"totalTables": totalTables,
"totalRecords": totalRecords,
},
"databases": databases,
}
instanceLabel = _getInstanceLabel() logger.info("SysAdmin migration export-download: user=%s dbs=%s tables=%s records=%s",
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%SZ") currentUser.username, len(databases), totalTables, totalRecords)
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) content = json.dumps(exportData, ensure_ascii=False, default=str)
return StreamingResponse( return StreamingResponse(
streamExportGenerator(dbList, instanceLabel), iter([content]),
media_type="application/json", media_type="application/json",
headers={ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
"Content-Disposition": f'attachment; filename="{filename}"',
"X-Export-Databases": ",".join(dbList),
},
) )

View file

@ -298,7 +298,6 @@ def get_folder_tree(
folders = managementInterface.getSharedFolderTree() folders = managementInterface.getSharedFolderTree()
else: else:
raise HTTPException(status_code=400, detail="owner must be 'me' or 'shared'") raise HTTPException(status_code=400, detail="owner must be 'me' or 'shared'")
_enrichFoldersWithMixed(managementInterface.db, str(currentUser.id), folders, o)
return folders return folders
except HTTPException: except HTTPException:
raise raise
@ -373,33 +372,6 @@ def getAttributesForIds(
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
def _enrichFoldersWithMixed(
db, userId: str, folders: List[Dict[str, Any]], ownerMode: str,
) -> None:
"""Enrich folder dicts in-place: replace raw neutralize/scope with
computed values that include ``'mixed'`` when children diverge.
For ``ownerMode='me'``, files owned by the user are loaded.
For ``'shared'``, files inside the visible shared folders are loaded."""
if not folders:
return
if ownerMode == "me":
allFiles = db.getRecordset(FileItem, recordFilter={"sysCreatedBy": userId}) or []
else:
folderIds = {f["id"] for f in folders}
allFiles = []
for fid in folderIds:
allFiles.extend(db.getRecordset(FileItem, recordFilter={"folderId": fid}) or [])
computed: Dict[str, Dict[str, Any]] = {}
for folder in folders:
computed[folder["id"]] = _computeFolderAttrs(folder, folders, allFiles)
for folder in folders:
attrs = computed[folder["id"]]
folder["neutralize"] = attrs["neutralize"]
folder["scope"] = attrs["scope"]
def _computeFolderAttrs( def _computeFolderAttrs(
folder: Dict[str, Any], folder: Dict[str, Any],
allFolders: List[Dict[str, Any]], allFolders: List[Dict[str, Any]],

View file

@ -180,121 +180,6 @@ def _readTableRows(conn, tableName: str) -> List[dict]:
return [{k: _jsonSafe(v) for k, v in dict(row).items()} for row in cur.fetchall()] return [{k: _jsonSafe(v) for k, v in dict(row).items()} for row in cur.fetchall()]
# ---------------------------------------------------------------------------
# Streaming Export (memory-safe, handles arbitrarily large databases)
# ---------------------------------------------------------------------------
def streamExportGenerator(databases: List[str], instanceLabel: str = ""):
"""Yield JSON fragments for a streaming database export.
Writes valid JSON incrementally (row-by-row, table-by-table) so that
neither the backend RAM nor the browser JS heap is saturated works
for databases of any size.
The output format is identical to the non-streaming _exportDatabases():
{"meta": {...}, "databases": {"dbName": {"tables": {"tbl": [rows]}, ...}}}
"""
import json
registeredDbs = getRegisteredDatabases()
validDbs = [db for db in databases if db in registeredDbs]
totalDbs = 0
totalTables = 0
totalRecords = 0
yield '{"meta":'
metaPlaceholder = json.dumps({
"exportedAt": datetime.now(timezone.utc).isoformat(),
"version": _EXPORT_FORMAT_VERSION,
"instanceLabel": instanceLabel,
"databaseCount": "<<PLACEHOLDER>>",
}, ensure_ascii=False)
yield metaPlaceholder
yield ',"databases":{'
firstDb = True
for dbName in validDbs:
excluded = _EXCLUDED_TABLES.get(dbName, set())
conn = None
try:
conn = _getConnection(dbName)
allTables = _listTables(conn)
modelTables = _getModelTablesForDb(dbName, allTables)
if not firstDb:
yield ','
firstDb = False
yield json.dumps(dbName, ensure_ascii=False)
yield ':{"tables":{'
firstTable = True
dbTableCount = 0
dbRecordCount = 0
for tbl in modelTables:
if tbl in excluded:
continue
if not firstTable:
yield ','
firstTable = False
yield json.dumps(tbl, ensure_ascii=False)
yield ':['
with conn.cursor(name=f"export_{dbName}_{tbl}") as cur:
cur.itersize = 2000
cur.execute(f'SELECT * FROM "{tbl}"')
firstRow = True
rowCount = 0
for row in cur:
if not firstRow:
yield ','
firstRow = False
safeRow = {k: _jsonSafe(v) for k, v in dict(row).items()}
yield json.dumps(safeRow, ensure_ascii=False, default=str)
rowCount += 1
yield ']'
dbTableCount += 1
dbRecordCount += rowCount
yield '},"summary":{'
firstSummaryTable = True
for tbl in modelTables:
if tbl in excluded:
continue
if not firstSummaryTable:
yield ','
firstSummaryTable = False
yield json.dumps(tbl, ensure_ascii=False)
yield ':{"recordCount":0}'
yield '}'
yield f',"tableCount":{dbTableCount},"totalRecords":{dbRecordCount}'
yield '}'
totalDbs += 1
totalTables += dbTableCount
totalRecords += dbRecordCount
logger.info("Stream export: %s done (%d tables, %d records)", dbName, dbTableCount, dbRecordCount)
except Exception as e:
logger.error("Stream export failed for %s: %s", dbName, e)
if not firstDb or not firstTable:
pass
finally:
if conn:
try:
conn.close()
except Exception:
pass
yield '}}'
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Validate # Validate
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -2,7 +2,6 @@
fastapi==0.115.0 # Upgraded for Pydantic v2 compatibility fastapi==0.115.0 # Upgraded for Pydantic v2 compatibility
websockets==12.0 websockets==12.0
uvicorn==0.23.2 uvicorn==0.23.2
gunicorn==23.0.0
python-multipart==0.0.6 python-multipart==0.0.6
httpx>=0.25.2 httpx>=0.25.2
pydantic>=2.0.0 # Upgraded to v2 for compatibility pydantic>=2.0.0 # Upgraded to v2 for compatibility