merge conflicts
Some checks failed
Deploy Plattform-Core INT / test (push) Failing after 4s
Deploy Plattform-Core INT / deploy (push) Has been skipped
Deploy Plattform-Core (Int) / test (push) Successful in 1m9s
Deploy Plattform-Core (Int) / deploy (push) Successful in 9s

This commit is contained in:
Ida 2026-05-28 09:56:32 +02:00
commit 30567bc38b
23 changed files with 2523 additions and 1329 deletions

View file

@ -46,4 +46,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD python -c "import requests; requests.get('http://localhost:8000/api/admin/health', timeout=5)" || exit 1
# 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

19
app.py
View file

@ -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))
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)

View file

@ -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

View file

@ -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=

View file

@ -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

View file

@ -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=(

View file

@ -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})

View file

@ -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),
},
)

View file

@ -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]],

View file

@ -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 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
if model is DataSource:
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")
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)

View file

@ -265,7 +265,7 @@ def _buildFeatureInstanceInventory(featureInstanceIds, rootIf, knowledgeIf) -> L
st = (r.get("status") if isinstance(r, dict) else getattr(r, "status", "unknown")) or "unknown"
statusCounts[st] = statusCounts.get(st, 0) + 1
allFds = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"workspaceInstanceId": fiId})
allFds = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"featureInstanceId": fiId})
dsItems = []
anyRagEnabled = False
for fds in allFds:
@ -287,7 +287,7 @@ def _buildFeatureInstanceInventory(featureInstanceIds, rootIf, knowledgeIf) -> L
fiJobs = [
j for j in allFeatureJobs
if (j.get("payload") or {}).get("workspaceInstanceId") == fiId
if (j.get("payload") or {}).get("featureInstanceId") == fiId
]
runningJobs = [
{
@ -572,17 +572,18 @@ async def _reindexConnection(
raise HTTPException(status_code=500, detail=str(e))
@router.post("/reindex-feature/{workspaceInstanceId}")
@router.post("/reindex-feature/{featureInstanceId}")
@limiter.limit("10/minute")
async def _reindexFeature(
request: Request,
workspaceInstanceId: str,
featureInstanceId: str,
currentUser: User = Depends(getCurrentUser),
) -> Dict[str, Any]:
"""Re-trigger feature data bootstrap for a workspace instance.
"""Re-trigger feature data bootstrap for a feature instance.
Indexes all RAG-enabled FeatureDataSource rows into the knowledge store.
Must be ``async def`` so ``await startJob(...)`` registers in the main loop.
Indexes all RAG-enabled FeatureDataSource rows owned by this feature
instance into the knowledge store. Must be ``async def`` so
``await startJob(...)`` registers in the main loop.
"""
try:
from modules.interfaces.interfaceDbApp import getRootInterface
@ -592,7 +593,7 @@ async def _reindexFeature(
rootIf = getRootInterface()
featureAccesses = rootIf.getFeatureAccessesForUser(str(currentUser.id))
hasAccess = any(
str(fa.featureInstanceId) == workspaceInstanceId and fa.enabled
str(fa.featureInstanceId) == featureInstanceId and fa.enabled
for fa in featureAccesses
)
if not hasAccess and not getattr(currentUser, "isSysAdmin", False):
@ -600,12 +601,12 @@ async def _reindexFeature(
jobId = await startJob(
FEATURE_BOOTSTRAP_JOB_TYPE,
{"workspaceInstanceId": workspaceInstanceId},
{"featureInstanceId": featureInstanceId},
triggeredBy=str(currentUser.id),
)
logger.info("Feature reindex triggered for workspace %s (jobId=%s)", workspaceInstanceId, jobId)
return {"status": "queued", "workspaceInstanceId": workspaceInstanceId, "jobId": jobId}
logger.info("Feature reindex triggered for feature %s (jobId=%s)", featureInstanceId, jobId)
return {"status": "queued", "featureInstanceId": featureInstanceId, "jobId": jobId}
except HTTPException:
raise
except Exception as e:

229
modules/routes/routeUdb.py Normal file
View file

@ -0,0 +1,229 @@
# Copyright (c) 2026 Patrick Motsch
# All rights reserved.
"""Generic UDB (Unified Data Bar) router.
The UDB is feature-agnostic: it can render the user's accessible data
sources (personal + feature-owned) without being coupled to a specific
caller feature instance. This router owns two endpoints:
POST /api/udb/tree/children
Resolve the children for a list of parent tree keys (UI walks).
POST /api/udb/node/{nodeKey}/flag/{flag}
Persist a new value for a single flag on a single node.
Permission policy:
- DataSource-family nodes: owner-of-record (rec.userId == user).
- FdsRecord / FdsField nodes: feature-admin on the FDS's
featureInstanceId (a FeatureAccessRole whose Role.roleLabel ends
with '-admin').
- Synthetic containers (personalRoot, mgrp): never editable.
See wiki/b-reference/platform/unified-data-bar.md for the full domain
model and the rationale behind the hard cut from the previous
feature-instance-scoped endpoints.
"""
import logging
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Request
from pydantic import BaseModel, Field
from modules.auth import getRequestContext, limiter, RequestContext
from modules.shared.i18nRegistry import apiRouteContext
routeApiMsg = apiRouteContext("routeUdb")
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/api/udb",
tags=["Unified Data Bar"],
responses={
400: {"description": "Bad request"},
401: {"description": "Unauthorized"},
403: {"description": "Forbidden"},
404: {"description": "Not found"},
500: {"description": "Internal server error"},
},
)
_VALID_SCOPES = {"personal", "featureInstance", "mandate", "global"}
# ---------------------------------------------------------------------------
# POST /api/udb/tree/children
# ---------------------------------------------------------------------------
class _UdbTreeChildrenRequest(BaseModel):
"""Request body for the generic UDB tree children endpoint."""
parents: List[Optional[str]] = Field(
default_factory=list,
description="List of parent keys to fetch children for. Use null for top-level.",
)
@router.post("/tree/children")
@limiter.limit("300/minute")
async def _udbTreeChildren(
request: Request,
body: _UdbTreeChildrenRequest = Body(...),
context: RequestContext = Depends(getRequestContext),
) -> Dict[str, Any]:
"""Resolve children for the given parent keys.
The UDB is feature-agnostic; this endpoint requires only that the
user is authenticated. Visibility is driven by the user's accessible
mandates and feature instances inside `getChildrenForParents`.
"""
from modules.serviceCenter.services.serviceKnowledge._buildTree import getChildrenForParents
try:
nodesByParent = await getChildrenForParents(body.parents, context)
except Exception as exc:
logger.exception("UDB tree children build failed: %s", exc)
raise HTTPException(status_code=500, detail=str(exc))
return {"nodesByParent": nodesByParent}
# ---------------------------------------------------------------------------
# POST /api/udb/node/{nodeKey}/flag/{flag}
# ---------------------------------------------------------------------------
class _UdbFlagBody(BaseModel):
"""Generic flag-mutation body.
Exactly one of `value` / `neutralizeFields` is expected depending on
the flag (see `_extractFlagValue` for the mapping). `value` is typed
as Any because the legal type depends on the flag:
- neutralize/ragIndexEnabled : bool | null (null = inherit)
- scope : str | null (one of _VALID_SCOPES, null = inherit)
"""
value: Any = Field(default=None, description="New flag value or null to reset to inherit.")
@router.post("/node/{nodeKey:path}/flag/{flag}")
@limiter.limit("60/minute")
async def _udbNodeFlag(
request: Request,
nodeKey: str = Path(..., description="Tree key of the node to modify"),
flag: str = Path(..., description="One of: neutralize | scope | ragIndexEnabled"),
body: _UdbFlagBody = Body(default_factory=_UdbFlagBody),
context: RequestContext = Depends(getRequestContext),
) -> Dict[str, Any]:
"""Persist a new value for `flag` on the node identified by `nodeKey`.
`value=null` resets the node to inherit from its ancestor chain (no
cascade, no purge). `value=true/false` (or a scope string) writes
the explicit override and cascade-resets any explicit child
descendants so they re-inherit.
RBAC: `node.canEdit(context, rootIf)` decides; the route never
re-implements ownership rules.
"""
if flag not in ("neutralize", "scope", "ragIndexEnabled"):
raise HTTPException(status_code=400, detail=f"Unknown flag: {flag}")
value = _validateFlagValue(flag, body.value, context)
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.serviceCenter.services.serviceKnowledge.udbNodes import buildNodeForKey
rootIf = getRootInterface()
node = buildNodeForKey(nodeKey, context, rootIf)
if node is None:
raise HTTPException(status_code=404, detail=f"Unknown UDB node key: {nodeKey}")
if not node.supportsFlag(flag):
raise HTTPException(
status_code=400,
detail=f"{type(node).__name__} does not support flag '{flag}'",
)
if not node.canEdit(context, rootIf):
raise HTTPException(status_code=403, detail=routeApiMsg("Not allowed to edit this UDB node"))
try:
resetIds = node.setFlag(flag, value, rootIf)
except NotImplementedError as exc:
raise HTTPException(status_code=400, detail=str(exc))
except RuntimeError as exc:
raise HTTPException(status_code=409, detail=str(exc))
except Exception as exc:
logger.exception("UDB setFlag failed: key=%s flag=%s: %s", nodeKey, flag, exc)
raise HTTPException(status_code=500, detail=str(exc))
effective = _computeEffectiveAfterWrite(rootIf, context, node, flag)
import json
from modules.shared.auditLogger import audit_logger
from modules.datamodels.datamodelAudit import AuditCategory
audit_logger.logEvent(
userId=str(context.user.id),
mandateId=context.mandateId,
category=AuditCategory.PERMISSION.value,
action="udb_flag_changed",
details=json.dumps({
"nodeKey": nodeKey,
"flag": flag,
"value": value,
"resetDescendants": len(resetIds),
"nodeKind": type(node).__name__,
}),
)
return {
"nodeKey": nodeKey,
"flag": flag,
"value": value,
"effective": effective,
"resetDescendantIds": resetIds,
}
def _validateFlagValue(flag: str, value: Any, context: RequestContext) -> Any:
"""Validate the incoming value matches the flag's expected shape.
Returns the validated value (possibly normalised) or raises HTTPException.
"""
if value is None:
return None
if flag == "scope":
if not isinstance(value, str) or value not in _VALID_SCOPES:
raise HTTPException(
status_code=400,
detail=f"Invalid scope: {value!r}. Must be one of {sorted(_VALID_SCOPES)}",
)
if value == "global" and not context.isSysAdmin:
raise HTTPException(status_code=403, detail=routeApiMsg("Only sysadmins can set global scope"))
return value
# neutralize / ragIndexEnabled
if isinstance(value, bool):
return value
raise HTTPException(
status_code=400,
detail=f"Invalid value for flag {flag!r}: expected bool or null, got {type(value).__name__}",
)
def _computeEffectiveAfterWrite(rootIf: Any, context: RequestContext,
node: Any, flag: str) -> Any:
"""Recompute the node's effective value after the write.
Re-loads the relevant recordsets so the cascade resets are visible.
"""
from modules.datamodels.datamodelDataSource import DataSource
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
userId = str(context.user.id)
allDs = rootIf.db.getRecordset(DataSource, recordFilter={"userId": userId}) or []
fdsFilter: Dict[str, Any] = {}
featureInstanceId = getattr(node, "featureInstanceId", None)
if featureInstanceId:
fdsFilter["featureInstanceId"] = featureInstanceId
allFds = rootIf.db.getRecordset(FeatureDataSource, recordFilter=fdsFilter) or []
try:
return node.getEffectiveFlag(flag, allDs, allFds, mode="aggregate")
except Exception as exc:
logger.warning("Effective-after-write failed for %s flag=%s: %s",
getattr(node, "key", "?"), flag, exc)
return None

View file

@ -91,7 +91,6 @@ def _registerFeatureSubAgentTools(registry: ToolRegistry, services):
mandateId = instance.mandateId or ""
instanceLabel = instance.label or ""
userId = context.get("userId", "")
workspaceInstanceId = context.get("featureInstanceId", "")
requestLang = None
if userId:
langUser = rootIf.getUser(userId)
@ -107,7 +106,7 @@ def _registerFeatureSubAgentTools(registry: ToolRegistry, services):
featureDataSources = rootDbConn.getRecordset(
FeatureDataSource,
recordFilter={"featureInstanceId": featureInstanceId, "workspaceInstanceId": workspaceInstanceId},
recordFilter={"featureInstanceId": featureInstanceId},
)
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlagFds

File diff suppressed because it is too large Load diff

View file

@ -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),
}

View file

@ -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,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,83 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Per-request frontend base URL (same idea as auth emails: frontendUrl from the client).
Set from the incoming HTTP request (X-Frontend-Url, Origin, or Referer) in app middleware.
Read when building billing alert links during that request (e.g. pool exhausted email).
"""
from __future__ import annotations
import logging
import re
from contextvars import ContextVar
from typing import Optional
from urllib.parse import urlparse
logger = logging.getLogger(__name__)
_CURRENT_FRONTEND_URL: ContextVar[str] = ContextVar("request_frontend_url", default="")
_CORS_ORIGIN_REGEX = re.compile(
r"^https://.*\.(poweron\.swiss|poweron-center\.net)$",
re.IGNORECASE,
)
def setRequestFrontendUrl(url: str) -> None:
"""Store the frontend base URL for the current request (no trailing slash)."""
_CURRENT_FRONTEND_URL.set((url or "").strip().rstrip("/"))
def getRequestFrontendUrl() -> str:
"""Return the frontend base URL for the current request, or empty if unset."""
return _CURRENT_FRONTEND_URL.get()
def _allowedOrigins() -> list[str]:
try:
from modules.shared.configuration import APP_CONFIG
raw = APP_CONFIG.get("APP_ALLOWED_ORIGINS") or ""
return [o.strip().rstrip("/") for o in raw.split(",") if o.strip()]
except Exception:
return []
def _isAllowedFrontendOrigin(origin: str) -> bool:
if not origin:
return False
normalized = origin.strip().rstrip("/")
if normalized in _allowedOrigins():
return True
return bool(_CORS_ORIGIN_REGEX.match(normalized))
def resolveFrontendUrlFromRequest(request) -> str:
"""
Resolve frontend base URL from the current HTTP request.
Priority: X-Frontend-Url (explicit, like auth body frontendUrl) Origin Referer origin.
Only returns origins that match APP_ALLOWED_ORIGINS or the CORS subdomain regex.
"""
explicit = (request.headers.get("X-Frontend-Url") or "").strip().rstrip("/")
if explicit and _isAllowedFrontendOrigin(explicit):
return explicit
if explicit:
logger.debug("Ignoring X-Frontend-Url not in allowed origins: %s", explicit)
origin = (request.headers.get("Origin") or "").strip().rstrip("/")
if origin and _isAllowedFrontendOrigin(origin):
return origin
referer = (request.headers.get("Referer") or "").strip()
if referer:
try:
ref_origin = f"{urlparse(referer).scheme}://{urlparse(referer).netloc}".rstrip("/")
if ref_origin and _isAllowedFrontendOrigin(ref_origin):
return ref_origin
except Exception:
pass
return ""

View file

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

View file

@ -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

View file

@ -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__":

View file

@ -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()

View file

@ -0,0 +1,449 @@
"""Unit tests for the polymorphic UDB node hierarchy (udbNodes.py).
Each concrete node class is exercised for:
- `supportsFlag` returns the right set per kind
- `canEdit` enforces DS-owner vs FDS-feature-admin
- `getEffectiveFlag` resolves walk + aggregate correctly
- `setFlag` writes the right record and (where applicable) cascades
- `toDict` produces the expected wire shape
"""
from __future__ import annotations
import unittest
from unittest.mock import MagicMock, patch
from modules.serviceCenter.services.serviceKnowledge.udbNodes import (
UdbNode,
SyntheticContainerNode,
MandateGroupNode,
ConnectionNode,
ServiceNode,
FolderNode,
FileNode,
FdsWorkspaceNode,
FdsTableNode,
FdsFieldNode,
_isFeatureAdmin,
)
class _FakeUser:
def __init__(self, userId: str = "user-1"):
self.id = userId
class _FakeContext:
def __init__(self, userId: str = "user-1"):
self.user = _FakeUser(userId)
self.mandateId = "m1"
class TestSupportsFlag(unittest.TestCase):
def test_synthetic_container_supports_nothing(self):
n = SyntheticContainerNode("k", "label", icon="x")
self.assertFalse(n.supportsFlag("neutralize"))
self.assertFalse(n.supportsFlag("scope"))
self.assertFalse(n.supportsFlag("ragIndexEnabled"))
def test_connection_supports_all_three(self):
n = ConnectionNode("c1", "msft", label="m", parentKey="personalRoot", rec=None)
self.assertTrue(n.supportsFlag("neutralize"))
self.assertTrue(n.supportsFlag("scope"))
self.assertTrue(n.supportsFlag("ragIndexEnabled"))
def test_fds_table_supports_neutralize_and_rag_but_not_scope(self):
n = FdsTableNode(
featureInstanceId="fi1", featureCode="trustee", tableName="Pos",
objectKey="data.feature.trustee.Pos", label="Positions",
parentKey="feat|m1|trustee|fi1", rec=None, hasFields=False,
)
self.assertTrue(n.supportsFlag("neutralize"))
self.assertTrue(n.supportsFlag("ragIndexEnabled"))
self.assertFalse(n.supportsFlag("scope"))
def test_fds_field_supports_only_neutralize(self):
n = FdsFieldNode(
featureInstanceId="fi1", tableName="Pos", fieldName="amount",
parentKey="fdstbl|fi1|Pos", tableRec=None, featureCode="trustee",
)
self.assertTrue(n.supportsFlag("neutralize"))
self.assertFalse(n.supportsFlag("scope"))
self.assertFalse(n.supportsFlag("ragIndexEnabled"))
class TestCanEditDataSourceOwner(unittest.TestCase):
def test_owner_can_edit(self):
rec = {"id": "ds1", "userId": "user-1"}
node = ConnectionNode("c1", "msft", "m", "personalRoot", rec=rec)
self.assertTrue(node.canEdit(_FakeContext("user-1"), MagicMock()))
def test_non_owner_cannot_edit(self):
rec = {"id": "ds1", "userId": "user-other"}
node = ConnectionNode("c1", "msft", "m", "personalRoot", rec=rec)
self.assertFalse(node.canEdit(_FakeContext("user-1"), MagicMock()))
def test_virtual_node_own_connection_can_edit(self):
rootIf = MagicMock()
rootIf.db.getRecord.return_value = {"id": "c1", "userId": "user-1"}
node = ConnectionNode("c1", "msft", "m", "personalRoot", rec=None)
self.assertTrue(node.canEdit(_FakeContext("user-1"), rootIf))
def test_virtual_node_other_connection_cannot_edit(self):
rootIf = MagicMock()
rootIf.db.getRecord.return_value = {"id": "c1", "userId": "user-other"}
node = ConnectionNode("c1", "msft", "m", "personalRoot", rec=None)
self.assertFalse(node.canEdit(_FakeContext("user-1"), rootIf))
def test_virtual_node_missing_connection_cannot_edit(self):
rootIf = MagicMock()
rootIf.db.getRecord.return_value = None
node = ConnectionNode("c1", "msft", "m", "personalRoot", rec=None)
self.assertFalse(node.canEdit(_FakeContext("user-1"), rootIf))
class TestCanEditFdsFeatureAdmin(unittest.TestCase):
def _buildRootIfWithAdminRole(self, hasAdmin: bool):
rootIf = MagicMock()
access = MagicMock(id="acc1", enabled=True)
rootIf.getFeatureAccess.return_value = access
rootIf.getRoleIdsForFeatureAccess.return_value = ["role-1"]
rootIf.db.getRecord.return_value = {
"id": "role-1",
"roleLabel": "trustee-admin" if hasAdmin else "trustee-user",
}
return rootIf
def test_admin_can_edit_fds_table(self):
rootIf = self._buildRootIfWithAdminRole(hasAdmin=True)
node = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
"feat|m1|trustee|fi1", rec={"id": "fds1"}, hasFields=False)
self.assertTrue(node.canEdit(_FakeContext(), rootIf))
def test_non_admin_cannot_edit_fds_table(self):
rootIf = self._buildRootIfWithAdminRole(hasAdmin=False)
node = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
"feat|m1|trustee|fi1", rec={"id": "fds1"}, hasFields=False)
self.assertFalse(node.canEdit(_FakeContext(), rootIf))
def test_fds_field_uses_feature_admin_check(self):
rootIf = self._buildRootIfWithAdminRole(hasAdmin=True)
field = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
tableRec={"id": "fds1"}, featureCode="trustee")
self.assertTrue(field.canEdit(_FakeContext(), rootIf))
class TestGetEffectiveFlag(unittest.TestCase):
def test_ds_walk_inherits_from_authority_root(self):
root = {
"id": "r", "connectionId": "c", "sourceType": "msft", "path": "/",
"userId": "user-1", "neutralize": True, "scope": None, "ragIndexEnabled": None,
}
node = FolderNode(
connectionId="c", service="sharepoint", sourceType="sharepointFolder",
path="/sites/x", label="x", parentKey="svc|c|sharepoint",
rec=None, hasChildren=True,
)
self.assertTrue(node.getEffectiveFlag("neutralize", [root], [], "walk"))
def test_fds_field_neutralize_from_neutralize_fields(self):
rec = {
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
"recordFilter": None, "neutralizeFields": ["amount"],
}
node = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
tableRec=rec, featureCode="trustee")
self.assertTrue(node.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
other = FdsFieldNode("fi1", "Pos", "currency", "fdstbl|fi1|Pos",
tableRec=rec, featureCode="trustee")
# currency is not in the override list and the table has no
# explicit neutralize -> inherits the default (False).
self.assertFalse(other.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
def test_fds_field_inherits_true_from_table(self):
"""Field without explicit override inherits the table's explicit
neutralize. Regression: previously fell through to False, so
toggling the table left the field icon unchanged."""
rec = {
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
"recordFilter": None, "neutralize": True, "neutralizeFields": None,
}
node = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
tableRec=rec, featureCode="trustee")
self.assertTrue(node.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
def test_fds_field_inherits_from_workspace_via_table(self):
"""Field walks the whole FDS ancestor chain: table -> workspace."""
ws = {
"id": "fds-ws", "featureInstanceId": "fi1", "tableName": "*",
"recordFilter": None, "neutralize": True,
}
tbl = {
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
"recordFilter": None, "neutralize": None, "neutralizeFields": None,
}
node = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
tableRec=tbl, featureCode="trustee")
self.assertTrue(node.getEffectiveFlag("neutralize", [], [ws, tbl], "aggregate"))
def test_fds_field_explicit_override_beats_table_false(self):
"""Per-column override (True via list entry) beats an explicit
table False -- this is the one case where the two-source model
diverges intentionally and produces the 'mixed' aggregate."""
rec = {
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
"recordFilter": None, "neutralize": False, "neutralizeFields": ["amount"],
}
amount = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
tableRec=rec, featureCode="trustee")
other = FdsFieldNode("fi1", "Pos", "currency", "fdstbl|fi1|Pos",
tableRec=rec, featureCode="trustee")
self.assertTrue(amount.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
# currency inherits from table -> False
self.assertFalse(other.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
def test_fds_table_mixed_when_field_and_table_disagree(self):
# table.neutralize=False, field "amount" is in neutralizeFields => mixed
rec = {
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
"recordFilter": None, "neutralize": False, "ragIndexEnabled": None,
"neutralizeFields": ["amount"],
}
table = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
"feat|m1|trustee|fi1", rec=rec, hasFields=True)
# FdsTableNode.getEffectiveFlag itself only consults FDS records,
# not field nodes. The aggregation across field nodes is wired
# in _buildTree via `_wireTableFieldsAsLogicalChildren`. So we
# exercise the explicit FDS walk here:
val = table.getEffectiveFlag("neutralize", [], [rec], "walk")
self.assertFalse(val)
class TestSetFlag(unittest.TestCase):
def test_setflag_writes_value_on_ds(self):
rec = {"id": "ds1", "connectionId": "c", "sourceType": "msft", "path": "/",
"userId": "user-1"}
node = ConnectionNode("c", "msft", "m", "personalRoot", rec=rec)
rootIf = MagicMock()
rootIf.db.getRecordset.return_value = [] # no siblings -> no cascade
node.setFlag("neutralize", True, rootIf)
rootIf.db.recordModify.assert_called()
args = rootIf.db.recordModify.call_args[0]
self.assertEqual(args[1], "ds1")
self.assertEqual(args[2], {"neutralize": True})
def test_setflag_virtual_ds_auto_creates_record(self):
"""Toggling a flag on a virtual DS node must auto-create the
DataSource record so the flag can be persisted."""
node = FolderNode(
connectionId="c1", service="sharepoint",
sourceType="sharepointFolder", path="/sites/x/docs",
label="docs", parentKey="svc|c1|sharepoint",
rec=None, hasChildren=True,
)
rootIf = MagicMock()
rootIf.db.getRecordset.return_value = []
rootIf.db.getRecord.return_value = {"id": "c1", "userId": "user-1"}
createdRec = {"id": "ds-new", "connectionId": "c1",
"sourceType": "sharepointFolder", "path": "/sites/x/docs",
"userId": "user-1"}
rootIf.db.recordCreate.return_value = createdRec
node.setFlag("neutralize", True, rootIf)
rootIf.db.recordCreate.assert_called_once()
rootIf.db.recordModify.assert_called()
args = rootIf.db.recordModify.call_args[0]
self.assertEqual(args[1], "ds-new")
self.assertEqual(args[2], {"neutralize": True})
def test_fds_field_setflag_mutates_neutralize_fields(self):
rec = {
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
"recordFilter": None, "neutralize": False, "neutralizeFields": None,
}
node = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
tableRec=rec, featureCode="trustee")
rootIf = MagicMock()
node.setFlag("neutralize", True, rootIf)
rootIf.db.recordModify.assert_called()
# last call: set neutralizeFields to ["amount"]
args = rootIf.db.recordModify.call_args[0]
self.assertEqual(args[1], "fds-tbl")
self.assertEqual(args[2], {"neutralizeFields": ["amount"]})
def test_fds_field_setflag_removes_field_when_toggled_off(self):
rec = {
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
"recordFilter": None, "neutralizeFields": ["amount", "currency"],
}
node = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
tableRec=rec, featureCode="trustee")
rootIf = MagicMock()
node.setFlag("neutralize", False, rootIf)
args = rootIf.db.recordModify.call_args[0]
self.assertEqual(args[2], {"neutralizeFields": ["currency"]})
def test_fds_field_setflag_roundtrip(self):
rec = {
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
"recordFilter": None, "neutralizeFields": None,
}
node = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
tableRec=rec, featureCode="trustee")
rootIf = MagicMock()
node.setFlag("neutralize", True, rootIf)
self.assertTrue(node.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
node.setFlag("neutralize", False, rootIf)
self.assertFalse(node.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
def test_fds_table_explicit_neutralize_wipes_own_neutralize_fields(self):
"""Setting an explicit neutralize on a table must clear its own
`neutralizeFields` list. Otherwise the table's aggregate stays
'mixed' because field children walk to True via that list and the
UI shows no change after the toggle."""
rec = {
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
"recordFilter": None, "neutralize": None,
"neutralizeFields": ["amount", "currency"],
}
node = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
"feat|m1|trustee|fi1", rec=rec, hasFields=True)
rootIf = MagicMock()
rootIf.db.getRecordset.return_value = [rec] # no descendants
node.setFlag("neutralize", False, rootIf)
rootIf.db.recordModify.assert_called()
args = rootIf.db.recordModify.call_args[0]
self.assertEqual(args[1], "fds-tbl")
self.assertEqual(
args[2], {"neutralize": False, "neutralizeFields": None},
)
def test_fds_table_setflag_inherit_keeps_neutralize_fields(self):
"""`value=None` (reset to inherit) must NOT cascade and must NOT
wipe `neutralizeFields`; that matches the cascade-reset spec
(only explicit toggles clear descendants)."""
rec = {
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
"recordFilter": None, "neutralize": True,
"neutralizeFields": ["amount"],
}
node = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
"feat|m1|trustee|fi1", rec=rec, hasFields=True)
rootIf = MagicMock()
rootIf.db.getRecordset.return_value = [rec]
node.setFlag("neutralize", None, rootIf)
args = rootIf.db.recordModify.call_args[0]
self.assertEqual(args[2], {"neutralize": None})
def test_fds_table_setflag_rag_does_not_touch_neutralize_fields(self):
"""A RAG toggle on the table must leave `neutralizeFields` alone
(it is neutralize-only field state)."""
rec = {
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
"recordFilter": None, "ragIndexEnabled": None,
"neutralizeFields": ["amount"],
}
node = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
"feat|m1|trustee|fi1", rec=rec, hasFields=True)
rootIf = MagicMock()
rootIf.db.getRecordset.return_value = [rec]
node.setFlag("ragIndexEnabled", True, rootIf)
args = rootIf.db.recordModify.call_args[0]
self.assertEqual(args[2], {"ragIndexEnabled": True})
def test_fds_workspace_neutralize_clears_descendant_neutralize_fields(self):
"""Workspace toggle must clear per-column overrides on descendant
tables; otherwise the table aggregate stays 'mixed' because some
field children still read True from the list."""
wsRec = {
"id": "fds-ws", "featureInstanceId": "fi1", "tableName": "*",
"recordFilter": None, "neutralize": None,
}
tblRec = {
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
"recordFilter": None, "neutralize": None,
"neutralizeFields": ["amount", "currency"],
}
node = FdsWorkspaceNode("m1", "trustee", "fi1", label="Trustee",
icon="trustee", parentKey="mgrp|m1", rec=wsRec)
rootIf = MagicMock()
rootIf.db.getRecordset.return_value = [wsRec, tblRec]
node.setFlag("neutralize", True, rootIf)
calls = rootIf.db.recordModify.call_args_list
modifyMap = {c[0][1]: c[0][2] for c in calls}
self.assertEqual(modifyMap["fds-tbl"], {"neutralizeFields": None})
self.assertEqual(modifyMap["fds-ws"], {"neutralize": True})
def test_fds_workspace_rag_does_not_clear_descendant_neutralize_fields(self):
"""A RAG toggle on the workspace must not touch descendant
`neutralizeFields`."""
wsRec = {
"id": "fds-ws", "featureInstanceId": "fi1", "tableName": "*",
"recordFilter": None, "ragIndexEnabled": None,
}
tblRec = {
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
"recordFilter": None, "ragIndexEnabled": None,
"neutralizeFields": ["amount"],
}
node = FdsWorkspaceNode("m1", "trustee", "fi1", label="Trustee",
icon="trustee", parentKey="mgrp|m1", rec=wsRec)
rootIf = MagicMock()
rootIf.db.getRecordset.return_value = [wsRec, tblRec]
node.setFlag("ragIndexEnabled", True, rootIf)
calls = rootIf.db.recordModify.call_args_list
modifyIds = [c[0][1] for c in calls]
self.assertNotIn("fds-tbl", modifyIds)
class TestToDict(unittest.TestCase):
def test_fds_table_dict_has_neutralize_fields(self):
rec = {
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
"neutralizeFields": ["amount"],
}
node = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
"feat|m1|trustee|fi1", rec=rec, hasFields=True)
out = node.toDict([], [rec])
self.assertEqual(out["neutralizeFields"], ["amount"])
self.assertEqual(out["kind"], "fdsTable")
self.assertEqual(out["modelType"], "FeatureDataSource")
self.assertEqual(out["effectiveScope"], "personal") # FDS has no scope
def test_synthetic_container_has_no_dataSourceId(self):
n = SyntheticContainerNode("personalRoot", "Personal", icon="person",
defaultExpanded=True)
d = n.toDict([], [])
self.assertIsNone(d["dataSourceId"])
self.assertEqual(d["effectiveNeutralize"], False)
class TestIsFeatureAdmin(unittest.TestCase):
def test_no_access_returns_false(self):
rootIf = MagicMock()
rootIf.getFeatureAccess.return_value = None
self.assertFalse(_isFeatureAdmin(rootIf, "user-1", "fi1"))
def test_no_roles_returns_false(self):
rootIf = MagicMock()
rootIf.getFeatureAccess.return_value = MagicMock(id="acc1", enabled=True)
rootIf.getRoleIdsForFeatureAccess.return_value = []
self.assertFalse(_isFeatureAdmin(rootIf, "user-1", "fi1"))
def test_non_admin_role_returns_false(self):
rootIf = MagicMock()
rootIf.getFeatureAccess.return_value = MagicMock(id="acc1", enabled=True)
rootIf.getRoleIdsForFeatureAccess.return_value = ["r1"]
rootIf.db.getRecord.return_value = {"id": "r1", "roleLabel": "trustee-user"}
self.assertFalse(_isFeatureAdmin(rootIf, "user-1", "fi1"))
def test_admin_role_returns_true(self):
rootIf = MagicMock()
rootIf.getFeatureAccess.return_value = MagicMock(id="acc1", enabled=True)
rootIf.getRoleIdsForFeatureAccess.return_value = ["r1"]
rootIf.db.getRecord.return_value = {"id": "r1", "roleLabel": "workspace-admin"}
self.assertTrue(_isFeatureAdmin(rootIf, "user-1", "fi1"))
if __name__ == "__main__":
unittest.main()