Compare commits

..

5 commits

Author SHA1 Message Date
29cc6312d5 Merge pull request 'main' (#3) from main into int
All checks were successful
Deploy Plattform-Core (Int) / test (push) Successful in 32s
Deploy Plattform-Core (Int) / deploy (push) Successful in 9s
Reviewed-on: #3
2026-05-27 21:18:07 +00:00
3e2c07a776 streaming export with log
All checks were successful
Deploy Plattform-Core / test (push) Successful in 46s
Deploy Plattform-Core / deploy (push) Successful in 4s
2026-05-27 23:04:58 +02:00
f24b67ed85 db-export streaming
All checks were successful
Deploy Plattform-Core / test (push) Successful in 49s
Deploy Plattform-Core / deploy (push) Successful in 4s
2026-05-27 19:38:12 +02:00
2b58f7a45d fixed db export
All checks were successful
Deploy Plattform-Core / test (push) Successful in 24s
Deploy Plattform-Core / deploy (push) Successful in 4s
2026-05-27 18:06:59 +02:00
2d796a34ed fix db sync
All checks were successful
Deploy Plattform-Core / test (push) Successful in 50s
Deploy Plattform-Core / deploy (push) Successful in 4s
2026-05-27 17:43:48 +02:00
7 changed files with 204 additions and 59 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 uvicorn app:app --host 0.0.0.0 --port ${PORT:-8000} --workers 1 --timeout-graceful-shutdown 5 CMD exec gunicorn app:app --bind 0.0.0.0:${PORT:-8000} --timeout 600 --worker-class uvicorn.workers.UvicornWorker --workers 1

16
app.py
View file

@ -730,7 +730,19 @@ logger.info(f"Feature router load results: {featureLoadResults}")
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn
port = int(os.environ.get("PORT", 8000)) 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
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/local/notes/key.txt APP_KEY_SYSVAR = D:/Athi/Local/Web/poweron-swiss/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/local/logs APP_LOGGING_LOG_DIR = D:/Athi/Local/Web/poweron-swiss/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/local/debug APP_DEBUG_CHAT_WORKFLOW_DIR = D:/Athi/Local/Web/poweron-swiss/local/debug
APP_DEBUG_ACCOUNTING_SYNC_ENABLED = True APP_DEBUG_ACCOUNTING_SYNC_ENABLED = True
APP_DEBUG_ACCOUNTING_SYNC_DIR = D:/Athi/Local/Web/poweron/local/debug/sync APP_DEBUG_ACCOUNTING_SYNC_DIR = D:/Athi/Local/Web/poweron-swiss/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,6 +36,7 @@ from modules.system.databaseMigration import (
_importSingleDb, _importSingleDb,
_prepareImport, _prepareImport,
_validateImportPayload, _validateImportPayload,
streamExportGenerator,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -438,38 +439,18 @@ 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 and store it server-side. Returns only metadata.""" """Export a single database. Returns full payload so the frontend can
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,
@ -487,7 +468,6 @@ 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))
@ -495,48 +475,57 @@ 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-download") @router.get("/migration/export-stream")
@limiter.limit("5/minute") @limiter.limit("2/minute")
def getMigrationExportDownload( def getMigrationExportStream(
request: Request, request: Request,
token: str, databases: str = "all",
filename: str = "backup.json",
currentUser: User = Depends(requireSysAdmin), currentUser: User = Depends(requireSysAdmin),
) -> StreamingResponse: ):
"""Assemble and stream the final export file from server-side data.""" """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 datetime import datetime, timezone from datetime import datetime, timezone
from modules.shared.dbRegistry import getRegisteredDatabases
pending = _pendingExports.pop(token, None) registeredDbs = getRegisteredDatabases()
if not pending or not pending.get("databases"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired export token.")
databases = pending["databases"] if databases == "all":
totalTables = sum(d.get("tableCount", 0) for d in databases.values()) dbList = sorted(registeredDbs.keys())
totalRecords = sum(d.get("totalRecords", 0) for d in databases.values()) 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)}",
)
exportData = { if not dbList:
"meta": { raise HTTPException(
"exportedAt": datetime.now(timezone.utc).isoformat(), status_code=status.HTTP_400_BAD_REQUEST,
"version": "1.0", detail="No databases selected for export.",
"databaseCount": len(databases), )
"totalTables": totalTables,
"totalRecords": totalRecords,
},
"databases": databases,
}
logger.info("SysAdmin migration export-download: user=%s dbs=%s tables=%s records=%s", instanceLabel = _getInstanceLabel()
currentUser.username, len(databases), totalTables, totalRecords) 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"
content = json.dumps(exportData, ensure_ascii=False, default=str) logger.info("SysAdmin stream export: user=%s databases=%s", currentUser.username, dbList)
return StreamingResponse( return StreamingResponse(
iter([content]), streamExportGenerator(dbList, instanceLabel),
media_type="application/json", media_type="application/json",
headers={"Content-Disposition": f'attachment; filename="{filename}"'}, headers={
"Content-Disposition": f'attachment; filename="{filename}"',
"X-Export-Databases": ",".join(dbList),
},
) )

View file

@ -298,6 +298,7 @@ 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
@ -372,6 +373,33 @@ 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,6 +180,121 @@ 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,6 +2,7 @@
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