merge conflicts
This commit is contained in:
commit
30567bc38b
23 changed files with 2523 additions and 1329 deletions
|
|
@ -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
|
||||
|
||||
# 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
|
||||
|
|
|
|||
21
app.py
21
app.py
|
|
@ -616,6 +616,9 @@ app.include_router(fileRouter)
|
|||
from modules.routes.routeDataSources import router as dataSourceRouter
|
||||
app.include_router(dataSourceRouter)
|
||||
|
||||
from modules.routes.routeUdb import router as udbRouter
|
||||
app.include_router(udbRouter)
|
||||
|
||||
from modules.routes.routeDataPrompts import router as promptRouter
|
||||
app.include_router(promptRouter)
|
||||
|
||||
|
|
@ -727,7 +730,19 @@ logger.info(f"Feature router load results: {featureLoadResults}")
|
|||
|
||||
|
||||
if __name__ == "__main__":
|
||||
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)
|
||||
|
||||
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)
|
||||
12
env-dev.env
12
env-dev.env
|
|
@ -4,7 +4,7 @@
|
|||
APP_ENV_TYPE = dev
|
||||
APP_ENV_LABEL = Development Instance Patrick
|
||||
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_EVENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERzZjNm56WGVBdjJTeG5Udjd6OGQwUVotYXUzQjJ1YVNyVXVBa3NZVml3ODU0MVNkZjhWWmJwNUFkc19BcHlHMTU1Q3BRcHU0cDBoZkFlR2l6UEZQU3d2U3MtMDh5UDZteGFoQ0EyMUE1ckE9
|
||||
|
||||
|
|
@ -23,7 +23,7 @@ APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://nyla.pow
|
|||
|
||||
# Logging configuration
|
||||
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_DATE_FORMAT = %Y-%m-%d %H:%M:%S
|
||||
APP_LOGGING_CONSOLE_ENABLED = True
|
||||
|
|
@ -61,8 +61,8 @@ STRIPE_AUTOMATIC_TAX_ENABLED = false
|
|||
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0
|
||||
|
||||
# AI configuration
|
||||
Connector_AiOpenai_API_SECRET = DEV_ENC:Z0FBQUFBQnFGcldVVnVRcFo4MGUxaDU3Tkl3R2EwUHhibm1BTVkzMmhBYVNDMGRoQUozQkVCNmVqTXpvZ3V2ZkNwLWVDLWZFZ3dBaWJKdVY3OGJTUTdhVnZ4bW9CMjBKclhSQldTYm1Bb2RDb3ZtYVg1UXEtMHdaelROajBYTGZzc3NEeDk2V09ndlRTdTFHZWFOQjdBbUNxTnJ5SHYxcUJ3NEN6QzZnVllCakJVazRHc2h4Q2FyRWlVZ3lNam9GMm1TQmxJTkhlOHVvSHllMVEwWDYyYktGdllyTEFmUzFVRWp3LWdxVklWcFZ3SXZQcEwwempSem5oWFlWYmk3MVdhNldqZEhPWmxfczdYTnRHcElld1IySjY2SkxxRzZrMFVKWTJyVzJkNmJWa0VOSTgtbW83NmM9
|
||||
Connector_AiAnthropic_API_SECRET = DEV_ENC:Z0FBQUFBQnFCdlFlbXEzdGc2NFExb3AzVUw0cEhkZzlNRjZxTVlJMV81LTZhVXhoNXBpYUlMN0FxUUJHQlJnS0N3OV85Uk9sa1J3M1lyZExSMWVsbzVSdzdWQUVsUVp2dzhfLThmY2lNb1NhVGlvbnhLR0NnSmhsOVp2RkxfODc2SFpDYlBkcWp4aFFtNldtZGQ2LUhBZVM5VXk4RTNHNzQyV3FnMVNJMW9yOGpRRkQxUC1hZ3NOOHhqV3Y4LWJSNjFYQ3dwQmhrRWJRRzhaX1N4aFlWLTVsaEJmOWxkTjZMZz09
|
||||
Connector_AiOpenai_API_SECRET = DEV_ENC:Z0FBQUFBQnFGdnVHZlpWWWVaV1dERUItVjRfNWFMRXZUVjY5Ulp1ZXkyMmVZWUJPNzJ5anRucGNlOUFYSVNzZ1FaWlZLemVNbk5pbDgwOEZxbkxxM3U3UU1EdDJzczBaYmRDbld1N0hHdjROQUFmMUJmbDRMS1JWc3c4ZTFPMHY3OWVublBsUjFxNjhDSGRCaE9PR1JUN29iQjRqVFRINHB5dnJXeGxiQ2FTdnNnN283b3o1MnV3X09uc1pXeXZUclNTbjN4YWZyb2tGVmtGVmRnQmNlczI0WlZKRGZSYWgycnB0R2RfR1Fvbkt5bXN1UVBwS202SkZyRmJGTEU3MkxpcTk2d0QtOGxRckNLaTFLRnJBYlVSZDAydlpjMDVqYmktdHhuV3FLa2xrYkh4cGc3S3FGcnM9
|
||||
Connector_AiAnthropic_API_SECRET = DEV_ENC:Z0FBQUFBQnFGd0hadGRhRFBOaXJSN2E1UnpLcHlkUHhoQS1FWFJBQlJ5cGsxcHNNMDlRM1JVbEJkWWE1ZXQzTThFQkRmQTBOeVNvUERXTVRTQ3Y4ekt5NU1XR3E2cWw4UlFNUXlGSmZmZU1JZXlwT3lUVGw0aWF4V1d6cVR6LTFsbGRjWWx5dVlodWNEUFJkZ0tUa3hSbjk3WjZ1Z3RPczZMYzA5QTlMbkVudFNVcG1xaTJuM3g3dDdSSFczbWJnODQ1S1J2djBnS3lQc2NFd0ttUThRVnFma3NOVlFIZm1ZZz09
|
||||
Connector_AiPerplexity_API_SECRET = DEV_ENC:Z0FBQUFBQnFCdlFlNV9felVPcHVyMU9kVGhGZEt0MG9iRzRrTVM4TFJvSHhGOVo0U1ROWkdEMzRSWjhtMnFrZUhHTHNXelpLZ014RzRkMlIxZDJwcjEwc1dRamY5ekJMR1VLb2w4eEZqZENBRnFaZlRhb1h5VE05Tml1ZlVBWHBaTkJaZUE5NWprVklva0ZFZnB4cFFudGdkalpmTlBhdV9nPT0=
|
||||
Connector_AiTavily_API_SECRET = DEV_ENC:Z0FBQUFBQnFCdlFlY1R2WGpuazk5M05SeDIyLWd3bHpKN3lUdlVFdjhvZEJXdlM4bGlBdTB1TjRia051YllDQ2lwM0V3R3dPd2lKVWxoSm9BNWl1ZFFlVkZ5cXh4TFRVU0Z4NVU5WVRjSUJPc01La3JyaVZSNkhYWU9PR00yMENEb0dRT3l5enEwSFlWZVVzTVR0UWQ4eUxvRmZvWHl0c0xRPT0=
|
||||
Connector_AiPrivateLlm_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGRHM5eFdUVmVZU1R1cHBwN1RlMUx4T0NlLTJLUFFVX3J2OElDWFpuZmJHVmp4Z3BNNWMwZUVVZUd2TFhRSjVmVkVlcFlVRWtybXh0ZHloZ01ZcnVvX195YjdlWVdEcjZSWFFTTlNBWUlaTlNoLWhqVFBIb0thVlBiaWhjYjFQOFY=
|
||||
|
|
@ -80,9 +80,9 @@ TEAMSBOT_BROWSER_BOT_URL = http://localhost:4100
|
|||
|
||||
# Debug Configuration
|
||||
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_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
|
||||
MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt
|
||||
|
|
|
|||
|
|
@ -63,8 +63,8 @@ STRIPE_AUTOMATIC_TAX_ENABLED = false
|
|||
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0
|
||||
|
||||
# AI configuration
|
||||
Connector_AiOpenai_API_SECRET = INT_ENC:Z0FBQUFBQnFGcldVdG9pOUFMRzFHOEM2WDJVYU5fWEVQcjVjUlFuUWl4bS05TVlPSTJUX1pVWEIwRi12VU1tT2E3LTIyblpieTlJWTZZRXdGU2xNajJWc0JrcDZac19iYUJKS19GSWgzRld1V01LczJrYTUzYkxITjkxNUpBNk9UWnRHY2JBZnRzNk5aQ0VNNUZodTV2c2FXbnhZR2dERGstUUxJNW92Uld0Q0hwaWxYRGhLY3Rtb1B0Ynl5clR4ZGF1SFllc09Ka0puMS1EM1FHREFRMFB5bVpqVE0tNXJaTDBhSjNCeGNTVk1UVDM3M2hwQW5mVFVNVkhESHVldEhSSlBSalB4aXYxeXZ4RGtLMk9EM2hJNk9QNmNoREZBMXNGUmRPczZ6elAyQXBOc0F3WnJha289
|
||||
Connector_AiAnthropic_API_SECRET = INT_ENC:Z0FBQUFBQnFCdlFlRHFpNThJb3g3UU05cUw4SVJpOXBTblU5QzU1WFItZ2JkNXVILVN4VHp0Umh2RjJyZXJMNVp5OWFxLWhjRjhub3cxajkxMVRQMnZQdVBGT21obWN0Q0NlOU80MVhMMXRWb1l3cWNpR2Ytc1d0WnVlRUN1TTZ4NjFQcDd0Wll4cFN6dzk1OU5SZGNJck54WmNoeElITzEzejJrczVSQnp6ZTBINGtENHFiT3NnWjdUME9xXzJ5Y0N3dHk5QnpBRkpyVTgxOE0xTVllR2JMUC0yTkwyWWxHQT09
|
||||
Connector_AiOpenai_API_SECRET = INT_ENC:Z0FBQUFBQnFGdnVIcUxPOUVXT0NlUlJNVENjRi1iLXdsR1ZuOXU3Sk1qbmVZOThYdUZrNGlDREJmMkttRVNyWlNsMHlDc2pnQ1VyZ0lzYXVkc0hHNm95bjFrejNRWVVGUWZOVTVYOGpKcF9QNGttc001TE9VdFdKa2FyUEYxY1VYOE1RenBObmNMbVFHeTdHbGpORVAwOWc3Rng1dWtlUEphZmVKV1otSE03a2FTVHVvMlNONWZ3N3hMR2FmdTEtdkdWOHV5d0RYWlZ3dVV1SEpKNHBjWG1QTEZ6SE5oS1VESEJ2MmVSRmh4azd3d1RmQ3FjMDVsbWxNc2EzZDdWMXYyaFBOMTZFSUltUkk2ZTEtZ0FHRW5pZkhON1Rna3lYX1Z5TWRQNkZEX3NXVHlPYkVuMW5zcE09
|
||||
Connector_AiAnthropic_API_SECRET = INT_ENC:Z0FBQUFBQnFGd0hhbGxMRUlZc1A2d1RvOWdFX3NkQXM0RG5LOTZQYWpOc21tTTJWU09nbS12M29YLVVzVk8zWGdrWXIzb05meW56dkRtTElVN1ZndkZ5eHdWMGxGVjBPTlRvTGxpTzVzcFlzdnVhTTh0R0gtM2M2Mk9ac3dnc0RYYkx2c3BDdnoxVXJLX2tPMTVpZXdmQll3cHF3dEhGWGRlb3JLZjlNTVJpZTN1TzFtMU5yZmdXTnZuZ1lXN0p5VUdsVXBDUXJoY1Y3aFBkUW1HbmJJZmZaR1cwTVNQR0VZUT09
|
||||
Connector_AiPerplexity_API_SECRET = INT_ENC:Z0FBQUFBQnFCdlFla1h1R1M3QlQ5XzJhS0x4eXFpTkZ3WHpLMWVZZldRMGpMX2psMFZ2RmpETTZMZ3ZXblo2MnhyemxYWXRsMHN1LXdZU3k5ampEMjMtdzcyb1J4Ri1rTmxPOWhJMF9MMEtzZ3d5dFZxSFY3TjNac3ZpTVJxUFFmUVpXeHEtbVBTUmtiR0lhQjhVcjM3U1NNX1ZHY1NxUFJ3PT0=
|
||||
Connector_AiTavily_API_SECRET = INT_ENC:Z0FBQUFBQnFCdlFlbmRSZVRjTzVKRklFbFgwdVZJaE5jNVoyX3dVTVlRUFVUenc4X1JOX2laOHRoTU9mN1lTUVRzb2xNZjJXVjhEYnVIaXdkSWN4NEpJbTFJZFN2cmkwUkJ0ZXNKT2NidktjdDFJX1BkZ3QwU3dQRzg0aG9aNmtxc1FZZ1ZBRjQyM3lOSS1EYkpqWmxoV0xWWE1Fc01uN3RnPT0=
|
||||
Connector_AiPrivateLlm_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGSjZ1NWh0aWc1R3Z4MHNaeS1HamtUbndhcUZFZDlqUDhjSmg5eHFfdlVkU0RsVkJ2UVRaMWs3aWhraG5jSlc0YkxNWHVmR2JoSW5ENFFCdkJBM0VienlKSnhzNnBKbTJOUTFKczRfWlQ3bWpmUkRTT1I1OGNUSTlQdExacGRpeXg=
|
||||
|
|
|
|||
10
env-prod.env
10
env-prod.env
|
|
@ -62,12 +62,12 @@ STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQZG8WqlVsabrfFEu49pah
|
|||
|
||||
|
||||
# AI configuration
|
||||
Connector_AiOpenai_API_SECRET = PROD_ENC:Z0FBQUFBQnFGcldVM1phS2F4TUdmSDBUVUtfZFZoOHZLTWpzSjg4aVhkNTdnemRCenQ4Wi1HYjB1RTZ1YVUzMzVBUlg0STQybFhiQmF5VnRRd2NIZ1JCR1BwMXU5b0dycDVlVjkzWHNfYWdsV0dVZmRxa2JGU3BULXBubjJvcFdRYUdzMWR5dXRRY2JXazNtVTQ1cGFjTk1QbS1WUVo5YzlIZGJCS09GQTl2QkJXYW5oZlJJdVdxbWV4aGlyQXFUM1lUU3hGZ0NEeXoyMEpPMVp3VFpZbnhwdGN0T2pHOFVsYnhhZ1FlT0ZuVTBkbjMzUGpoZEhxSzdZdUdtVXZkd2FMUm9VYlNESHljTF9qZlZSS0tjN0o0MWNCazZ6SFc4SF83WHVHa2VabkN3dnk4RU9ER2xpR0k9
|
||||
Connector_AiAnthropic_API_SECRET = PROD_ENC:Z0FBQUFBQnFGcldVbkcybm1mbWcwNGFCQmFZbE41X0RJS0NhUEdOcXM0TDdRN3lhVnZMNC1XX0M2VWlYak5YWWtBWXBqNzNaWGlBOWRING1ZczVVcnFOVE9makE5TUJYREVKZ2hseDMwVy03dk9MTXhDSVhrQWZDY3NBSXJ0QUF5WkJCeGg2RTY5WkFDRmU3N0lvYlhYU3lacUJ5MVRwMVhVYUJqS1N1OVZVXzJSVkxaSEgxWEZxM0twVUdvUTE0TnU0VXE3Z25DaXRfQl8tWkJNMUdwQW0tQWZEdTZYVXJNdz09
|
||||
Connector_AiPerplexity_API_SECRET = PROD_ENC:Z0FBQUFBQnFGcldVaWNjSmVxbUJZWTh1andfU0ZKSzdIcDc5bUFkMnc2MU9PME5XVEhDREJYNmZRMk1XM3huYkFJQU5KbFd0WVlnaUJndUUxVlpybE4tVHZ0VndOYXBoWUI5MmV3VzNtd0lMbmdJYzdNQWMzTDNkSkQ1anV6emRJUC1RWUFDUFNTN1JEa25KWVRqUE5aekNXSkZjMWlncXRRPT0=
|
||||
Connector_AiTavily_API_SECRET = PROD_ENC:Z0FBQUFBQnFGcldVMXlWdUM0NXhlNk02aWlyaDFtS05PR2ZlYzF2VlFVZ1kwbEp0akRBazIweEFLd2drY1ZzRFRpUHBsTUdkYmRZXzBIaUVEM2tfclVySVVQOFQ3SFdHYzZNNGU4RG9NSnNuN0pGQnRVRFRzdDZVZWI1dlZWM2lIMXR5TE1CU25mbEhaOGxiVnh6MFZOcEdRNDNfbFhvYUJBPT0=
|
||||
Connector_AiOpenai_API_SECRET = PROD_ENC:Z0FBQUFBQnFGdnVKZ2Z0U2s4cnpUN01mRVkzQUYyVm43NzZLOWJBODlvRlNFdTNGbzZHblNzUFJ2X0I3SXRFQTRXWlFYZjY1aUVVOTgxSU1KemZ4Wkl1NzFQb2JIcnM3bjg5bkRDYmpNVjVjTG55QmtSUVpZejEtckZTd20xRzd2NmhVSkJUQTFUZWk0dzhrUnJuNWZPa2NPSDR6QnQ1a0RCbWM4Y1h3Mmh2NmQ5SHFOR2FISndEMzF4Y29YcVlKaVNyNGM2VWFINUg4MjVMcHZJTUxVWXJNNUZVdW9GUkx0ZkZlZTJqRGI4ZkVuaklHMEotb3FyOEFka1c2WC1VclJZeHFucmJlRjhlUUhLNWdFX0xaRFp0ODNFNFZaWEdSTU5QbDcxbUxlclN0X2t0c1dpWXVJeFE9
|
||||
Connector_AiAnthropic_API_SECRET = PROD_ENC:Z0FBQUFBQnFGd0hjRzFXSXhjMVZWOW40ZFRRREItclVxODVDdFdSa2tzVVJ2TWVZaVl5UE40YzgxR2d4RVdhVUFaQy1VZVRRMzFnZW1NcjlNY1h6ZVJta3F5STI5Y2taRVlXbFREb2paMTZpRVZpdEVBVnJrSjlvS1lSMzB3V3FkWW56WlNhQUFiby1Mb2RCb0VHQ2NYYmNOUGZ5UEdseGJic2ZSQk1ReXlTRnJITVY3SEdPb296eGNIdXNRME5LOTlZUlRvclJRX2R3ZHlxM0puXzlWRzY2eHliY1FUNmxSZz09
|
||||
Connector_AiPerplexity_API_SECRET = PROD_ENC:Z0FBQUFBQnFGdnVLODRmYXo5T3BxSDJnZXgzRlFfR0oyWXVkeVRZbk14VkdDV3pTaWVfV3Y3R21LaWJpSC1laTg1T3NYREI2RzBBWWtraFJud0U2ZnhVQzJ0bnViVzJtOWh4dDZ3VUdoZUxaUzdhSkM4N3ZOOTFINmV1TGNmRE9RRmtfeTduVEV6QnYyRTZJaGxGb3ZFSmZmZ1JxUDdFSVBRPT0=
|
||||
Connector_AiTavily_API_SECRET = PROD_ENC:Z0FBQUFBQnFGdnVLcTlLSFJ5b0gwRmJLMFB5MzA3S3FYbmhKV2VzbHI0ZFUzOUJNdHVYQlQ0ckdicW1WWG5CNEkyWVlrR0gwQ0ZramJ1c19JS290MmlvWVhYWW92cEhIdmRTRXdPQzZpVFdDaU9MQzFlMEdPYUVnYy1HZlM1ODVuYnZGRnVZVFZpYzZBcUNRekVBZFFzVExQV254OUZ0aHVBPT0=
|
||||
Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo=
|
||||
Connector_AiMistral_API_SECRET = PROD_ENC:Z0FBQUFBQnFGcldVWThJZGQtSFcwTTlnZTAwOTBoSHFhYzQ4eTVYdEhmSEdpSW5VQTlQbE1KakMzMkNZZXBNRnhtRDNYMHBHV0lSSVVTOXZwYXA4VkltTjVfcmRhQnhkSTNzVVFaUnZxMFNHeThQZ1ZGeEo4a0ZsQjNUdXVyRm1ORXc0QkpLWEk1cV8=
|
||||
Connector_AiMistral_API_SECRET = PROD_ENC:Z0FBQUFBQnFGdnVLRHplbzNheDhIdndsU0xUeGlBYVVXWDRzOF9Tek41WjEtSmNqbnVHRXFaZ0dramlfZWlQelpJWVh5T0F2azBaQWU3ajU0TWljaGpMeTlra0g0LVhKeTRKNGxKY0ZqSkxwdTJLdWM5cWdMVC1TVkpLb2lPdHhyeWtieFJFOHdkVy0=
|
||||
|
||||
Service_MSFT_TENANT_ID = common
|
||||
|
||||
|
|
|
|||
|
|
@ -43,31 +43,9 @@ class FeatureDataSource(PowerOnModel):
|
|||
)
|
||||
mandateId: str = Field(
|
||||
default="",
|
||||
description="Mandate scope",
|
||||
description="Mandate scope (set automatically from featureInstance.mandateId on create).",
|
||||
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
|
||||
)
|
||||
userId: str = Field(
|
||||
default="",
|
||||
description="Owner user ID",
|
||||
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
|
||||
)
|
||||
workspaceInstanceId: str = Field(
|
||||
description="Workspace feature instance where this source is used",
|
||||
json_schema_extra={"label": "Workspace", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
|
||||
)
|
||||
scope: Optional[str] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Data visibility scope with inherit semantics. "
|
||||
"None = inherit; values: personal, featureInstance, mandate, global."
|
||||
),
|
||||
json_schema_extra={"label": "Sichtbarkeit", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
|
||||
{"value": "personal", "label": "Persönlich"},
|
||||
{"value": "featureInstance", "label": "Feature-Instanz"},
|
||||
{"value": "mandate", "label": "Mandant"},
|
||||
{"value": "global", "label": "Global"},
|
||||
]},
|
||||
)
|
||||
neutralize: Optional[bool] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
|
|
|
|||
|
|
@ -986,7 +986,11 @@ async def listWorkspaceWorkflows(
|
|||
"startedAt": getattr(wf, "startedAt", None),
|
||||
"lastActivity": getattr(wf, "lastActivity", None),
|
||||
"featureInstanceId": getattr(wf, "featureInstanceId", instanceId),
|
||||
"workflowMode": getattr(wf, "workflowMode", None),
|
||||
"linkedWorkflowId": getattr(wf, "linkedWorkflowId", None),
|
||||
}
|
||||
if item.get("workflowMode") == "Automation" or item.get("linkedWorkflowId"):
|
||||
continue
|
||||
if not includeArchived and item.get("status") == "archived":
|
||||
continue
|
||||
fiId = item.get("featureInstanceId") or instanceId
|
||||
|
|
@ -1311,73 +1315,6 @@ async def listWorkspaceDataSources(
|
|||
return JSONResponse({"dataSources": []})
|
||||
|
||||
|
||||
class _TreeChildrenRequest(BaseModel):
|
||||
"""Request body for the generic tree children endpoint."""
|
||||
parents: List[Optional[str]] = Field(
|
||||
default_factory=list,
|
||||
description="List of parent keys to fetch children for. Use null for top-level.",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{instanceId}/tree/children")
|
||||
@limiter.limit("300/minute")
|
||||
async def getTreeChildren(
|
||||
request: Request,
|
||||
instanceId: str = Path(...),
|
||||
body: _TreeChildrenRequest = Body(...),
|
||||
context: RequestContext = Depends(getRequestContext),
|
||||
):
|
||||
"""Generic UDB tree children resolver.
|
||||
|
||||
The UI sends a list of parent keys (or null for top-level). The backend
|
||||
returns children for each requested parent, with all effective flag
|
||||
values pre-computed. The UI builds the visible tree from the resulting
|
||||
flat per-parent map.
|
||||
"""
|
||||
_validateInstanceAccess(instanceId, context)
|
||||
from modules.serviceCenter.services.serviceKnowledge._buildTree import getChildrenForParents
|
||||
|
||||
try:
|
||||
nodesByParent = await getChildrenForParents(instanceId, body.parents, context)
|
||||
except Exception as exc:
|
||||
logger.exception("Tree children build failed: %s", exc)
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
return JSONResponse({"nodesByParent": nodesByParent})
|
||||
|
||||
|
||||
class _TreeAttributesRequest(BaseModel):
|
||||
"""Request body for the attribute-refresh endpoint."""
|
||||
keys: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="List of node keys to fetch current attributes for.",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{instanceId}/tree/attributes")
|
||||
@limiter.limit("300/minute")
|
||||
async def getTreeAttributes(
|
||||
request: Request,
|
||||
instanceId: str = Path(...),
|
||||
body: _TreeAttributesRequest = Body(...),
|
||||
context: RequestContext = Depends(getRequestContext),
|
||||
):
|
||||
"""Return current effective attribute values (neutralize, scope,
|
||||
ragIndexEnabled) for a list of node keys. Used after a toggle action
|
||||
to refresh only the visible nodes without reloading tree structure."""
|
||||
_validateInstanceAccess(instanceId, context)
|
||||
from modules.serviceCenter.services.serviceKnowledge._buildTree import getAttributesForKeys
|
||||
|
||||
if len(body.keys) > 500:
|
||||
raise HTTPException(status_code=400, detail="Max 500 keys per request")
|
||||
|
||||
try:
|
||||
attrs = await getAttributesForKeys(instanceId, body.keys, context)
|
||||
except Exception as exc:
|
||||
logger.exception("Tree attributes failed: %s", exc)
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
return JSONResponse({"attributes": attrs})
|
||||
|
||||
|
||||
class CreateDataSourceRequest(BaseModel):
|
||||
"""Request body for creating a DataSource."""
|
||||
connectionId: str = Field(description="Connection ID")
|
||||
|
|
@ -1458,19 +1395,15 @@ async def createFeatureDataSource(
|
|||
body: CreateFeatureDataSourceRequest = Body(...),
|
||||
context: RequestContext = Depends(getRequestContext),
|
||||
):
|
||||
"""Create a FeatureDataSource for this workspace instance.
|
||||
"""Create a FeatureDataSource for the referenced feature instance.
|
||||
|
||||
The FDS lives under the WORKSPACE's mandate (not the feature's): that
|
||||
matches how the tree (`allFds = recordset where workspaceInstanceId =
|
||||
instanceId`) and the PATCH endpoints scope these records — by workspace,
|
||||
not by feature mandate. The user can legitimately reference a feature
|
||||
from another mandate they have access to (via the UDB mandate-group
|
||||
nodes), and a hard cross-mandate block here would silently 403 those
|
||||
toggles. Access to the referenced feature is verified by the user's
|
||||
`FeatureAccess` and the existing tree-children RBAC, which run before
|
||||
the user can ever click on this node.
|
||||
The FDS belongs to the FEATURE-INSTANCE (not to a workspace). Flag editing
|
||||
is governed by feature-admin RBAC on that feature instance (see the
|
||||
UDB reference page for the polymorphic node model). The `instanceId`
|
||||
in the URL path is the calling consumer's feature instance and is used
|
||||
only for access validation, not for FDS scoping.
|
||||
"""
|
||||
wsMandateId, _ = _validateInstanceAccess(instanceId, context)
|
||||
_validateInstanceAccess(instanceId, context)
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
|
||||
|
||||
|
|
@ -1478,8 +1411,10 @@ async def createFeatureDataSource(
|
|||
if not rootIf.getFeatureAccess(str(context.user.id), body.featureInstanceId):
|
||||
raise HTTPException(status_code=403, detail=routeApiMsg("Access denied to this feature instance"))
|
||||
|
||||
fi = rootIf.getFeatureInstance(body.featureInstanceId)
|
||||
fiMandateId = str(fi.mandateId) if fi and getattr(fi, "mandateId", None) else ""
|
||||
|
||||
existing = rootIf.db.getRecordset(FeatureDataSource, recordFilter={
|
||||
"workspaceInstanceId": instanceId,
|
||||
"featureInstanceId": body.featureInstanceId,
|
||||
"tableName": body.tableName,
|
||||
}) or []
|
||||
|
|
@ -1494,9 +1429,7 @@ async def createFeatureDataSource(
|
|||
tableName=body.tableName,
|
||||
objectKey=body.objectKey,
|
||||
label=body.label,
|
||||
mandateId=wsMandateId or "",
|
||||
userId=str(context.user.id),
|
||||
workspaceInstanceId=instanceId,
|
||||
mandateId=fiMandateId,
|
||||
recordFilter=body.recordFilter,
|
||||
)
|
||||
created = rootIf.db.recordCreate(FeatureDataSource, fds.model_dump())
|
||||
|
|
@ -1510,27 +1443,28 @@ async def listFeatureDataSources(
|
|||
instanceId: str = Path(...),
|
||||
context: RequestContext = Depends(getRequestContext),
|
||||
):
|
||||
"""List active FeatureDataSources for this workspace instance, scoped to mandate."""
|
||||
"""List FeatureDataSources visible to this caller. Filters by mandate of
|
||||
the calling feature-instance (RBAC). FDS records are now feature-owned;
|
||||
visibility is governed by the user's accessible feature instances within
|
||||
the mandate."""
|
||||
wsMandateId, _ = _validateInstanceAccess(instanceId, context)
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
|
||||
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import buildEffectiveByWorkspaceFds
|
||||
|
||||
rootIf = getRootInterface()
|
||||
recordFilter: dict = {"workspaceInstanceId": instanceId}
|
||||
recordFilter: dict = {}
|
||||
if wsMandateId:
|
||||
recordFilter["mandateId"] = wsMandateId
|
||||
records = rootIf.db.getRecordset(FeatureDataSource, recordFilter=recordFilter)
|
||||
records = rootIf.db.getRecordset(FeatureDataSource, recordFilter=recordFilter) or []
|
||||
if not records:
|
||||
return JSONResponse({"featureDataSources": []})
|
||||
|
||||
effNeutralize = buildEffectiveByWorkspaceFds(records, "neutralize", mode="aggregate")
|
||||
effScope = buildEffectiveByWorkspaceFds(records, "scope", mode="aggregate")
|
||||
effRag = buildEffectiveByWorkspaceFds(records, "ragIndexEnabled", mode="aggregate")
|
||||
for fds in records:
|
||||
fdsId = fds.get("id", "")
|
||||
fds["effectiveNeutralize"] = effNeutralize.get(fdsId, False)
|
||||
fds["effectiveScope"] = effScope.get(fdsId, "personal")
|
||||
fds["effectiveRagIndexEnabled"] = effRag.get(fdsId, False)
|
||||
|
||||
return JSONResponse({"featureDataSources": records})
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ from modules.system.databaseMigration import (
|
|||
_importSingleDb,
|
||||
_prepareImport,
|
||||
_validateImportPayload,
|
||||
streamExportGenerator,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -438,38 +439,18 @@ async def postMigrationImport(
|
|||
# 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")
|
||||
@limiter.limit("60/minute")
|
||||
def getMigrationExportSingle(
|
||||
request: Request,
|
||||
token: str,
|
||||
database: str,
|
||||
currentUser: User = Depends(requireSysAdmin),
|
||||
) -> 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
|
||||
|
||||
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():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
|
|
@ -487,7 +468,6 @@ def getMigrationExportSingle(
|
|||
detail=f"Export failed for '{database}': {e}",
|
||||
) from e
|
||||
|
||||
pending["databases"][database] = dbPayload
|
||||
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))
|
||||
|
||||
|
|
@ -495,48 +475,57 @@ def getMigrationExportSingle(
|
|||
"database": database,
|
||||
"tableCount": dbPayload.get("tableCount", 0),
|
||||
"totalRecords": dbPayload.get("totalRecords", 0),
|
||||
"payload": dbPayload,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/migration/export-download")
|
||||
@limiter.limit("5/minute")
|
||||
def getMigrationExportDownload(
|
||||
@router.get("/migration/export-stream")
|
||||
@limiter.limit("2/minute")
|
||||
def getMigrationExportStream(
|
||||
request: Request,
|
||||
token: str,
|
||||
filename: str = "backup.json",
|
||||
databases: str = "all",
|
||||
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 modules.shared.dbRegistry import 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.")
|
||||
registeredDbs = getRegisteredDatabases()
|
||||
|
||||
databases = pending["databases"]
|
||||
totalTables = sum(d.get("tableCount", 0) for d in databases.values())
|
||||
totalRecords = sum(d.get("totalRecords", 0) for d in databases.values())
|
||||
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)}",
|
||||
)
|
||||
|
||||
exportData = {
|
||||
"meta": {
|
||||
"exportedAt": datetime.now(timezone.utc).isoformat(),
|
||||
"version": "1.0",
|
||||
"databaseCount": len(databases),
|
||||
"totalTables": totalTables,
|
||||
"totalRecords": totalRecords,
|
||||
},
|
||||
"databases": databases,
|
||||
}
|
||||
if not dbList:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="No databases selected for export.",
|
||||
)
|
||||
|
||||
logger.info("SysAdmin migration export-download: user=%s dbs=%s tables=%s records=%s",
|
||||
currentUser.username, len(databases), totalTables, totalRecords)
|
||||
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"
|
||||
|
||||
content = json.dumps(exportData, ensure_ascii=False, default=str)
|
||||
logger.info("SysAdmin stream export: user=%s databases=%s", currentUser.username, dbList)
|
||||
|
||||
return StreamingResponse(
|
||||
iter([content]),
|
||||
streamExportGenerator(dbList, instanceLabel),
|
||||
media_type="application/json",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"X-Export-Databases": ",".join(dbList),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -298,6 +298,7 @@ def get_folder_tree(
|
|||
folders = managementInterface.getSharedFolderTree()
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="owner must be 'me' or 'shared'")
|
||||
_enrichFoldersWithMixed(managementInterface.db, str(currentUser.id), folders, o)
|
||||
return folders
|
||||
except HTTPException:
|
||||
raise
|
||||
|
|
@ -372,6 +373,33 @@ def getAttributesForIds(
|
|||
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(
|
||||
folder: Dict[str, Any],
|
||||
allFolders: List[Dict[str, Any]],
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""PATCH endpoints for DataSource and FeatureDataSource scope/neutralize/rag-index tagging."""
|
||||
"""DataSource auxiliary endpoints: settings (ragLimits) and cost estimate.
|
||||
|
||||
Flag toggles (neutralize / scope / ragIndexEnabled) have moved to the
|
||||
generic UDB router (`POST /api/udb/node/{key}/flag/{flag}`); see
|
||||
`modules/routes/routeUdb.py` and the wiki UDB reference page.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
|
@ -43,49 +48,6 @@ def _ensureConnectionKnowledgeFlag(rootIf, connectionId: str) -> None:
|
|||
except Exception as e:
|
||||
logger.warning("Could not auto-enable knowledgeIngestionEnabled for connection %s: %s", connectionId, e)
|
||||
|
||||
def _computeOwnEffective(rootIf, rec, model, sourceId: str, flag: str) -> Any:
|
||||
"""Re-load the record after modification and compute its aggregate effective value."""
|
||||
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import (
|
||||
getEffectiveFlag, getEffectiveFlagFds,
|
||||
)
|
||||
freshRec = rootIf.db.getRecord(model, sourceId)
|
||||
if not freshRec:
|
||||
return None
|
||||
if model is DataSource:
|
||||
connectionId = freshRec.get("connectionId", "")
|
||||
allDs = rootIf.db.getRecordset(DataSource, recordFilter={"connectionId": connectionId})
|
||||
return getEffectiveFlag(freshRec, flag, allDs, mode="aggregate")
|
||||
else:
|
||||
wsId = freshRec.get("workspaceInstanceId", "")
|
||||
allFds = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"workspaceInstanceId": wsId})
|
||||
return getEffectiveFlagFds(freshRec, flag, allFds, mode="aggregate")
|
||||
|
||||
|
||||
def _computeAncestorEffectives(rootIf, rec, model, flag: str) -> List[Dict[str, Any]]:
|
||||
"""Compute the aggregate effective value for all ancestors of `rec`."""
|
||||
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import (
|
||||
collectAncestorChain, collectAncestorChainFds,
|
||||
getEffectiveFlag, getEffectiveFlagFds,
|
||||
)
|
||||
effectiveKey = f"effective{flag[0].upper()}{flag[1:]}"
|
||||
if model is DataSource:
|
||||
connectionId = rec.get("connectionId", "")
|
||||
allDs = rootIf.db.getRecordset(DataSource, recordFilter={"connectionId": connectionId})
|
||||
ancestors = collectAncestorChain(rec, allDs)
|
||||
return [
|
||||
{"id": a.get("id") or getattr(a, "id", ""), effectiveKey: getEffectiveFlag(a, flag, allDs, mode="aggregate")}
|
||||
for a in ancestors
|
||||
]
|
||||
else:
|
||||
wsId = rec.get("workspaceInstanceId", "")
|
||||
allFds = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"workspaceInstanceId": wsId})
|
||||
ancestors = collectAncestorChainFds(rec, allFds)
|
||||
return [
|
||||
{"id": a.get("id") or getattr(a, "id", ""), effectiveKey: getEffectiveFlagFds(a, flag, allFds, mode="aggregate")}
|
||||
for a in ancestors
|
||||
]
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/datasources",
|
||||
tags=["Data Sources"],
|
||||
|
|
@ -98,9 +60,6 @@ router = APIRouter(
|
|||
},
|
||||
)
|
||||
|
||||
_VALID_SCOPES = {"personal", "featureInstance", "mandate", "global"}
|
||||
|
||||
|
||||
def _findSourceRecord(db, sourceId: str):
|
||||
"""Look up a source by ID, checking DataSource first, then FeatureDataSource."""
|
||||
rec = db.getRecord(DataSource, sourceId)
|
||||
|
|
@ -112,250 +71,6 @@ def _findSourceRecord(db, sourceId: str):
|
|||
return None, None
|
||||
|
||||
|
||||
@router.patch("/{sourceId}/scope")
|
||||
@limiter.limit("30/minute")
|
||||
def _updateDataSourceScope(
|
||||
request: Request,
|
||||
sourceId: str = Path(..., description="ID of the DataSource or FeatureDataSource"),
|
||||
scope: Optional[str] = Body(None, embed=True),
|
||||
context: RequestContext = Depends(getRequestContext),
|
||||
) -> Dict[str, Any]:
|
||||
"""Update the scope of a DataSource. Cascade-resets explicit descendants.
|
||||
|
||||
`scope=None` resets this node to inherit (no cascade). Global scope
|
||||
requires sysAdmin.
|
||||
"""
|
||||
if scope is not None:
|
||||
if scope not in _VALID_SCOPES:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid scope: {scope}. Must be one of {_VALID_SCOPES}")
|
||||
if scope == "global" and not context.isSysAdmin:
|
||||
raise HTTPException(status_code=403, detail=routeApiMsg("Only sysadmins can set global scope"))
|
||||
|
||||
try:
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import (
|
||||
cascadeResetDescendants, cascadeResetDescendantsFds,
|
||||
getEffectiveFlag, getEffectiveFlagFds,
|
||||
collectAncestorChain, collectAncestorChainFds,
|
||||
)
|
||||
rootIf = getRootInterface()
|
||||
rec, model = _findSourceRecord(rootIf.db, sourceId)
|
||||
if not rec:
|
||||
raise HTTPException(status_code=404, detail=f"DataSource {sourceId} not found")
|
||||
|
||||
# 1. Cascade reset descendants bottom-up (before modifying master)
|
||||
resetIds: List[str] = []
|
||||
if scope is not None:
|
||||
if model is DataSource:
|
||||
resetIds = cascadeResetDescendants(rootIf, rec, "scope")
|
||||
else:
|
||||
resetIds = cascadeResetDescendantsFds(rootIf, rec, "scope")
|
||||
|
||||
# 2. Set master value last (crash-safe)
|
||||
rootIf.db.recordModify(model, sourceId, {"scope": scope})
|
||||
|
||||
# 3. Compute effective + ancestor chain for response
|
||||
updatedAncestors = _computeAncestorEffectives(rootIf, rec, model, "scope")
|
||||
effectiveScope = _computeOwnEffective(rootIf, rec, model, sourceId, "scope")
|
||||
|
||||
logger.info(
|
||||
"Updated scope=%s for %s %s (cascade-reset %d descendants)",
|
||||
scope, model.__name__, sourceId, len(resetIds),
|
||||
)
|
||||
return {
|
||||
"sourceId": sourceId,
|
||||
"scope": scope,
|
||||
"effectiveScope": effectiveScope,
|
||||
"resetDescendantIds": resetIds,
|
||||
"updatedAncestors": updatedAncestors,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error updating datasource scope: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/{sourceId}/neutralize")
|
||||
@limiter.limit("30/minute")
|
||||
def _updateDataSourceNeutralize(
|
||||
request: Request,
|
||||
sourceId: str = Path(..., description="ID of the DataSource or FeatureDataSource"),
|
||||
neutralize: Optional[bool] = Body(None, embed=True),
|
||||
context: RequestContext = Depends(getRequestContext),
|
||||
) -> Dict[str, Any]:
|
||||
"""Set neutralize flag on a DataSource. Cascade-resets explicit descendants.
|
||||
|
||||
`neutralize=None` resets this node to inherit (no cascade).
|
||||
"""
|
||||
try:
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import (
|
||||
cascadeResetDescendants, cascadeResetDescendantsFds,
|
||||
)
|
||||
rootIf = getRootInterface()
|
||||
rec, model = _findSourceRecord(rootIf.db, sourceId)
|
||||
if not rec:
|
||||
raise HTTPException(status_code=404, detail=f"DataSource {sourceId} not found")
|
||||
|
||||
# 1. Cascade reset descendants bottom-up (before modifying master)
|
||||
resetIds: List[str] = []
|
||||
if neutralize is not None:
|
||||
if model is DataSource:
|
||||
resetIds = cascadeResetDescendants(rootIf, rec, "neutralize")
|
||||
else:
|
||||
resetIds = cascadeResetDescendantsFds(rootIf, rec, "neutralize")
|
||||
|
||||
# 2. Set master value last (crash-safe)
|
||||
rootIf.db.recordModify(model, sourceId, {"neutralize": neutralize})
|
||||
|
||||
# 3. Compute effective + ancestor chain for response
|
||||
updatedAncestors = _computeAncestorEffectives(rootIf, rec, model, "neutralize")
|
||||
effectiveNeutralize = _computeOwnEffective(rootIf, rec, model, sourceId, "neutralize")
|
||||
|
||||
logger.info(
|
||||
"Updated neutralize=%s for %s %s (cascade-reset %d descendants)",
|
||||
neutralize, model.__name__, sourceId, len(resetIds),
|
||||
)
|
||||
return {
|
||||
"sourceId": sourceId,
|
||||
"neutralize": neutralize,
|
||||
"effectiveNeutralize": effectiveNeutralize,
|
||||
"resetDescendantIds": resetIds,
|
||||
"updatedAncestors": updatedAncestors,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error updating datasource neutralize: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/{sourceId}/neutralize-fields")
|
||||
@limiter.limit("30/minute")
|
||||
def _updateNeutralizeFields(
|
||||
request: Request,
|
||||
sourceId: str = Path(..., description="ID of the FeatureDataSource"),
|
||||
neutralizeFields: List[str] = Body(..., embed=True),
|
||||
context: RequestContext = Depends(getRequestContext),
|
||||
) -> Dict[str, Any]:
|
||||
"""Update the list of field names to neutralize on a FeatureDataSource."""
|
||||
try:
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
rootIf = getRootInterface()
|
||||
rec = rootIf.db.getRecord(FeatureDataSource, sourceId)
|
||||
if not rec:
|
||||
raise HTTPException(status_code=404, detail=f"FeatureDataSource {sourceId} not found")
|
||||
|
||||
cleanFields = [f for f in neutralizeFields if f and isinstance(f, str)] if neutralizeFields else []
|
||||
rootIf.db.recordModify(FeatureDataSource, sourceId, {
|
||||
"neutralizeFields": cleanFields if cleanFields else None,
|
||||
})
|
||||
logger.info("Updated neutralizeFields=%s for FeatureDataSource %s", cleanFields, sourceId)
|
||||
return {"sourceId": sourceId, "neutralizeFields": cleanFields, "updated": True}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error updating neutralizeFields: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/{sourceId}/rag-index")
|
||||
@limiter.limit("30/minute")
|
||||
async def _updateDataSourceRagIndex(
|
||||
request: Request,
|
||||
sourceId: str = Path(..., description="ID of the DataSource"),
|
||||
ragIndexEnabled: Optional[bool] = Body(None, embed=True),
|
||||
context: RequestContext = Depends(getRequestContext),
|
||||
) -> Dict[str, Any]:
|
||||
"""Set RAG indexing flag on a DataSource. Cascade-resets explicit descendants.
|
||||
|
||||
`ragIndexEnabled=None` resets this node to inherit (no cascade, no purge,
|
||||
no bootstrap — the node simply follows its ancestor chain afterwards).
|
||||
`True` enqueues a mini-bootstrap. `False` synchronously purges chunks.
|
||||
|
||||
Must be `async def` so `await startJob(...)` registers `_runJob` in the
|
||||
main event loop.
|
||||
"""
|
||||
try:
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import (
|
||||
cascadeResetDescendants, cascadeResetDescendantsFds,
|
||||
)
|
||||
rootIf = getRootInterface()
|
||||
rec, model = _findSourceRecord(rootIf.db, sourceId)
|
||||
if not rec:
|
||||
raise HTTPException(status_code=404, detail=f"DataSource {sourceId} not found")
|
||||
|
||||
# 1. Cascade reset descendants bottom-up (before modifying master)
|
||||
resetIds: List[str] = []
|
||||
if ragIndexEnabled is not None:
|
||||
if model is DataSource:
|
||||
resetIds = cascadeResetDescendants(rootIf, rec, "ragIndexEnabled")
|
||||
else:
|
||||
resetIds = cascadeResetDescendantsFds(rootIf, rec, "ragIndexEnabled")
|
||||
|
||||
# 2. Set master value last (crash-safe)
|
||||
rootIf.db.recordModify(model, sourceId, {"ragIndexEnabled": ragIndexEnabled})
|
||||
|
||||
logger.info(
|
||||
"Updated ragIndexEnabled=%s for %s %s (cascade-reset %d descendants)",
|
||||
ragIndexEnabled, model.__name__, sourceId, len(resetIds),
|
||||
)
|
||||
|
||||
# Bootstrap / purge only for personal DataSource (file/folder-based RAG).
|
||||
# FDS RAG is handled by the feature pipeline; the flag alone is enough.
|
||||
if model is DataSource:
|
||||
connectionId = rec.get("connectionId") or rec.get("connection_id") or ""
|
||||
if ragIndexEnabled is True:
|
||||
_ensureConnectionKnowledgeFlag(rootIf, connectionId)
|
||||
from modules.serviceCenter.services.serviceBackgroundJobs import startJob
|
||||
|
||||
conn = rootIf.getUserConnectionById(connectionId) if connectionId else None
|
||||
authority = ""
|
||||
if conn:
|
||||
authority = conn.authority.value if hasattr(conn.authority, "value") else str(conn.authority or "")
|
||||
|
||||
await startJob(
|
||||
"connection.bootstrap",
|
||||
{"connectionId": connectionId, "authority": authority.lower(), "dataSourceIds": [sourceId]},
|
||||
triggeredBy=str(context.user.id),
|
||||
)
|
||||
elif ragIndexEnabled is False:
|
||||
from modules.interfaces.interfaceDbKnowledge import getInterface as getKnowledgeInterface
|
||||
purgeResult = getKnowledgeInterface(None).deleteFileContentIndexByDataSource(sourceId)
|
||||
logger.info("Purged %d index rows / %d chunks for DataSource %s",
|
||||
purgeResult.get("indexRows", 0), purgeResult.get("chunks", 0), sourceId)
|
||||
|
||||
import json
|
||||
from modules.shared.auditLogger import audit_logger
|
||||
from modules.datamodels.datamodelAudit import AuditCategory
|
||||
audit_logger.logEvent(
|
||||
userId=str(context.user.id),
|
||||
mandateId=context.mandateId,
|
||||
category=AuditCategory.PERMISSION.value,
|
||||
action="rag_index_toggled",
|
||||
details=json.dumps({"sourceId": sourceId, "ragIndexEnabled": ragIndexEnabled, "resetDescendants": len(resetIds), "model": model.__name__}),
|
||||
)
|
||||
|
||||
# 3. Compute effective + ancestors for response
|
||||
updatedAncestors = _computeAncestorEffectives(rootIf, rec, model, "ragIndexEnabled")
|
||||
effectiveRag = _computeOwnEffective(rootIf, rec, model, sourceId, "ragIndexEnabled")
|
||||
|
||||
return {
|
||||
"sourceId": sourceId,
|
||||
"ragIndexEnabled": ragIndexEnabled,
|
||||
"effectiveRagIndexEnabled": effectiveRag,
|
||||
"resetDescendantIds": resetIds,
|
||||
"updatedAncestors": updatedAncestors,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error updating datasource ragIndexEnabled: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
_CLICKUP_SOURCE_TYPES = {"clickup", "clickupList", "clickupSpace", "clickupFolder"}
|
||||
_ALLOWED_RAG_LIMIT_KEYS = {
|
||||
"files": {"maxItems", "maxBytes", "maxFileSize", "maxDepth"},
|
||||
|
|
@ -412,8 +127,9 @@ def _updateDataSourceSettings(
|
|||
Currently supports `ragLimits` only. Unknown top-level keys in the body are
|
||||
rejected to avoid silently storing garbage that no consumer reads.
|
||||
|
||||
Owner-only for personal DataSources; mandate/feature scopes additionally
|
||||
accept the mandate or workspace admins of that scope.
|
||||
DataSource: owner-only (or sysadmin). For mandate/feature scopes the
|
||||
mandateAdmin also passes. FeatureDataSource has no userId/scope; for
|
||||
those we require a feature-admin role on the FDS's featureInstanceId.
|
||||
"""
|
||||
if not isinstance(settings, dict):
|
||||
raise HTTPException(status_code=400, detail="settings must be an object")
|
||||
|
|
@ -428,23 +144,22 @@ def _updateDataSourceSettings(
|
|||
if not rec:
|
||||
raise HTTPException(status_code=404, detail=f"DataSource {sourceId} not found")
|
||||
|
||||
ownerId = str(rec.get("userId") or "")
|
||||
currentUserId = str(context.user.id)
|
||||
if ownerId and ownerId != currentUserId and not context.isSysAdmin:
|
||||
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlag
|
||||
if model is DataSource:
|
||||
if model is DataSource:
|
||||
ownerId = str(rec.get("userId") or "")
|
||||
if ownerId and ownerId != currentUserId and not context.isSysAdmin:
|
||||
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlag
|
||||
connectionId = rec.get("connectionId", "")
|
||||
allDs = rootIf.db.getRecordset(DataSource, recordFilter={"connectionId": connectionId})
|
||||
scope = str(getEffectiveFlag(rec, "scope", allDs, mode="walk"))
|
||||
else:
|
||||
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource as FDS
|
||||
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlagFds
|
||||
wsId = rec.get("workspaceInstanceId", "")
|
||||
allFds = rootIf.db.getRecordset(FDS, recordFilter={"workspaceInstanceId": wsId})
|
||||
scope = str(getEffectiveFlagFds(rec, "scope", allFds, mode="walk"))
|
||||
isMandateAdmin = getattr(context, "isMandateAdmin", False)
|
||||
if scope == "personal" or not isMandateAdmin:
|
||||
raise HTTPException(status_code=403, detail="Not allowed to modify this DataSource's settings")
|
||||
isMandateAdmin = getattr(context, "isMandateAdmin", False)
|
||||
if scope == "personal" or not isMandateAdmin:
|
||||
raise HTTPException(status_code=403, detail="Not allowed to modify this DataSource's settings")
|
||||
else:
|
||||
from modules.serviceCenter.services.serviceKnowledge.udbNodes import _isFeatureAdmin
|
||||
featureInstanceId = str(rec.get("featureInstanceId") or "")
|
||||
if not (context.isSysAdmin or _isFeatureAdmin(rootIf, currentUserId, featureInstanceId)):
|
||||
raise HTTPException(status_code=403, detail="Not allowed to modify this FeatureDataSource's settings")
|
||||
|
||||
kind = _kindForSource(rec, model)
|
||||
|
||||
|
|
|
|||
|
|
@ -265,7 +265,7 @@ def _buildFeatureInstanceInventory(featureInstanceIds, rootIf, knowledgeIf) -> L
|
|||
st = (r.get("status") if isinstance(r, dict) else getattr(r, "status", "unknown")) or "unknown"
|
||||
statusCounts[st] = statusCounts.get(st, 0) + 1
|
||||
|
||||
allFds = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"workspaceInstanceId": fiId})
|
||||
allFds = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"featureInstanceId": fiId})
|
||||
dsItems = []
|
||||
anyRagEnabled = False
|
||||
for fds in allFds:
|
||||
|
|
@ -287,7 +287,7 @@ def _buildFeatureInstanceInventory(featureInstanceIds, rootIf, knowledgeIf) -> L
|
|||
|
||||
fiJobs = [
|
||||
j for j in allFeatureJobs
|
||||
if (j.get("payload") or {}).get("workspaceInstanceId") == fiId
|
||||
if (j.get("payload") or {}).get("featureInstanceId") == fiId
|
||||
]
|
||||
runningJobs = [
|
||||
{
|
||||
|
|
@ -572,17 +572,18 @@ async def _reindexConnection(
|
|||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/reindex-feature/{workspaceInstanceId}")
|
||||
@router.post("/reindex-feature/{featureInstanceId}")
|
||||
@limiter.limit("10/minute")
|
||||
async def _reindexFeature(
|
||||
request: Request,
|
||||
workspaceInstanceId: str,
|
||||
featureInstanceId: str,
|
||||
currentUser: User = Depends(getCurrentUser),
|
||||
) -> Dict[str, Any]:
|
||||
"""Re-trigger feature data bootstrap for a workspace instance.
|
||||
"""Re-trigger feature data bootstrap for a feature instance.
|
||||
|
||||
Indexes all RAG-enabled FeatureDataSource rows into the knowledge store.
|
||||
Must be ``async def`` so ``await startJob(...)`` registers in the main loop.
|
||||
Indexes all RAG-enabled FeatureDataSource rows owned by this feature
|
||||
instance into the knowledge store. Must be ``async def`` so
|
||||
``await startJob(...)`` registers in the main loop.
|
||||
"""
|
||||
try:
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
|
|
@ -592,7 +593,7 @@ async def _reindexFeature(
|
|||
rootIf = getRootInterface()
|
||||
featureAccesses = rootIf.getFeatureAccessesForUser(str(currentUser.id))
|
||||
hasAccess = any(
|
||||
str(fa.featureInstanceId) == workspaceInstanceId and fa.enabled
|
||||
str(fa.featureInstanceId) == featureInstanceId and fa.enabled
|
||||
for fa in featureAccesses
|
||||
)
|
||||
if not hasAccess and not getattr(currentUser, "isSysAdmin", False):
|
||||
|
|
@ -600,12 +601,12 @@ async def _reindexFeature(
|
|||
|
||||
jobId = await startJob(
|
||||
FEATURE_BOOTSTRAP_JOB_TYPE,
|
||||
{"workspaceInstanceId": workspaceInstanceId},
|
||||
{"featureInstanceId": featureInstanceId},
|
||||
triggeredBy=str(currentUser.id),
|
||||
)
|
||||
|
||||
logger.info("Feature reindex triggered for workspace %s (jobId=%s)", workspaceInstanceId, jobId)
|
||||
return {"status": "queued", "workspaceInstanceId": workspaceInstanceId, "jobId": jobId}
|
||||
logger.info("Feature reindex triggered for feature %s (jobId=%s)", featureInstanceId, jobId)
|
||||
return {"status": "queued", "featureInstanceId": featureInstanceId, "jobId": jobId}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
|
|||
229
modules/routes/routeUdb.py
Normal file
229
modules/routes/routeUdb.py
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
# Copyright (c) 2026 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Generic UDB (Unified Data Bar) router.
|
||||
|
||||
The UDB is feature-agnostic: it can render the user's accessible data
|
||||
sources (personal + feature-owned) without being coupled to a specific
|
||||
caller feature instance. This router owns two endpoints:
|
||||
|
||||
POST /api/udb/tree/children
|
||||
Resolve the children for a list of parent tree keys (UI walks).
|
||||
|
||||
POST /api/udb/node/{nodeKey}/flag/{flag}
|
||||
Persist a new value for a single flag on a single node.
|
||||
|
||||
Permission policy:
|
||||
- DataSource-family nodes: owner-of-record (rec.userId == user).
|
||||
- FdsRecord / FdsField nodes: feature-admin on the FDS's
|
||||
featureInstanceId (a FeatureAccessRole whose Role.roleLabel ends
|
||||
with '-admin').
|
||||
- Synthetic containers (personalRoot, mgrp): never editable.
|
||||
|
||||
See wiki/b-reference/platform/unified-data-bar.md for the full domain
|
||||
model and the rationale behind the hard cut from the previous
|
||||
feature-instance-scoped endpoints.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from modules.auth import getRequestContext, limiter, RequestContext
|
||||
from modules.shared.i18nRegistry import apiRouteContext
|
||||
|
||||
routeApiMsg = apiRouteContext("routeUdb")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/udb",
|
||||
tags=["Unified Data Bar"],
|
||||
responses={
|
||||
400: {"description": "Bad request"},
|
||||
401: {"description": "Unauthorized"},
|
||||
403: {"description": "Forbidden"},
|
||||
404: {"description": "Not found"},
|
||||
500: {"description": "Internal server error"},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
_VALID_SCOPES = {"personal", "featureInstance", "mandate", "global"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/udb/tree/children
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _UdbTreeChildrenRequest(BaseModel):
|
||||
"""Request body for the generic UDB tree children endpoint."""
|
||||
parents: List[Optional[str]] = Field(
|
||||
default_factory=list,
|
||||
description="List of parent keys to fetch children for. Use null for top-level.",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/tree/children")
|
||||
@limiter.limit("300/minute")
|
||||
async def _udbTreeChildren(
|
||||
request: Request,
|
||||
body: _UdbTreeChildrenRequest = Body(...),
|
||||
context: RequestContext = Depends(getRequestContext),
|
||||
) -> Dict[str, Any]:
|
||||
"""Resolve children for the given parent keys.
|
||||
|
||||
The UDB is feature-agnostic; this endpoint requires only that the
|
||||
user is authenticated. Visibility is driven by the user's accessible
|
||||
mandates and feature instances inside `getChildrenForParents`.
|
||||
"""
|
||||
from modules.serviceCenter.services.serviceKnowledge._buildTree import getChildrenForParents
|
||||
try:
|
||||
nodesByParent = await getChildrenForParents(body.parents, context)
|
||||
except Exception as exc:
|
||||
logger.exception("UDB tree children build failed: %s", exc)
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
return {"nodesByParent": nodesByParent}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /api/udb/node/{nodeKey}/flag/{flag}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _UdbFlagBody(BaseModel):
|
||||
"""Generic flag-mutation body.
|
||||
|
||||
Exactly one of `value` / `neutralizeFields` is expected depending on
|
||||
the flag (see `_extractFlagValue` for the mapping). `value` is typed
|
||||
as Any because the legal type depends on the flag:
|
||||
- neutralize/ragIndexEnabled : bool | null (null = inherit)
|
||||
- scope : str | null (one of _VALID_SCOPES, null = inherit)
|
||||
"""
|
||||
value: Any = Field(default=None, description="New flag value or null to reset to inherit.")
|
||||
|
||||
|
||||
@router.post("/node/{nodeKey:path}/flag/{flag}")
|
||||
@limiter.limit("60/minute")
|
||||
async def _udbNodeFlag(
|
||||
request: Request,
|
||||
nodeKey: str = Path(..., description="Tree key of the node to modify"),
|
||||
flag: str = Path(..., description="One of: neutralize | scope | ragIndexEnabled"),
|
||||
body: _UdbFlagBody = Body(default_factory=_UdbFlagBody),
|
||||
context: RequestContext = Depends(getRequestContext),
|
||||
) -> Dict[str, Any]:
|
||||
"""Persist a new value for `flag` on the node identified by `nodeKey`.
|
||||
|
||||
`value=null` resets the node to inherit from its ancestor chain (no
|
||||
cascade, no purge). `value=true/false` (or a scope string) writes
|
||||
the explicit override and cascade-resets any explicit child
|
||||
descendants so they re-inherit.
|
||||
|
||||
RBAC: `node.canEdit(context, rootIf)` decides; the route never
|
||||
re-implements ownership rules.
|
||||
"""
|
||||
if flag not in ("neutralize", "scope", "ragIndexEnabled"):
|
||||
raise HTTPException(status_code=400, detail=f"Unknown flag: {flag}")
|
||||
|
||||
value = _validateFlagValue(flag, body.value, context)
|
||||
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
from modules.serviceCenter.services.serviceKnowledge.udbNodes import buildNodeForKey
|
||||
rootIf = getRootInterface()
|
||||
|
||||
node = buildNodeForKey(nodeKey, context, rootIf)
|
||||
if node is None:
|
||||
raise HTTPException(status_code=404, detail=f"Unknown UDB node key: {nodeKey}")
|
||||
|
||||
if not node.supportsFlag(flag):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"{type(node).__name__} does not support flag '{flag}'",
|
||||
)
|
||||
if not node.canEdit(context, rootIf):
|
||||
raise HTTPException(status_code=403, detail=routeApiMsg("Not allowed to edit this UDB node"))
|
||||
|
||||
try:
|
||||
resetIds = node.setFlag(flag, value, rootIf)
|
||||
except NotImplementedError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc))
|
||||
except Exception as exc:
|
||||
logger.exception("UDB setFlag failed: key=%s flag=%s: %s", nodeKey, flag, exc)
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
|
||||
effective = _computeEffectiveAfterWrite(rootIf, context, node, flag)
|
||||
|
||||
import json
|
||||
from modules.shared.auditLogger import audit_logger
|
||||
from modules.datamodels.datamodelAudit import AuditCategory
|
||||
audit_logger.logEvent(
|
||||
userId=str(context.user.id),
|
||||
mandateId=context.mandateId,
|
||||
category=AuditCategory.PERMISSION.value,
|
||||
action="udb_flag_changed",
|
||||
details=json.dumps({
|
||||
"nodeKey": nodeKey,
|
||||
"flag": flag,
|
||||
"value": value,
|
||||
"resetDescendants": len(resetIds),
|
||||
"nodeKind": type(node).__name__,
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
"nodeKey": nodeKey,
|
||||
"flag": flag,
|
||||
"value": value,
|
||||
"effective": effective,
|
||||
"resetDescendantIds": resetIds,
|
||||
}
|
||||
|
||||
|
||||
def _validateFlagValue(flag: str, value: Any, context: RequestContext) -> Any:
|
||||
"""Validate the incoming value matches the flag's expected shape.
|
||||
|
||||
Returns the validated value (possibly normalised) or raises HTTPException.
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if flag == "scope":
|
||||
if not isinstance(value, str) or value not in _VALID_SCOPES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid scope: {value!r}. Must be one of {sorted(_VALID_SCOPES)}",
|
||||
)
|
||||
if value == "global" and not context.isSysAdmin:
|
||||
raise HTTPException(status_code=403, detail=routeApiMsg("Only sysadmins can set global scope"))
|
||||
return value
|
||||
# neutralize / ragIndexEnabled
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid value for flag {flag!r}: expected bool or null, got {type(value).__name__}",
|
||||
)
|
||||
|
||||
|
||||
def _computeEffectiveAfterWrite(rootIf: Any, context: RequestContext,
|
||||
node: Any, flag: str) -> Any:
|
||||
"""Recompute the node's effective value after the write.
|
||||
|
||||
Re-loads the relevant recordsets so the cascade resets are visible.
|
||||
"""
|
||||
from modules.datamodels.datamodelDataSource import DataSource
|
||||
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
|
||||
userId = str(context.user.id)
|
||||
allDs = rootIf.db.getRecordset(DataSource, recordFilter={"userId": userId}) or []
|
||||
fdsFilter: Dict[str, Any] = {}
|
||||
featureInstanceId = getattr(node, "featureInstanceId", None)
|
||||
if featureInstanceId:
|
||||
fdsFilter["featureInstanceId"] = featureInstanceId
|
||||
allFds = rootIf.db.getRecordset(FeatureDataSource, recordFilter=fdsFilter) or []
|
||||
try:
|
||||
return node.getEffectiveFlag(flag, allDs, allFds, mode="aggregate")
|
||||
except Exception as exc:
|
||||
logger.warning("Effective-after-write failed for %s flag=%s: %s",
|
||||
getattr(node, "key", "?"), flag, exc)
|
||||
return None
|
||||
|
|
@ -91,7 +91,6 @@ def _registerFeatureSubAgentTools(registry: ToolRegistry, services):
|
|||
mandateId = instance.mandateId or ""
|
||||
instanceLabel = instance.label or ""
|
||||
userId = context.get("userId", "")
|
||||
workspaceInstanceId = context.get("featureInstanceId", "")
|
||||
requestLang = None
|
||||
if userId:
|
||||
langUser = rootIf.getUser(userId)
|
||||
|
|
@ -107,7 +106,7 @@ def _registerFeatureSubAgentTools(registry: ToolRegistry, services):
|
|||
|
||||
featureDataSources = rootDbConn.getRecordset(
|
||||
FeatureDataSource,
|
||||
recordFilter={"featureInstanceId": featureInstanceId, "workspaceInstanceId": workspaceInstanceId},
|
||||
recordFilter={"featureInstanceId": featureInstanceId},
|
||||
)
|
||||
|
||||
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlagFds
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -28,7 +28,7 @@ from typing import Any, Dict, Iterable, List, Literal, Optional, Tuple
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
_INHERITABLE_FLAGS = ("neutralize", "ragIndexEnabled", "scope")
|
||||
_INHERITABLE_FDS_FLAGS = ("neutralize", "ragIndexEnabled", "scope")
|
||||
_INHERITABLE_FDS_FLAGS = ("neutralize", "ragIndexEnabled")
|
||||
|
||||
# Connection-root DataSources carry the authority as their sourceType
|
||||
# (e.g. 'msft', 'google'). They sit one level above all service DataSources
|
||||
|
|
@ -458,11 +458,11 @@ def cascadeResetDescendantsFds(
|
|||
raise ValueError(f"Unknown inheritable FDS flag: {flag}")
|
||||
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
|
||||
|
||||
workspaceInstanceId = _getRecordValue(parentRec, "workspaceInstanceId")
|
||||
if not workspaceInstanceId:
|
||||
featureInstanceId = _getRecordValue(parentRec, "featureInstanceId")
|
||||
if not featureInstanceId:
|
||||
return []
|
||||
siblings = rootIf.db.getRecordset(
|
||||
FeatureDataSource, recordFilter={"workspaceInstanceId": workspaceInstanceId}
|
||||
FeatureDataSource, recordFilter={"featureInstanceId": featureInstanceId}
|
||||
)
|
||||
|
||||
toReset: List[Tuple[int, str]] = []
|
||||
|
|
@ -475,7 +475,6 @@ def cascadeResetDescendantsFds(
|
|||
sibId = _getRecordValue(sib, "id")
|
||||
toReset.append((_fdsDepth(sib), sibId))
|
||||
|
||||
# Sort deepest first (bottom-up)
|
||||
toReset.sort(key=lambda x: x[0], reverse=True)
|
||||
|
||||
resetIds: List[str] = []
|
||||
|
|
@ -576,9 +575,9 @@ def resolveEffectiveForPath(
|
|||
"ragIndexEnabled": None,
|
||||
}
|
||||
return {
|
||||
"effectiveNeutralize": _resolveWalkValue(virtualRec, "neutralize", allDs),
|
||||
"effectiveScope": _resolveWalkValue(virtualRec, "scope", allDs),
|
||||
"effectiveRagIndexEnabled": _resolveWalkValue(virtualRec, "ragIndexEnabled", allDs),
|
||||
"effectiveNeutralize": getEffectiveFlag(virtualRec, "neutralize", allDs, mode=mode),
|
||||
"effectiveScope": getEffectiveFlag(virtualRec, "scope", allDs, mode=mode),
|
||||
"effectiveRagIndexEnabled": getEffectiveFlag(virtualRec, "ragIndexEnabled", allDs, mode=mode),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -591,11 +590,11 @@ def resolveEffectiveForFds(
|
|||
) -> Dict[str, Any]:
|
||||
"""Resolve effective flags for ANY FDS tuple (even without DB record).
|
||||
|
||||
`allFds` is pre-scoped to a single workspace (loaded with
|
||||
workspaceInstanceId filter). Within that set, the coordinate is
|
||||
featureInstanceId + tableName + recordFilter.
|
||||
`allFds` is pre-scoped (typically to a mandate). Within that set, the
|
||||
coordinate is featureInstanceId + tableName + recordFilter.
|
||||
|
||||
Returns dict with effectiveNeutralize, effectiveScope, effectiveRagIndexEnabled.
|
||||
Returns dict with effectiveNeutralize, effectiveRagIndexEnabled.
|
||||
FDS has no `scope` attribute (visibility is governed by feature RBAC).
|
||||
"""
|
||||
exactRecord = None
|
||||
for fds in allFds:
|
||||
|
|
@ -611,7 +610,6 @@ def resolveEffectiveForFds(
|
|||
if exactRecord:
|
||||
return {
|
||||
"effectiveNeutralize": getEffectiveFlagFds(exactRecord, "neutralize", allFds, mode=mode),
|
||||
"effectiveScope": getEffectiveFlagFds(exactRecord, "scope", allFds, mode=mode),
|
||||
"effectiveRagIndexEnabled": getEffectiveFlagFds(exactRecord, "ragIndexEnabled", allFds, mode=mode),
|
||||
}
|
||||
|
||||
|
|
@ -621,11 +619,9 @@ def resolveEffectiveForFds(
|
|||
"tableName": tableName,
|
||||
"recordFilter": recordFilter,
|
||||
"neutralize": None,
|
||||
"scope": None,
|
||||
"ragIndexEnabled": None,
|
||||
}
|
||||
return {
|
||||
"effectiveNeutralize": _resolveWalkValueFds(virtualRec, "neutralize", allFds),
|
||||
"effectiveScope": _resolveWalkValueFds(virtualRec, "scope", allFds),
|
||||
"effectiveRagIndexEnabled": _resolveWalkValueFds(virtualRec, "ragIndexEnabled", allFds),
|
||||
"effectiveNeutralize": getEffectiveFlagFds(virtualRec, "neutralize", allFds, mode=mode),
|
||||
"effectiveRagIndexEnabled": getEffectiveFlagFds(virtualRec, "ragIndexEnabled", allFds, mode=mode),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ text, and feeds it through KnowledgeService.requestIngestion so the data
|
|||
appears in ContentChunk embeddings for semantic RAG search.
|
||||
|
||||
Job type: ``feature.bootstrap``
|
||||
Payload: ``{"workspaceInstanceId": "...", "featureDataSourceIds": [...] (optional)}``
|
||||
Payload: ``{"featureInstanceId": "...", "featureDataSourceIds": [...] (optional)}``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -23,7 +23,7 @@ logger = logging.getLogger(__name__)
|
|||
FEATURE_BOOTSTRAP_JOB_TYPE = "feature.bootstrap"
|
||||
|
||||
|
||||
def _loadRagEnabledFds(workspaceInstanceId: str, featureDataSourceIds: Optional[List[str]] = None):
|
||||
def _loadRagEnabledFds(featureInstanceId: str, featureDataSourceIds: Optional[List[str]] = None):
|
||||
"""Load FeatureDataSource rows whose effective ragIndexEnabled is True.
|
||||
|
||||
Returns dicts with resolved flags so downstream code can read them directly.
|
||||
|
|
@ -34,7 +34,7 @@ def _loadRagEnabledFds(workspaceInstanceId: str, featureDataSourceIds: Optional[
|
|||
|
||||
rootIf = getRootInterface()
|
||||
allFds = rootIf.db.getRecordset(
|
||||
FeatureDataSource, recordFilter={"workspaceInstanceId": workspaceInstanceId}
|
||||
FeatureDataSource, recordFilter={"featureInstanceId": featureInstanceId}
|
||||
)
|
||||
resolved = []
|
||||
for fds in allFds:
|
||||
|
|
@ -47,7 +47,6 @@ def _loadRagEnabledFds(workspaceInstanceId: str, featureDataSourceIds: Optional[
|
|||
continue
|
||||
row = dict(fds) if isinstance(fds, dict) else {**fds.__dict__}
|
||||
row["_effectiveNeutralize"] = getEffectiveFlagFds(fds, "neutralize", allFds, mode="aggregate")
|
||||
row["_effectiveScope"] = getEffectiveFlagFds(fds, "scope", allFds, mode="aggregate") or "featureInstance"
|
||||
row["ragIndexEnabled"] = True
|
||||
resolved.append(row)
|
||||
|
||||
|
|
@ -104,20 +103,20 @@ async def _featureBootstrapHandler(
|
|||
) -> Dict[str, Any]:
|
||||
"""Walk RAG-enabled FeatureDataSources and index their rows."""
|
||||
payload = job.get("payload") or {}
|
||||
workspaceInstanceId = payload.get("workspaceInstanceId")
|
||||
featureInstanceId = payload.get("featureInstanceId")
|
||||
featureDataSourceIds = payload.get("featureDataSourceIds")
|
||||
if not workspaceInstanceId:
|
||||
raise ValueError("feature.bootstrap requires payload.workspaceInstanceId")
|
||||
if not featureInstanceId:
|
||||
raise ValueError("feature.bootstrap requires payload.featureInstanceId")
|
||||
|
||||
progressCb(5, messageKey="Feature-Datenquellen werden geladen...")
|
||||
|
||||
fdsList = _loadRagEnabledFds(workspaceInstanceId, featureDataSourceIds)
|
||||
fdsList = _loadRagEnabledFds(featureInstanceId, featureDataSourceIds)
|
||||
if not fdsList:
|
||||
logger.info(
|
||||
"feature.bootstrap.skipped — no rag-enabled FDS for workspace %s",
|
||||
workspaceInstanceId,
|
||||
"feature.bootstrap.skipped — no rag-enabled FDS for feature %s",
|
||||
featureInstanceId,
|
||||
)
|
||||
return {"workspaceInstanceId": workspaceInstanceId, "skipped": True, "reason": "no_rag_enabled_fds"}
|
||||
return {"featureInstanceId": featureInstanceId, "skipped": True, "reason": "no_rag_enabled_fds"}
|
||||
|
||||
from modules.serviceCenter.services.serviceAgent.featureDataProvider import FeatureDataProvider
|
||||
from modules.serviceCenter.services.serviceKnowledge.mainServiceKnowledge import IngestionJob
|
||||
|
|
@ -134,11 +133,10 @@ async def _featureBootstrapHandler(
|
|||
fdsId = fds.get("id", "")
|
||||
featureCode = fds.get("featureCode", "")
|
||||
tableName = fds.get("tableName", "")
|
||||
featureInstanceId = fds.get("featureInstanceId", "")
|
||||
fdsFeatureInstanceId = fds.get("featureInstanceId", "")
|
||||
mandateId = fds.get("mandateId", "")
|
||||
neutralizeFields = fds.get("neutralizeFields") or []
|
||||
recordFilter = fds.get("recordFilter") or {}
|
||||
effectiveScope = fds.get("_effectiveScope", "featureInstance")
|
||||
effectiveNeutralize = bool(fds.get("_effectiveNeutralize", False))
|
||||
|
||||
progressPct = 5 + int(90 * fdsIdx / len(fdsList))
|
||||
|
|
@ -148,7 +146,7 @@ async def _featureBootstrapHandler(
|
|||
messageParams={"table": tableName, "n": fdsIdx + 1, "total": len(fdsList)},
|
||||
)
|
||||
|
||||
if not featureCode or not tableName or not featureInstanceId:
|
||||
if not featureCode or not tableName or not fdsFeatureInstanceId:
|
||||
logger.warning("feature.bootstrap: skipping FDS %s — missing featureCode/tableName/fiId", fdsId)
|
||||
continue
|
||||
|
||||
|
|
@ -160,7 +158,7 @@ async def _featureBootstrapHandler(
|
|||
ctx = ServiceCenterContext(
|
||||
user=rootUser,
|
||||
mandate_id=mandateId,
|
||||
feature_instance_id=workspaceInstanceId,
|
||||
feature_instance_id=fdsFeatureInstanceId,
|
||||
)
|
||||
knowledgeService = getService("knowledge", ctx)
|
||||
|
||||
|
|
@ -178,7 +176,7 @@ async def _featureBootstrapHandler(
|
|||
while True:
|
||||
result = provider.browseTable(
|
||||
tableName=tableName,
|
||||
featureInstanceId=featureInstanceId,
|
||||
featureInstanceId=fdsFeatureInstanceId,
|
||||
mandateId=mandateId,
|
||||
limit=batchSize,
|
||||
offset=offset,
|
||||
|
|
@ -202,11 +200,11 @@ async def _featureBootstrapHandler(
|
|||
|
||||
ingestionJob = IngestionJob(
|
||||
sourceKind="feature_record",
|
||||
sourceId=f"{workspaceInstanceId}:{tableName}:{rowId}",
|
||||
sourceId=f"{fdsFeatureInstanceId}:{tableName}:{rowId}",
|
||||
fileName=f"{tableName}-{rowId}",
|
||||
mimeType="application/vnd.poweron.feature-record+json",
|
||||
userId=fds.get("userId") or "system",
|
||||
featureInstanceId=workspaceInstanceId,
|
||||
userId="system",
|
||||
featureInstanceId=fdsFeatureInstanceId,
|
||||
mandateId=mandateId,
|
||||
contentObjects=[{
|
||||
"contentType": "text",
|
||||
|
|
@ -214,7 +212,7 @@ async def _featureBootstrapHandler(
|
|||
"contextRef": {
|
||||
"table": tableName,
|
||||
"featureCode": featureCode,
|
||||
"featureInstanceId": featureInstanceId,
|
||||
"featureInstanceId": fdsFeatureInstanceId,
|
||||
"rowId": rowId,
|
||||
},
|
||||
"contentObjectId": f"{tableName}:{rowId}",
|
||||
|
|
@ -225,7 +223,7 @@ async def _featureBootstrapHandler(
|
|||
"featureDataSourceId": fdsId,
|
||||
"tableName": tableName,
|
||||
"featureCode": featureCode,
|
||||
"featureInstanceId": featureInstanceId,
|
||||
"featureInstanceId": fdsFeatureInstanceId,
|
||||
},
|
||||
neutralize=effectiveNeutralize,
|
||||
)
|
||||
|
|
@ -281,7 +279,7 @@ async def _featureBootstrapHandler(
|
|||
progressCb(100, messageKey="Feature-Daten-Sync abgeschlossen.")
|
||||
|
||||
return {
|
||||
"workspaceInstanceId": workspaceInstanceId,
|
||||
"featureInstanceId": featureInstanceId,
|
||||
"indexed": totalIndexed,
|
||||
"skippedDuplicate": totalSkipped,
|
||||
"failed": totalFailed,
|
||||
|
|
|
|||
1055
modules/serviceCenter/services/serviceKnowledge/udbNodes.py
Normal file
1055
modules/serviceCenter/services/serviceKnowledge/udbNodes.py
Normal file
File diff suppressed because it is too large
Load diff
83
modules/shared/requestFrontendUrl.py
Normal file
83
modules/shared/requestFrontendUrl.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Per-request frontend base URL (same idea as auth emails: frontendUrl from the client).
|
||||
|
||||
Set from the incoming HTTP request (X-Frontend-Url, Origin, or Referer) in app middleware.
|
||||
Read when building billing alert links during that request (e.g. pool exhausted email).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from contextvars import ContextVar
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CURRENT_FRONTEND_URL: ContextVar[str] = ContextVar("request_frontend_url", default="")
|
||||
|
||||
_CORS_ORIGIN_REGEX = re.compile(
|
||||
r"^https://.*\.(poweron\.swiss|poweron-center\.net)$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def setRequestFrontendUrl(url: str) -> None:
|
||||
"""Store the frontend base URL for the current request (no trailing slash)."""
|
||||
_CURRENT_FRONTEND_URL.set((url or "").strip().rstrip("/"))
|
||||
|
||||
|
||||
def getRequestFrontendUrl() -> str:
|
||||
"""Return the frontend base URL for the current request, or empty if unset."""
|
||||
return _CURRENT_FRONTEND_URL.get()
|
||||
|
||||
|
||||
def _allowedOrigins() -> list[str]:
|
||||
try:
|
||||
from modules.shared.configuration import APP_CONFIG
|
||||
|
||||
raw = APP_CONFIG.get("APP_ALLOWED_ORIGINS") or ""
|
||||
return [o.strip().rstrip("/") for o in raw.split(",") if o.strip()]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
def _isAllowedFrontendOrigin(origin: str) -> bool:
|
||||
if not origin:
|
||||
return False
|
||||
normalized = origin.strip().rstrip("/")
|
||||
if normalized in _allowedOrigins():
|
||||
return True
|
||||
return bool(_CORS_ORIGIN_REGEX.match(normalized))
|
||||
|
||||
|
||||
def resolveFrontendUrlFromRequest(request) -> str:
|
||||
"""
|
||||
Resolve frontend base URL from the current HTTP request.
|
||||
|
||||
Priority: X-Frontend-Url (explicit, like auth body frontendUrl) → Origin → Referer origin.
|
||||
Only returns origins that match APP_ALLOWED_ORIGINS or the CORS subdomain regex.
|
||||
"""
|
||||
explicit = (request.headers.get("X-Frontend-Url") or "").strip().rstrip("/")
|
||||
if explicit and _isAllowedFrontendOrigin(explicit):
|
||||
return explicit
|
||||
if explicit:
|
||||
logger.debug("Ignoring X-Frontend-Url not in allowed origins: %s", explicit)
|
||||
|
||||
origin = (request.headers.get("Origin") or "").strip().rstrip("/")
|
||||
if origin and _isAllowedFrontendOrigin(origin):
|
||||
return origin
|
||||
|
||||
referer = (request.headers.get("Referer") or "").strip()
|
||||
if referer:
|
||||
try:
|
||||
ref_origin = f"{urlparse(referer).scheme}://{urlparse(referer).netloc}".rstrip("/")
|
||||
if ref_origin and _isAllowedFrontendOrigin(ref_origin):
|
||||
return ref_origin
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ""
|
||||
|
|
@ -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()]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
fastapi==0.115.0 # Upgraded for Pydantic v2 compatibility
|
||||
websockets==12.0
|
||||
uvicorn==0.23.2
|
||||
gunicorn==23.0.0
|
||||
python-multipart==0.0.6
|
||||
httpx>=0.25.2
|
||||
pydantic>=2.0.0 # Upgraded to v2 for compatibility
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
"""Unit tests for the generic UDB tree builder.
|
||||
"""Unit tests for the generic UDB tree builder (`_buildTree.py`).
|
||||
|
||||
Verifies key encoding/decoding and that children for parent keys with
|
||||
existing handlers (top-level, conn, mgrp, feat) are produced with the
|
||||
correct effective-flag triplet.
|
||||
Most node-level behavior moved into the polymorphic class hierarchy
|
||||
(`udbNodes.py`) and has its own dedicated tests in `test_udbNodes.py`.
|
||||
This file covers the orchestrator (`getChildrenForParents`) and the
|
||||
remaining lookup helpers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -27,37 +28,6 @@ class TestKeyCoding(unittest.TestCase):
|
|||
self.assertEqual(_buildTree._decode("feat|m1|trustee|fi-1")[1], ["m1", "trustee", "fi-1"])
|
||||
|
||||
|
||||
class TestEffectiveTriplets(unittest.TestCase):
|
||||
def test_ds_triplet_no_record_returns_defaults(self):
|
||||
result = _buildTree._effectiveTripletDs("c", "msft", "/", [])
|
||||
self.assertEqual(result, {
|
||||
"effectiveNeutralize": False,
|
||||
"effectiveScope": "personal",
|
||||
"effectiveRagIndexEnabled": False,
|
||||
})
|
||||
|
||||
def test_ds_triplet_inherits_from_root(self):
|
||||
root = {
|
||||
"id": "r", "connectionId": "c", "sourceType": "msft", "path": "/",
|
||||
"neutralize": True, "scope": "mandate", "ragIndexEnabled": True,
|
||||
}
|
||||
result = _buildTree._effectiveTripletDs("c", "sharepointFolder", "/sites/x", [root])
|
||||
self.assertEqual(result["effectiveNeutralize"], True)
|
||||
self.assertEqual(result["effectiveScope"], "mandate")
|
||||
self.assertEqual(result["effectiveRagIndexEnabled"], True)
|
||||
|
||||
def test_fds_triplet_inherits_from_workspace_wildcard(self):
|
||||
ws = {
|
||||
"id": "ws", "workspaceInstanceId": "ws-inst", "featureInstanceId": "fi1",
|
||||
"tableName": "*", "recordFilter": None, "neutralize": True,
|
||||
"scope": "mandate", "ragIndexEnabled": True,
|
||||
}
|
||||
result = _buildTree._effectiveTripletFds("fi1", "Pos", None, [ws])
|
||||
self.assertEqual(result["effectiveNeutralize"], True)
|
||||
self.assertEqual(result["effectiveScope"], "mandate")
|
||||
self.assertEqual(result["effectiveRagIndexEnabled"], True)
|
||||
|
||||
|
||||
class TestRecordLookup(unittest.TestCase):
|
||||
def test_finds_ds_record_by_normalised_path(self):
|
||||
rec = {"id": "x", "connectionId": "c", "sourceType": "msft", "path": "/folder"}
|
||||
|
|
@ -65,18 +35,78 @@ class TestRecordLookup(unittest.TestCase):
|
|||
self.assertIsNone(_buildTree._findDsRecord([rec], "c", "msft", "/other"))
|
||||
|
||||
def test_finds_fds_record_with_matching_filter(self):
|
||||
rec = {"id": "f", "workspaceInstanceId": "ws", "featureInstanceId": "fi1", "tableName": "Pos", "recordFilter": {"id": "5"}}
|
||||
rec = {"id": "f", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||
"recordFilter": {"id": "5"}}
|
||||
self.assertEqual(_buildTree._findFdsRecord([rec], "fi1", "Pos", {"id": "5"}).get("id"), "f")
|
||||
self.assertIsNone(_buildTree._findFdsRecord([rec], "fi1", "Pos", {"id": "99"}))
|
||||
|
||||
def test_fds_record_with_none_filter_matches_only_none(self):
|
||||
rec = {"id": "f", "workspaceInstanceId": "ws", "featureInstanceId": "fi1", "tableName": "*", "recordFilter": None}
|
||||
rec = {"id": "f", "featureInstanceId": "fi1", "tableName": "*", "recordFilter": None}
|
||||
self.assertEqual(_buildTree._findFdsRecord([rec], "fi1", "*", None).get("id"), "f")
|
||||
self.assertIsNone(_buildTree._findFdsRecord([rec], "fi1", "*", {"id": "1"}))
|
||||
|
||||
|
||||
class TestWiredTableFieldAggregation(unittest.TestCase):
|
||||
"""`_wireTableFieldsAsLogicalChildren` rewraps `FdsTableNode.getEffectiveFlag`
|
||||
so the table aggregates with its declared field children. The aggregate
|
||||
must respect the new FdsField inheritance: if all fields inherit from the
|
||||
table (no list entries), the table stays non-mixed."""
|
||||
|
||||
def _buildTableWithFields(self, *, tableNeutralize, neutralizeFields, fieldNames):
|
||||
from modules.serviceCenter.services.serviceKnowledge.udbNodes import (
|
||||
FdsTableNode, FdsFieldNode,
|
||||
)
|
||||
rec = {
|
||||
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||
"recordFilter": None,
|
||||
"neutralize": tableNeutralize,
|
||||
"neutralizeFields": neutralizeFields,
|
||||
}
|
||||
tableNode = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
|
||||
"feat|m1|trustee|fi1", rec=rec, hasFields=True)
|
||||
fields = [
|
||||
FdsFieldNode("fi1", "Pos", name, tableNode.key,
|
||||
tableRec=rec, featureCode="trustee")
|
||||
for name in fieldNames
|
||||
]
|
||||
tableNode._logicalFieldChildren = fields # type: ignore[attr-defined]
|
||||
_buildTree._wireTableFieldsAsLogicalChildren(tableNode)
|
||||
return tableNode, [rec]
|
||||
|
||||
def test_table_true_no_overrides_stays_true(self):
|
||||
"""Regression: toggling a table to True must NOT leave it 'mixed'
|
||||
because the declared field children should inherit the table value."""
|
||||
tableNode, allFds = self._buildTableWithFields(
|
||||
tableNeutralize=True, neutralizeFields=None,
|
||||
fieldNames=["amount", "currency"],
|
||||
)
|
||||
self.assertTrue(tableNode.getEffectiveFlag("neutralize", [], allFds, "aggregate"))
|
||||
|
||||
def test_table_false_with_override_is_mixed(self):
|
||||
tableNode, allFds = self._buildTableWithFields(
|
||||
tableNeutralize=False, neutralizeFields=["amount"],
|
||||
fieldNames=["amount", "currency"],
|
||||
)
|
||||
self.assertEqual(
|
||||
tableNode.getEffectiveFlag("neutralize", [], allFds, "aggregate"),
|
||||
"mixed",
|
||||
)
|
||||
|
||||
def test_table_inherit_no_overrides_walks_default(self):
|
||||
"""Implicit table + no overrides + no workspace -> default False."""
|
||||
tableNode, allFds = self._buildTableWithFields(
|
||||
tableNeutralize=None, neutralizeFields=None,
|
||||
fieldNames=["amount", "currency"],
|
||||
)
|
||||
self.assertFalse(tableNode.getEffectiveFlag("neutralize", [], allFds, "aggregate"))
|
||||
|
||||
|
||||
class TestGetChildrenForParents(unittest.TestCase):
|
||||
"""End-to-end orchestrator test with mocked dependencies."""
|
||||
"""End-to-end orchestrator tests with mocked dependencies. The
|
||||
orchestrator returns serialised node dicts produced by
|
||||
`UdbNode.toDict(...)`, so the keys/kinds/parentKey wiring is what
|
||||
matters here -- not the inheritance arithmetic (covered in
|
||||
test_udbNodes.py)."""
|
||||
|
||||
def _runAsync(self, coro):
|
||||
return asyncio.run(coro)
|
||||
|
|
@ -92,12 +122,11 @@ class TestGetChildrenForParents(unittest.TestCase):
|
|||
ctx.mandateId = "m1"
|
||||
|
||||
result = self._runAsync(
|
||||
_buildTree.getChildrenForParents("inst-1", ["bogus|key"], ctx)
|
||||
_buildTree.getChildrenForParents(["bogus|key"], ctx)
|
||||
)
|
||||
self.assertEqual(result["bogus|key"], [])
|
||||
|
||||
def test_top_level_emits_personal_root_first(self):
|
||||
"""Top-level emits personalRoot first, then mandate-group nodes inline."""
|
||||
with patch("modules.interfaces.interfaceDbApp.getRootInterface") as mockRoot:
|
||||
rootIf = MagicMock()
|
||||
rootIf.db.getRecordset.return_value = []
|
||||
|
|
@ -109,7 +138,7 @@ class TestGetChildrenForParents(unittest.TestCase):
|
|||
ctx.mandateId = "m1"
|
||||
|
||||
result = self._runAsync(
|
||||
_buildTree.getChildrenForParents("inst-1", [None], ctx)
|
||||
_buildTree.getChildrenForParents([None], ctx)
|
||||
)
|
||||
children = result["__root__"]
|
||||
self.assertGreaterEqual(len(children), 1)
|
||||
|
|
@ -120,92 +149,7 @@ class TestGetChildrenForParents(unittest.TestCase):
|
|||
self.assertTrue(personalRoot["hasChildren"])
|
||||
self.assertTrue(personalRoot["defaultExpanded"])
|
||||
|
||||
|
||||
class TestTopLevelLayout(unittest.TestCase):
|
||||
"""Tests for the flat top-level layout (personalRoot + mandate groups)."""
|
||||
|
||||
def _runAsync(self, coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
def test_personal_root_carries_neutral_default_triplet(self):
|
||||
with patch("modules.interfaces.interfaceDbApp.getRootInterface") as mockRoot:
|
||||
rootIf = MagicMock()
|
||||
rootIf.db.getRecordset.return_value = []
|
||||
rootIf.getUserMandates.return_value = []
|
||||
mockRoot.return_value = rootIf
|
||||
|
||||
ctx = MagicMock()
|
||||
ctx.user.id = "u1"
|
||||
ctx.mandateId = "m1"
|
||||
|
||||
result = self._runAsync(
|
||||
_buildTree.getChildrenForParents("inst-1", [None], ctx)
|
||||
)
|
||||
personalRoot = result["__root__"][0]
|
||||
self.assertFalse(personalRoot["effectiveNeutralize"])
|
||||
self.assertEqual(personalRoot["effectiveScope"], "personal")
|
||||
self.assertFalse(personalRoot["effectiveRagIndexEnabled"])
|
||||
self.assertFalse(personalRoot["supportsRag"])
|
||||
self.assertFalse(personalRoot["canBeAdded"])
|
||||
self.assertIsNone(personalRoot["dataSourceId"])
|
||||
self.assertIsNone(personalRoot["modelType"])
|
||||
|
||||
def test_personal_root_emits_active_connection_with_correct_parent(self):
|
||||
with patch("modules.interfaces.interfaceDbApp.getRootInterface") as mockRoot, \
|
||||
patch("modules.serviceCenter.getService") as mockGetService:
|
||||
rootIf = MagicMock()
|
||||
rootIf.db.getRecordset.return_value = []
|
||||
mockRoot.return_value = rootIf
|
||||
|
||||
chatService = MagicMock()
|
||||
chatService.getUserConnections.return_value = [{
|
||||
"id": "conn-1",
|
||||
"status": "active",
|
||||
"authority": "msft",
|
||||
"externalEmail": "user@example.com",
|
||||
}]
|
||||
mockGetService.return_value = chatService
|
||||
|
||||
ctx = MagicMock()
|
||||
ctx.user.id = "u1"
|
||||
ctx.mandateId = "m1"
|
||||
|
||||
result = self._runAsync(
|
||||
_buildTree.getChildrenForParents("inst-1", ["personalRoot"], ctx)
|
||||
)
|
||||
children = result["personalRoot"]
|
||||
self.assertEqual(len(children), 1)
|
||||
self.assertEqual(children[0]["key"], "conn|conn-1")
|
||||
self.assertEqual(children[0]["kind"], "connection")
|
||||
self.assertEqual(children[0]["parentKey"], "personalRoot")
|
||||
self.assertEqual(children[0]["label"], "user@example.com")
|
||||
self.assertTrue(children[0]["supportsRag"])
|
||||
|
||||
def test_personal_root_skips_inactive_connection(self):
|
||||
with patch("modules.interfaces.interfaceDbApp.getRootInterface") as mockRoot, \
|
||||
patch("modules.serviceCenter.getService") as mockGetService:
|
||||
rootIf = MagicMock()
|
||||
rootIf.db.getRecordset.return_value = []
|
||||
mockRoot.return_value = rootIf
|
||||
|
||||
chatService = MagicMock()
|
||||
chatService.getUserConnections.return_value = [
|
||||
{"id": "c1", "status": "active", "authority": "msft", "externalEmail": "a"},
|
||||
{"id": "c2", "status": "expired", "authority": "google", "externalEmail": "b"},
|
||||
]
|
||||
mockGetService.return_value = chatService
|
||||
|
||||
ctx = MagicMock()
|
||||
ctx.user.id = "u1"
|
||||
ctx.mandateId = "m1"
|
||||
|
||||
result = self._runAsync(
|
||||
_buildTree.getChildrenForParents("inst-1", ["personalRoot"], ctx)
|
||||
)
|
||||
self.assertEqual(len(result["personalRoot"]), 1)
|
||||
self.assertEqual(result["personalRoot"][0]["connectionId"], "c1")
|
||||
|
||||
def test_mandate_groups_emitted_inline_at_top_level(self):
|
||||
def test_top_level_emits_mandate_groups_inline(self):
|
||||
with patch("modules.interfaces.interfaceDbApp.getRootInterface") as mockRoot, \
|
||||
patch("modules.security.rbacCatalog.getCatalogService") as mockCatalog:
|
||||
rootIf = MagicMock()
|
||||
|
|
@ -218,8 +162,7 @@ class TestTopLevelLayout(unittest.TestCase):
|
|||
featureInst.featureCode = "trustee"
|
||||
featureInst.enabled = True
|
||||
rootIf.getFeatureInstancesByMandate.return_value = [featureInst]
|
||||
featureAccess = MagicMock()
|
||||
featureAccess.enabled = True
|
||||
featureAccess = MagicMock(enabled=True)
|
||||
rootIf.getFeatureAccess.return_value = featureAccess
|
||||
mockRoot.return_value = rootIf
|
||||
|
||||
|
|
@ -231,11 +174,8 @@ class TestTopLevelLayout(unittest.TestCase):
|
|||
ctx.user.id = "u1"
|
||||
ctx.mandateId = None
|
||||
|
||||
result = self._runAsync(
|
||||
_buildTree.getChildrenForParents("inst-1", [None], ctx)
|
||||
)
|
||||
children = result["__root__"]
|
||||
byKey = {c["key"]: c for c in children}
|
||||
result = self._runAsync(_buildTree.getChildrenForParents([None], ctx))
|
||||
byKey = {c["key"]: c for c in result["__root__"]}
|
||||
self.assertIn("personalRoot", byKey)
|
||||
self.assertIn("mgrp|m1", byKey)
|
||||
mgroup = byKey["mgrp|m1"]
|
||||
|
|
@ -243,116 +183,6 @@ class TestTopLevelLayout(unittest.TestCase):
|
|||
self.assertIsNone(mgroup["parentKey"])
|
||||
self.assertEqual(mgroup["mandateId"], "m1")
|
||||
self.assertTrue(mgroup["defaultExpanded"])
|
||||
self.assertFalse(mgroup["supportsRag"])
|
||||
|
||||
def test_top_level_omits_mandates_without_data_features(self):
|
||||
with patch("modules.interfaces.interfaceDbApp.getRootInterface") as mockRoot, \
|
||||
patch("modules.security.rbacCatalog.getCatalogService") as mockCatalog:
|
||||
rootIf = MagicMock()
|
||||
rootIf.db.getRecordset.return_value = []
|
||||
userMandate = MagicMock()
|
||||
userMandate.mandateId = "m1"
|
||||
rootIf.getUserMandates.return_value = [userMandate]
|
||||
rootIf.getFeatureInstancesByMandate.return_value = []
|
||||
mockRoot.return_value = rootIf
|
||||
|
||||
catalog = MagicMock()
|
||||
catalog.getFeaturesWithDataObjects.return_value = ["trustee"]
|
||||
mockCatalog.return_value = catalog
|
||||
|
||||
ctx = MagicMock()
|
||||
ctx.user.id = "u1"
|
||||
ctx.mandateId = None
|
||||
|
||||
result = self._runAsync(
|
||||
_buildTree.getChildrenForParents("inst-1", [None], ctx)
|
||||
)
|
||||
keys = [c["key"] for c in result["__root__"]]
|
||||
self.assertEqual(keys, ["personalRoot"])
|
||||
|
||||
def test_personal_root_listed_first_via_display_order(self):
|
||||
with patch("modules.interfaces.interfaceDbApp.getRootInterface") as mockRoot, \
|
||||
patch("modules.security.rbacCatalog.getCatalogService") as mockCatalog:
|
||||
rootIf = MagicMock()
|
||||
rootIf.db.getRecordset.return_value = []
|
||||
userMandate = MagicMock()
|
||||
userMandate.mandateId = "m1"
|
||||
rootIf.getUserMandates.return_value = [userMandate]
|
||||
featureInst = MagicMock()
|
||||
featureInst.id = "fi-1"
|
||||
featureInst.featureCode = "trustee"
|
||||
featureInst.enabled = True
|
||||
rootIf.getFeatureInstancesByMandate.return_value = [featureInst]
|
||||
featureAccess = MagicMock()
|
||||
featureAccess.enabled = True
|
||||
rootIf.getFeatureAccess.return_value = featureAccess
|
||||
mockRoot.return_value = rootIf
|
||||
|
||||
catalog = MagicMock()
|
||||
catalog.getFeaturesWithDataObjects.return_value = ["trustee"]
|
||||
mockCatalog.return_value = catalog
|
||||
|
||||
ctx = MagicMock()
|
||||
ctx.user.id = "u1"
|
||||
ctx.mandateId = None
|
||||
|
||||
result = self._runAsync(
|
||||
_buildTree.getChildrenForParents("inst-1", [None], ctx)
|
||||
)
|
||||
children = result["__root__"]
|
||||
self.assertEqual(children[0]["key"], "personalRoot")
|
||||
self.assertEqual(children[0]["displayOrder"], 0)
|
||||
|
||||
|
||||
class TestFeatureTableFields(unittest.TestCase):
|
||||
"""Per-column field expansion under a feature data-source table."""
|
||||
|
||||
def test_emits_one_node_per_field(self):
|
||||
nodes = _buildTree._featureTableFields(
|
||||
parentKey="fdstbl|fi-1|TrusteePosition",
|
||||
featureInstanceId="fi-1",
|
||||
tableName="TrusteePosition",
|
||||
fieldNames=["id", "valuta", "company"],
|
||||
allFds=[],
|
||||
)
|
||||
self.assertEqual(len(nodes), 3)
|
||||
self.assertEqual(nodes[0]["kind"], "fdsField")
|
||||
self.assertEqual(nodes[0]["fieldName"], "id")
|
||||
self.assertEqual(nodes[0]["parentKey"], "fdstbl|fi-1|TrusteePosition")
|
||||
self.assertEqual(nodes[0]["key"], "fdsfld|fi-1|TrusteePosition|id")
|
||||
self.assertFalse(nodes[0]["hasChildren"])
|
||||
self.assertFalse(nodes[0]["supportsRag"])
|
||||
|
||||
def test_field_neutralize_inherits_from_table_blanket(self):
|
||||
rec = {"id": "f", "workspaceInstanceId": "ws-1", "featureInstanceId": "fi-1",
|
||||
"tableName": "TrusteePosition", "recordFilter": None,
|
||||
"neutralize": True, "neutralizeFields": None,
|
||||
"scope": None, "ragIndexEnabled": False}
|
||||
nodes = _buildTree._featureTableFields(
|
||||
parentKey="fdstbl|fi-1|TrusteePosition",
|
||||
featureInstanceId="fi-1",
|
||||
tableName="TrusteePosition",
|
||||
fieldNames=["email", "company"],
|
||||
allFds=[rec],
|
||||
)
|
||||
self.assertTrue(nodes[0]["effectiveNeutralize"])
|
||||
self.assertTrue(nodes[1]["effectiveNeutralize"])
|
||||
|
||||
def test_field_neutralize_explicit_via_neutralize_fields(self):
|
||||
rec = {"id": "f", "workspaceInstanceId": "ws-1", "featureInstanceId": "fi-1",
|
||||
"tableName": "TrusteePosition", "recordFilter": None,
|
||||
"neutralize": False, "neutralizeFields": ["email"],
|
||||
"scope": None, "ragIndexEnabled": False}
|
||||
nodes = _buildTree._featureTableFields(
|
||||
parentKey="fdstbl|fi-1|TrusteePosition",
|
||||
featureInstanceId="fi-1",
|
||||
tableName="TrusteePosition",
|
||||
fieldNames=["email", "company"],
|
||||
allFds=[rec],
|
||||
)
|
||||
byField = {n["fieldName"]: n for n in nodes}
|
||||
self.assertTrue(byField["email"]["effectiveNeutralize"])
|
||||
self.assertFalse(byField["company"]["effectiveNeutralize"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -34,15 +34,19 @@ def _ds(idVal: str, path: str, **flags) -> dict:
|
|||
|
||||
|
||||
def _fds(idVal: str, *, tableName: str, recordFilter=None, featureInstanceId="fi-1", **flags) -> dict:
|
||||
"""Build a FeatureDataSource dict fixture."""
|
||||
"""Build a FeatureDataSource dict fixture.
|
||||
|
||||
FDS records no longer carry `userId`, `workspaceInstanceId`, or
|
||||
`scope`; visibility/edit-permission live on the feature instance
|
||||
via RBAC. Tests should only set neutralize/ragIndexEnabled.
|
||||
"""
|
||||
base = {
|
||||
"id": idVal,
|
||||
"workspaceInstanceId": "ws-1",
|
||||
"featureInstanceId": featureInstanceId,
|
||||
"tableName": tableName,
|
||||
"recordFilter": recordFilter,
|
||||
"neutralize": None,
|
||||
"scope": None,
|
||||
"ragIndexEnabled": None,
|
||||
}
|
||||
base.update(flags)
|
||||
return base
|
||||
|
|
@ -473,6 +477,7 @@ class TestFdsCascadeReset(unittest.TestCase):
|
|||
_inheritFlags.cascadeResetDescendantsFds(rootIf, ws, "doesNotExist")
|
||||
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# FeatureDataSource: collectAncestorChainFds
|
||||
# ===========================================================================
|
||||
|
|
@ -572,28 +577,32 @@ class TestResolveEffectiveForPath(unittest.TestCase):
|
|||
|
||||
|
||||
class TestResolveEffectiveForFds(unittest.TestCase):
|
||||
"""FDS records carry only `neutralize` + `ragIndexEnabled`. No scope.
|
||||
|
||||
`resolveEffectiveForFds` therefore returns a two-key dict; tests
|
||||
must not assert anything about `effectiveScope` on FDS results.
|
||||
"""
|
||||
|
||||
def test_with_exact_record(self):
|
||||
ws = _fds("ws", tableName="*", neutralize=True, scope="mandate")
|
||||
tbl = _fds("t", tableName="Pos", neutralize=False, scope="personal")
|
||||
ws = _fds("ws", tableName="*", neutralize=True)
|
||||
tbl = _fds("t", tableName="Pos", neutralize=False)
|
||||
allFds = [ws, tbl]
|
||||
result = _inheritFlags.resolveEffectiveForFds("fi-1", "Pos", None, allFds)
|
||||
self.assertEqual(result["effectiveNeutralize"], False)
|
||||
self.assertEqual(result["effectiveScope"], "personal")
|
||||
self.assertEqual(result["effectiveRagIndexEnabled"], False)
|
||||
self.assertNotIn("effectiveScope", result)
|
||||
|
||||
def test_without_record_inherits_from_workspace_wildcard(self):
|
||||
ws = _fds("ws", tableName="*", neutralize=True, scope="mandate", ragIndexEnabled=True)
|
||||
def test_without_record_inherits_from_feature_wildcard(self):
|
||||
ws = _fds("ws", tableName="*", neutralize=True, ragIndexEnabled=True)
|
||||
allFds = [ws]
|
||||
result = _inheritFlags.resolveEffectiveForFds("fi-1", "Unknown", None, allFds)
|
||||
self.assertEqual(result["effectiveNeutralize"], True)
|
||||
self.assertEqual(result["effectiveScope"], "mandate")
|
||||
self.assertEqual(result["effectiveRagIndexEnabled"], True)
|
||||
|
||||
def test_without_record_no_ancestors_returns_defaults(self):
|
||||
allFds: list = []
|
||||
result = _inheritFlags.resolveEffectiveForFds("fi-1", "Pos", None, allFds)
|
||||
self.assertEqual(result["effectiveNeutralize"], False)
|
||||
self.assertEqual(result["effectiveScope"], "personal")
|
||||
self.assertEqual(result["effectiveRagIndexEnabled"], False)
|
||||
|
||||
def test_rag_inherits_when_table_overrides_neutralize_only(self):
|
||||
|
|
@ -611,10 +620,10 @@ class TestResolveEffectiveForFds(unittest.TestCase):
|
|||
result = _inheritFlags.resolveEffectiveForFds("fi-1", "*", None, allFds, mode="aggregate")
|
||||
self.assertEqual(result["effectiveRagIndexEnabled"], "mixed")
|
||||
|
||||
def test_inheritable_fds_flags_includes_rag(self):
|
||||
def test_inheritable_fds_flags_excludes_scope(self):
|
||||
self.assertIn("ragIndexEnabled", _inheritFlags._INHERITABLE_FDS_FLAGS)
|
||||
self.assertIn("neutralize", _inheritFlags._INHERITABLE_FDS_FLAGS)
|
||||
self.assertIn("scope", _inheritFlags._INHERITABLE_FDS_FLAGS)
|
||||
self.assertNotIn("scope", _inheritFlags._INHERITABLE_FDS_FLAGS)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
|
|
@ -651,5 +660,80 @@ class TestPathNormalization(unittest.TestCase):
|
|||
self.assertEqual(_inheritFlags._normalisePath("foo/bar"), "/foo/bar")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Virtual coordinates (no DB record) must support aggregate mode (mixed)
|
||||
# ===========================================================================
|
||||
|
||||
class TestVirtualCoordAggregate(unittest.TestCase):
|
||||
"""After the spec-recovery fix, resolveEffectiveForPath/Fds with
|
||||
mode='aggregate' must return 'mixed' for coordinates that have no DB
|
||||
record but whose descendants in the DB diverge."""
|
||||
|
||||
def test_virtual_folder_mixed_neutralize(self):
|
||||
child1 = _ds("c1", "/virtual/a", neutralize=True)
|
||||
child2 = _ds("c2", "/virtual/b", neutralize=False)
|
||||
allDs = [child1, child2]
|
||||
result = _inheritFlags.resolveEffectiveForPath(
|
||||
"conn-1", "sharepointFolder", "/virtual", allDs, mode="aggregate",
|
||||
)
|
||||
self.assertEqual(result["effectiveNeutralize"], "mixed")
|
||||
|
||||
def test_virtual_folder_mixed_scope(self):
|
||||
child1 = _ds("c1", "/virtual/a", scope="mandate")
|
||||
child2 = _ds("c2", "/virtual/b", scope="personal")
|
||||
allDs = [child1, child2]
|
||||
result = _inheritFlags.resolveEffectiveForPath(
|
||||
"conn-1", "sharepointFolder", "/virtual", allDs, mode="aggregate",
|
||||
)
|
||||
self.assertEqual(result["effectiveScope"], "mixed")
|
||||
|
||||
def test_virtual_folder_mixed_rag(self):
|
||||
child1 = _ds("c1", "/virtual/a", ragIndexEnabled=True)
|
||||
child2 = _ds("c2", "/virtual/b", ragIndexEnabled=False)
|
||||
allDs = [child1, child2]
|
||||
result = _inheritFlags.resolveEffectiveForPath(
|
||||
"conn-1", "sharepointFolder", "/virtual", allDs, mode="aggregate",
|
||||
)
|
||||
self.assertEqual(result["effectiveRagIndexEnabled"], "mixed")
|
||||
|
||||
def test_virtual_folder_uniform_returns_concrete(self):
|
||||
child1 = _ds("c1", "/virtual/a", neutralize=True)
|
||||
child2 = _ds("c2", "/virtual/b", neutralize=True)
|
||||
allDs = [child1, child2]
|
||||
result = _inheritFlags.resolveEffectiveForPath(
|
||||
"conn-1", "sharepointFolder", "/virtual", allDs, mode="aggregate",
|
||||
)
|
||||
self.assertTrue(result["effectiveNeutralize"])
|
||||
|
||||
def test_virtual_fds_workspace_mixed_neutralize(self):
|
||||
tblA = _fds("tA", tableName="A", neutralize=True)
|
||||
tblB = _fds("tB", tableName="B", neutralize=False)
|
||||
allFds = [tblA, tblB]
|
||||
result = _inheritFlags.resolveEffectiveForFds(
|
||||
"fi-1", "*", None, allFds, mode="aggregate",
|
||||
)
|
||||
self.assertEqual(result["effectiveNeutralize"], "mixed")
|
||||
|
||||
def test_virtual_fds_workspace_uniform_returns_concrete(self):
|
||||
tblA = _fds("tA", tableName="A", neutralize=True)
|
||||
tblB = _fds("tB", tableName="B", neutralize=True)
|
||||
allFds = [tblA, tblB]
|
||||
result = _inheritFlags.resolveEffectiveForFds(
|
||||
"fi-1", "*", None, allFds, mode="aggregate",
|
||||
)
|
||||
self.assertTrue(result["effectiveNeutralize"])
|
||||
|
||||
def test_virtual_connection_root_mixed_via_services(self):
|
||||
"""Connection root (authority sourceType, path='/') with no DB record
|
||||
but services that diverge must return 'mixed'."""
|
||||
spRecord = _ds("sp", "/", sourceType="sharepointFolder", neutralize=True)
|
||||
olRecord = _ds("ol", "/", sourceType="outlookFolder", neutralize=False)
|
||||
allDs = [spRecord, olRecord]
|
||||
result = _inheritFlags.resolveEffectiveForPath(
|
||||
"conn-1", "msft", "/", allDs, mode="aggregate",
|
||||
)
|
||||
self.assertEqual(result["effectiveNeutralize"], "mixed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
449
tests/unit/services/test_udbNodes.py
Normal file
449
tests/unit/services/test_udbNodes.py
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
"""Unit tests for the polymorphic UDB node hierarchy (udbNodes.py).
|
||||
|
||||
Each concrete node class is exercised for:
|
||||
- `supportsFlag` returns the right set per kind
|
||||
- `canEdit` enforces DS-owner vs FDS-feature-admin
|
||||
- `getEffectiveFlag` resolves walk + aggregate correctly
|
||||
- `setFlag` writes the right record and (where applicable) cascades
|
||||
- `toDict` produces the expected wire shape
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from modules.serviceCenter.services.serviceKnowledge.udbNodes import (
|
||||
UdbNode,
|
||||
SyntheticContainerNode,
|
||||
MandateGroupNode,
|
||||
ConnectionNode,
|
||||
ServiceNode,
|
||||
FolderNode,
|
||||
FileNode,
|
||||
FdsWorkspaceNode,
|
||||
FdsTableNode,
|
||||
FdsFieldNode,
|
||||
_isFeatureAdmin,
|
||||
)
|
||||
|
||||
|
||||
class _FakeUser:
|
||||
def __init__(self, userId: str = "user-1"):
|
||||
self.id = userId
|
||||
|
||||
|
||||
class _FakeContext:
|
||||
def __init__(self, userId: str = "user-1"):
|
||||
self.user = _FakeUser(userId)
|
||||
self.mandateId = "m1"
|
||||
|
||||
|
||||
class TestSupportsFlag(unittest.TestCase):
|
||||
def test_synthetic_container_supports_nothing(self):
|
||||
n = SyntheticContainerNode("k", "label", icon="x")
|
||||
self.assertFalse(n.supportsFlag("neutralize"))
|
||||
self.assertFalse(n.supportsFlag("scope"))
|
||||
self.assertFalse(n.supportsFlag("ragIndexEnabled"))
|
||||
|
||||
def test_connection_supports_all_three(self):
|
||||
n = ConnectionNode("c1", "msft", label="m", parentKey="personalRoot", rec=None)
|
||||
self.assertTrue(n.supportsFlag("neutralize"))
|
||||
self.assertTrue(n.supportsFlag("scope"))
|
||||
self.assertTrue(n.supportsFlag("ragIndexEnabled"))
|
||||
|
||||
def test_fds_table_supports_neutralize_and_rag_but_not_scope(self):
|
||||
n = FdsTableNode(
|
||||
featureInstanceId="fi1", featureCode="trustee", tableName="Pos",
|
||||
objectKey="data.feature.trustee.Pos", label="Positions",
|
||||
parentKey="feat|m1|trustee|fi1", rec=None, hasFields=False,
|
||||
)
|
||||
self.assertTrue(n.supportsFlag("neutralize"))
|
||||
self.assertTrue(n.supportsFlag("ragIndexEnabled"))
|
||||
self.assertFalse(n.supportsFlag("scope"))
|
||||
|
||||
def test_fds_field_supports_only_neutralize(self):
|
||||
n = FdsFieldNode(
|
||||
featureInstanceId="fi1", tableName="Pos", fieldName="amount",
|
||||
parentKey="fdstbl|fi1|Pos", tableRec=None, featureCode="trustee",
|
||||
)
|
||||
self.assertTrue(n.supportsFlag("neutralize"))
|
||||
self.assertFalse(n.supportsFlag("scope"))
|
||||
self.assertFalse(n.supportsFlag("ragIndexEnabled"))
|
||||
|
||||
|
||||
class TestCanEditDataSourceOwner(unittest.TestCase):
|
||||
def test_owner_can_edit(self):
|
||||
rec = {"id": "ds1", "userId": "user-1"}
|
||||
node = ConnectionNode("c1", "msft", "m", "personalRoot", rec=rec)
|
||||
self.assertTrue(node.canEdit(_FakeContext("user-1"), MagicMock()))
|
||||
|
||||
def test_non_owner_cannot_edit(self):
|
||||
rec = {"id": "ds1", "userId": "user-other"}
|
||||
node = ConnectionNode("c1", "msft", "m", "personalRoot", rec=rec)
|
||||
self.assertFalse(node.canEdit(_FakeContext("user-1"), MagicMock()))
|
||||
|
||||
def test_virtual_node_own_connection_can_edit(self):
|
||||
rootIf = MagicMock()
|
||||
rootIf.db.getRecord.return_value = {"id": "c1", "userId": "user-1"}
|
||||
node = ConnectionNode("c1", "msft", "m", "personalRoot", rec=None)
|
||||
self.assertTrue(node.canEdit(_FakeContext("user-1"), rootIf))
|
||||
|
||||
def test_virtual_node_other_connection_cannot_edit(self):
|
||||
rootIf = MagicMock()
|
||||
rootIf.db.getRecord.return_value = {"id": "c1", "userId": "user-other"}
|
||||
node = ConnectionNode("c1", "msft", "m", "personalRoot", rec=None)
|
||||
self.assertFalse(node.canEdit(_FakeContext("user-1"), rootIf))
|
||||
|
||||
def test_virtual_node_missing_connection_cannot_edit(self):
|
||||
rootIf = MagicMock()
|
||||
rootIf.db.getRecord.return_value = None
|
||||
node = ConnectionNode("c1", "msft", "m", "personalRoot", rec=None)
|
||||
self.assertFalse(node.canEdit(_FakeContext("user-1"), rootIf))
|
||||
|
||||
|
||||
class TestCanEditFdsFeatureAdmin(unittest.TestCase):
|
||||
def _buildRootIfWithAdminRole(self, hasAdmin: bool):
|
||||
rootIf = MagicMock()
|
||||
access = MagicMock(id="acc1", enabled=True)
|
||||
rootIf.getFeatureAccess.return_value = access
|
||||
rootIf.getRoleIdsForFeatureAccess.return_value = ["role-1"]
|
||||
rootIf.db.getRecord.return_value = {
|
||||
"id": "role-1",
|
||||
"roleLabel": "trustee-admin" if hasAdmin else "trustee-user",
|
||||
}
|
||||
return rootIf
|
||||
|
||||
def test_admin_can_edit_fds_table(self):
|
||||
rootIf = self._buildRootIfWithAdminRole(hasAdmin=True)
|
||||
node = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
|
||||
"feat|m1|trustee|fi1", rec={"id": "fds1"}, hasFields=False)
|
||||
self.assertTrue(node.canEdit(_FakeContext(), rootIf))
|
||||
|
||||
def test_non_admin_cannot_edit_fds_table(self):
|
||||
rootIf = self._buildRootIfWithAdminRole(hasAdmin=False)
|
||||
node = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
|
||||
"feat|m1|trustee|fi1", rec={"id": "fds1"}, hasFields=False)
|
||||
self.assertFalse(node.canEdit(_FakeContext(), rootIf))
|
||||
|
||||
def test_fds_field_uses_feature_admin_check(self):
|
||||
rootIf = self._buildRootIfWithAdminRole(hasAdmin=True)
|
||||
field = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
|
||||
tableRec={"id": "fds1"}, featureCode="trustee")
|
||||
self.assertTrue(field.canEdit(_FakeContext(), rootIf))
|
||||
|
||||
|
||||
class TestGetEffectiveFlag(unittest.TestCase):
|
||||
def test_ds_walk_inherits_from_authority_root(self):
|
||||
root = {
|
||||
"id": "r", "connectionId": "c", "sourceType": "msft", "path": "/",
|
||||
"userId": "user-1", "neutralize": True, "scope": None, "ragIndexEnabled": None,
|
||||
}
|
||||
node = FolderNode(
|
||||
connectionId="c", service="sharepoint", sourceType="sharepointFolder",
|
||||
path="/sites/x", label="x", parentKey="svc|c|sharepoint",
|
||||
rec=None, hasChildren=True,
|
||||
)
|
||||
self.assertTrue(node.getEffectiveFlag("neutralize", [root], [], "walk"))
|
||||
|
||||
def test_fds_field_neutralize_from_neutralize_fields(self):
|
||||
rec = {
|
||||
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||
"recordFilter": None, "neutralizeFields": ["amount"],
|
||||
}
|
||||
node = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
|
||||
tableRec=rec, featureCode="trustee")
|
||||
self.assertTrue(node.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
|
||||
|
||||
other = FdsFieldNode("fi1", "Pos", "currency", "fdstbl|fi1|Pos",
|
||||
tableRec=rec, featureCode="trustee")
|
||||
# currency is not in the override list and the table has no
|
||||
# explicit neutralize -> inherits the default (False).
|
||||
self.assertFalse(other.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
|
||||
|
||||
def test_fds_field_inherits_true_from_table(self):
|
||||
"""Field without explicit override inherits the table's explicit
|
||||
neutralize. Regression: previously fell through to False, so
|
||||
toggling the table left the field icon unchanged."""
|
||||
rec = {
|
||||
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||
"recordFilter": None, "neutralize": True, "neutralizeFields": None,
|
||||
}
|
||||
node = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
|
||||
tableRec=rec, featureCode="trustee")
|
||||
self.assertTrue(node.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
|
||||
|
||||
def test_fds_field_inherits_from_workspace_via_table(self):
|
||||
"""Field walks the whole FDS ancestor chain: table -> workspace."""
|
||||
ws = {
|
||||
"id": "fds-ws", "featureInstanceId": "fi1", "tableName": "*",
|
||||
"recordFilter": None, "neutralize": True,
|
||||
}
|
||||
tbl = {
|
||||
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||
"recordFilter": None, "neutralize": None, "neutralizeFields": None,
|
||||
}
|
||||
node = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
|
||||
tableRec=tbl, featureCode="trustee")
|
||||
self.assertTrue(node.getEffectiveFlag("neutralize", [], [ws, tbl], "aggregate"))
|
||||
|
||||
def test_fds_field_explicit_override_beats_table_false(self):
|
||||
"""Per-column override (True via list entry) beats an explicit
|
||||
table False -- this is the one case where the two-source model
|
||||
diverges intentionally and produces the 'mixed' aggregate."""
|
||||
rec = {
|
||||
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||
"recordFilter": None, "neutralize": False, "neutralizeFields": ["amount"],
|
||||
}
|
||||
amount = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
|
||||
tableRec=rec, featureCode="trustee")
|
||||
other = FdsFieldNode("fi1", "Pos", "currency", "fdstbl|fi1|Pos",
|
||||
tableRec=rec, featureCode="trustee")
|
||||
self.assertTrue(amount.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
|
||||
# currency inherits from table -> False
|
||||
self.assertFalse(other.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
|
||||
|
||||
def test_fds_table_mixed_when_field_and_table_disagree(self):
|
||||
# table.neutralize=False, field "amount" is in neutralizeFields => mixed
|
||||
rec = {
|
||||
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||
"recordFilter": None, "neutralize": False, "ragIndexEnabled": None,
|
||||
"neutralizeFields": ["amount"],
|
||||
}
|
||||
table = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
|
||||
"feat|m1|trustee|fi1", rec=rec, hasFields=True)
|
||||
# FdsTableNode.getEffectiveFlag itself only consults FDS records,
|
||||
# not field nodes. The aggregation across field nodes is wired
|
||||
# in _buildTree via `_wireTableFieldsAsLogicalChildren`. So we
|
||||
# exercise the explicit FDS walk here:
|
||||
val = table.getEffectiveFlag("neutralize", [], [rec], "walk")
|
||||
self.assertFalse(val)
|
||||
|
||||
|
||||
class TestSetFlag(unittest.TestCase):
|
||||
def test_setflag_writes_value_on_ds(self):
|
||||
rec = {"id": "ds1", "connectionId": "c", "sourceType": "msft", "path": "/",
|
||||
"userId": "user-1"}
|
||||
node = ConnectionNode("c", "msft", "m", "personalRoot", rec=rec)
|
||||
rootIf = MagicMock()
|
||||
rootIf.db.getRecordset.return_value = [] # no siblings -> no cascade
|
||||
node.setFlag("neutralize", True, rootIf)
|
||||
rootIf.db.recordModify.assert_called()
|
||||
args = rootIf.db.recordModify.call_args[0]
|
||||
self.assertEqual(args[1], "ds1")
|
||||
self.assertEqual(args[2], {"neutralize": True})
|
||||
|
||||
def test_setflag_virtual_ds_auto_creates_record(self):
|
||||
"""Toggling a flag on a virtual DS node must auto-create the
|
||||
DataSource record so the flag can be persisted."""
|
||||
node = FolderNode(
|
||||
connectionId="c1", service="sharepoint",
|
||||
sourceType="sharepointFolder", path="/sites/x/docs",
|
||||
label="docs", parentKey="svc|c1|sharepoint",
|
||||
rec=None, hasChildren=True,
|
||||
)
|
||||
rootIf = MagicMock()
|
||||
rootIf.db.getRecordset.return_value = []
|
||||
rootIf.db.getRecord.return_value = {"id": "c1", "userId": "user-1"}
|
||||
createdRec = {"id": "ds-new", "connectionId": "c1",
|
||||
"sourceType": "sharepointFolder", "path": "/sites/x/docs",
|
||||
"userId": "user-1"}
|
||||
rootIf.db.recordCreate.return_value = createdRec
|
||||
node.setFlag("neutralize", True, rootIf)
|
||||
rootIf.db.recordCreate.assert_called_once()
|
||||
rootIf.db.recordModify.assert_called()
|
||||
args = rootIf.db.recordModify.call_args[0]
|
||||
self.assertEqual(args[1], "ds-new")
|
||||
self.assertEqual(args[2], {"neutralize": True})
|
||||
|
||||
def test_fds_field_setflag_mutates_neutralize_fields(self):
|
||||
rec = {
|
||||
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||
"recordFilter": None, "neutralize": False, "neutralizeFields": None,
|
||||
}
|
||||
node = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
|
||||
tableRec=rec, featureCode="trustee")
|
||||
rootIf = MagicMock()
|
||||
node.setFlag("neutralize", True, rootIf)
|
||||
rootIf.db.recordModify.assert_called()
|
||||
# last call: set neutralizeFields to ["amount"]
|
||||
args = rootIf.db.recordModify.call_args[0]
|
||||
self.assertEqual(args[1], "fds-tbl")
|
||||
self.assertEqual(args[2], {"neutralizeFields": ["amount"]})
|
||||
|
||||
def test_fds_field_setflag_removes_field_when_toggled_off(self):
|
||||
rec = {
|
||||
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||
"recordFilter": None, "neutralizeFields": ["amount", "currency"],
|
||||
}
|
||||
node = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
|
||||
tableRec=rec, featureCode="trustee")
|
||||
rootIf = MagicMock()
|
||||
node.setFlag("neutralize", False, rootIf)
|
||||
args = rootIf.db.recordModify.call_args[0]
|
||||
self.assertEqual(args[2], {"neutralizeFields": ["currency"]})
|
||||
|
||||
def test_fds_field_setflag_roundtrip(self):
|
||||
rec = {
|
||||
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||
"recordFilter": None, "neutralizeFields": None,
|
||||
}
|
||||
node = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
|
||||
tableRec=rec, featureCode="trustee")
|
||||
rootIf = MagicMock()
|
||||
node.setFlag("neutralize", True, rootIf)
|
||||
self.assertTrue(node.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
|
||||
node.setFlag("neutralize", False, rootIf)
|
||||
self.assertFalse(node.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
|
||||
|
||||
def test_fds_table_explicit_neutralize_wipes_own_neutralize_fields(self):
|
||||
"""Setting an explicit neutralize on a table must clear its own
|
||||
`neutralizeFields` list. Otherwise the table's aggregate stays
|
||||
'mixed' because field children walk to True via that list and the
|
||||
UI shows no change after the toggle."""
|
||||
rec = {
|
||||
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||
"recordFilter": None, "neutralize": None,
|
||||
"neutralizeFields": ["amount", "currency"],
|
||||
}
|
||||
node = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
|
||||
"feat|m1|trustee|fi1", rec=rec, hasFields=True)
|
||||
rootIf = MagicMock()
|
||||
rootIf.db.getRecordset.return_value = [rec] # no descendants
|
||||
node.setFlag("neutralize", False, rootIf)
|
||||
rootIf.db.recordModify.assert_called()
|
||||
args = rootIf.db.recordModify.call_args[0]
|
||||
self.assertEqual(args[1], "fds-tbl")
|
||||
self.assertEqual(
|
||||
args[2], {"neutralize": False, "neutralizeFields": None},
|
||||
)
|
||||
|
||||
def test_fds_table_setflag_inherit_keeps_neutralize_fields(self):
|
||||
"""`value=None` (reset to inherit) must NOT cascade and must NOT
|
||||
wipe `neutralizeFields`; that matches the cascade-reset spec
|
||||
(only explicit toggles clear descendants)."""
|
||||
rec = {
|
||||
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||
"recordFilter": None, "neutralize": True,
|
||||
"neutralizeFields": ["amount"],
|
||||
}
|
||||
node = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
|
||||
"feat|m1|trustee|fi1", rec=rec, hasFields=True)
|
||||
rootIf = MagicMock()
|
||||
rootIf.db.getRecordset.return_value = [rec]
|
||||
node.setFlag("neutralize", None, rootIf)
|
||||
args = rootIf.db.recordModify.call_args[0]
|
||||
self.assertEqual(args[2], {"neutralize": None})
|
||||
|
||||
def test_fds_table_setflag_rag_does_not_touch_neutralize_fields(self):
|
||||
"""A RAG toggle on the table must leave `neutralizeFields` alone
|
||||
(it is neutralize-only field state)."""
|
||||
rec = {
|
||||
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||
"recordFilter": None, "ragIndexEnabled": None,
|
||||
"neutralizeFields": ["amount"],
|
||||
}
|
||||
node = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
|
||||
"feat|m1|trustee|fi1", rec=rec, hasFields=True)
|
||||
rootIf = MagicMock()
|
||||
rootIf.db.getRecordset.return_value = [rec]
|
||||
node.setFlag("ragIndexEnabled", True, rootIf)
|
||||
args = rootIf.db.recordModify.call_args[0]
|
||||
self.assertEqual(args[2], {"ragIndexEnabled": True})
|
||||
|
||||
def test_fds_workspace_neutralize_clears_descendant_neutralize_fields(self):
|
||||
"""Workspace toggle must clear per-column overrides on descendant
|
||||
tables; otherwise the table aggregate stays 'mixed' because some
|
||||
field children still read True from the list."""
|
||||
wsRec = {
|
||||
"id": "fds-ws", "featureInstanceId": "fi1", "tableName": "*",
|
||||
"recordFilter": None, "neutralize": None,
|
||||
}
|
||||
tblRec = {
|
||||
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||
"recordFilter": None, "neutralize": None,
|
||||
"neutralizeFields": ["amount", "currency"],
|
||||
}
|
||||
node = FdsWorkspaceNode("m1", "trustee", "fi1", label="Trustee",
|
||||
icon="trustee", parentKey="mgrp|m1", rec=wsRec)
|
||||
rootIf = MagicMock()
|
||||
rootIf.db.getRecordset.return_value = [wsRec, tblRec]
|
||||
node.setFlag("neutralize", True, rootIf)
|
||||
calls = rootIf.db.recordModify.call_args_list
|
||||
modifyMap = {c[0][1]: c[0][2] for c in calls}
|
||||
self.assertEqual(modifyMap["fds-tbl"], {"neutralizeFields": None})
|
||||
self.assertEqual(modifyMap["fds-ws"], {"neutralize": True})
|
||||
|
||||
def test_fds_workspace_rag_does_not_clear_descendant_neutralize_fields(self):
|
||||
"""A RAG toggle on the workspace must not touch descendant
|
||||
`neutralizeFields`."""
|
||||
wsRec = {
|
||||
"id": "fds-ws", "featureInstanceId": "fi1", "tableName": "*",
|
||||
"recordFilter": None, "ragIndexEnabled": None,
|
||||
}
|
||||
tblRec = {
|
||||
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||
"recordFilter": None, "ragIndexEnabled": None,
|
||||
"neutralizeFields": ["amount"],
|
||||
}
|
||||
node = FdsWorkspaceNode("m1", "trustee", "fi1", label="Trustee",
|
||||
icon="trustee", parentKey="mgrp|m1", rec=wsRec)
|
||||
rootIf = MagicMock()
|
||||
rootIf.db.getRecordset.return_value = [wsRec, tblRec]
|
||||
node.setFlag("ragIndexEnabled", True, rootIf)
|
||||
calls = rootIf.db.recordModify.call_args_list
|
||||
modifyIds = [c[0][1] for c in calls]
|
||||
self.assertNotIn("fds-tbl", modifyIds)
|
||||
|
||||
|
||||
class TestToDict(unittest.TestCase):
|
||||
def test_fds_table_dict_has_neutralize_fields(self):
|
||||
rec = {
|
||||
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||
"neutralizeFields": ["amount"],
|
||||
}
|
||||
node = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
|
||||
"feat|m1|trustee|fi1", rec=rec, hasFields=True)
|
||||
out = node.toDict([], [rec])
|
||||
self.assertEqual(out["neutralizeFields"], ["amount"])
|
||||
self.assertEqual(out["kind"], "fdsTable")
|
||||
self.assertEqual(out["modelType"], "FeatureDataSource")
|
||||
self.assertEqual(out["effectiveScope"], "personal") # FDS has no scope
|
||||
|
||||
def test_synthetic_container_has_no_dataSourceId(self):
|
||||
n = SyntheticContainerNode("personalRoot", "Personal", icon="person",
|
||||
defaultExpanded=True)
|
||||
d = n.toDict([], [])
|
||||
self.assertIsNone(d["dataSourceId"])
|
||||
self.assertEqual(d["effectiveNeutralize"], False)
|
||||
|
||||
|
||||
class TestIsFeatureAdmin(unittest.TestCase):
|
||||
def test_no_access_returns_false(self):
|
||||
rootIf = MagicMock()
|
||||
rootIf.getFeatureAccess.return_value = None
|
||||
self.assertFalse(_isFeatureAdmin(rootIf, "user-1", "fi1"))
|
||||
|
||||
def test_no_roles_returns_false(self):
|
||||
rootIf = MagicMock()
|
||||
rootIf.getFeatureAccess.return_value = MagicMock(id="acc1", enabled=True)
|
||||
rootIf.getRoleIdsForFeatureAccess.return_value = []
|
||||
self.assertFalse(_isFeatureAdmin(rootIf, "user-1", "fi1"))
|
||||
|
||||
def test_non_admin_role_returns_false(self):
|
||||
rootIf = MagicMock()
|
||||
rootIf.getFeatureAccess.return_value = MagicMock(id="acc1", enabled=True)
|
||||
rootIf.getRoleIdsForFeatureAccess.return_value = ["r1"]
|
||||
rootIf.db.getRecord.return_value = {"id": "r1", "roleLabel": "trustee-user"}
|
||||
self.assertFalse(_isFeatureAdmin(rootIf, "user-1", "fi1"))
|
||||
|
||||
def test_admin_role_returns_true(self):
|
||||
rootIf = MagicMock()
|
||||
rootIf.getFeatureAccess.return_value = MagicMock(id="acc1", enabled=True)
|
||||
rootIf.getRoleIdsForFeatureAccess.return_value = ["r1"]
|
||||
rootIf.db.getRecord.return_value = {"id": "r1", "roleLabel": "workspace-admin"}
|
||||
self.assertTrue(_isFeatureAdmin(rootIf, "user-1", "fi1"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in a new issue