diff --git a/Dockerfile b/Dockerfile index e8300a5a..79231bfc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/app.py b/app.py index b53caebc..a69e9a7e 100644 --- a/app.py +++ b/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) \ No newline at end of file + + 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) \ No newline at end of file diff --git a/env-dev.env b/env-dev.env index 0474bcb5..467f70b4 100644 --- a/env-dev.env +++ b/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 diff --git a/env-int.env b/env-int.env index 73a0c9c5..72099314 100644 --- a/env-int.env +++ b/env-int.env @@ -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= diff --git a/env-prod.env b/env-prod.env index 03c70b26..d268450c 100644 --- a/env-prod.env +++ b/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 diff --git a/modules/datamodels/datamodelFeatureDataSource.py b/modules/datamodels/datamodelFeatureDataSource.py index 10fd76a7..2f234742 100644 --- a/modules/datamodels/datamodelFeatureDataSource.py +++ b/modules/datamodels/datamodelFeatureDataSource.py @@ -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=( diff --git a/modules/features/workspace/routeFeatureWorkspace.py b/modules/features/workspace/routeFeatureWorkspace.py index 5c24c113..e5ba470a 100644 --- a/modules/features/workspace/routeFeatureWorkspace.py +++ b/modules/features/workspace/routeFeatureWorkspace.py @@ -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}) diff --git a/modules/routes/routeAdminDatabaseHealth.py b/modules/routes/routeAdminDatabaseHealth.py index 4e74646d..15ab1c5a 100644 --- a/modules/routes/routeAdminDatabaseHealth.py +++ b/modules/routes/routeAdminDatabaseHealth.py @@ -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), + }, ) diff --git a/modules/routes/routeDataFiles.py b/modules/routes/routeDataFiles.py index eb44db44..825932a9 100644 --- a/modules/routes/routeDataFiles.py +++ b/modules/routes/routeDataFiles.py @@ -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]], diff --git a/modules/routes/routeDataSources.py b/modules/routes/routeDataSources.py index 9ffd42ed..eccbf276 100644 --- a/modules/routes/routeDataSources.py +++ b/modules/routes/routeDataSources.py @@ -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) diff --git a/modules/routes/routeRagInventory.py b/modules/routes/routeRagInventory.py index 6a5e9eb5..0ca7fade 100644 --- a/modules/routes/routeRagInventory.py +++ b/modules/routes/routeRagInventory.py @@ -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: diff --git a/modules/routes/routeUdb.py b/modules/routes/routeUdb.py new file mode 100644 index 00000000..177778d2 --- /dev/null +++ b/modules/routes/routeUdb.py @@ -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 diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py index 2ebc2720..7249e959 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py @@ -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 diff --git a/modules/serviceCenter/services/serviceKnowledge/_buildTree.py b/modules/serviceCenter/services/serviceKnowledge/_buildTree.py index 9179f3d8..63bcc0f1 100644 --- a/modules/serviceCenter/services/serviceKnowledge/_buildTree.py +++ b/modules/serviceCenter/services/serviceKnowledge/_buildTree.py @@ -4,7 +4,7 @@ The UDB shows three logical hierarchies as a single user-facing tree: 1. Personal connections: UserConnection -> Service -> Folder -> File - 2. Mandate groups -> Feature instances -> FDS Workspace(*) -> FDS Table -> FDS Record + 2. Mandate groups -> Feature instances -> FDS Table -> FDS Field 3. (Settings/diagnostics nodes can be added later under the same model.) For every visible node the UI needs: @@ -15,9 +15,11 @@ For every visible node the UI needs: - whether the node has children to expand This module exposes one function: `getChildrenForParents(parents, ...)`. -The caller asks for the children of a list of parent keys. The orchestrator -does NOT decide what to expand; it only returns the children of what was -asked for. This keeps the contract minimal and predictable. +Builders construct `UdbNode` instances (see `udbNodes.py`); the entry +point serializes them via `node.toDict(...)`. The aggregation (whether a +parent shows 'mixed') is delegated to each node's polymorphic +`getEffectiveFlag(mode='aggregate')` -- there is no separate +post-processing pass any more. """ from __future__ import annotations @@ -25,10 +27,17 @@ from __future__ import annotations import logging from typing import Any, Dict, List, Optional, Tuple -from modules.serviceCenter.services.serviceKnowledge._inheritFlags import ( - resolveEffectiveForPath, - resolveEffectiveForFds, - _normalisePath, +from modules.serviceCenter.services.serviceKnowledge.udbNodes import ( + UdbNode, + SyntheticContainerNode, + MandateGroupNode, + ConnectionNode, + ServiceNode, + FolderNode, + FileNode, + FdsWorkspaceNode, + FdsTableNode, + FdsFieldNode, ) logger = logging.getLogger(__name__) @@ -41,8 +50,8 @@ logger = logging.getLogger(__name__) # Synthetic container keys use a single literal token without separator. # # Top-level (parent=None) returns: -# personalRoot (synthetic, groups all UserConnections) -# mgrp| (one per accessible mandate) +# personalRoot (synthetic, groups all UserConnections) +# mgrp| (one per accessible mandate) # # Data-bearing: # conn| @@ -50,14 +59,10 @@ logger = logging.getLogger(__name__) # ds||| # mgrp| # feat||| -# fdsws|| (synthetic '*' wildcard) -# fdstbl|| -# fdsrec||| +# fdstbl|| +# fdsfld||| _KEY_SEP = "|" - -# Stable, parseable synthetic-container key. Never encoded with `_encode` -# (no payload parts), always emitted/matched as literal. _KEY_PERSONAL_ROOT = "personalRoot" @@ -101,47 +106,13 @@ _SERVICE_LABELS: Dict[str, str] = { } -# --------------------------------------------------------------------------- -# Per-node effective-value helpers -# --------------------------------------------------------------------------- - -def _effectiveTripletDs( - connectionId: str, - sourceType: str, - path: str, - allDs: List[Dict[str, Any]], -) -> Dict[str, Any]: - """Return {effectiveNeutralize, effectiveScope, effectiveRagIndexEnabled} - for an arbitrary DS coordinate (whether or not a record exists).""" - out = resolveEffectiveForPath(connectionId, sourceType, path, allDs, mode="aggregate") - return { - "effectiveNeutralize": out.get("effectiveNeutralize", False), - "effectiveScope": out.get("effectiveScope", "personal"), - "effectiveRagIndexEnabled": out.get("effectiveRagIndexEnabled", False), - } - - -def _effectiveTripletFds( - featureInstanceId: str, - tableName: str, - recordFilter: Optional[Dict[str, str]], - allFds: List[Dict[str, Any]], -) -> Dict[str, Any]: - """Return effective-triplet for an FDS coordinate.""" - out = resolveEffectiveForFds(featureInstanceId, tableName, recordFilter, allFds, mode="aggregate") - return { - "effectiveNeutralize": out.get("effectiveNeutralize", False), - "effectiveScope": out.get("effectiveScope", "personal"), - "effectiveRagIndexEnabled": out.get("effectiveRagIndexEnabled", False), - } - - def _findDsRecord( allDs: List[Dict[str, Any]], connectionId: str, sourceType: str, path: str, ) -> Optional[Dict[str, Any]]: + from modules.serviceCenter.services.serviceKnowledge._inheritFlags import _normalisePath norm = _normalisePath(path) for ds in allDs: if ( @@ -159,12 +130,7 @@ def _findFdsRecord( tableName: str, recordFilter: Optional[Dict[str, str]] = None, ) -> Optional[Dict[str, Any]]: - """Find a FeatureDataSource record by featureInstanceId + tableName. - - `allFds` is already scoped to the workspace (loaded with - recordFilter={'workspaceInstanceId': wsInstanceId}), so the - distinguishing coordinate is featureInstanceId + tableName. - """ + """Find a FeatureDataSource by featureInstanceId + tableName + recordFilter.""" target = recordFilter or None for fds in allFds: if ( @@ -176,72 +142,37 @@ def _findFdsRecord( return None -# --------------------------------------------------------------------------- -# Synthetic container helpers -# --------------------------------------------------------------------------- - -def _emptyTriplet() -> Dict[str, Any]: - """Synthetic container nodes carry no DB record and no inherited flags. - Backend reports neutral defaults so the UI never reads stale values for them.""" - return { - "effectiveNeutralize": False, - "effectiveScope": "personal", - "effectiveRagIndexEnabled": False, - } - - -def _syntheticNode( - key: str, - parentKey: Optional[str], - label: str, - icon: str, - displayOrder: int, - defaultExpanded: bool = False, -) -> Dict[str, Any]: - """Build a synthetic container node (no DB record, not flag-toggleable).""" - return { - "key": key, - "kind": "synthRoot", - "parentKey": parentKey, - "label": label, - "icon": icon, - "hasChildren": True, - "dataSourceId": None, - "modelType": None, - **_emptyTriplet(), - "supportsRag": False, - "canBeAdded": False, - "displayOrder": displayOrder, - "defaultExpanded": defaultExpanded, - } - - # --------------------------------------------------------------------------- # Top-level (parent = None) -> personalRoot + mandate groups (flat layout) # --------------------------------------------------------------------------- def _topLevel( - instanceId: str, context: Any, rootIf: Any, - _allDs: List[Dict[str, Any]], + allDs: List[Dict[str, Any]], allFds: List[Dict[str, Any]], -) -> List[Dict[str, Any]]: +) -> List[UdbNode]: """Return the visible top-level: 'personalRoot' first, then one node per - accessible mandate group. Both layers are marked `defaultExpanded=True` - so the UI opens down to the data-source level on first render. - """ - nodes: List[Dict[str, Any]] = [ - _syntheticNode( - key=_KEY_PERSONAL_ROOT, - parentKey=None, - label=resolveTextSafe("Persönliche Quellen"), - icon="person", - displayOrder=0, - defaultExpanded=True, - ) - ] - nodes.extend(_listMandateGroups(instanceId, context, rootIf, allFds)) + accessible mandate group. Both layers carry their logical children + pre-populated so the synthetic containers can compute an aggregate + 'mixed' indicator without a second pass.""" + personalRoot = SyntheticContainerNode( + key=_KEY_PERSONAL_ROOT, + label=resolveTextSafe("Persönliche Quellen"), + icon="person", + parentKey=None, + displayOrder=0, + defaultExpanded=True, + ) + # Populate personalRoot's logical children for aggregate computation. + for child in _personalRootChildrenNodes(context, allDs): + personalRoot.addLogicalChild(child) + + nodes: List[UdbNode] = [personalRoot] + for mgrp in _listMandateGroupNodes(context, rootIf, allFds): + for child in _featureConnectionsForMandateNodes(rootIf, context, mgrp.mandateId, allFds): + mgrp.addLogicalChild(child) + nodes.append(mgrp) return nodes @@ -249,11 +180,10 @@ def _topLevel( # Children of personalRoot -> active UserConnections # --------------------------------------------------------------------------- -def _personalRootChildren( - instanceId: str, +def _personalRootChildrenNodes( context: Any, allDs: List[Dict[str, Any]], -) -> List[Dict[str, Any]]: +) -> List[UdbNode]: """Return one node per active UserConnection of the current user.""" from modules.serviceCenter import getService from modules.serviceCenter.context import ServiceCenterContext @@ -262,12 +192,12 @@ def _personalRootChildren( ctx = ServiceCenterContext( user=context.user, mandate_id=mandateId, - feature_instance_id=instanceId, + feature_instance_id="", ) chatService = getService("chat", ctx) connections = chatService.getUserConnections() or [] - nodes: List[Dict[str, Any]] = [] + nodes: List[UdbNode] = [] for c in connections: conn = c if isinstance(c, dict) else (c.model_dump() if hasattr(c, "model_dump") else {}) status = conn.get("status") @@ -280,24 +210,14 @@ def _personalRootChildren( authority = authority.value connId = conn.get("id") or "" label = conn.get("externalEmail") or conn.get("externalUsername") or authority or "" - # Connection root = path '/' on its authority sourceType. - triplet = _effectiveTripletDs(connId, str(authority), "/", allDs) rec = _findDsRecord(allDs, connId, str(authority), "/") - nodes.append({ - "key": _encode("conn", connId), - "kind": "connection", - "parentKey": _KEY_PERSONAL_ROOT, - "label": label, - "icon": str(authority), - "hasChildren": True, - "dataSourceId": rec.get("id") if rec else None, - "modelType": "DataSource" if rec else None, - **triplet, - "supportsRag": True, - "canBeAdded": rec is None, - "authority": authority, - "connectionId": connId, - }) + nodes.append(ConnectionNode( + connectionId=connId, + authority=str(authority), + label=label, + parentKey=_KEY_PERSONAL_ROOT, + rec=rec, + )) return nodes @@ -305,19 +225,13 @@ def _personalRootChildren( # Mandate-group nodes (rendered top-level next to personalRoot) # --------------------------------------------------------------------------- -def _listMandateGroups( - _instanceId: str, +def _listMandateGroupNodes( context: Any, rootIf: Any, _allFds: List[Dict[str, Any]], -) -> List[Dict[str, Any]]: +) -> List[MandateGroupNode]: """Return one mandate-group node per accessible mandate that has at least - one enabled feature instance with registered DATA objects. - - Emitted at the top level (parentKey=None). `defaultExpanded=True` so the - UI shows feature-instance children (= mandate data sources) without a - second user click. - """ + one enabled feature instance with registered DATA objects.""" from modules.security.rbacCatalog import getCatalogService from modules.datamodels.datamodelUam import Mandate @@ -343,7 +257,7 @@ def _listMandateGroups( except Exception: mandateLabels[um.mandateId] = um.mandateId - nodes: List[Dict[str, Any]] = [] + nodes: List[MandateGroupNode] = [] seenMandates: set = set() for um in userMandates or []: mid = um.mandateId @@ -360,21 +274,7 @@ def _listMandateGroups( break if not hasFeature: continue - nodes.append({ - "key": _encode("mgrp", mid), - "kind": "mandateGroup", - "parentKey": None, - "label": mandateLabels.get(mid, mid), - "icon": "mandate", - "hasChildren": True, - "dataSourceId": None, - "modelType": None, - **_emptyTriplet(), - "supportsRag": False, - "canBeAdded": False, - "mandateId": mid, - "defaultExpanded": True, - }) + nodes.append(MandateGroupNode(mandateId=mid, label=mandateLabels.get(mid, mid))) return nodes @@ -382,12 +282,12 @@ def _listMandateGroups( # Children of a connection -> services # --------------------------------------------------------------------------- -async def _connectionServices( +async def _connectionServiceNodes( instanceId: str, context: Any, connectionId: str, allDs: List[Dict[str, Any]], -) -> List[Dict[str, Any]]: +) -> List[UdbNode]: from modules.connectors.connectorResolver import ConnectorResolver from modules.serviceCenter import getService from modules.serviceCenter.context import ServiceCenterContext @@ -410,28 +310,19 @@ async def _connectionServices( logger.error("Tree: cannot resolve services for connection %s: %s", connectionId, exc) return [] - nodes: List[Dict[str, Any]] = [] + parentKey = _encode("conn", connectionId) + nodes: List[UdbNode] = [] for service in services or []: sourceType = _SERVICE_TO_SOURCE_TYPE.get(service, service) - triplet = _effectiveTripletDs(connectionId, sourceType, "/", allDs) rec = _findDsRecord(allDs, connectionId, sourceType, "/") - nodes.append({ - "key": _encode("svc", connectionId, service), - "kind": "service", - "parentKey": _encode("conn", connectionId), - "label": _SERVICE_LABELS.get(service, service), - "icon": service, - "hasChildren": True, - "dataSourceId": rec.get("id") if rec else None, - "modelType": "DataSource" if rec else None, - **triplet, - "supportsRag": True, - "canBeAdded": rec is None, - "connectionId": connectionId, - "service": service, - "sourceType": sourceType, - "path": "/", - }) + nodes.append(ServiceNode( + connectionId=connectionId, + service=service, + sourceType=sourceType, + label=_SERVICE_LABELS.get(service, service), + parentKey=parentKey, + rec=rec, + )) return nodes @@ -439,7 +330,7 @@ async def _connectionServices( # Children of a folder/service -> next-level folders+files via browse # --------------------------------------------------------------------------- -async def _browseChildren( +async def _browseChildNodes( instanceId: str, context: Any, connectionId: str, @@ -447,8 +338,8 @@ async def _browseChildren( sourceType: str, parentPath: str, allDs: List[Dict[str, Any]], - parentKey: Optional[str] = None, -) -> List[Dict[str, Any]]: + parentKey: str, +) -> List[UdbNode]: from modules.connectors.connectorResolver import ConnectorResolver from modules.serviceCenter import getService from modules.serviceCenter.context import ServiceCenterContext @@ -471,33 +362,22 @@ async def _browseChildren( logger.error("Tree: cannot browse %s on connection %s path=%s: %s", service, connectionId, parentPath, exc) return [] - # Children parentKey must equal the key the caller asked for (= the - # currently-expanded node in the UI). If the caller doesn't pass an - # explicit key, fall back to the encoded ds-coordinate. - effectiveParentKey = parentKey if parentKey is not None else _encode("ds", connectionId, sourceType, parentPath) - nodes: List[Dict[str, Any]] = [] + nodes: List[UdbNode] = [] for e in entries or []: path = getattr(e, "path", "") or "" - kind = "folder" if getattr(e, "isFolder", False) else "file" - triplet = _effectiveTripletDs(connectionId, sourceType, path, allDs) + isFolder = bool(getattr(e, "isFolder", False)) rec = _findDsRecord(allDs, connectionId, sourceType, path) - nodes.append({ - "key": _encode("ds", connectionId, sourceType, path), - "kind": kind, - "parentKey": effectiveParentKey, - "label": getattr(e, "name", "") or path, - "icon": kind, - "hasChildren": kind == "folder", - "dataSourceId": rec.get("id") if rec else None, - "modelType": "DataSource" if rec else None, - **triplet, - "supportsRag": True, - "canBeAdded": rec is None, - "connectionId": connectionId, - "service": service, - "sourceType": sourceType, - "path": path, - }) + cls = FolderNode if isFolder else FileNode + nodes.append(cls( + connectionId=connectionId, + service=service, + sourceType=sourceType, + path=path, + label=getattr(e, "name", "") or path, + parentKey=parentKey, + rec=rec, + hasChildren=isFolder, + )) return nodes @@ -505,13 +385,12 @@ async def _browseChildren( # Mandate group -> feature connections # --------------------------------------------------------------------------- -def _featureConnectionsForMandate( - instanceId: str, - context: Any, +def _featureConnectionsForMandateNodes( rootIf: Any, + context: Any, mandateId: str, allFds: List[Dict[str, Any]], -) -> List[Dict[str, Any]]: +) -> List[UdbNode]: from modules.security.rbacCatalog import getCatalogService userId = str(context.user.id) @@ -520,7 +399,7 @@ def _featureConnectionsForMandate( instances = rootIf.getFeatureInstancesByMandate(mandateId) parentKey = _encode("mgrp", mandateId) - nodes: List[Dict[str, Any]] = [] + nodes: List[UdbNode] = [] for inst in instances or []: if not inst.enabled: continue @@ -529,28 +408,18 @@ def _featureConnectionsForMandate( fa = rootIf.getFeatureAccess(userId, inst.id) if not fa or not fa.enabled: continue - # Effective values come from the FDS workspace-wildcard for this featureInstanceId - wsId = inst.id - triplet = _effectiveTripletFds(wsId, "*", None, allFds) - rec = _findFdsRecord(allFds, wsId, "*", None) + rec = _findFdsRecord(allFds, inst.id, "*", None) featureDef = catalog.getFeatureDefinition(inst.featureCode) or {} - nodes.append({ - "key": _encode("feat", mandateId, inst.featureCode, inst.id), - "kind": "featureNode", - "parentKey": parentKey, - "label": inst.label or inst.featureCode, - "icon": featureDef.get("icon", "mdi-database"), - "hasChildren": True, - "dataSourceId": rec.get("id") if rec else None, - "modelType": "FeatureDataSource" if rec else None, - **triplet, - "supportsRag": True, - "canBeAdded": rec is None, - "featureInstanceId": wsId, - "featureCode": inst.featureCode, - "mandateId": mandateId, - "tableName": "*", - }) + node = FdsWorkspaceNode( + mandateId=mandateId, + featureCode=inst.featureCode, + featureInstanceId=inst.id, + label=inst.label or inst.featureCode, + icon=featureDef.get("icon", "mdi-database"), + parentKey=parentKey, + rec=rec, + ) + nodes.append(node) return nodes @@ -558,14 +427,14 @@ def _featureConnectionsForMandate( # Feature node -> tables # --------------------------------------------------------------------------- -def _featureTables( +def _featureTableNodes( context: Any, rootIf: Any, parentKey: str, featureInstanceId: str, featureCode: str, allFds: List[Dict[str, Any]], -) -> List[Dict[str, Any]]: +) -> List[UdbNode]: from modules.security.rbacCatalog import getCatalogService inst = rootIf.getFeatureInstance(featureInstanceId) @@ -589,7 +458,7 @@ def _featureTables( accessibleKeys = {obj.get("objectKey", "") for obj in accessible} - nodes: List[Dict[str, Any]] = [] + nodes: List[UdbNode] = [] for obj in catalog.getDataObjects(inst.featureCode): meta = obj.get("meta", {}) if meta.get("wildcard") or meta.get("isGroup"): @@ -600,88 +469,100 @@ def _featureTables( tableName = meta.get("table", "") if not tableName: continue - triplet = _effectiveTripletFds(featureInstanceId, tableName, None, allFds) rec = _findFdsRecord(allFds, featureInstanceId, tableName, None) fields = meta.get("fields") if isinstance(meta, dict) else None hasFields = bool(isinstance(fields, list) and len(fields) > 0) - # Surface the persisted per-field neutralize list so the UI can - # render & toggle field-level icons without an extra GET. - neutralizeFields: List[str] = [] - if rec and isinstance(rec.get("neutralizeFields"), list): - neutralizeFields = [f for f in rec["neutralizeFields"] if isinstance(f, str)] - nodes.append({ - "key": _encode("fdstbl", featureInstanceId, tableName), - "kind": "fdsTable", - "parentKey": parentKey, - "label": resolveTextSafe(obj.get("label", "")) or tableName, - "icon": "table", - # Children = the per-column field nodes. Only emitted when the - # data-object metadata declared a non-empty `fields` list. - "hasChildren": hasFields, - "dataSourceId": rec.get("id") if rec else None, - "modelType": "FeatureDataSource" if rec else None, - **triplet, - "supportsRag": True, - "canBeAdded": rec is None, - "featureInstanceId": featureInstanceId, - "featureCode": featureCode, - "tableName": tableName, - "objectKey": objectKey, - "neutralizeFields": neutralizeFields, - }) + tableNode = FdsTableNode( + featureInstanceId=featureInstanceId, + featureCode=featureCode, + tableName=tableName, + objectKey=objectKey, + label=resolveTextSafe(obj.get("label", "")) or tableName, + parentKey=parentKey, + rec=rec, + hasFields=hasFields, + ) + # Populate logical children (field-level nodes) so the table's + # aggregate flag can show 'mixed' when individual columns diverge + # from the table-level neutralize value. + if hasFields and isinstance(fields, list): + for f in fields: + if isinstance(f, str) and f: + tableNode_field = FdsFieldNode( + featureInstanceId=featureInstanceId, + tableName=tableName, + fieldName=f, + parentKey=tableNode.key, + tableRec=rec, + featureCode=featureCode, + ) + if not hasattr(tableNode, "_logicalFieldChildren"): + tableNode._logicalFieldChildren = [] # type: ignore[attr-defined] + tableNode._logicalFieldChildren.append(tableNode_field) # type: ignore[attr-defined] + _wireTableFieldsAsLogicalChildren(tableNode) + nodes.append(tableNode) return nodes -def _featureTableFields( +def _wireTableFieldsAsLogicalChildren(tableNode: FdsTableNode) -> None: + """Make `tableNode.getLogicalChildren` return its field nodes so the + aggregate computation can detect divergence between table-level + neutralize and individual column overrides.""" + fields = getattr(tableNode, "_logicalFieldChildren", []) + + def _children(_allDs, _allFds, _rootIf, _context, _f=fields): + return list(_f) + + tableNode.getLogicalChildren = _children # type: ignore[assignment] + + # Wrap getEffectiveFlag so the table's neutralize aggregates from its + # own value AND its field children. scope/rag stay unaffected. + origGetEffective = tableNode.getEffectiveFlag + + def _aggGetEffective(flag, allDs, allFds, mode="aggregate", _orig=origGetEffective, _f=fields, _tbl=tableNode): + ownValue = _orig(flag, allDs, allFds, mode) + if flag != "neutralize" or mode != "aggregate": + return ownValue + if not _f: + return ownValue + seen = set() + if isinstance(ownValue, str) and ownValue == "mixed": + return "mixed" + if isinstance(ownValue, bool): + seen.add(int(ownValue)) + for fld in _f: + v = fld.getEffectiveFlag("neutralize", allDs, allFds, "walk") + if isinstance(v, bool): + seen.add(int(v)) + if len(seen) > 1: + return "mixed" + return ownValue + + tableNode.getEffectiveFlag = _aggGetEffective # type: ignore[assignment] + + +def _featureTableFieldNodes( parentKey: str, featureInstanceId: str, tableName: str, fieldNames: List[str], allFds: List[Dict[str, Any]], -) -> List[Dict[str, Any]]: - """Emit one node per declared column of a feature data table. - - Per-field neutralize semantics: - - The table-level FDS record carries `neutralizeFields: List[str]`. - - A field is "effectively neutralized" iff its name is in that list - OR the table's effective `neutralize` is True (blanket). - - Only `neutralize` is meaningful per-field; `scope` and `ragIndexEnabled` - are inherited from the parent table and not toggleable here. - """ +) -> List[UdbNode]: + """Emit one node per declared column of a feature data table.""" rec = _findFdsRecord(allFds, featureInstanceId, tableName, None) - tableNeutralize = bool(rec.get("neutralize")) if rec else False - neutralizeFields = rec.get("neutralizeFields") if rec else None - if not isinstance(neutralizeFields, list): - neutralizeFields = [] - - nodes: List[Dict[str, Any]] = [] + featureCode = str(rec.get("featureCode") or "") if rec else "" + nodes: List[UdbNode] = [] for field in fieldNames: if not field: continue - fieldNeutralized = bool(tableNeutralize or field in neutralizeFields) - nodes.append({ - "key": _encode("fdsfld", featureInstanceId, tableName, field), - "kind": "fdsField", - "parentKey": parentKey, - "label": field, - "icon": "field", - "hasChildren": False, - "dataSourceId": rec.get("id") if rec else None, - "modelType": "FeatureDataSource" if rec else None, - "effectiveNeutralize": fieldNeutralized, - # Field-level scope/RAG do not exist as a concept; the FE hides - # those affordances when supportsRag=False. We still need - # `effectiveScope` + `effectiveRagIndexEnabled` for the - # contract; they reflect the parent's effective values so the - # backend stays single source of truth. - "effectiveScope": "personal", - "effectiveRagIndexEnabled": False, - "supportsRag": False, - "canBeAdded": rec is None, - "featureInstanceId": featureInstanceId, - "tableName": tableName, - "fieldName": field, - }) + nodes.append(FdsFieldNode( + featureInstanceId=featureInstanceId, + tableName=tableName, + fieldName=field, + parentKey=parentKey, + tableRec=rec, + featureCode=featureCode, + )) return nodes @@ -698,16 +579,19 @@ def resolveTextSafe(label: Any) -> str: # --------------------------------------------------------------------------- async def getChildrenForParents( - instanceId: str, parents: List[Optional[str]], context: Any, ) -> Dict[str, List[Dict[str, Any]]]: - """Return per-parent children lists. + """Return per-parent children lists serialised to dicts for the wire. - `parents` is a list with `None` representing the top-level. Order is preserved. - Returns a dict keyed by parent key (or '__root__' for None). + `parents` is a list with `None` representing the top-level. Order is + preserved. Returns a dict keyed by parent key (or '__root__' for + None). Each child is a fully-rendered TreeNode dict produced by + `UdbNode.toDict(...)`. - Each child is a fully-rendered TreeNode dict (see module docstring for shape). + The UDB is feature-agnostic: there is no `instanceId` parameter. The + visible scope is determined entirely by the caller's accessible + mandates and feature instances. """ from modules.interfaces.interfaceDbApp import getRootInterface from modules.datamodels.datamodelDataSource import DataSource @@ -715,17 +599,23 @@ async def getChildrenForParents( rootIf = getRootInterface() - # Pre-load DS (per user) and FDS (per workspace) once for the whole request. userId = str(context.user.id) allDs = rootIf.db.getRecordset(DataSource, recordFilter={"userId": userId}) or [] - allFds = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"workspaceInstanceId": instanceId}) or [] + # FDS visibility: all FDS in accessible mandates. Restrict by the + # context's mandateId when present (workspace caller), otherwise + # union of accessible mandates. + fdsFilter: Dict[str, Any] = {} + wsMandateId = getattr(context, "mandateId", None) + if wsMandateId: + fdsFilter["mandateId"] = wsMandateId + allFds = rootIf.db.getRecordset(FeatureDataSource, recordFilter=fdsFilter) or [] - out: Dict[str, List[Dict[str, Any]]] = {} + out: Dict[str, List[UdbNode]] = {} for parentKey in parents: if parentKey is None: try: - out["__root__"] = _topLevel(instanceId, context, rootIf, allDs, allFds) + out["__root__"] = _topLevel(context, rootIf, allDs, allFds) except Exception as exc: logger.exception("Tree top-level failed: %s", exc) out["__root__"] = [] @@ -739,39 +629,40 @@ async def getChildrenForParents( try: if parentKey == _KEY_PERSONAL_ROOT: - out[parentKey] = _personalRootChildren(instanceId, context, allDs) + out[parentKey] = _personalRootChildrenNodes(context, allDs) elif kind == "conn" and len(parts) == 1: - out[parentKey] = await _connectionServices(instanceId, context, parts[0], allDs) + out[parentKey] = await _connectionServiceNodes(_callerInstanceId(context), context, parts[0], allDs) elif kind == "svc" and len(parts) == 2: connId, service = parts sourceType = _SERVICE_TO_SOURCE_TYPE.get(service, service) - out[parentKey] = await _browseChildren( - instanceId, context, connId, service, sourceType, "/", allDs, + out[parentKey] = await _browseChildNodes( + _callerInstanceId(context), context, connId, service, sourceType, "/", allDs, parentKey=parentKey, ) - elif kind == "ds" and len(parts) == 3: - connId, sourceType, path = parts - # Determine service from sourceType (reverse map) + elif kind == "ds" and len(parts) >= 3: + connId = parts[0] + sourceType = parts[1] + path = _KEY_SEP.join(parts[2:]) service = _reverseService(sourceType) - out[parentKey] = await _browseChildren( - instanceId, context, connId, service, sourceType, path, allDs, + out[parentKey] = await _browseChildNodes( + _callerInstanceId(context), context, connId, service, sourceType, path, allDs, parentKey=parentKey, ) elif kind == "mgrp" and len(parts) == 1: - out[parentKey] = _featureConnectionsForMandate(instanceId, context, rootIf, parts[0], allFds) + out[parentKey] = _featureConnectionsForMandateNodes(rootIf, context, parts[0], allFds) elif kind == "feat" and len(parts) == 3: _mandateId, featureCode, featureInstanceId = parts - out[parentKey] = _featureTables(context, rootIf, parentKey, featureInstanceId, featureCode, allFds) + out[parentKey] = _featureTableNodes(context, rootIf, parentKey, featureInstanceId, featureCode, allFds) elif kind == "fdstbl" and len(parts) == 2: featureInstanceId, tableName = parts fieldNames = _resolveTableFieldNames(featureInstanceId, tableName, rootIf) - out[parentKey] = _featureTableFields( + out[parentKey] = _featureTableFieldNodes( parentKey, featureInstanceId, tableName, fieldNames, allFds, ) @@ -781,7 +672,21 @@ async def getChildrenForParents( logger.exception("Tree children for %s failed: %s", parentKey, exc) out[parentKey] = [] - return out + # Serialize all nodes to dicts via their polymorphic toDict(). + serialised: Dict[str, List[Dict[str, Any]]] = {} + for parentKey, nodes in out.items(): + serialised[parentKey] = [n.toDict(allDs, allFds) for n in nodes] + return serialised + + +def _callerInstanceId(context: Any) -> str: + """The UDB is feature-agnostic, but `_browseChildNodes` and + `_connectionServiceNodes` need a feature instance id for the + ServiceCenterContext (the underlying connector resolver wants one). + Use the caller's current feature_instance_id (workspace) when + available, else an empty string. The id is NOT used for FDS scoping.""" + fid = getattr(context, "feature_instance_id", None) or getattr(context, "featureInstanceId", None) + return str(fid) if fid else "" def _reverseService(sourceType: str) -> str: @@ -808,213 +713,3 @@ def _resolveTableFieldNames(featureInstanceId: str, tableName: str, rootIf: Any) return [f for f in fields if isinstance(f, str) and f] return [] return [] - - -# --------------------------------------------------------------------------- -# Attribute-only refresh: given node keys, return current effective values -# --------------------------------------------------------------------------- - -async def getAttributesForKeys( - instanceId: str, - keys: List[str], - context: Any, -) -> Dict[str, Dict[str, Any]]: - """Return effective attribute values for a list of node keys. - - Used by the frontend after a toggle to refresh only attributes (neutralize, - scope, ragIndexEnabled) without reloading the tree structure. For container - nodes (personalRoot, mgrp), aggregates child values and returns 'mixed' - when children diverge.""" - from modules.interfaces.interfaceDbApp import getRootInterface - from modules.datamodels.datamodelDataSource import DataSource - from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource - - rootIf = getRootInterface() - userId = str(context.user.id) - allDs = rootIf.db.getRecordset(DataSource, recordFilter={"userId": userId}) or [] - allFds = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"workspaceInstanceId": instanceId}) or [] - - result: Dict[str, Dict[str, Any]] = {} - - for key in keys: - try: - attrs = _resolveAttrsForKey(key, allDs, allFds, instanceId, context, rootIf) - if attrs is not None: - result[key] = attrs - if "mixed" in str(attrs.values()): - logger.info("getAttributesForKeys key=%s returned MIXED: %s", key, attrs) - except Exception as exc: - logger.warning("getAttributesForKeys failed for key=%s: %s", key, exc) - - logger.info("getAttributesForKeys: %d keys requested, %d resolved", len(keys), len(result)) - return result - - -def _resolveAttrsForKey( - key: str, - allDs: List[Dict[str, Any]], - allFds: List[Dict[str, Any]], - instanceId: str, - context: Any, - rootIf: Any, -) -> Optional[Dict[str, Any]]: - """Resolve effective attributes for a single node key.""" - if key == _KEY_PERSONAL_ROOT: - return _aggregatePersonalRoot(allDs) - - try: - kind, parts = _decode(key) - except Exception: - return None - - if kind == "mgrp" and len(parts) == 1: - return _aggregateMandateGroup(parts[0], allFds, instanceId, context, rootIf) - - if kind == "conn" and len(parts) == 1: - connId = parts[0] - return _aggregateConnection(connId, allDs) - - if kind == "svc" and len(parts) == 2: - connId, service = parts - sourceType = _SERVICE_TO_SOURCE_TYPE.get(service, service) - return _effectiveTripletDs(connId, sourceType, "/", allDs) - - if kind == "ds" and len(parts) == 3: - connId, sourceType, path = parts - return _effectiveTripletDs(connId, sourceType, path, allDs) - - if kind == "feat" and len(parts) == 3: - _mandateId, _featureCode, featureInstanceId = parts - return _effectiveTripletFds(featureInstanceId, "*", None, allFds) - - if kind == "fdsws" and len(parts) == 2: - workspaceInstanceId, _featureCode = parts - return _effectiveTripletFds(workspaceInstanceId, "*", None, allFds) - - if kind == "fdstbl" and len(parts) == 2: - featureInstanceId, tableName = parts - return _effectiveTripletFds(featureInstanceId, tableName, None, allFds) - - if kind == "fdsrec" and len(parts) == 3: - featureInstanceId, tableName, recordId = parts - return _effectiveTripletFds(featureInstanceId, tableName, {"objectKey": recordId}, allFds) - - if kind == "fdsfld" and len(parts) >= 3: - featureInstanceId, tableName = parts[0], parts[1] - fieldName = parts[2] if len(parts) > 2 else "" - parentFds = None - for fds in allFds: - if (fds.get("featureInstanceId") == featureInstanceId - and (fds.get("tableName") or "") == tableName - and fds.get("recordFilter") is None): - parentFds = fds - break - neutralizeFields = (parentFds.get("neutralizeFields") or []) if parentFds else [] - return {"effectiveNeutralize": fieldName in neutralizeFields} - - return None - - -def _aggregateConnection(connId: str, allDs: List[Dict[str, Any]]) -> Dict[str, Any]: - """Aggregate effective values for a connection node. - - If the connection has an authority-level DS record (path="/"), use the - standard aggregate mode on it (which already handles subtree correctly). - Otherwise compute effective values for each child DS using walk mode and - aggregate them manually.""" - from modules.serviceCenter.services.serviceKnowledge._inheritFlags import ( - getEffectiveFlag, _AUTHORITY_SOURCE_TYPES, - ) - connRecords = [d for d in allDs if d.get("connectionId") == connId] - if not connRecords: - return {"effectiveNeutralize": False, "effectiveScope": "personal", "effectiveRagIndexEnabled": False} - - rootRec = None - for r in connRecords: - st = r.get("sourceType", "") - if st in _AUTHORITY_SOURCE_TYPES and _normalisePath(r.get("path", "")) == "/": - rootRec = r - break - - if rootRec: - return _effectiveTripletDs(connId, rootRec.get("sourceType", ""), "/", allDs) - - neutralizeVals = set() - scopeVals = set() - ragVals = set() - for r in connRecords: - neutralizeVals.add(getEffectiveFlag(r, "neutralize", allDs, mode="walk")) - scopeVals.add(getEffectiveFlag(r, "scope", allDs, mode="walk")) - ragVals.add(getEffectiveFlag(r, "ragIndexEnabled", allDs, mode="walk")) - return { - "effectiveNeutralize": "mixed" if len(neutralizeVals) > 1 else (neutralizeVals.pop() if neutralizeVals else False), - "effectiveScope": "mixed" if len(scopeVals) > 1 else (scopeVals.pop() if scopeVals else "personal"), - "effectiveRagIndexEnabled": "mixed" if len(ragVals) > 1 else (ragVals.pop() if ragVals else False), - } - - -def _aggregatePersonalRoot(allDs: List[Dict[str, Any]]) -> Dict[str, Any]: - """Aggregate effective values across all personal DS records. - - Uses getEffectiveFlag in aggregate mode on each connection-root record. - If no root records exist, aggregates walk-effective values of all records.""" - from modules.serviceCenter.services.serviceKnowledge._inheritFlags import ( - getEffectiveFlag, _AUTHORITY_SOURCE_TYPES, - ) - if not allDs: - return {"effectiveNeutralize": False, "effectiveScope": "personal", "effectiveRagIndexEnabled": False} - - rootRecords = [ - d for d in allDs - if d.get("sourceType", "") in _AUTHORITY_SOURCE_TYPES - and _normalisePath(d.get("path", "")) == "/" - ] - targets = rootRecords if rootRecords else allDs - - neutralizeVals = set() - scopeVals = set() - ragVals = set() - for ds in targets: - neutralizeVals.add(getEffectiveFlag(ds, "neutralize", allDs, mode="aggregate")) - scopeVals.add(getEffectiveFlag(ds, "scope", allDs, mode="aggregate")) - ragVals.add(getEffectiveFlag(ds, "ragIndexEnabled", allDs, mode="aggregate")) - return { - "effectiveNeutralize": "mixed" if len(neutralizeVals) > 1 else (neutralizeVals.pop() if neutralizeVals else False), - "effectiveScope": "mixed" if len(scopeVals) > 1 else (scopeVals.pop() if scopeVals else "personal"), - "effectiveRagIndexEnabled": "mixed" if len(ragVals) > 1 else (ragVals.pop() if ragVals else False), - } - - -def _aggregateMandateGroup( - mandateId: str, - allFds: List[Dict[str, Any]], - instanceId: str, - context: Any, - rootIf: Any, -) -> Dict[str, Any]: - """Aggregate effective values across FDS records belonging to this mandate group. - - Uses getEffectiveFlagFds in aggregate mode on each workspace-level FDS - (tableName="*") that belongs to the given mandateId. This correctly resolves - inherited values from the full FDS hierarchy.""" - from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlagFds - - groupFds = [f for f in allFds if f.get("mandateId") == mandateId] - workspaceLevelFds = [f for f in groupFds if (f.get("tableName") or "") == "*"] - targets = workspaceLevelFds if workspaceLevelFds else groupFds - - if not targets: - return {"effectiveNeutralize": False, "effectiveScope": "personal", "effectiveRagIndexEnabled": False} - - neutralizeVals = set() - scopeVals = set() - ragVals = set() - for fds in targets: - neutralizeVals.add(getEffectiveFlagFds(fds, "neutralize", allFds, mode="aggregate")) - scopeVals.add(getEffectiveFlagFds(fds, "scope", allFds, mode="aggregate")) - ragVals.add(getEffectiveFlagFds(fds, "ragIndexEnabled", allFds, mode="aggregate")) - return { - "effectiveNeutralize": "mixed" if len(neutralizeVals) > 1 else (neutralizeVals.pop() if neutralizeVals else False), - "effectiveScope": "mixed" if len(scopeVals) > 1 else (scopeVals.pop() if scopeVals else "personal"), - "effectiveRagIndexEnabled": "mixed" if len(ragVals) > 1 else (ragVals.pop() if ragVals else False), - } diff --git a/modules/serviceCenter/services/serviceKnowledge/_inheritFlags.py b/modules/serviceCenter/services/serviceKnowledge/_inheritFlags.py index 64a0019c..b211839f 100644 --- a/modules/serviceCenter/services/serviceKnowledge/_inheritFlags.py +++ b/modules/serviceCenter/services/serviceKnowledge/_inheritFlags.py @@ -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), } diff --git a/modules/serviceCenter/services/serviceKnowledge/subFeatureBootstrap.py b/modules/serviceCenter/services/serviceKnowledge/subFeatureBootstrap.py index aa81d929..e2aba02b 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subFeatureBootstrap.py +++ b/modules/serviceCenter/services/serviceKnowledge/subFeatureBootstrap.py @@ -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, diff --git a/modules/serviceCenter/services/serviceKnowledge/udbNodes.py b/modules/serviceCenter/services/serviceKnowledge/udbNodes.py new file mode 100644 index 00000000..2d12554e --- /dev/null +++ b/modules/serviceCenter/services/serviceKnowledge/udbNodes.py @@ -0,0 +1,1055 @@ +# Copyright (c) 2026 Patrick Motsch +# All rights reserved. +"""Polymorphic UdbNode hierarchy for the Unified Data Bar. + +Each UDB tree node is represented by an `UdbNode` subclass that encapsulates +its own behavior: + + - which flags it supports (`supportsFlag`) + - whether the current user may edit it (`canEdit`) + - how to compute an effective flag value (`getEffectiveFlag`) + - how to persist a flag change (`setFlag`) + - how to enumerate its logical children for aggregation + (`getLogicalChildren`) + - how to render itself to a JSON dict (`toDict`) + +Concrete subclasses fall into four families that mirror the UDB domain +model (see wiki/b-reference/platform/unified-data-bar.md): + + - SyntheticContainerNode -- structural containers, no DB record + - DataSourceNode (+children)-- user-private DataSource records + - FdsRecordNode (+children)-- feature-owned FeatureDataSource records + - FdsFieldNode -- virtual per-column nodes under fdsTable + +The classes use `_inheritFlags.py` as a helper module for the actual +walk/aggregate/cascade arithmetic, so the inheritance semantics live in +one place. The classes themselves only express "what does this node type +DO" -- ownership, RBAC, persistence routing, child enumeration. +""" + +from __future__ import annotations + +import logging +from abc import ABC, abstractmethod +from typing import Any, Dict, List, Optional, Tuple + +logger = logging.getLogger(__name__) + + +_KEY_SEP = "|" + + +def _decode(key: str) -> Tuple[str, List[str]]: + """Decode a UDB tree key into (kind, [parts...]).""" + parts = key.split(_KEY_SEP) + return parts[0], parts[1:] + + +def _encode(kind: str, *parts: str) -> str: + """Encode kind + parts into a stable tree key.""" + return _KEY_SEP.join((kind, *parts)) + + +# --------------------------------------------------------------------------- +# Abstract base +# --------------------------------------------------------------------------- + +class UdbNode(ABC): + """Polymorphic UDB tree node. + + Subclasses MUST implement the abstract methods. Defaults are kept + minimal so each subclass's responsibilities are explicit. + + `parentKey` is set by the builder when the node is emitted; it is the + tree key of the directly-rendered parent (which may differ from the + semantic ancestor used for flag inheritance). + """ + + kind: str = "abstract" + + def __init__(self, key: str, label: str, parentKey: Optional[str] = None) -> None: + self.key = key + self.label = label + self.parentKey = parentKey + + # --- domain hooks ------------------------------------------------------- + + def supportsFlag(self, flag: str) -> bool: + """Whether this node carries a value for `flag` at all. + + Subclasses override to restrict (e.g. FDS has no scope; fdsField + only has neutralize). + """ + return flag in ("neutralize", "scope", "ragIndexEnabled") + + @abstractmethod + def canEdit(self, context: Any, rootIf: Any) -> bool: + """Permission check: may the calling user mutate flags on this node? + + Polymorph rule: + - DataSource* nodes: owner-of-record check (rec.userId == user). + - FdsRecord* / FdsField nodes: feature-admin check on the FDS's + featureInstanceId. + - Synthetic containers: never editable (defensive). + """ + + @abstractmethod + def getEffectiveFlag(self, flag: str, allDs: List[Dict[str, Any]], + allFds: List[Dict[str, Any]], mode: str = "aggregate") -> Any: + """Compute the effective value of `flag` for this node. + + `mode='walk'` returns the concrete inherited value (never 'mixed'). + `mode='aggregate'` returns 'mixed' iff the logical subtree disagrees. + Synthetic containers compute aggregate from their logical children. + """ + + def setFlag(self, flag: str, value: Any, rootIf: Any) -> List[str]: + """Persist a new value for `flag`. Default: not supported. + + Returns the IDs of descendant records reset to None (cascade). + Subclasses that own a DB record override; synthetic containers and + virtual nodes refuse. + """ + raise NotImplementedError( + f"{type(self).__name__} does not support setFlag({flag!r})" + ) + + def getLogicalChildren(self, allDs: List[Dict[str, Any]], + allFds: List[Dict[str, Any]], + rootIf: Any, context: Any) -> List["UdbNode"]: + """Return the children used for aggregate flag computation. + + These are NOT the rendered tree children -- they are the logical + descendants whose effective values determine whether this node is + 'mixed'. Default: empty (leaf). + """ + return [] + + @abstractmethod + def toDict(self, allDs: List[Dict[str, Any]], + allFds: List[Dict[str, Any]]) -> Dict[str, Any]: + """Serialize to the dict shape consumed by the frontend tree. + + Implementations include effective flag values for the flags this + node supports, plus rendering hints (icon, hasChildren, etc.). + """ + + +# --------------------------------------------------------------------------- +# Synthetic containers +# --------------------------------------------------------------------------- + +class SyntheticContainerNode(UdbNode): + """Structural container with no DB record (personalRoot, mandateGroup). + + Aggregates the effective flag values from its logical children for + display only; cannot be edited. + """ + + kind = "synthRoot" + + def __init__(self, key: str, label: str, icon: str, + parentKey: Optional[str] = None, + displayOrder: int = 0, + defaultExpanded: bool = False, + logicalChildren: Optional[List[UdbNode]] = None) -> None: + super().__init__(key, label, parentKey) + self.icon = icon + self.displayOrder = displayOrder + self.defaultExpanded = defaultExpanded + self._logicalChildren: List[UdbNode] = list(logicalChildren or []) + + def addLogicalChild(self, child: UdbNode) -> None: + self._logicalChildren.append(child) + + def supportsFlag(self, flag: str) -> bool: + return False + + def canEdit(self, context: Any, rootIf: Any) -> bool: + return False + + def getLogicalChildren(self, allDs, allFds, rootIf, context) -> List[UdbNode]: + return list(self._logicalChildren) + + def getEffectiveFlag(self, flag, allDs, allFds, mode="aggregate") -> Any: + return _aggregateFromChildren(self, flag, allDs, allFds, mode) + + def toDict(self, allDs, allFds) -> Dict[str, Any]: + return { + "key": self.key, + "kind": "synthRoot" if self.key != _KEY_MANDATE_GROUP_PREFIX else self.kind, + "parentKey": self.parentKey, + "label": self.label, + "icon": self.icon, + "hasChildren": True, + "dataSourceId": None, + "modelType": None, + "effectiveNeutralize": self.getEffectiveFlag("neutralize", allDs, allFds, "aggregate"), + "effectiveScope": self.getEffectiveFlag("scope", allDs, allFds, "aggregate") or "personal", + "effectiveRagIndexEnabled": self.getEffectiveFlag("ragIndexEnabled", allDs, allFds, "aggregate"), + "supportsRag": False, + "canBeAdded": False, + "displayOrder": self.displayOrder, + "defaultExpanded": self.defaultExpanded, + } + + +_KEY_MANDATE_GROUP_PREFIX = "mgrp" + + +class MandateGroupNode(SyntheticContainerNode): + """Synthetic top-level container, one per accessible mandate.""" + + kind = "mandateGroup" + + def __init__(self, mandateId: str, label: str) -> None: + super().__init__( + key=_encode("mgrp", mandateId), + label=label, + icon="mandate", + parentKey=None, + defaultExpanded=True, + ) + self.mandateId = mandateId + + def toDict(self, allDs, allFds) -> Dict[str, Any]: + out = super().toDict(allDs, allFds) + out["kind"] = "mandateGroup" + out["mandateId"] = self.mandateId + return out + + +# --------------------------------------------------------------------------- +# DataSource family +# --------------------------------------------------------------------------- + +class _DataSourceFamilyNode(UdbNode): + """Shared behavior for DS-backed nodes (connection, service, folder, file). + + A node either has an existing DataSource record (`rec`) or is "virtual" + -- a coordinate (connectionId/sourceType/path) that the user has not + yet pinned. Virtual nodes can still report effective flag values (they + walk the ancestor chain) but cannot be edited until the record exists. + """ + + def __init__(self, key: str, label: str, parentKey: Optional[str], + connectionId: str, sourceType: str, path: str, + rec: Optional[Dict[str, Any]]) -> None: + super().__init__(key, label, parentKey) + self.connectionId = connectionId + self.sourceType = sourceType + self.path = path + self.rec = rec + + def canEdit(self, context: Any, rootIf: Any) -> bool: + if self.rec: + ownerId = str(self.rec.get("userId") or "") + return ownerId == str(context.user.id) + return _isConnectionOwner(rootIf, str(context.user.id), self.connectionId) + + def getEffectiveFlag(self, flag, allDs, allFds, mode="aggregate") -> Any: + from modules.serviceCenter.services.serviceKnowledge._inheritFlags import ( + resolveEffectiveForPath, + ) + out = resolveEffectiveForPath(self.connectionId, self.sourceType, self.path, allDs, mode=mode) + key = "effective" + flag[0].upper() + flag[1:] + return out.get(key, False if flag != "scope" else "personal") + + def setFlag(self, flag, value, rootIf) -> List[str]: + from modules.datamodels.datamodelDataSource import DataSource + from modules.serviceCenter.services.serviceKnowledge._inheritFlags import ( + cascadeResetDescendants, + ) + if not self.rec: + self.rec = _findOrCreateDs( + rootIf, self.connectionId, self.sourceType, self.path, + ) + sourceId = self.rec.get("id") + resetIds: List[str] = [] + if value is not None: + resetIds = cascadeResetDescendants(rootIf, self.rec, flag) + rootIf.db.recordModify(DataSource, sourceId, {flag: value}) + return resetIds + + +class ConnectionNode(_DataSourceFamilyNode): + """Connection-root DataSource node (path='/', authority sourceType).""" + + kind = "connection" + + def __init__(self, connectionId: str, authority: str, label: str, + parentKey: str, rec: Optional[Dict[str, Any]]) -> None: + super().__init__( + key=_encode("conn", connectionId), + label=label, + parentKey=parentKey, + connectionId=connectionId, + sourceType=str(authority), + path="/", + rec=rec, + ) + self.authority = authority + + def toDict(self, allDs, allFds) -> Dict[str, Any]: + return _dsDict(self, allDs) + + +class ServiceNode(_DataSourceFamilyNode): + """Service-level node (e.g. sharepoint, drive) under a Connection.""" + + kind = "service" + + def __init__(self, connectionId: str, service: str, sourceType: str, + label: str, parentKey: str, rec: Optional[Dict[str, Any]]) -> None: + super().__init__( + key=_encode("svc", connectionId, service), + label=label, + parentKey=parentKey, + connectionId=connectionId, + sourceType=sourceType, + path="/", + rec=rec, + ) + self.service = service + + def toDict(self, allDs, allFds) -> Dict[str, Any]: + out = _dsDict(self, allDs) + out["service"] = self.service + return out + + +class _BrowseNode(_DataSourceFamilyNode): + """Folder/File node from a connector browse() result.""" + + def __init__(self, kind: str, connectionId: str, service: str, + sourceType: str, path: str, label: str, + parentKey: str, rec: Optional[Dict[str, Any]], + hasChildren: bool) -> None: + super().__init__( + key=_encode("ds", connectionId, sourceType, path), + label=label, + parentKey=parentKey, + connectionId=connectionId, + sourceType=sourceType, + path=path, + rec=rec, + ) + self.kind = kind + self.service = service + self._hasChildren = hasChildren + + def toDict(self, allDs, allFds) -> Dict[str, Any]: + out = _dsDict(self, allDs) + out["service"] = self.service + out["hasChildren"] = self._hasChildren + return out + + +class FolderNode(_BrowseNode): + """Folder DataSource node from connector browse().""" + + def __init__(self, **kwargs) -> None: + super().__init__(kind="folder", **kwargs) + + +class FileNode(_BrowseNode): + """File DataSource node from connector browse().""" + + def __init__(self, **kwargs) -> None: + super().__init__(kind="file", **kwargs) + + +def _dsDict(node: _DataSourceFamilyNode, allDs: List[Dict[str, Any]]) -> Dict[str, Any]: + """Shared serialization for DS-family nodes.""" + return { + "key": node.key, + "kind": node.kind, + "parentKey": node.parentKey, + "label": node.label, + "icon": getattr(node, "authority", None) or getattr(node, "service", None) or node.kind, + "hasChildren": True, + "dataSourceId": node.rec.get("id") if node.rec else None, + "modelType": "DataSource" if node.rec else None, + "effectiveNeutralize": node.getEffectiveFlag("neutralize", allDs, [], "aggregate"), + "effectiveScope": node.getEffectiveFlag("scope", allDs, [], "aggregate"), + "effectiveRagIndexEnabled": node.getEffectiveFlag("ragIndexEnabled", allDs, [], "aggregate"), + "supportsRag": True, + "canBeAdded": node.rec is None, + "connectionId": node.connectionId, + "sourceType": node.sourceType, + "path": node.path, + "authority": getattr(node, "authority", None), + } + + +# --------------------------------------------------------------------------- +# FDS family +# --------------------------------------------------------------------------- + +class _FdsFamilyNode(UdbNode): + """Shared behavior for FDS-backed nodes (featureNode/fdsTable/fdsRecord). + + FDS has no `scope` attribute (visibility is feature RBAC). Edit + permission requires the user to hold a feature-admin role on the + FDS's `featureInstanceId`. + """ + + def __init__(self, key: str, label: str, parentKey: Optional[str], + featureInstanceId: str, tableName: str, + recordFilter: Optional[Dict[str, str]], + rec: Optional[Dict[str, Any]]) -> None: + super().__init__(key, label, parentKey) + self.featureInstanceId = featureInstanceId + self.tableName = tableName + self.recordFilter = recordFilter + self.rec = rec + + def supportsFlag(self, flag: str) -> bool: + return flag in ("neutralize", "ragIndexEnabled") + + def canEdit(self, context: Any, rootIf: Any) -> bool: + return _isFeatureAdmin(rootIf, str(context.user.id), self.featureInstanceId) + + def getEffectiveFlag(self, flag, allDs, allFds, mode="aggregate") -> Any: + if not self.supportsFlag(flag): + return None + from modules.serviceCenter.services.serviceKnowledge._inheritFlags import ( + resolveEffectiveForFds, + ) + out = resolveEffectiveForFds(self.featureInstanceId, self.tableName, + self.recordFilter, allFds, mode=mode) + key = "effective" + flag[0].upper() + flag[1:] + return out.get(key, False) + + def setFlag(self, flag, value, rootIf) -> List[str]: + if not self.supportsFlag(flag): + raise ValueError(f"FDS does not support flag {flag!r}") + from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + from modules.serviceCenter.services.serviceKnowledge._inheritFlags import ( + cascadeResetDescendantsFds, + ) + if not self.rec: + raise RuntimeError( + f"Cannot setFlag on virtual FDS node {self.key}: " + "create the FeatureDataSource record first." + ) + sourceId = self.rec.get("id") + resetIds: List[str] = [] + if value is not None: + resetIds = cascadeResetDescendantsFds(rootIf, self.rec, flag) + modifyFields: Dict[str, Any] = {flag: value} + self._onSetFlag(modifyFields, flag, value, rootIf) + rootIf.db.recordModify(FeatureDataSource, sourceId, modifyFields) + return resetIds + + def _onSetFlag(self, modifyFields: Dict[str, Any], flag: str, + value: Any, rootIf: Any) -> None: + """Subclass hook: extend `modifyFields` or perform side-effects.""" + pass + + +class FdsWorkspaceNode(_FdsFamilyNode): + """Synthetic feature-wildcard FDS node (tableName='*'). + + Rendered as 'featureNode' in the tree; one per accessible feature + instance under its mandate group. + """ + + kind = "featureNode" + + def __init__(self, mandateId: str, featureCode: str, featureInstanceId: str, + label: str, icon: str, parentKey: str, + rec: Optional[Dict[str, Any]]) -> None: + super().__init__( + key=_encode("feat", mandateId, featureCode, featureInstanceId), + label=label, + parentKey=parentKey, + featureInstanceId=featureInstanceId, + tableName="*", + recordFilter=None, + rec=rec, + ) + self.mandateId = mandateId + self.featureCode = featureCode + self.icon = icon + + def _onSetFlag(self, modifyFields, flag, value, rootIf): + if flag == "neutralize" and value is not None: + self._clearDescendantNeutralizeFields(rootIf) + + def _clearDescendantNeutralizeFields(self, rootIf): + """Wipe `neutralizeFields` on all descendant table FDS records. + + When the workspace sets an explicit neutralize, per-column + overrides on descendant tables become obsolete — the workspace + value cascades down via inheritance. Without clearing them the + table aggregate stays 'mixed' because some field children still + read True from the list while others inherit the new value. + """ + from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + allFds = rootIf.db.getRecordset( + FeatureDataSource, + recordFilter={"featureInstanceId": self.featureInstanceId}, + ) or [] + ownId = self.rec.get("id") if self.rec else None + for fds in allFds: + fdsId = fds.get("id") + if fdsId == ownId: + continue + nf = fds.get("neutralizeFields") + if isinstance(nf, list) and len(nf) > 0: + rootIf.db.recordModify( + FeatureDataSource, fdsId, {"neutralizeFields": None}, + ) + + def toDict(self, allDs, allFds) -> Dict[str, Any]: + return { + "key": self.key, + "kind": "featureNode", + "parentKey": self.parentKey, + "label": self.label, + "icon": self.icon, + "hasChildren": True, + "dataSourceId": self.rec.get("id") if self.rec else None, + "modelType": "FeatureDataSource" if self.rec else None, + "effectiveNeutralize": self.getEffectiveFlag("neutralize", allDs, allFds, "aggregate"), + "effectiveScope": "personal", + "effectiveRagIndexEnabled": self.getEffectiveFlag("ragIndexEnabled", allDs, allFds, "aggregate"), + "supportsRag": True, + "canBeAdded": self.rec is None, + "featureInstanceId": self.featureInstanceId, + "featureCode": self.featureCode, + "mandateId": self.mandateId, + "tableName": "*", + } + + +class FdsTableNode(_FdsFamilyNode): + """Table-level FDS node (concrete tableName, no recordFilter).""" + + kind = "fdsTable" + + def __init__(self, featureInstanceId: str, featureCode: str, tableName: str, + objectKey: str, label: str, parentKey: str, + rec: Optional[Dict[str, Any]], hasFields: bool) -> None: + super().__init__( + key=_encode("fdstbl", featureInstanceId, tableName), + label=label, + parentKey=parentKey, + featureInstanceId=featureInstanceId, + tableName=tableName, + recordFilter=None, + rec=rec, + ) + self.featureCode = featureCode + self.objectKey = objectKey + self._hasFields = hasFields + + def _onSetFlag(self, modifyFields, flag, value, rootIf): + if flag == "neutralize" and value is not None: + modifyFields["neutralizeFields"] = None + + def toDict(self, allDs, allFds) -> Dict[str, Any]: + neutralizeFields: List[str] = [] + if self.rec and isinstance(self.rec.get("neutralizeFields"), list): + neutralizeFields = [f for f in self.rec["neutralizeFields"] if isinstance(f, str)] + return { + "key": self.key, + "kind": "fdsTable", + "parentKey": self.parentKey, + "label": self.label, + "icon": "table", + "hasChildren": self._hasFields, + "dataSourceId": self.rec.get("id") if self.rec else None, + "modelType": "FeatureDataSource" if self.rec else None, + "effectiveNeutralize": self.getEffectiveFlag("neutralize", allDs, allFds, "aggregate"), + "effectiveScope": "personal", + "effectiveRagIndexEnabled": self.getEffectiveFlag("ragIndexEnabled", allDs, allFds, "aggregate"), + "supportsRag": True, + "canBeAdded": self.rec is None, + "featureInstanceId": self.featureInstanceId, + "featureCode": self.featureCode, + "tableName": self.tableName, + "objectKey": self.objectKey, + "neutralizeFields": neutralizeFields, + } + + +class FdsRowNode(_FdsFamilyNode): + """Row-level FDS node (recordFilter pins specific rows).""" + + kind = "fdsRecord" + + def __init__(self, featureInstanceId: str, tableName: str, recordId: str, + recordFilter: Dict[str, str], label: str, parentKey: str, + rec: Optional[Dict[str, Any]]) -> None: + super().__init__( + key=_encode("fdsrec", featureInstanceId, tableName, recordId), + label=label, + parentKey=parentKey, + featureInstanceId=featureInstanceId, + tableName=tableName, + recordFilter=recordFilter, + rec=rec, + ) + + def toDict(self, allDs, allFds) -> Dict[str, Any]: + return { + "key": self.key, + "kind": "fdsRecord", + "parentKey": self.parentKey, + "label": self.label, + "icon": "row", + "hasChildren": False, + "dataSourceId": self.rec.get("id") if self.rec else None, + "modelType": "FeatureDataSource" if self.rec else None, + "effectiveNeutralize": self.getEffectiveFlag("neutralize", allDs, allFds, "aggregate"), + "effectiveScope": "personal", + "effectiveRagIndexEnabled": self.getEffectiveFlag("ragIndexEnabled", allDs, allFds, "aggregate"), + "supportsRag": True, + "canBeAdded": self.rec is None, + "featureInstanceId": self.featureInstanceId, + "tableName": self.tableName, + } + + +class FdsFieldNode(UdbNode): + """Per-column virtual node under an FdsTableNode. + + Has no DB record of its own. The 'neutralize' state is two-source: + + 1. `field in tableRec.neutralizeFields` -> explicit override -> True. + 2. otherwise -> field INHERITS the effective neutralize of its + table (which itself walks the FDS ancestor chain up to the + workspace wildcard). + + Setting the flag toggles the field's membership in + `tableRec.neutralizeFields` only; it never writes a dedicated record. + + Supports only the `neutralize` flag; scope and rag do not exist + field-level. The per-list mechanic intentionally cannot express + "explicit False"; an explicit table value covers all fields equally + via inheritance (matching the cascade-reset semantics in + `_FdsFamilyNode.setFlag`, which wipes `neutralizeFields` when the + table's neutralize is set explicitly). + """ + + kind = "fdsField" + + def __init__(self, featureInstanceId: str, tableName: str, fieldName: str, + parentKey: str, tableRec: Optional[Dict[str, Any]], + featureCode: str = "") -> None: + super().__init__( + key=_encode("fdsfld", featureInstanceId, tableName, fieldName), + label=fieldName, + parentKey=parentKey, + ) + self.featureInstanceId = featureInstanceId + self.tableName = tableName + self.fieldName = fieldName + self.featureCode = featureCode + self.tableRec = tableRec + + def supportsFlag(self, flag: str) -> bool: + return flag == "neutralize" + + def canEdit(self, context: Any, rootIf: Any) -> bool: + return _isFeatureAdmin(rootIf, str(context.user.id), self.featureInstanceId) + + def getEffectiveFlag(self, flag, allDs, allFds, mode="aggregate") -> Any: + if flag != "neutralize": + return False + rec = self.tableRec or _findFdsByCoord(allFds, self.featureInstanceId, self.tableName, None) + fields = rec.get("neutralizeFields") if rec else None + if isinstance(fields, list) and self.fieldName in fields: + return True + # Not explicitly overridden -> inherit from the table's effective + # neutralize. Use walk mode so the inherited value is concrete + # (never 'mixed'); a single field cannot itself be ambiguous. + from modules.serviceCenter.services.serviceKnowledge._inheritFlags import ( + resolveEffectiveForFds, + ) + out = resolveEffectiveForFds( + self.featureInstanceId, self.tableName, None, allFds, mode="walk", + ) + value = out.get("effectiveNeutralize", False) + return bool(value) if isinstance(value, bool) else False + + def setFlag(self, flag, value, rootIf) -> List[str]: + if flag != "neutralize": + raise ValueError(f"FdsFieldNode does not support flag {flag!r}") + from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + # Resolve or auto-create the underlying table-record FDS so we + # have somewhere to persist the neutralizeFields entry. + rec = self.tableRec + if not rec: + rec = _findOrCreateTableFds(rootIf, self.featureInstanceId, self.tableName, + self.featureCode, self.fieldName) + recId = rec.get("id") + currentFields = rec.get("neutralizeFields") if isinstance(rec.get("neutralizeFields"), list) else [] + fields = set(currentFields) + if value: + fields.add(self.fieldName) + else: + fields.discard(self.fieldName) + newList = sorted(fields) + rootIf.db.recordModify(FeatureDataSource, recId, { + "neutralizeFields": newList if newList else None, + }) + # Update the in-memory record so subsequent reads see the change. + rec["neutralizeFields"] = newList if newList else None + self.tableRec = rec + return [] + + def toDict(self, allDs, allFds) -> Dict[str, Any]: + rec = self.tableRec or _findFdsByCoord(allFds, self.featureInstanceId, self.tableName, None) + return { + "key": self.key, + "kind": "fdsField", + "parentKey": self.parentKey, + "label": self.label, + "icon": "field", + "hasChildren": False, + "dataSourceId": rec.get("id") if rec else None, + "modelType": "FeatureDataSource" if rec else None, + "effectiveNeutralize": self.getEffectiveFlag("neutralize", allDs, allFds, "aggregate"), + "effectiveScope": "personal", + "effectiveRagIndexEnabled": False, + "supportsRag": False, + "canBeAdded": rec is None, + "featureInstanceId": self.featureInstanceId, + "tableName": self.tableName, + "fieldName": self.fieldName, + } + + +# --------------------------------------------------------------------------- +# Cross-cutting helpers +# --------------------------------------------------------------------------- + +def _isConnectionOwner(rootIf: Any, userId: str, connectionId: str) -> bool: + """Return True iff the UserConnection belongs to this user.""" + try: + from modules.datamodels.datamodelUam import UserConnection + conn = rootIf.db.getRecord(UserConnection, connectionId) + if not conn: + return False + return str(conn.get("userId") or "") == userId + except Exception: + return False + + +def _findOrCreateDs(rootIf: Any, connectionId: str, sourceType: str, + path: str) -> Dict[str, Any]: + """Look up a DataSource by coordinate. Create a stub if missing. + + Analogous to `_findOrCreateTableFds` for FDS fields: the user + clicks toggle on a browse-discovered folder that has no DataSource + record yet. Instead of returning 403, we auto-create the record + so the flag can be persisted. + """ + from modules.datamodels.datamodelDataSource import DataSource + from modules.datamodels.datamodelUam import UserConnection + from modules.serviceCenter.services.serviceKnowledge._inheritFlags import _normalisePath + + normPath = _normalisePath(path) + + existing = rootIf.db.getRecordset(DataSource, recordFilter={ + "connectionId": connectionId, "sourceType": sourceType, + }) or [] + for rec in existing: + if _normalisePath(rec.get("path")) == normPath: + return rec + + conn = rootIf.db.getRecord(UserConnection, connectionId) + if not conn: + raise RuntimeError(f"UserConnection {connectionId} not found") + userId = str(conn.get("userId") or "") + mandateId = "" + for rec in existing: + mid = rec.get("mandateId") + if mid: + mandateId = str(mid) + break + + pathLabel = normPath.rsplit("/", 1)[-1] or normPath + stub = DataSource( + connectionId=connectionId, + sourceType=sourceType, + path=normPath, + label=pathLabel, + displayPath=normPath, + userId=userId, + mandateId=mandateId, + ) + created = rootIf.db.recordCreate(DataSource, stub.model_dump()) + if isinstance(created, dict): + return created + return stub.model_dump() + + +def _isFeatureAdmin(rootIf: Any, userId: str, featureInstanceId: str) -> bool: + """Return True iff the user holds a `*-admin` role on this feature instance. + + Convention: feature-specific admin role labels end with `-admin` + (e.g. `workspace-admin`, `automation-admin`). See `.cursor/rules/ + rbac-role-separation.mdc`. + """ + try: + access = rootIf.getFeatureAccess(userId, featureInstanceId) + if not access or not getattr(access, "enabled", True): + return False + accessId = getattr(access, "id", None) or (access.get("id") if isinstance(access, dict) else None) + if not accessId: + return False + roleIds = rootIf.getRoleIdsForFeatureAccess(accessId) or [] + if not roleIds: + return False + from modules.datamodels.datamodelRbac import Role + for rid in roleIds: + rec = rootIf.db.getRecord(Role, rid) + if not rec: + continue + label = str(rec.get("roleLabel") or "").lower() + if label.endswith("-admin"): + return True + return False + except Exception as exc: + logger.warning("isFeatureAdmin check failed (user=%s feature=%s): %s", + userId, featureInstanceId, exc) + return False + + +def _findFdsByCoord(allFds: List[Dict[str, Any]], featureInstanceId: str, + tableName: str, recordFilter: Optional[Dict[str, str]]) -> Optional[Dict[str, Any]]: + target = recordFilter or None + for fds in allFds: + if fds.get("featureInstanceId") != featureInstanceId: + continue + if (fds.get("tableName") or "") != tableName: + continue + if (fds.get("recordFilter") or None) == target: + return fds + return None + + +def _findOrCreateTableFds(rootIf: Any, featureInstanceId: str, tableName: str, + featureCode: str, anyFieldName: str) -> Dict[str, Any]: + """Look up the table-level FDS record. Create a minimal stub if missing. + + Required so an `fdsField` neutralize toggle can persist its state + without forcing the user to first 'add' the table via a separate UI + affordance. The stub carries the same featureInstanceId/tableName + coordinate; its `objectKey`/`label` are filled from the RBAC catalog + so list endpoints still render it correctly. + """ + from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + existing = rootIf.db.getRecordset(FeatureDataSource, recordFilter={ + "featureInstanceId": featureInstanceId, + "tableName": tableName, + }) or [] + for rec in existing: + if (rec.get("recordFilter") or None) is None: + return rec + # Resolve mandate + objectKey/label via catalog so the stub is well-formed. + fi = rootIf.getFeatureInstance(featureInstanceId) + mandateId = str(fi.mandateId) if fi and getattr(fi, "mandateId", None) else "" + objectKey = "" + label = tableName + try: + from modules.security.rbacCatalog import getCatalogService + catalog = getCatalogService() + for obj in catalog.getDataObjects(featureCode) or []: + meta = obj.get("meta", {}) if isinstance(obj, dict) else {} + if meta.get("table") == tableName: + objectKey = obj.get("objectKey", "") + lbl = obj.get("label") + if lbl: + try: + from modules.shared.i18nRegistry import resolveText + label = resolveText(lbl) or tableName + except Exception: + label = str(lbl) or tableName + break + except Exception: + pass + stub = FeatureDataSource( + featureInstanceId=featureInstanceId, + featureCode=featureCode, + tableName=tableName, + objectKey=objectKey, + label=label, + mandateId=mandateId, + recordFilter=None, + ) + created = rootIf.db.recordCreate(FeatureDataSource, stub.model_dump()) + if isinstance(created, dict): + return created + return stub.model_dump() + + +def _aggregateFromChildren(node: UdbNode, flag: str, + allDs: List[Dict[str, Any]], + allFds: List[Dict[str, Any]], + mode: str) -> Any: + """Aggregate `flag` across a node's logical children. + + Returns 'mixed' iff at least two children disagree (in walk-mode), + otherwise the agreed value or the default for the flag. Used by + synthetic containers that have no DB record of their own. + """ + children = node.getLogicalChildren(allDs, allFds, None, None) + if not children: + if flag == "scope": + return "personal" + return False + seen = set() + last: Any = None + for child in children: + if not child.supportsFlag(flag): + continue + val = child.getEffectiveFlag(flag, allDs, allFds, "aggregate") + if val == "mixed": + return "mixed" + norm = int(val) if isinstance(val, bool) else val + seen.add(norm) + last = val + if len(seen) > 1: + return "mixed" + if not seen: + return "personal" if flag == "scope" else False + return last + + +# --------------------------------------------------------------------------- +# Lookup by tree key -> UdbNode +# --------------------------------------------------------------------------- + +def buildNodeForKey(key: str, context: Any, rootIf: Any) -> Optional[UdbNode]: + """Materialize the `UdbNode` instance for a given tree key. + + Used by the generic /flag/ endpoint: the caller passes the key it sees + in the tree, and we resolve it back to the polymorphic node so we can + call canEdit / setFlag without the route knowing the node type. + + Returns None when the key is unknown (caller should 404). + """ + from modules.interfaces.interfaceDbApp import getRootInterface + if rootIf is None: + rootIf = getRootInterface() + from modules.datamodels.datamodelDataSource import DataSource + from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + + kind, parts = _decode(key) + + if kind == "conn" and len(parts) == 1: + connId = parts[0] + rec = _findDsByCoord(rootIf, connId, None, "/") + # Authority is whatever sourceType the connection-root record has; + # for setFlag we only need rec. ConnectionNode's label is not used. + authority = rec.get("sourceType") if rec else "" + return ConnectionNode(connId, authority, label="", parentKey="", rec=rec) + + if kind == "svc" and len(parts) == 2: + connId, service = parts + sourceType = _SERVICE_TO_SOURCE_TYPE.get(service, service) + rec = _findDsByCoord(rootIf, connId, sourceType, "/") + return ServiceNode(connId, service, sourceType, label="", parentKey="", rec=rec) + + if kind == "ds" and len(parts) >= 3: + connId = parts[0] + sourceType = parts[1] + path = _KEY_SEP.join(parts[2:]) + rec = _findDsByCoord(rootIf, connId, sourceType, path) + # We do not know if it is folder or file at this point; use FolderNode. + return FolderNode( + connectionId=connId, service=_reverseService(sourceType), + sourceType=sourceType, path=path, label="", parentKey="", + rec=rec, hasChildren=True, + ) + + if kind == "feat" and len(parts) == 3: + mandateId, featureCode, featureInstanceId = parts + rec = _findFdsByCoord(_loadAllFds(rootIf, featureInstanceId), featureInstanceId, "*", None) + return FdsWorkspaceNode(mandateId, featureCode, featureInstanceId, + label="", icon="", parentKey="", rec=rec) + + if kind == "fdstbl" and len(parts) == 2: + featureInstanceId, tableName = parts + rec = _findFdsByCoord(_loadAllFds(rootIf, featureInstanceId), featureInstanceId, tableName, None) + featureCode = "" + if rec: + featureCode = str(rec.get("featureCode") or "") + else: + fi = rootIf.getFeatureInstance(featureInstanceId) + featureCode = str(fi.featureCode) if fi else "" + return FdsTableNode(featureInstanceId, featureCode, tableName, + objectKey="", label="", parentKey="", + rec=rec, hasFields=True) + + if kind == "fdsfld" and len(parts) >= 3: + featureInstanceId = parts[0] + tableName = parts[1] + fieldName = _KEY_SEP.join(parts[2:]) + allFds = _loadAllFds(rootIf, featureInstanceId) + tableRec = _findFdsByCoord(allFds, featureInstanceId, tableName, None) + featureCode = str(tableRec.get("featureCode") or "") if tableRec else "" + if not featureCode: + fi = rootIf.getFeatureInstance(featureInstanceId) + featureCode = str(fi.featureCode) if fi else "" + return FdsFieldNode(featureInstanceId, tableName, fieldName, + parentKey="", tableRec=tableRec, featureCode=featureCode) + + return None + + +def _findDsByCoord(rootIf: Any, connectionId: str, sourceType: Optional[str], + path: str) -> Optional[Dict[str, Any]]: + from modules.datamodels.datamodelDataSource import DataSource + from modules.serviceCenter.services.serviceKnowledge._inheritFlags import _normalisePath + rf = {"connectionId": connectionId} + if sourceType is not None: + rf["sourceType"] = sourceType + records = rootIf.db.getRecordset(DataSource, recordFilter=rf) or [] + norm = _normalisePath(path) + if sourceType is None: + # connection-root: any record with path='/' on this connection + for r in records: + if _normalisePath(r.get("path")) == "/": + return r + return None + for r in records: + if _normalisePath(r.get("path")) == norm: + return r + return None + + +def _loadAllFds(rootIf: Any, featureInstanceId: str) -> List[Dict[str, Any]]: + from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + return rootIf.db.getRecordset( + FeatureDataSource, recordFilter={"featureInstanceId": featureInstanceId} + ) or [] + + +# Imported from _buildTree for buildNodeForKey resolution; kept here as a +# small private mirror to avoid import cycles. +_SERVICE_TO_SOURCE_TYPE: Dict[str, str] = { + "sharepoint": "sharepointFolder", + "onedrive": "onedriveFolder", + "outlook": "outlookFolder", + "drive": "googleDriveFolder", + "gmail": "gmailFolder", + "files": "ftpFolder", + "clickup": "clickup", + "kdrive": "kdriveFolder", + "mail": "mailFolder", + "calendar": "calendarFolder", + "contact": "contactFolder", +} + + +def _reverseService(sourceType: str) -> str: + for svc, st in _SERVICE_TO_SOURCE_TYPE.items(): + if st == sourceType: + return svc + return sourceType diff --git a/modules/shared/requestFrontendUrl.py b/modules/shared/requestFrontendUrl.py new file mode 100644 index 00000000..806bf6f7 --- /dev/null +++ b/modules/shared/requestFrontendUrl.py @@ -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 "" diff --git a/modules/system/databaseMigration.py b/modules/system/databaseMigration.py index 645fcab7..8244ca4e 100644 --- a/modules/system/databaseMigration.py +++ b/modules/system/databaseMigration.py @@ -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": "<>", + }, 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 # --------------------------------------------------------------------------- diff --git a/requirements.txt b/requirements.txt index 3d8ee88a..9aafd048 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/tests/unit/services/test_buildTree.py b/tests/unit/services/test_buildTree.py index 8db4cfba..d285e867 100644 --- a/tests/unit/services/test_buildTree.py +++ b/tests/unit/services/test_buildTree.py @@ -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__": diff --git a/tests/unit/services/test_inheritFlags.py b/tests/unit/services/test_inheritFlags.py index 98e6fb41..40099e56 100644 --- a/tests/unit/services/test_inheritFlags.py +++ b/tests/unit/services/test_inheritFlags.py @@ -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() diff --git a/tests/unit/services/test_udbNodes.py b/tests/unit/services/test_udbNodes.py new file mode 100644 index 00000000..8048eee8 --- /dev/null +++ b/tests/unit/services/test_udbNodes.py @@ -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()