Compare commits
3 commits
86bd48eeb5
...
6ab51cf67e
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ab51cf67e | |||
| 19bc4819ee | |||
| 51b789b5aa |
30 changed files with 2292 additions and 1784 deletions
|
|
@ -1,60 +0,0 @@
|
||||||
name: Deploy Plattform-Core INT
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- int
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Tests auf Infomaniak VM
|
|
||||||
env:
|
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
|
||||||
run: |
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
echo "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
|
|
||||||
chmod 600 ~/.ssh/deploy_key
|
|
||||||
echo "StrictHostKeyChecking=no" >> ~/.ssh/config
|
|
||||||
echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config
|
|
||||||
ssh -i ~/.ssh/deploy_key ubuntu@api-int.poweron.swiss "
|
|
||||||
set -e
|
|
||||||
cd /srv/gateway/current
|
|
||||||
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/platform-core.git
|
|
||||||
git fetch origin int
|
|
||||||
git reset --hard origin/int
|
|
||||||
test -f env-gateway-int-forgejo.env
|
|
||||||
cp env-gateway-int-forgejo.env .env
|
|
||||||
rm -f env-gateway-dev.env env-gateway-int.env env-gateway-prod.env env-gateway-prod-forgejo.env env-gateway-int-forgejo.env
|
|
||||||
source .venv/bin/activate
|
|
||||||
pip install -r requirements.txt --no-cache-dir
|
|
||||||
python -m pytest tests/ --ignore=tests/demo
|
|
||||||
"
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: test
|
|
||||||
steps:
|
|
||||||
- name: Deploy to Infomaniak VM
|
|
||||||
env:
|
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
|
||||||
run: |
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
echo "$SSH_PRIVATE_KEY" > ~/.ssh/deploy_key
|
|
||||||
chmod 600 ~/.ssh/deploy_key
|
|
||||||
echo "StrictHostKeyChecking=no" >> ~/.ssh/config
|
|
||||||
echo "UserKnownHostsFile=/dev/null" >> ~/.ssh/config
|
|
||||||
ssh -i ~/.ssh/deploy_key ubuntu@api-int.poweron.swiss "
|
|
||||||
set -e
|
|
||||||
cd /srv/gateway/current
|
|
||||||
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/platform-core.git
|
|
||||||
git fetch origin int
|
|
||||||
git reset --hard origin/int
|
|
||||||
test -f env-gateway-int-forgejo.env
|
|
||||||
cp env-gateway-int-forgejo.env .env
|
|
||||||
rm -f env-gateway-dev.env env-gateway-int.env env-gateway-prod.env env-gateway-prod-forgejo.env env-gateway-int-forgejo.env
|
|
||||||
source .venv/bin/activate
|
|
||||||
pip install -r requirements.txt --no-cache-dir
|
|
||||||
sudo systemctl restart gateway
|
|
||||||
"
|
|
||||||
3
app.py
3
app.py
|
|
@ -616,6 +616,9 @@ app.include_router(fileRouter)
|
||||||
from modules.routes.routeDataSources import router as dataSourceRouter
|
from modules.routes.routeDataSources import router as dataSourceRouter
|
||||||
app.include_router(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
|
from modules.routes.routeDataPrompts import router as promptRouter
|
||||||
app.include_router(promptRouter)
|
app.include_router(promptRouter)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,94 +0,0 @@
|
||||||
# Development Environment Configuration
|
|
||||||
|
|
||||||
# System Configuration
|
|
||||||
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_INIT_PASS_ADMIN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEeFFtRGtQeVUtcjlrU3dab1ZxUm9WSks0MlJVYUtERFlqUElHemZrOGNENk1tcmJNX3Vxc01UMDhlNU40VzZZRVBpUGNmT3podzZrOGhOeEJIUEt4eVlSWG5UYXA3d09DVXlLT21Kb1JYSUU9
|
|
||||||
APP_INIT_PASS_EVENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERzZjNm56WGVBdjJTeG5Udjd6OGQwUVotYXUzQjJ1YVNyVXVBa3NZVml3ODU0MVNkZjhWWmJwNUFkc19BcHlHMTU1Q3BRcHU0cDBoZkFlR2l6UEZQU3d2U3MtMDh5UDZteGFoQ0EyMUE1ckE9
|
|
||||||
|
|
||||||
# PostgreSQL DB Host
|
|
||||||
DB_HOST=localhost
|
|
||||||
DB_USER=poweron_dev
|
|
||||||
DB_PASSWORD_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEcUIxNEFfQ2xnS0RrSC1KNnUxTlVvTGZoMHgzaEI4Z3NlVzVROTVLak5Ubi1vaEZubFZaMTFKMGd6MXAxekN2d2NvMy1hRjg2UVhybktlcFA5anZ1WjFlQmZhcXdwaGhWdzRDc3ExeUhzWTg9
|
|
||||||
DB_PORT=5432
|
|
||||||
|
|
||||||
# Security Configuration
|
|
||||||
APP_JWT_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERjlrSktmZHVuQnJ1VVJDdndLaUcxZGJsT2ZlUFRlcFdOZ001RnlzM2FhLWhRV2tjWWFhaWQwQ3hkcUFvbThMcndxSjFpYTdfRV9OZGhTcksxbXFTZWg5MDZvOHpCVXBHcDJYaHlJM0tyNWRZckZsVHpQcmxTZHJoZUs1M3lfU2ljRnJaTmNSQ0w0X085OXI0QW80M2xfQnJqZmZ6VEh3TUltX0xzeE42SGtZPQ==
|
|
||||||
APP_TOKEN_EXPIRY=300
|
|
||||||
|
|
||||||
# CORS Configuration
|
|
||||||
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
|
|
||||||
|
|
||||||
# Logging configuration
|
|
||||||
APP_LOGGING_LOG_LEVEL = DEBUG
|
|
||||||
APP_LOGGING_LOG_DIR = D:/Athi/Local/Web/poweron/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
|
|
||||||
APP_LOGGING_FILE_ENABLED = True
|
|
||||||
APP_LOGGING_ROTATION_SIZE = 10485760
|
|
||||||
APP_LOGGING_BACKUP_COUNT = 5
|
|
||||||
|
|
||||||
# OAuth: Auth app (login/JWT) vs Data app (Microsoft Graph / Google APIs). Same IDs until you split apps in Azure / GCP.
|
|
||||||
Service_MSFT_AUTH_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
|
|
||||||
Service_MSFT_AUTH_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnFBa1kxaG9WY1FJaWdCbVFVaTllUlJfU3Y3MmJkRmkzMDVDWUNtZEhlNVhISzJPcy00ZUVZcklYLXFMV0dIODV3NXNSSFBKQ0ZsZllES3diTEgySDF0T1ZCbFZHREZtcXFGSWNZN1NJbzJzczRRQWxoeVNsNzlsa0VzMHJPWHUydjBBclo=
|
|
||||||
Service_MSFT_AUTH_REDIRECT_URI = http://localhost:8000/api/msft/auth/login/callback
|
|
||||||
Service_MSFT_DATA_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
|
|
||||||
Service_MSFT_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnFBa1kyUW96aXFVOVJlLUdyRlVvT1hVU09ILWtMZnV2M19mVUxGMnFPV3FzNTdQa3dTbHVGTDBHTk01ZThLcjh6QUR5VldVZUpfcDlZNTh5YldtLWtjTll6VzJNQ3JCQ3ZubHdmd2JvaExDOXdvQ1pjWDVQTUtFWVAtUHhwS1lFQnJXWk4=
|
|
||||||
Service_MSFT_DATA_REDIRECT_URI = http://localhost:8000/api/msft/auth/connect/callback
|
|
||||||
|
|
||||||
Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
|
||||||
Service_GOOGLE_AUTH_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnFBa1kyd1hPd09vcVFtbVg0Sm5Nd1VYVEEtWjZMZkFndmFVS0ZlcTU0dzJnYVYzRkZWbjh0QldyZkhseDV2cUgxYkNHTzF6MXhqQlZ2N0UtbmhPeWRKUHBVdzV0Q1ROaWNuN2xjMmVzMjNZQ2ZYZ3dOTHgxaU5sTGRjVHpfakhYeWF0ZGU=
|
|
||||||
Service_GOOGLE_AUTH_REDIRECT_URI = http://localhost:8000/api/google/auth/login/callback
|
|
||||||
Service_GOOGLE_DATA_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
|
||||||
Service_GOOGLE_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnFBa1kySXoyd1BmTnhOd1owTUJOWm53WlZMMjFHNGJhSUwyd2NDUW9BanlRWVJPLU5jYzRlcm5QeW96d0JYUkVWVWd2dGNBVEpJbElZY2lWb0o5S0gyNnhoV1pnNXhpSFEyaklZZjcwX2lVU0ktMEJGN01DMDhXQ3k4R1BXc1Q3ejFjOEg=
|
|
||||||
Service_GOOGLE_DATA_REDIRECT_URI = http://localhost:8000/api/google/auth/connect/callback
|
|
||||||
|
|
||||||
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
|
|
||||||
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
|
|
||||||
Service_CLICKUP_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd4ZWVBeHVtRnpIT0VBN0tSZDhLRmFmN05DOVBOelJtLWhkVnJDRVBqUkh3bDFTZFRWaWQ1cWowdGNLUk5IQzlGN1J6RFVCaW8zRnBwLVBnclJfdWgxV3pVRzFEV2lwcW5Rc19Xa1ROWXNJcUF0ajZaYUxOUXk0WHRsRmJLM25FaHV5T2IxdV92ZW1nRjhzaGpwU0l2Wm9FTkRnY2lJVjhuNHUwT29salAxYV8wPQ==
|
|
||||||
Service_CLICKUP_OAUTH_REDIRECT_URI = http://localhost:8000/api/clickup/auth/connect/callback
|
|
||||||
|
|
||||||
# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.
|
|
||||||
|
|
||||||
# Stripe Billing (both end with _SECRET for encryption script)
|
|
||||||
STRIPE_SECRET_KEY_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5aHNGejgzQmpTdmprdzQxR19KZkh3MlhYUTNseFN3WnlaWjh2SDZyalN6aU9xSktkbUQwUnZrVnlvbGVRQm4yZFdiRU5aSEk5WVJuUnR4VUwtTm9OVk1WWmJQeU5QaDdib0hfVWV5U1BfYTFXRmdoOWdnOWxkb3JFQmF3bm45UjFUVUxmWGtGRkFKUGd6bmhpQlFnaVI3Q2lLdDlsY1VESk1vOEM0ZFBJNW1qcVZ0N2tPYmRLNmVKajZ2M3o3S05lWnRRVG5LdkRseW4wQ3VjNHNQZTZUdz09
|
|
||||||
STRIPE_WEBHOOK_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5dDJMSHBrVk8wTzJhU2xzTTZCZWdvWmU2NGI2WklfRXRJZVUzaVYyOU9GLUZsalUwa2lPdEgtUHo0dVVvRDU1cy1saHJyU0Rxa2xQZjBuakExQzk3bmxBcU9WbEIxUEtpR1JoUFMxZG9ISGRZUXFhdFpSMGxvQUV3a0VLQllfUUtCOHZwTGdteV9rYTFOazBfSlN3ekNWblFpakJlZVlCTmNkWWQ4Sm01a1RCWTlnTlFHWVA0MkZYMlprUExrWFN2V0NVU1BTd1NKczFJbVo3VHpLdlc4UT09
|
|
||||||
STRIPE_API_VERSION = 2026-01-28.clover
|
|
||||||
STRIPE_AUTOMATIC_TAX_ENABLED = false
|
|
||||||
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0
|
|
||||||
|
|
||||||
# AI configuration
|
|
||||||
Connector_AiOpenai_API_SECRET = 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
|
|
||||||
Connector_AiAnthropic_API_SECRET = DEV_ENC:Z0FBQUFBQnFCdlFlbXEzdGc2NFExb3AzVUw0cEhkZzlNRjZxTVlJMV81LTZhVXhoNXBpYUlMN0FxUUJHQlJnS0N3OV85Uk9sa1J3M1lyZExSMWVsbzVSdzdWQUVsUVp2dzhfLThmY2lNb1NhVGlvbnhLR0NnSmhsOVp2RkxfODc2SFpDYlBkcWp4aFFtNldtZGQ2LUhBZVM5VXk4RTNHNzQyV3FnMVNJMW9yOGpRRkQxUC1hZ3NOOHhqV3Y4LWJSNjFYQ3dwQmhrRWJRRzhaX1N4aFlWLTVsaEJmOWxkTjZMZz09
|
|
||||||
Connector_AiPerplexity_API_SECRET = DEV_ENC:Z0FBQUFBQnFCdlFlNV9felVPcHVyMU9kVGhGZEt0MG9iRzRrTVM4TFJvSHhGOVo0U1ROWkdEMzRSWjhtMnFrZUhHTHNXelpLZ014RzRkMlIxZDJwcjEwc1dRamY5ekJMR1VLb2w4eEZqZENBRnFaZlRhb1h5VE05Tml1ZlVBWHBaTkJaZUE5NWprVklva0ZFZnB4cFFudGdkalpmTlBhdV9nPT0=
|
|
||||||
Connector_AiTavily_API_SECRET = DEV_ENC:Z0FBQUFBQnFCdlFlY1R2WGpuazk5M05SeDIyLWd3bHpKN3lUdlVFdjhvZEJXdlM4bGlBdTB1TjRia051YllDQ2lwM0V3R3dPd2lKVWxoSm9BNWl1ZFFlVkZ5cXh4TFRVU0Z4NVU5WVRjSUJPc01La3JyaVZSNkhYWU9PR00yMENEb0dRT3l5enEwSFlWZVVzTVR0UWQ4eUxvRmZvWHl0c0xRPT0=
|
|
||||||
Connector_AiPrivateLlm_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGRHM5eFdUVmVZU1R1cHBwN1RlMUx4T0NlLTJLUFFVX3J2OElDWFpuZmJHVmp4Z3BNNWMwZUVVZUd2TFhRSjVmVkVlcFlVRWtybXh0ZHloZ01ZcnVvX195YjdlWVdEcjZSWFFTTlNBWUlaTlNoLWhqVFBIb0thVlBiaWhjYjFQOFY=
|
|
||||||
Connector_AiMistral_API_SECRET = DEV_ENC:Z0FBQUFBQnFCdlFlelh2T2hqNGcxV0hMV1FKbmFDZjVHUWF6T2FXbGlCSnQzSzNXLWJHeXBFWE1nUlh1b1NHY1JRSEVtTVEtc1MtUnZrX2ZCcURqQ2FYNmFWa2xudGJtS3g2eVo4MFZMd09nZTBNMmo1ZHU0bzBJdFRqLVhHSVZNb2Zrc0VkUXI0SVk=
|
|
||||||
|
|
||||||
Service_MSFT_TENANT_ID = common
|
|
||||||
|
|
||||||
# Google Cloud Speech Services configuration
|
|
||||||
Connector_GoogleSpeech_API_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETk5FWWM3Q0JKMzhIYTlyMkhuNjA4NlF4dk82U2NScHhTVGY3UG83NkhfX3RrcWVtWWcyLXRjU1dTT21zWEl6YWRMMUFndXpsUnJOeHh3QThsNDZKRXROTzdXRUdsT0JZajZJNVlfb0gtMXkwWm9DOERPVnpjU0pyUEZfOGJsUnprT3ltMVVhalUyUm9hMUFtZEtHUnJqOGZ4dEZjZm5SWVVTckVCWnY1UkdVSHVmUlgwbnAyc0xDQW84R3ViSko5OHVCVWZRUVNiaG1pVFB6X3EwS0FPd2dUYjhiSmRjcXh2WEZiXzI4SFZqT21tbDduUWRyVWdFZXpmcVM5ZDR0VWtzZnF5UER6cGwwS2JlLV9CSTZ0Z0IyQ1h0YW9TcmhRTXZEckp4bWhmTkt6UTNYMk4zVkpnbUJmaDIxZnoyR2dWTEYwTUFEV0w2eUdUUGpoZk9XRkt4RVF1Z1NPdUpBeTcyWV9PY1Ffd2s0ZEdVekxGekhoeEl4TmNqaXYtbUJuSVdycFducERWdWtZajZnX011Q2w4eE9VMTBqQ1ZxRmdScWhXY1E3WWhzX1JZcHhxam9FbDVPN3Q1MWtrMUZuTUg3LVFQVHp1T1hpQWNDMzEzekVJWk9ybl91YUVjSkFob1VaMi1ONEtuMnRSOEg1S3QybUMwbVZDejItajBLTjM2Zy1hNzZQMW5LLVVDVGdFWm5BZUxNeEFnUkZzU3dxV0lCUlc0LWo4b05GczVpOGZSV2ZxbFBwUml6OU5tYjdnTks3Y3hrVEZVTHlmc1NPdFh4WE5pWldEZklOQUxBbjBpMTlkX3FFQVJ6c2NSZGdzTThycE92VW82enZKamhiRGFnU25aZGlHZHhZd2lUUmhuTVptNjhoWVlJQkxIOEkzbzJNMjZCZFJyM25tdXBnQ2ZWaHV3b2p6UWJpdk9xUEhBc1dyTlNmeF9wbm5yYUhHV01UZnVXWDFlNzBkdXlWUWhvcmJpSmljbmE3LUpUZEg4VzRwZ2JVSjdYUm1sODViQXVxUzdGTmZFbVpiN2V1YW5XV3U4b2VRWmxldGVGVHZsSldoekhVLU9wZ2V0cGZIYkNqM2pXVGctQVAyUm4xTHhpd1VVLXFhcnVEV21Rby1hbTlqTl84TjVveHdYTExUVkhHQ0ltaTB2WXJnY1NQVE5PbWg3ejgySElYc1JSTlQ3NDlFUWR6STZVUjVqaXFRN200NF9LY1ljQ0R2UldlWUtKY1NQVnJ4QXRyYTBGSWVuenhyM0Z0cWtndTd1eG8xRzY5a2dNZ1hkQm5MV3BHVzA2N1QwUkd6WlRGYTZQOUhnVWQ2S0Y5U0s1dXFNVXh5Q2pLWVUxSUQ2MlR1ak52NmRIZ2hlYTk1SGZGWS1RV3hWVU9rR3d1Rk9MLS11REZXbzhqMHpsSm1HYW1jMUNLT29YOHZsRWNaLTVvOFpmT3l3MHVwaERTT0dNLWFjcGRYZ25qT2szTkVFUnRFR3JWYS1aNXFIRnMyalozTlQzNFF2NXJLVHVPVF9zdTF6ZjlkbzJ4RFc2ZENmNFFxZDZzTzhfMUl0bW96V0lPZkh1dXFYZlEteFBlSG84Si1FNS1TTi1OMkFnX2pOYW8xY3MxMVJnVC02MDUyaXZfMEVHWDQtVlRpcENmV0h3V0dCWEFRS2prQXdNRlQ5dnRFVHU0Q1dNTmh0SlBCaU55bFMydWM1TTFFLW96ODBnV3dNZHFZTWZhRURYSHlrdzF3RlRuWDBoQUhSOUJWemtRM3pxcDJFbGJoaTJ3ZktRTlJxbXltaHBoZXVJVDlxS3cxNWo2c0ZBV0NzaUstRWdsMW1xLXFkanZGYUFiU0tSLXFQa0tkcDFoMV9kak41ZjQ0R214UmtOR1ZBanRuemY3Mmw1SkZ5aDZodGIzT3N2aV85MW9kcld6c0g0ZDgtTWo3b3Y3VjJCRnR2U2tMVm9rUXNVRnVHbzZXVTZ6RmI2RkNmajBfMWVnODVFbnpkT0oyci15czJHU0p1cUowTGZJMzVnd3hIRjQyTVhKOGRkcFRKdVpyQ3Yzd01Jb1lSajFmV0paeEV0cjk1SmpmdWpDVFJMUmMtUFctOGhaTmlKQXNRVlVUNlhJemxudHZCR056SVlBb3NOTEYxRTRLaFlVd2d3TWtxVlB6ZEtQLTkxOGMyY3N0a2pYRFUweDBNaGhja2xSSklPOUZla1dKTWRNbG8tUGdSNEV5cW90OWlOZFlIUExBd3U2b2hyS1owbXVMM3p0Qm41cUtzWUxYNzB1N3JpUTNBSGdsT0NuamNTb1lIbXR4MG1sakNPVkxBUXRLVE1xX0YxWDhOcERIY1lTQVFqS01CaXZKNllFaXlIR0JsM1pKMmV1OUo3TGI1WkRaVnYxUTl1LTM0SU1qN1V1b0RCT0x0VHNLTmNLZnk1S0MxYnBBcm03WnVua0xqaEhGUzhOU253ZkppRzdudXBSVlMxeFVOSWxtZ1o2RVBSQUhEUEFuQ1hxSVZMME4yWUtaU3VyRGo3RkUyRUNjT0pNcE1BdE1ZRzdXVl8ydUtXZjdMdHdEVW4teHUtTi1HSGliLUxud21TX0NtcGVkRFBHNkZ1WTlNczR4OUJfUVluc1BoV09oWS1scUdsNnB5d1U5M1huX3k4QzAyNldtb2hybktYN2xKZ1NTNWFsaWwzV3pCRVhkaGR5eTNlV1d6ZzFfaFZTT0E4UjRpQ3pKdEZxUlJ6UFZXM3laUndyWEk2NlBXLUpoajVhZzVwQXpWVzUtVjVNZFBwdWdQa3AxZC1KdGdqNnhibjN4dmFYb2cxcEVwc1g5R09zRUdINUZtOE5QRjVUU0dpZy1QVl9odnFtVDNuWFZLSURtMXlSMlhRNTBWSVFJbEdOOWpfVWV0SmdRWDdlUXZZWE8xRUxDN1I0aEN6MHYwNzM1cmpJS0ZpMnBYWkxfb3FsbEV1VnlqWGxqdVJ6SHlwSjAzRlMycTBaQ295NXNnZERpUnJQcjhrUUd3bkI4bDVzRmxQblhkaFJPTTdISnVUQmhET3BOMTM4bjVvUEc2VmZhb2lrR1FyTUl2RWNEeGg0U0dsNnV6eU5zOUxiNDY5SXBxR0hBS00wOTgyWTFnWkQyaEtLVUloT3ZxZGh0RWVGRmJzenFsaUtfZENQM0JzdkVVeTdXR3hUSmJST1NBMUI1NkVFWncwNW5JZVVLX1p1RXdqVnFfQWpvQ08yQjZhN1NkTkpTSnUxOVRXZXE0WFEtZWxhZW1NNXYtQ2sya0VGLURmS01lMkctNVY3c2ZhN0ZGRFgwWHlabTFkeS1hcUZ1dDZ3cnpPQ3hha2IzVE11M0pqbklmU0diczBqTFBNZC1QZGp6VzNTSnJVSjJoWkJUQjVORG4tYUJmMEJtSUNUdVpEaGt6OTM3TjFOdVhXUHItZjRtZ25nU3NhZC1sVTVXNTRDTmxZbnlfeHNsdkpuMXhUYnE1MnpVQ0ZOclRWM1M4eHdXTzRXbFRZZVQtTS1iRVdXVWZMSGotcWg3MUxUYTFnSEEtanBCRHlZRUNIdGdpUFhsYjdYUndCZnRITzhMZVJ1dHFoVlVNb0duVjlxd0U4OGRuQVV3MG90R0hiYW5MWkxWVklzbWFRNzBfSUNrdzc5bVdtTXg0dExEYnRCaDI3c1I4TWFwLXZKR0wxSjRZYjZIV3ZqZjNqTWhFT0RGSDVMc1A1UzY2bDBiMGFSUy1fNVRQRzRJWDVydUpqb1ZfSHNVbldVeUN2YlAxSW5WVDdxVzJ1WHpLeUdmb0xWMDNHN05oQzY3YnhvUUdhS2xaOHNidkVvbTZtSHFlblhOYmwyR3NQdVJDRUdxREhWdF9ZcXhwUWxHc2hyLW5vUGhIUVhJNUNhY0hFU0ptVnI0TFVhZDE1TFBBUEstSkRoZWJ5MHJhUmZrR1ZrRlFtRGpxS1pOMmFMQjBsdjluY3FiYUU4eGJVVXlZVEpuNWdHVVhJMGtwaTdZR2NDbXd2eHpOQ09SeTV6N1BaVUpsR1pQVDBZcElJUUt6VnVpQmxSYnE4Y1BCWV9IRWdVV0p3enBGVHItdnBGN3NyNWFBWmkySnByWThsbDliSlExQmp3LVlBaDIyZXp6UnR6cU9rTzJmTDBlSVpON0tiWllMdm1oME1zTFl2S2ZYYllhQlY2VHNZRGtHUDY4U1lIVExLZTU4VzZxSTZrZHl1ZTBDc0g4SjI4WGYyZHV1bm9wQ3R2Z09ld1ZmUkN5alJGeHZKSHl1bWhQVXpNMzdjblpLcUhfSm02Qlh5S1FVN3lIcHl0NnlRPT0=
|
|
||||||
|
|
||||||
# Teamsbot Browser Bot Service
|
|
||||||
# For local testing: run the bot locally with `npm run dev` in service-teams-browser-bot
|
|
||||||
# The bot will connect back to localhost:8000 via WebSocket
|
|
||||||
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_ACCOUNTING_SYNC_ENABLED = True
|
|
||||||
APP_DEBUG_ACCOUNTING_SYNC_DIR = D:/Athi/Local/Web/poweron/local/debug/sync
|
|
||||||
|
|
||||||
# Azure Communication Services Email Configuration
|
|
||||||
MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt
|
|
||||||
MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss
|
|
||||||
|
|
||||||
# Zurich WFS Parcels (dynamic map layer). Default: Stadt Zürich OGD. Override for full canton if wfs.zh.ch resolves.
|
|
||||||
# Connector_ZhWfsParcels_WFS_URL = https://wfs.zh.ch/av
|
|
||||||
# Connector_ZhWfsParcels_TYPENAMES = av_li_liegenschaften_a
|
|
||||||
|
|
||||||
|
|
@ -61,8 +61,8 @@ STRIPE_AUTOMATIC_TAX_ENABLED = false
|
||||||
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0
|
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0
|
||||||
|
|
||||||
# AI configuration
|
# AI configuration
|
||||||
Connector_AiOpenai_API_SECRET = DEV_ENC:Z0FBQUFBQnFGcldVVnVRcFo4MGUxaDU3Tkl3R2EwUHhibm1BTVkzMmhBYVNDMGRoQUozQkVCNmVqTXpvZ3V2ZkNwLWVDLWZFZ3dBaWJKdVY3OGJTUTdhVnZ4bW9CMjBKclhSQldTYm1Bb2RDb3ZtYVg1UXEtMHdaelROajBYTGZzc3NEeDk2V09ndlRTdTFHZWFOQjdBbUNxTnJ5SHYxcUJ3NEN6QzZnVllCakJVazRHc2h4Q2FyRWlVZ3lNam9GMm1TQmxJTkhlOHVvSHllMVEwWDYyYktGdllyTEFmUzFVRWp3LWdxVklWcFZ3SXZQcEwwempSem5oWFlWYmk3MVdhNldqZEhPWmxfczdYTnRHcElld1IySjY2SkxxRzZrMFVKWTJyVzJkNmJWa0VOSTgtbW83NmM9
|
Connector_AiOpenai_API_SECRET = DEV_ENC:Z0FBQUFBQnFGdnVHZlpWWWVaV1dERUItVjRfNWFMRXZUVjY5Ulp1ZXkyMmVZWUJPNzJ5anRucGNlOUFYSVNzZ1FaWlZLemVNbk5pbDgwOEZxbkxxM3U3UU1EdDJzczBaYmRDbld1N0hHdjROQUFmMUJmbDRMS1JWc3c4ZTFPMHY3OWVublBsUjFxNjhDSGRCaE9PR1JUN29iQjRqVFRINHB5dnJXeGxiQ2FTdnNnN283b3o1MnV3X09uc1pXeXZUclNTbjN4YWZyb2tGVmtGVmRnQmNlczI0WlZKRGZSYWgycnB0R2RfR1Fvbkt5bXN1UVBwS202SkZyRmJGTEU3MkxpcTk2d0QtOGxRckNLaTFLRnJBYlVSZDAydlpjMDVqYmktdHhuV3FLa2xrYkh4cGc3S3FGcnM9
|
||||||
Connector_AiAnthropic_API_SECRET = DEV_ENC:Z0FBQUFBQnFCdlFlbXEzdGc2NFExb3AzVUw0cEhkZzlNRjZxTVlJMV81LTZhVXhoNXBpYUlMN0FxUUJHQlJnS0N3OV85Uk9sa1J3M1lyZExSMWVsbzVSdzdWQUVsUVp2dzhfLThmY2lNb1NhVGlvbnhLR0NnSmhsOVp2RkxfODc2SFpDYlBkcWp4aFFtNldtZGQ2LUhBZVM5VXk4RTNHNzQyV3FnMVNJMW9yOGpRRkQxUC1hZ3NOOHhqV3Y4LWJSNjFYQ3dwQmhrRWJRRzhaX1N4aFlWLTVsaEJmOWxkTjZMZz09
|
Connector_AiAnthropic_API_SECRET = DEV_ENC:Z0FBQUFBQnFGd0hadGRhRFBOaXJSN2E1UnpLcHlkUHhoQS1FWFJBQlJ5cGsxcHNNMDlRM1JVbEJkWWE1ZXQzTThFQkRmQTBOeVNvUERXTVRTQ3Y4ekt5NU1XR3E2cWw4UlFNUXlGSmZmZU1JZXlwT3lUVGw0aWF4V1d6cVR6LTFsbGRjWWx5dVlodWNEUFJkZ0tUa3hSbjk3WjZ1Z3RPczZMYzA5QTlMbkVudFNVcG1xaTJuM3g3dDdSSFczbWJnODQ1S1J2djBnS3lQc2NFd0ttUThRVnFma3NOVlFIZm1ZZz09
|
||||||
Connector_AiPerplexity_API_SECRET = DEV_ENC:Z0FBQUFBQnFCdlFlNV9felVPcHVyMU9kVGhGZEt0MG9iRzRrTVM4TFJvSHhGOVo0U1ROWkdEMzRSWjhtMnFrZUhHTHNXelpLZ014RzRkMlIxZDJwcjEwc1dRamY5ekJMR1VLb2w4eEZqZENBRnFaZlRhb1h5VE05Tml1ZlVBWHBaTkJaZUE5NWprVklva0ZFZnB4cFFudGdkalpmTlBhdV9nPT0=
|
Connector_AiPerplexity_API_SECRET = DEV_ENC:Z0FBQUFBQnFCdlFlNV9felVPcHVyMU9kVGhGZEt0MG9iRzRrTVM4TFJvSHhGOVo0U1ROWkdEMzRSWjhtMnFrZUhHTHNXelpLZ014RzRkMlIxZDJwcjEwc1dRamY5ekJMR1VLb2w4eEZqZENBRnFaZlRhb1h5VE05Tml1ZlVBWHBaTkJaZUE5NWprVklva0ZFZnB4cFFudGdkalpmTlBhdV9nPT0=
|
||||||
Connector_AiTavily_API_SECRET = DEV_ENC:Z0FBQUFBQnFCdlFlY1R2WGpuazk5M05SeDIyLWd3bHpKN3lUdlVFdjhvZEJXdlM4bGlBdTB1TjRia051YllDQ2lwM0V3R3dPd2lKVWxoSm9BNWl1ZFFlVkZ5cXh4TFRVU0Z4NVU5WVRjSUJPc01La3JyaVZSNkhYWU9PR00yMENEb0dRT3l5enEwSFlWZVVzTVR0UWQ4eUxvRmZvWHl0c0xRPT0=
|
Connector_AiTavily_API_SECRET = DEV_ENC:Z0FBQUFBQnFCdlFlY1R2WGpuazk5M05SeDIyLWd3bHpKN3lUdlVFdjhvZEJXdlM4bGlBdTB1TjRia051YllDQ2lwM0V3R3dPd2lKVWxoSm9BNWl1ZFFlVkZ5cXh4TFRVU0Z4NVU5WVRjSUJPc01La3JyaVZSNkhYWU9PR00yMENEb0dRT3l5enEwSFlWZVVzTVR0UWQ4eUxvRmZvWHl0c0xRPT0=
|
||||||
Connector_AiPrivateLlm_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGRHM5eFdUVmVZU1R1cHBwN1RlMUx4T0NlLTJLUFFVX3J2OElDWFpuZmJHVmp4Z3BNNWMwZUVVZUd2TFhRSjVmVkVlcFlVRWtybXh0ZHloZ01ZcnVvX195YjdlWVdEcjZSWFFTTlNBWUlaTlNoLWhqVFBIb0thVlBiaWhjYjFQOFY=
|
Connector_AiPrivateLlm_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGRHM5eFdUVmVZU1R1cHBwN1RlMUx4T0NlLTJLUFFVX3J2OElDWFpuZmJHVmp4Z3BNNWMwZUVVZUd2TFhRSjVmVkVlcFlVRWtybXh0ZHloZ01ZcnVvX195YjdlWVdEcjZSWFFTTlNBWUlaTlNoLWhqVFBIb0thVlBiaWhjYjFQOFY=
|
||||||
|
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
# Integration Environment Configuration
|
|
||||||
|
|
||||||
# System Configuration
|
|
||||||
APP_ENV_TYPE = int
|
|
||||||
APP_ENV_LABEL = Integration Instance
|
|
||||||
APP_API_URL = https://api-int.poweron.swiss
|
|
||||||
# Force SameSite=None+Secure for auth cookies (cross-site UI on poweron-center.net). Optional if APP_API_URL is https://
|
|
||||||
APP_COOKIE_SECURE = true
|
|
||||||
APP_KEY_SYSVAR = /srv/gateway/shared/secrets/master_key.txt
|
|
||||||
APP_INIT_PASS_ADMIN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjWm41MWZ4TUZGaVlrX3pWZWNwakJsY3Facm0wLVZDd1VKeTFoZEVZQnItcEdUUnVJS1NXeDBpM2xKbGRsYmxOSmRhc29PZjJSU2txQjdLbUVrTTE1NEJjUXBHbV9NOVJWZUR3QlJkQnJvTEU9
|
|
||||||
APP_INIT_PASS_EVENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjdmtrakgxa0djekZVNGtTZV8wM2I5UUpCZllveVBMWXROYk5yS3BiV3JEelJSM09VYTRONHpnY3VtMGxDRk5JTEZSRFhtcDZ0RVRmZ1RicTFhb3c5dVZRQ1o4SmlkLVpPTW5MMTU2eTQ0Vkk9
|
|
||||||
|
|
||||||
# PostgreSQL DB Host (porta-int-db on Infomaniak Public Cloud)
|
|
||||||
DB_HOST=db-int.poweron.swiss
|
|
||||||
DB_USER=poweron_dev
|
|
||||||
DB_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQnFGTFJneVFGQ09JYVgwVWRGXzRSQjJ2RnlGYS05WllIMURpUUNBS0poQS1yLUJDaFFQS2IyLTNTSTUtRTBfekF1R1U5dUhiOXdYdi1WSVF4bUltczVQUVJQN2Q0Mng3cHFWVndZVDJxc2ZicXRXVnc9
|
|
||||||
DB_PORT=5432
|
|
||||||
|
|
||||||
# Security Configuration
|
|
||||||
APP_JWT_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNUctb2RwU25iR3ZnanBOdHZhWUtIajZ1RnZzTEp4aDR0MktWRjNoeVBrY1Npd1R0VE9YVHp3M2w1cXRzbUxNaU82QUJvaDNFeVQyN05KblRWblBvbWtoT0VXbkNBbDQ5OHhwSUFnaDZGRG10Vmgtdm1YUkRsYUhFMzRVZURmSFlDTFIzVWg4MXNueDZyMGc5aVpFdWRxY3dkTExGM093ZTVUZVl5LUhGWnlRPQ==
|
|
||||||
APP_TOKEN_EXPIRY=300
|
|
||||||
|
|
||||||
# CORS Configuration
|
|
||||||
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://porta.poweron.swiss,https://porta-int.poweron.swiss,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
|
|
||||||
|
|
||||||
# Logging configuration
|
|
||||||
APP_LOGGING_LOG_LEVEL = DEBUG
|
|
||||||
APP_LOGGING_LOG_DIR = srv/gateway/shared/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
|
|
||||||
APP_LOGGING_FILE_ENABLED = True
|
|
||||||
APP_LOGGING_ROTATION_SIZE = 10485760
|
|
||||||
APP_LOGGING_BACKUP_COUNT = 5
|
|
||||||
|
|
||||||
# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
|
|
||||||
Service_MSFT_AUTH_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
|
|
||||||
Service_MSFT_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kydlVubld1d1h6SUNSWW1aZ3p4X3Zod1NDTjhZVnVYS2lqOERGTFp2OXJ4TGRiNlRLVFpzLUVDTUhkZGhGUWdxa1djdEV5UWkyblN1UHZoaFBjaExNTEpGMG1PRGJEbDdHVll0Ungwcl9JemZ4ZXFzZUNFQmFlZi1DZFlCekU1S3E=
|
|
||||||
Service_MSFT_AUTH_REDIRECT_URI = https://api-int.poweron.swiss/api/msft/auth/login/callback
|
|
||||||
Service_MSFT_DATA_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
|
|
||||||
Service_MSFT_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kyS1hWZXEzUzZTTE5MUlJncVowMU95Y0hmV1hveDBZOWdLU1RIUWt3SGlXNGxVTXVKc2QyQmtmWTlJRU43ZnRDdnlDTGxQY0hTU25CWWFFdDhUem9HU0VYcTFJTVFEbVk0dUhmVzJNVlEzNTNWdjdmaW9WeUVDVW5PRmNFZEQzNTY=
|
|
||||||
Service_MSFT_DATA_REDIRECT_URI = https://api-int.poweron.swiss/api/msft/auth/connect/callback
|
|
||||||
|
|
||||||
Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
|
||||||
Service_GOOGLE_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kyUTUwNXNGaHRNaGxxbF9sdWJ3Q0xLYU5yOHB4Yk8zMDZvQ29yaEhWOE5JMENXRk5jb2ZBdzRKQ2ZTTld6ZlIxemhOYzN1VE10TjBDRWZEMXlLVWRNYjZ0VG5RZ3I3NWt0SEJzMzdsUmRzcVNmbktRNHZqTUF6a2EyUkVUSFJnZFE=
|
|
||||||
Service_GOOGLE_AUTH_REDIRECT_URI = https://api-int.poweron.swiss/api/google/auth/login/callback
|
|
||||||
Service_GOOGLE_DATA_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
|
||||||
Service_GOOGLE_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnFBa1kyV1FRVjF0c0d3d0dyWU1TdW9HdXVkdHdsVWZKYTJjbGZPRDhMRjA2M0FkaUZIVmhIUmFKNjg2ekFodHd6NG80VTI3TC1icW1LZ01jWVZuQ1pKRm5nMW5UREJEaGp2Wl9oRDRCSmZVT0JpTnkwXzgwY0pkV29yczQ5akF2d1ZGcVY=
|
|
||||||
Service_GOOGLE_DATA_REDIRECT_URI = https://api-int.poweron.swiss/api/google/auth/connect/callback
|
|
||||||
|
|
||||||
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
|
|
||||||
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
|
|
||||||
Service_CLICKUP_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5SE1uVURMNVE3NkM4cHBKa2R2TjBnLWdpSXI5dHpKWGExZVFiUF95TFNnZ1NwLWFLdmh6eWFZTHVHYTBzU2FGRUpLYkVyM1NvZjZkWDZHN21qUER5ZVNOaGpCc3NrUGd3VnFTclF3OW1nUlVuWXQ1UVhDLVpyb1BwRExOeFpDeVhtbEhDVnd4TVdpbzNBNk5QQWFPdjdza0xBWGxFY1E3WFpCSUlNa1l4RDlBPQ==
|
|
||||||
Service_CLICKUP_OAUTH_REDIRECT_URI = https://api-int.poweron.swiss/api/clickup/auth/connect/callback
|
|
||||||
|
|
||||||
# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.
|
|
||||||
|
|
||||||
# Stripe Billing (both end with _SECRET for encryption script)
|
|
||||||
STRIPE_SECRET_KEY_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5ekdBaGNGVUlOQUpncTlzLWlTV0V5OWZzQkpDczhCUGw4U1JpTHZ0d3pfYlFNWElLRlNiNlNsaDRYTGZUTkg2OUFrTW1GZXpOUjBVbmRQWjN6ekhHd2ZSQ195OHlaeWh1TmxrUm10V2R3YmdncmFLbFMzVjdqcWJMSUJPR2xuSEozclNoZG1rZVBTaWg3OFQ1Qzdxb0wyQ2RKazc2dG1aZXBUTXlvbDZqLS1KOVI5M3BGc3NQZkZRbnFpRjIwWmh2ZHlVNlpxZVo2dWNmMjQ5eW02QmtzUT09
|
|
||||||
STRIPE_WEBHOOK_SECRET = INT_ENC:Z0FBQUFBQnA4UXZiUUVqTl9lREVRWTh1aHFDcFpwcXRkOUx4MS1ham9Ddkl6T0xzMnJuM1hhUHdGNG5CenY1MUg4RlJBOGFQTWl5cVd5MjJ2REItcHYyRmdLX3ZlT2p5Z3BRVkMtQnRoTVkteXlfaU92MVBtOEI0Ni1kbGlfa0NiRmFRRXNHLVE2NHI=
|
|
||||||
STRIPE_API_VERSION = 2026-01-28.clover
|
|
||||||
STRIPE_AUTOMATIC_TAX_ENABLED = false
|
|
||||||
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0
|
|
||||||
|
|
||||||
# AI configuration
|
|
||||||
Connector_AiOpenai_API_SECRET = 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
|
|
||||||
Connector_AiAnthropic_API_SECRET = INT_ENC:Z0FBQUFBQnFCdlFlRHFpNThJb3g3UU05cUw4SVJpOXBTblU5QzU1WFItZ2JkNXVILVN4VHp0Umh2RjJyZXJMNVp5OWFxLWhjRjhub3cxajkxMVRQMnZQdVBGT21obWN0Q0NlOU80MVhMMXRWb1l3cWNpR2Ytc1d0WnVlRUN1TTZ4NjFQcDd0Wll4cFN6dzk1OU5SZGNJck54WmNoeElITzEzejJrczVSQnp6ZTBINGtENHFiT3NnWjdUME9xXzJ5Y0N3dHk5QnpBRkpyVTgxOE0xTVllR2JMUC0yTkwyWWxHQT09
|
|
||||||
Connector_AiPerplexity_API_SECRET = INT_ENC:Z0FBQUFBQnFCdlFla1h1R1M3QlQ5XzJhS0x4eXFpTkZ3WHpLMWVZZldRMGpMX2psMFZ2RmpETTZMZ3ZXblo2MnhyemxYWXRsMHN1LXdZU3k5ampEMjMtdzcyb1J4Ri1rTmxPOWhJMF9MMEtzZ3d5dFZxSFY3TjNac3ZpTVJxUFFmUVpXeHEtbVBTUmtiR0lhQjhVcjM3U1NNX1ZHY1NxUFJ3PT0=
|
|
||||||
Connector_AiTavily_API_SECRET = INT_ENC:Z0FBQUFBQnFCdlFlbmRSZVRjTzVKRklFbFgwdVZJaE5jNVoyX3dVTVlRUFVUenc4X1JOX2laOHRoTU9mN1lTUVRzb2xNZjJXVjhEYnVIaXdkSWN4NEpJbTFJZFN2cmkwUkJ0ZXNKT2NidktjdDFJX1BkZ3QwU3dQRzg0aG9aNmtxc1FZZ1ZBRjQyM3lOSS1EYkpqWmxoV0xWWE1Fc01uN3RnPT0=
|
|
||||||
Connector_AiPrivateLlm_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGSjZ1NWh0aWc1R3Z4MHNaeS1HamtUbndhcUZFZDlqUDhjSmg5eHFfdlVkU0RsVkJ2UVRaMWs3aWhraG5jSlc0YkxNWHVmR2JoSW5ENFFCdkJBM0VienlKSnhzNnBKbTJOUTFKczRfWlQ3bWpmUkRTT1I1OGNUSTlQdExacGRpeXg=
|
|
||||||
Connector_AiMistral_API_SECRET = INT_ENC:Z0FBQUFBQnFCdlFlU2tMLTFnQWhET2Nia2pTcVpBakRaSVFDdUpHRzZ1bkhGVVhMeEVlSnFZU3F3UFRBUkNMMU4tQU92OUdTeDlpM2VZbXJzLURQZ1lPLVB3azgxSDZabkhkSHJ5Y005aWhtcDJzajk3a2JDQUxCZlNKRGw5elJuSzJMUUpTZ2hiSlU=
|
|
||||||
|
|
||||||
Service_MSFT_TENANT_ID = common
|
|
||||||
|
|
||||||
# Google Cloud Speech Services configuration
|
|
||||||
Connector_GoogleSpeech_API_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkNmVXZ1pWcHcydTF2MXF0ZGJoWHBydF85bTczTktiaEJ3Wk1vMW1mZVhDSG1yd0ZxR2ZuSGJTX0N3MWptWXFJTkNTWjh1SUVVTXI4UDVzcGdLMkU5SHJ2TUpkRlRoRWdnSldtYjNTQkh4UDJHY2xmdTdZQ1ZiMTZZcGZxS3RzaHdjV3dtVkZUcEpJcWx0b2xuQVR6ZmpoVFZPY1hNMTV2SnhDaC1IZEh4UUpLTy1ILXA4RG1zamJTbUJ4X0t2M2NkdzJPbEJxSmFpRzV3WC0wZThoVzlxcmpHZ3ZkLVlVY3REZk1vV19WQ05BOWN6cnJ4MWNYYnNiQ0FQSUVnUlpfM3BhMnlsVlZUOG5wM3pzM1lSN1UzWlZKUXRLczlHbjI1LTFvSUJ4SlVXMy1BNk43bE5Hb0RfTTVlWk9oZnFIaVg0SW5pbm9EcXRTTzU1RFlYY3dTcnpKWWNyNjN5T1BGZ0FmX253cEFncmhvZVRuM05KYzhkOEhFMFJsc2NBSEwzZVZ1R0JMOGxsekVwUE55alZaRXFrdzNWWVNGWXNmbnhKeWhQSFo2VXBTUlRPeHdvdVdncEFuOWgydEtsSUFneUN6cGVaTnBSdjNCdVJseGJFdmlMc203UFhLVlYyTENkaGg2dVN6Z2xwT1ZmTmN5bVZGUkM3ZWcyVkt2ckFUVVd3WFFwYnJjNVRobEh2SkVJbXRwUUpEOFJKQ1NUc0Q4NHNqUFhPSDh5cTV6MEcwSDEwRUJCQ2JiTTJlOE5nd3pMMkJaQ1dVYjMwZVVWWnlETmp2dkZ3aXEtQ29WNkxZTFkzYUkxdTlQUU1OTnhWWU12YU9MVnJQa1d2ZjRtUlhneTNubEMxTmp1eUNPOThSMlB3Y1F0T2tCdFNsNFlKalZPV25yR2QycVBUb096RmZ1V0FTaGsxLV9FWDBmenBIOXpMdGpLcUc0TWRoY2hlMFhYTzlET1ZRekw0ZHNwUVBQdVJBX2h6Q2ZzWVZJWTNybTJiekp3WmhmWF9SUFBXQzlqUjctcVlHWWVMZWVQallzR0JGTVF0WmtnWlg1aTM1bFprNVExZXY5dnNvWF93UjhwbkJ3RzNXaVJ2d2RRU3JJVlBvaVh4eTlBRUtqWkJia3dJQVVBV2Nqdm9FUTRUVW1TaHp2ZUwxT0N2ZndxQ2Nka1RYWXF0LWxIWFE0dTFQcVhncFFPM0hFdUUtYlFnemx3WkF4bjA1aDFULUdrZlVZbEJtRGRCdjJyVkdJSXozd0I0dF9zbWhOeHFqRDA4T1NVaWR5cjBwSVgwbllPU294NjZGTnM1bFhIdGpNQUxFOENWd3FCbGpSRFRmRXotQnU0N2lCVEU5RGF6Qi10S2U2NGdadDlrRjZtVE5oZkw5ZWFjXzhCTmxXQzNFTFgxRXVYY3J3YkxnbnlBSm9PY3h4MlM1NVFQbVNDRW5Ld1dvNWMxSmdoTXJuaE1pT2VFeXYwWXBHZ29MZDVlN2lwUUNIeGNCVVdQVi1rRXdJMWFncUlPTXR0MmZVQ1l0d09mZTdzWGFBWUJMUFd3b0RSOU8zeER2UWpNdzAxS0ZJWnB5S3FJdU9wUDJnTTNwMWw3VFVqVXQ3ZGZnU1RkUktkc0NhUHJ0SGFxZ0lVWDEzYjNtU2JfMGNWM1Y0dHlCTzNESEdENC1jUWF5MVppRzR1QlBNSUJySjFfRi1ENHEwcmJ4S3hQUFpXVHA0TG9DZWdoUlo5WnNSM1lCZm1KbEs2ak1yUUU4Wk9JcVJGUkJwc0NvUkMyTjhoTWxtZmVQeDREZVRKZkhYN2duLVNTeGZzdFdBVnhEandJSXB5QjM0azF0ckI3Tk1wSzFhNGVOUVRrNjU0cG9JQ29pN09xOFkwR1lMTlktaGp4TktxdTVtTnNEcldsV2pEZm5nQWpJc2hxY0hjQnVSWUR5VVdaUXBHWUloTzFZUC1oNzJ4UjZ1dnpLcDJxWEZtQlNIMWkzZ0hXWXdKeC1iLXdZWVJhcU04VFlpMU5pd2ZIdTdCdkVWVFVBdmJuRk16bEFFQTh4alBrcTV2RzliT2hGdTVPOXlRMjFuZktiRTZIamQ1VFVqS0hRTXhxcU1mdkgyQ1NjQmZfcjl4c3NJd0RIeDVMZUFBbHJqdEJxWWl3aWdGUEQxR3ZnMkNGdVB4RUxkZi1xOVlFQXh1NjRfbkFEaEJ5TVZlUGFrWVhSTVRPeGxqNlJDTHNsRWRrei1pYjhnUmZrb3BvWkQ2QXBzYjFHNXZoWU1LSExhLWtlYlJTZlJmYUM5Y1Rhb1pkMVYyWTByM3NTS0VXMG1ybm1BTVN2QXRYaXZqX2dKSkZrajZSS2cyVlNOQnd5Y29zMlVyaWlNbTJEb3FuUFFtbWNTNVpZTktUenFZSl91cVFXZjRkQUZyYmtPczU2S1RKQ19ONGFOTHlwX2hOOEE1UHZEVjhnT0xxRjMxTEE4SHhRbmlmTkZwVXJBdlJDbU5oZS05SzI4QVhEWDZaN2ZiSlFwUGRXSnB5TE9MZV9ia3pYcmZVa1dicG5FMHRXUFZXMWJQVDAwOEdDQzJmZEl0ZDhUOEFpZXZWWXl5Q2xwSmFienNCMldlb2NKb2ZRYV9KbUdHRzNUcjU1VUFhMzk1a2J6dDVuNTl6NTdpM0hGa3k0UWVtbF9pdDVsQVp2cndDLUU5dnNYOF9CLS0ySXhBSFdCSnpqV010bllBb3U0cEZZYVF5R2tSNFM5NlRhdS1fb1NqbDBKMkw0V2N0VEZhNExtQlR3ckZ3cVlCeHVXdXJ6X0s4cEtsaG5rVUxCN2RRbHQxTmcyVFBqYUxyOHJzeFBXVUJaRHpXbUoxdHZzMFBzQk1UTUFvX1pGNFNMNDFvZWdTdEUtMUNKMXNIeVlvQk1CeEdpZVdmN0tsSDVZZHJXSGt5c2o2MHdwSTZIMVBhRzM1eU43Q2FtcVNidExxczNJeUx5U2RuUG5EeHpCTlg2SV9WNk1ET3BRNXFuc0pNWlVvZUYtY21oRGtJSmwxQ09QbHBUV3BuS3B5NE9RVkhfellqZjJUQ0diSV94QlhQWmdaaC1TRWxsMUVWSXB0aE1McFZDZDNwQUVKZ2t5cXRTXzlRZVJwN0pZSnJSV21XMlh0TzFRVEl0c2I4QjBxOGRCYkNxek04a011X1lrb2poQ3h2LUhKTGJiUlhneHp5QWFBcE5nMElkNTVzM3JGOWtUQ19wNVBTaVVHUHFDNFJnNXJaWDNBSkMwbi1WbTdtSnFySkhNQl9ZQjZrR2xDcXhTRExhMmNHcGlyWjR3ZU9SSjRZd1l4ZjVPeHNiYk53SW5SYnZPTzNkd1lnZmFseV9tQ3BxM3lNYVBHT0J0elJnMTByZ3VHemxta0tVQzZZRllmQ2VLZ1ZCNDhUUTc3LWNCZXBMekFwWW1fQkQ1NktzNGFMYUdYTU0xbXprY1FONUNlUHNMY3h2NFJMMmhNa3VNdzF4TVFWQk9odnJUMjFJMVd3Z2N6Sms5aEM2SWlWZFViZ0JWTEpUWWM5NmIzOS1oQmRqdkt1NUUycFlVcUxERUZGbnZqTUxIYnJmMDBHZDEzbnJsWEEzSUo3UmNPUDg1dnRUU1FzcWtjTWZwUG9zM0JTY3RqMDdST2UxcXFTM0d0bGkwdFhnMk5LaUlxNWx3V1pLaVlLUFJXZzBzVl9Ia1V1OHdYUEFWOU50UndycGtCdzM0Q0NQamp2VTNqbFBLaGhsbUk5dUI5MjU5OHVySk1oY0drUWtXUloyVVRvOWJmbUVYRzFVeWNQczh2NXJCeVppRlZiWDNJaDhOSmRmX2lURTNVS3NXQXFZT1QtUmdvMWJoVWYxU3lqUUJhbzEyX3I3TXhwbm9wc1FoQ1ZUTlNBRjMyQTBTY2tzbHZ3RFUtTjVxQ0o1QXRTVks2WENwMGZCRGstNU1jN3FhUFJCQThyaFhhMVRsbnlSRXNGRmt3Yk01X21ldmV3bTItWm1JaGpZQWZROEFtT1d1UUtPQlhYVVFqT2NxLUxQenJHX3JfMEdscDRiMXcyZ1ZmU3NFMzVoelZJaDlvT0ZoRGQ2bmtlM0M5ZHlCd2ZMbnRZRkZUWHVBUEx4czNfTmtMckh5eXZrZFBzOEItOGRYOEhsMzBhZ0xlOWFjZzgteVBsdnpPT1pYdUxnbFNXYnhKaVB6QUxVdUJCOFpvU2x2c1FHZV94MDBOVWJhYkxISkswc0U5UmdPWFJLXzZNYklHTjN1QzRKaldKdEVHb0pOU284N3c2LXZGMGVleEZ5NGZ6OGV1dm1tM0J0aTQ3VFlNOEJrdEh3PT0=
|
|
||||||
|
|
||||||
# Teamsbot Browser Bot Service (service-main-teams-browser-bot on Infomaniak)
|
|
||||||
TEAMSBOT_BROWSER_BOT_URL = http://teamsbot.poweron.swiss:4100
|
|
||||||
|
|
||||||
# Debug Configuration
|
|
||||||
APP_DEBUG_CHAT_WORKFLOW_ENABLED = FALSE
|
|
||||||
APP_DEBUG_CHAT_WORKFLOW_DIR = ./test-chat
|
|
||||||
APP_DEBUG_ACCOUNTING_SYNC_ENABLED = FALSE
|
|
||||||
APP_DEBUG_ACCOUNTING_SYNC_DIR = ./debug/sync
|
|
||||||
|
|
||||||
# Azure Communication Services Email Configuration
|
|
||||||
MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt
|
|
||||||
MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss
|
|
||||||
|
|
@ -63,8 +63,8 @@ STRIPE_AUTOMATIC_TAX_ENABLED = false
|
||||||
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0
|
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0
|
||||||
|
|
||||||
# AI configuration
|
# AI configuration
|
||||||
Connector_AiOpenai_API_SECRET = INT_ENC:Z0FBQUFBQnFGcldVdG9pOUFMRzFHOEM2WDJVYU5fWEVQcjVjUlFuUWl4bS05TVlPSTJUX1pVWEIwRi12VU1tT2E3LTIyblpieTlJWTZZRXdGU2xNajJWc0JrcDZac19iYUJKS19GSWgzRld1V01LczJrYTUzYkxITjkxNUpBNk9UWnRHY2JBZnRzNk5aQ0VNNUZodTV2c2FXbnhZR2dERGstUUxJNW92Uld0Q0hwaWxYRGhLY3Rtb1B0Ynl5clR4ZGF1SFllc09Ka0puMS1EM1FHREFRMFB5bVpqVE0tNXJaTDBhSjNCeGNTVk1UVDM3M2hwQW5mVFVNVkhESHVldEhSSlBSalB4aXYxeXZ4RGtLMk9EM2hJNk9QNmNoREZBMXNGUmRPczZ6elAyQXBOc0F3WnJha289
|
Connector_AiOpenai_API_SECRET = INT_ENC:Z0FBQUFBQnFGdnVIcUxPOUVXT0NlUlJNVENjRi1iLXdsR1ZuOXU3Sk1qbmVZOThYdUZrNGlDREJmMkttRVNyWlNsMHlDc2pnQ1VyZ0lzYXVkc0hHNm95bjFrejNRWVVGUWZOVTVYOGpKcF9QNGttc001TE9VdFdKa2FyUEYxY1VYOE1RenBObmNMbVFHeTdHbGpORVAwOWc3Rng1dWtlUEphZmVKV1otSE03a2FTVHVvMlNONWZ3N3hMR2FmdTEtdkdWOHV5d0RYWlZ3dVV1SEpKNHBjWG1QTEZ6SE5oS1VESEJ2MmVSRmh4azd3d1RmQ3FjMDVsbWxNc2EzZDdWMXYyaFBOMTZFSUltUkk2ZTEtZ0FHRW5pZkhON1Rna3lYX1Z5TWRQNkZEX3NXVHlPYkVuMW5zcE09
|
||||||
Connector_AiAnthropic_API_SECRET = INT_ENC:Z0FBQUFBQnFCdlFlRHFpNThJb3g3UU05cUw4SVJpOXBTblU5QzU1WFItZ2JkNXVILVN4VHp0Umh2RjJyZXJMNVp5OWFxLWhjRjhub3cxajkxMVRQMnZQdVBGT21obWN0Q0NlOU80MVhMMXRWb1l3cWNpR2Ytc1d0WnVlRUN1TTZ4NjFQcDd0Wll4cFN6dzk1OU5SZGNJck54WmNoeElITzEzejJrczVSQnp6ZTBINGtENHFiT3NnWjdUME9xXzJ5Y0N3dHk5QnpBRkpyVTgxOE0xTVllR2JMUC0yTkwyWWxHQT09
|
Connector_AiAnthropic_API_SECRET = INT_ENC:Z0FBQUFBQnFGd0hhbGxMRUlZc1A2d1RvOWdFX3NkQXM0RG5LOTZQYWpOc21tTTJWU09nbS12M29YLVVzVk8zWGdrWXIzb05meW56dkRtTElVN1ZndkZ5eHdWMGxGVjBPTlRvTGxpTzVzcFlzdnVhTTh0R0gtM2M2Mk9ac3dnc0RYYkx2c3BDdnoxVXJLX2tPMTVpZXdmQll3cHF3dEhGWGRlb3JLZjlNTVJpZTN1TzFtMU5yZmdXTnZuZ1lXN0p5VUdsVXBDUXJoY1Y3aFBkUW1HbmJJZmZaR1cwTVNQR0VZUT09
|
||||||
Connector_AiPerplexity_API_SECRET = INT_ENC:Z0FBQUFBQnFCdlFla1h1R1M3QlQ5XzJhS0x4eXFpTkZ3WHpLMWVZZldRMGpMX2psMFZ2RmpETTZMZ3ZXblo2MnhyemxYWXRsMHN1LXdZU3k5ampEMjMtdzcyb1J4Ri1rTmxPOWhJMF9MMEtzZ3d5dFZxSFY3TjNac3ZpTVJxUFFmUVpXeHEtbVBTUmtiR0lhQjhVcjM3U1NNX1ZHY1NxUFJ3PT0=
|
Connector_AiPerplexity_API_SECRET = INT_ENC:Z0FBQUFBQnFCdlFla1h1R1M3QlQ5XzJhS0x4eXFpTkZ3WHpLMWVZZldRMGpMX2psMFZ2RmpETTZMZ3ZXblo2MnhyemxYWXRsMHN1LXdZU3k5ampEMjMtdzcyb1J4Ri1rTmxPOWhJMF9MMEtzZ3d5dFZxSFY3TjNac3ZpTVJxUFFmUVpXeHEtbVBTUmtiR0lhQjhVcjM3U1NNX1ZHY1NxUFJ3PT0=
|
||||||
Connector_AiTavily_API_SECRET = INT_ENC:Z0FBQUFBQnFCdlFlbmRSZVRjTzVKRklFbFgwdVZJaE5jNVoyX3dVTVlRUFVUenc4X1JOX2laOHRoTU9mN1lTUVRzb2xNZjJXVjhEYnVIaXdkSWN4NEpJbTFJZFN2cmkwUkJ0ZXNKT2NidktjdDFJX1BkZ3QwU3dQRzg0aG9aNmtxc1FZZ1ZBRjQyM3lOSS1EYkpqWmxoV0xWWE1Fc01uN3RnPT0=
|
Connector_AiTavily_API_SECRET = INT_ENC:Z0FBQUFBQnFCdlFlbmRSZVRjTzVKRklFbFgwdVZJaE5jNVoyX3dVTVlRUFVUenc4X1JOX2laOHRoTU9mN1lTUVRzb2xNZjJXVjhEYnVIaXdkSWN4NEpJbTFJZFN2cmkwUkJ0ZXNKT2NidktjdDFJX1BkZ3QwU3dQRzg0aG9aNmtxc1FZZ1ZBRjQyM3lOSS1EYkpqWmxoV0xWWE1Fc01uN3RnPT0=
|
||||||
Connector_AiPrivateLlm_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGSjZ1NWh0aWc1R3Z4MHNaeS1HamtUbndhcUZFZDlqUDhjSmg5eHFfdlVkU0RsVkJ2UVRaMWs3aWhraG5jSlc0YkxNWHVmR2JoSW5ENFFCdkJBM0VienlKSnhzNnBKbTJOUTFKczRfWlQ3bWpmUkRTT1I1OGNUSTlQdExacGRpeXg=
|
Connector_AiPrivateLlm_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGSjZ1NWh0aWc1R3Z4MHNaeS1HamtUbndhcUZFZDlqUDhjSmg5eHFfdlVkU0RsVkJ2UVRaMWs3aWhraG5jSlc0YkxNWHVmR2JoSW5ENFFCdkJBM0VienlKSnhzNnBKbTJOUTFKczRfWlQ3bWpmUkRTT1I1OGNUSTlQdExacGRpeXg=
|
||||||
|
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
# Production Environment Configuration
|
|
||||||
|
|
||||||
# System Configuration
|
|
||||||
APP_ENV_TYPE = prod
|
|
||||||
APP_ENV_LABEL = Production Instance Forgejo
|
|
||||||
APP_KEY_SYSVAR = /srv/gateway/shared/secrets/master_key.txt
|
|
||||||
APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW1WbUt6bGtQYkdrNkZDOXNOLXFua1hqeFF2RHJnRXJ5VlVGV3hOZm41QjZOMlNTb0duYXNxZi05dXVTc2xDVkx0SVBFLUhncVo5T0VUZHE0UTZLWWw3ck09
|
|
||||||
APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9
|
|
||||||
APP_API_URL = https://api.poweron.swiss
|
|
||||||
|
|
||||||
# PostgreSQL DB Host (porta-main-db on Infomaniak Public Cloud)
|
|
||||||
DB_HOST=db.poweron.swiss
|
|
||||||
DB_USER=poweron_dev
|
|
||||||
DB_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnA4UXZiMnRoUzVlbVRLX3JTRl94cVpMaURtMndZVmFBYXdvdnIxLV81dWwxWmhmcUlCMUFZbDhRT2NsQmNqSl9ZMmRWRVN1Y2JqNlVwOXRJY1VBTm1oSjNiaFE9PQ==
|
|
||||||
DB_PORT=5432
|
|
||||||
|
|
||||||
# Security Configuration
|
|
||||||
APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUlV5SVpaWXBNX1hpa2xPZGdMSWpnN2ZINHQxeGZnNHJweU5pZjlyYlY5Qm9zOUZEbl9wUEgtZHZXd1NhR19JSG9kbFU4MnFGQnllbFhRQVphRGQyNHlFVWR5VHQyUUpqN0stUmRuY2QyTi1oalczRHpLTEJqWURjZWs4YjZvT2U5YnFqcXEwdEpxV05fX05QMmtrPQ==
|
|
||||||
APP_TOKEN_EXPIRY=300
|
|
||||||
|
|
||||||
# CORS Configuration
|
|
||||||
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://porta.poweron.swiss,https://porta-int.poweron.swiss,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
|
|
||||||
|
|
||||||
# Logging configuration
|
|
||||||
APP_LOGGING_LOG_LEVEL = DEBUG
|
|
||||||
APP_LOGGING_LOG_DIR = srv/gateway/shared/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
|
|
||||||
APP_LOGGING_FILE_ENABLED = True
|
|
||||||
APP_LOGGING_ROTATION_SIZE = 10485760
|
|
||||||
APP_LOGGING_BACKUP_COUNT = 5
|
|
||||||
|
|
||||||
# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
|
|
||||||
Service_MSFT_AUTH_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
|
|
||||||
Service_MSFT_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kyeUZORDYxOFdlNHk1N25kV3pSQVJMUVFwLUFlMzlzQjQ1eVljOTlzX184RndsTmtTV1FjdWkyQlBiUkdCbGt5S2ltZjJxa2I2dHBMdnJqZnhFSnBCampHYjB3RG5URDM1YzZSLVd6TGdaRXRVcEdadE5zM2thNV9SZy1KZDdLSHY=
|
|
||||||
Service_MSFT_AUTH_REDIRECT_URI=https://api.poweron.swiss/api/msft/auth/login/callback
|
|
||||||
Service_MSFT_DATA_CLIENT_ID = 840b759a-4d79-4a7a-9598-f3ed204d99d8
|
|
||||||
Service_MSFT_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kySk5uMmlWczBWTE00MHBIcWlBbVJmVmc3MlBWbDA1YTFaS3psZjVLd3d1X2FvRHV0X0c5blpLV0FpY05aMTJMMzUtcG8wakF2TlM3SGQ2VjFZM3JLT1MwTlZ0bm9BRlpkbHVPQTFNaXJvazlQRzN4M2ZZNEVhV1JHV190dWluSUk=
|
|
||||||
Service_MSFT_DATA_REDIRECT_URI = https://api.poweron.swiss/api/msft/auth/connect/callback
|
|
||||||
|
|
||||||
Service_GOOGLE_AUTH_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
|
||||||
Service_GOOGLE_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kybjVVZ0FldUE1NTJiY2U1N0I0aVU0Z2hfeWlYc2tTdmlxTS1NdGxsRnFHdjZVcW5RRHZkUFhzUTVyX2RaZHlrQThRdTdCRmVBelBOcDlsbFQyd19SZExuWEM5aTcwQ0FvY3ctMUlWU1pndDE0MkdzeTZZRHkwLWU3aW56LW1jS20=
|
|
||||||
Service_GOOGLE_AUTH_REDIRECT_URI = https://api.poweron.swiss/api/google/auth/login/callback
|
|
||||||
Service_GOOGLE_DATA_CLIENT_ID = 813678306829-3f23dnf1cs4aaftubjfickt46tlmkgjm.apps.googleusercontent.com
|
|
||||||
Service_GOOGLE_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnFBa1kyMnFma3VPOVJtTFFrNDRLN0NkWHY2dUZDWlJzdDVMd3p3N19IY0tWdURRRzExOGZCMjJOYmpKT1E0cTVwYlgtcVJINTY0anZPc1VoTW00cHl6NVh3ZHVTek1oT1RqWUhtamRkZ1dENWlwNTlZSU1oNWczeGdEOC1Gbk5XU2RBcmI=
|
|
||||||
Service_GOOGLE_DATA_REDIRECT_URI = https://api.poweron.swiss/api/google/auth/connect/callback
|
|
||||||
|
|
||||||
# ClickUp OAuth (Verbindungen / automation). Create an app in ClickUp: Settings → Apps → API; set redirect URL to Service_CLICKUP_OAUTH_REDIRECT_URI exactly.
|
|
||||||
Service_CLICKUP_CLIENT_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
|
|
||||||
Service_CLICKUP_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6VGw5WDdhdDRsVENSalhSSUV0OFFxbEx0V1l6aktNV0E5Y18xU3JHLUlqMWVJdmxyajAydVZRaDJkZzJOVXhxRV9ROFRZbWxlRjh4c3NtQnRFMmRtZWpzTWVsdngtWldlNXRKTURHQjJCOEt6alMwQlkwOFYyVVJWNURJUGJIZDIxYVlfNnBrMU54M0Q3TVdVbFZqRkJKTUtqa05wUkV4eGZvbXNsVi1nNVdBPQ==
|
|
||||||
Service_CLICKUP_OAUTH_REDIRECT_URI = https://api.poweron.swiss/api/clickup/auth/connect/callback
|
|
||||||
|
|
||||||
# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.
|
|
||||||
|
|
||||||
# Stripe Billing (both end with _SECRET for encryption script)
|
|
||||||
STRIPE_SECRET_KEY_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6aVA3R3VRS3VHMUgzUEVjYkR4eUZKWFhPUzFTTVlHNnBvT3FienNQaUlBWVpPLXJyVGpGMWk4LXktMXphX0J6ZTVESkJxdjNNa3ZJbF9wX2ppYzdjYlF0cmdVamlEWWJDSmJYYkJseHctTlh4dnNoQWs4SG5haVl2TTNDdXpuaFpqeDBtNkFCbUxMa0RaWG14dmxyOEdILTNrZ2licmNpbXVkN2lFSWoxZW1BODNpV0ZTQ0VaeXRmR1d4RjExMlVFS3MtQU9zZXZlZE1mTmY3OWctUXJHdz09
|
|
||||||
STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGNUpTWldsakYydFhFelBrR1lSaWxYT3kyMENOMUljZTJUZHBWcEhhdWVCMzYxZXQ5b3VlTFVRalFiTVdsbGxrdUx0RDFwSEpsOC1sTDJRTEJNQlA3S3ZaQzBtV1h6bWp5VnlMZUgwUlF3cXYxcnljZVE5SWdzLVg3V0syOWRYS08=
|
|
||||||
STRIPE_API_VERSION = 2026-01-28.clover
|
|
||||||
STRIPE_AUTOMATIC_TAX_ENABLED = false
|
|
||||||
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQZG8WqlVsabrfFEu49pah
|
|
||||||
|
|
||||||
|
|
||||||
# AI configuration
|
|
||||||
Connector_AiOpenai_API_SECRET = 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
|
|
||||||
Connector_AiAnthropic_API_SECRET = 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
|
|
||||||
Connector_AiPerplexity_API_SECRET = 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
|
|
||||||
Connector_AiTavily_API_SECRET = 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
|
|
||||||
Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo=
|
|
||||||
Connector_AiMistral_API_SECRET = H55rGkR3ojIhcp4YMMlgUStgvz7Wym5c
|
|
||||||
|
|
||||||
Service_MSFT_TENANT_ID = common
|
|
||||||
|
|
||||||
# Google Cloud Speech Services configuration
|
|
||||||
Connector_GoogleSpeech_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4NFQxaF9uN3h1cVB6dnZid1c1R1VfNDlSQ1NHMEVDZWtKanpMQ29CLXc1MXBqRm1hQ0YtWVhaejBMY1ZTOEFEVlpWQ3hrYkFza1E2RDNsYkdMMndNR0VGNTMwVDRGdURJY3hyaVFxVjEtSEYwNHJzeWM3WmlpZW9jU2E3NTgycEV2allqQ3dJRTNyRFAzaDJ6dklKeXpNRkJhYjFzUkptN2dpbkNpMklrcGxuZl9vTkt3T0JvNm1YTXd5UlkwZWptUXdWVFpnV2J4X3J2WUhIUlFkSElFVnlqMnlJRnNHTnlpMWs2R1dZc2ROWjNYZG85cndmd1E5cUZnVmZRYnVjTG43dXFmSWd2bGFfVWFWSmtpWkpndWNlSUNwcnFNU2NqZXFaV0xsY3l3SElLRkVHcHZGZERKV1ltcGhTS0dhTko1VTJLYzNoZjRkSGVEX3dTMWVVTmdDczV5cE1JQUdSbUJGUm11eFhTVjJHbkt0SzB4UG1Dc2xmbnp1Y041Y2RTeWRuWGdmQy1sTGx0MGtnM2VJQ3EyLXViRlNhTU9ybzZkR1N1bXE5SXhlZENWRFpWSGlYOWx4SUQ3UlR0ZEVxQkxNakRUVFRiUmFnbklOalphLUZkRFVVaXBRUk5NZW5PaUZydTFmQkNPSTdTVTNZd0plWXllNVFJdmN4MVcyTGlwMGFtVjBzOGRxR1FjbzhfYW5zdTB0ZEZBTTJhakltazh1dktNMUZsOUItdFdTb1pIaUxySllXNkdlY20zUS0wTnpFNTB2SU5acG1VcXhyaHBmME8takw3RDh5T043T2VGOV92TzNya2pWSlpYVjZDdXlZcjM3a0hPTlhkaW9oQmxqQlpGRFYyTTY4WmZmT3k4Tk1tdXRuSGdTUVpNT2NKenhXb05PdXBfSEdhMTNxNjdpNXlKUUI2YUgydFFPX1VvXzVJb0UxWTU2YVNiNDQ0QndZanhMMHR1cGdHWGhvcEg1QXEtSXZJdTdZUE12ZEVVWkF4QmtsQS1GYnY3SFIxSHlsOGVfcEpGS1A4QUVEQWNEOFZYYlljQ3ByTU03YU16Y0UzUnJQZEprSWNjT1ZXVEtDWi03Y3ZzRVdYUTlabXJISEo5THRHVXVuM0xqbzA4bGVlZVpOMk1QMmptb21tV0pTMlVoOXdWVU95UW1iQmttc2w1RG9mMWwxXzg1T2IxYUVmTUJEZkpUdTFDTzZ3RlBFeUFiX01iRTZNWkNaSG45TkFOM2pzbUJRZ2N0VFpoejJUTG1RODY3TzZpSzVkYUQzaEpfY2pSTkRzU0VpanlkdXVQQmJ2WU5peno4QWNLTDVxZTlhSHI3NnNiM0k0Y3JkQ0xaOU05bGtsQl8zQklvaktWSDZ4aVp2MHlYelJuUDJyTU9CZC1OZjJxNFc1dDcwSUlxaVh1LTMyWWFwU0IwUU9kOUFpMWpnOERtLTh1VmJiNGVwcXBMbU5fMjVZc0hFbmxQT2puSFd1ZGpyTkphLU5sVlBZWWxrWEZrWGJQWmVkN19tZFZfZ1l1V3pSWlA0V0ZxM2lrWnl2NU9WeTdCbDROSmhfeENKTFhMVXk1d195S2JMUFJoRXZjcVo4V2g0MTNKRnZhUE1wRkNPM3FZOGdVazJPeW5PSGpuZnFGTTdJMkRnam5rUlV6NFlqODlIelRYaEN5VjdJNnVwbllNODNCTFRHMWlXbmM1VlRxbXB3Wm9LRjVrQUpjYzRNMThUMWwwSVhBMUlyamtPZnE4R0o4bEdHay1zMjR5RDJkZ1lYRHZaNHVHU2otR3ZpN25LZlEySEU0UmdTNzJGVHNWQXMyb0dVMV9WUE13ODhZWUFaakxGOWZieGNXZkNYRnV5djEyWTZLcmdrajRBLU1rS1Z0VVRkOWlDMU9fMGVmYXFhZXJGMUhpNkdmb2hkbzZ1OWV6VlNmVzNISjVYTFh6SjJNdWR5MWZidE8yVEo2dnRrZXhMRXBPczUwTG13OGhNUVpIQm0zQmRKRnJ0Nl8wNW1Ob0dHRDVpU0NWREV3TkY2SjktdVBkMFU1ZXBmSFpHQ3FHNTRZdTJvaExpZVEtLTU4YTVyeFBpNDdEajZtWUc4c1dBeUJqQ3NIY1NLS0FIMUxGZzZxNFNkOG9ORGNHWWJCVnZuNnJVTEtoQi1mRTZyUl81ZWJJMi1KOGdERzBhNVRZeHRYUUlqY2JvMFlaNHhWMU9pWFFiZjdaLUhkaG15TTBPZVlkS2R5UVdENTI4QVFiY1RJV0ZNZnlpVWxfZmlnN1BXbGdrbjFGUkhzYl9qeHBxVVJacUE4bjZETENHVFpSamh0NVpOM2hMYTZjYzBuS3J0a3hhZGxSM1V5UHd2OTU3ZHY0Yy1xWDBkWUk0Ymp0MWVrS3YzSktKODhQZnY3QTZ1Wm1VZkZJbS1jamdreks1ZlhpQjFOUDFiOHJ2Nm9NcmdTdU5LQXV2RkZWZEFNZnVKUjVwcVY3dDdhQnpmRVJ6SmlvVXpDM0ZiYXh5bGE2X04tTE9qZ3BiTnN3TF9ZaFRxSUpjNjB1dXZBcy1TZHRHTjFjSUR3WUl4cE9VNzB5Rkk4U3Z1SVZYTl9sYXlZVk83UnFrMlVmcnBpam9lRUlCY19DdVJwOXl2TVVDV1pMRFZTZk9MY3Z1eXA0MnhGazc5YllQaWtOeTc4NjlOa2lGY05RRzY1cG9nbGpYelc4c3FicWxWRkg0YzRSamFlQ19zOU14YWJreU9pNDREZVJ3a0REMUxGTzF1XzI1bEF3VXVZRjlBeWFiLXJsOXgza3VZem1WckhWSnVNbDBNcldadU8xQ3RwOTl5NGgtVlR0QklCLWl5WkE4V1FlQTBCOVU1RE9sQlRrYUNZOGdfUmEwbEZvUTFGUEFWVmQ4V1FhOU9VNjZqemRpZm1sUDhZQTJ0YVBRbWZldkF5THV4QXpfdUtNZ0tlcGdSRFM3c0lDOTNQbnBxdmxYYWNpTmI3MW9BMlZIdTQ5RldudHpNQWQ5NDNPLVVTLXVVNzdHZXh4UXpZa3dVa2J4dTFDV1RkYjRnWXU2M3lJekRYWGNMcWU5OVh6U2xZWDh6MmpqcnpiOHlnMjA5S3RFQm1NZjNSM21adkVnTUpSYVhkTzNkNnJCTmljY0x1cl9kMkx3UHhySjZEdHREanZERzNEUTFlTkR0NWlBczAtdmFGTjdZNVpTMlkxV2czYW5RN2lqemg4eUViZDV6RjdKNXdFcUlvcVhoNkJ6eVJkR1pua1hnNzQwOEs2TXJYSlpGcW9qRDU2QjBOWFFtdXBJRkRKbmdZUF9ZSmRPVEtvUjVhLTV1NjdXQjRhS0duaEtJb2FrQnNjUTRvdFMxdkdTNk1NYlFHUFhhYTJ1eUN3WHN4UlJ4UjdrZjY0SzFGYWVFN1k0cGJnc1RjNmFUenR4NHljbVhablZSWHZmUVN3cXRHNjhsX1BSZWEzdTJUZFA0S2pTaU9YMnZIQ1ZPcGhWMFJqZkVEMWRMR1h3SnU0Z2FzZ3VGM3puNzdhVjhaQXNIWHFsbjB0TDVYSFdSNV9rdWhUUUhSZHBGYkJIVDB5SDdlMC13QTVnS0g5Qkg5RGNxSGJlelVndUhPcEQ0QkRKMTJTZUM1OXJhVm0zYjU0OVY2dk9MQVBheklIQXpVNW9Yc0ROVjEzaFZTWmVxYlBWMlNlSzladzJ6TmNuMG5FVVZkN1VZN1pfS2ZHa0lQcE80S24wSnQtVlJVV09OVWJ3M09YMkZpV2ktVF9ENHhKU2dfYUQ2aUVyamk0VHJHQmVfVHU4clpUTFoteW5aSWRPV1M0RDRMTms4NGRoYmJfVE82aUl2X3VieVJOdDhBQmRwdzdnRTVBNzZwaW93dUlZb3ZRYUtOeG9ULWxvNVp5a0haSjdkcUhRb3d6UGIxRUpCVkVYX2d6TkRqQVozUWxkNGFoc1FXYVd2YWNkME9Qclo0bjYxMFRWTy1nbnI5NTBJNzRMMDluUXRKYTFqQUN4d0d5aHVlamN3Tkk3NWJXeXR0TW9BeUg5Vnp4Q2RnZUY3b3AtMDlrNmlrSGR0eGRtbUdUd2lFRWg4MklEeWJHN2wwZEpVSXMxNDNOWjRFS0tPdWxhMmFCckhfRENIY184aEFDZXNrRDl2dHQtQW12UnRuQXJjaDJoTUpiYkNWQUtfRG9GMUZoNWM4UnBYZ29RWWs2NHcyUm5kdTF3Vk1GeFpiRUJLaVZ2UGFjbi1jV3lMV0N2ZDl4VERPN295X01NNG56ZjZkRzZoYUtmY1E5NlVXemx2SnVfb19iSXg0R2M3Mjd1a2JRPT0=
|
|
||||||
|
|
||||||
# Teamsbot Browser Bot Service
|
|
||||||
TEAMSBOT_BROWSER_BOT_URL = http://teamsbot.poweron.swiss:4100
|
|
||||||
|
|
||||||
# Debug Configuration
|
|
||||||
APP_DEBUG_CHAT_WORKFLOW_ENABLED = FALSE
|
|
||||||
APP_DEBUG_CHAT_WORKFLOW_DIR = ./test-chat
|
|
||||||
APP_DEBUG_ACCOUNTING_SYNC_ENABLED = FALSE
|
|
||||||
APP_DEBUG_ACCOUNTING_SYNC_DIR = ./debug/sync
|
|
||||||
|
|
||||||
# Azure Communication Services Email Configuration
|
|
||||||
MESSAGING_ACS_CONNECTION_STRING = endpoint=https://mailing-poweron-prod.switzerland.communication.azure.com/;accesskey=4UizRfBKBgMhDgQ92IYINM6dJsO1HIeL6W1DvIX9S0GtaS1PjIXqJQQJ99CAACULyCpHwxUcAAAAAZCSuSCt
|
|
||||||
MESSAGING_ACS_SENDER_EMAIL = DoNotReply@poweron.swiss
|
|
||||||
10
env-prod.env
10
env-prod.env
|
|
@ -62,12 +62,12 @@ STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQZG8WqlVsabrfFEu49pah
|
||||||
|
|
||||||
|
|
||||||
# AI configuration
|
# AI configuration
|
||||||
Connector_AiOpenai_API_SECRET = PROD_ENC:Z0FBQUFBQnFGcldVM1phS2F4TUdmSDBUVUtfZFZoOHZLTWpzSjg4aVhkNTdnemRCenQ4Wi1HYjB1RTZ1YVUzMzVBUlg0STQybFhiQmF5VnRRd2NIZ1JCR1BwMXU5b0dycDVlVjkzWHNfYWdsV0dVZmRxa2JGU3BULXBubjJvcFdRYUdzMWR5dXRRY2JXazNtVTQ1cGFjTk1QbS1WUVo5YzlIZGJCS09GQTl2QkJXYW5oZlJJdVdxbWV4aGlyQXFUM1lUU3hGZ0NEeXoyMEpPMVp3VFpZbnhwdGN0T2pHOFVsYnhhZ1FlT0ZuVTBkbjMzUGpoZEhxSzdZdUdtVXZkd2FMUm9VYlNESHljTF9qZlZSS0tjN0o0MWNCazZ6SFc4SF83WHVHa2VabkN3dnk4RU9ER2xpR0k9
|
Connector_AiOpenai_API_SECRET = PROD_ENC:Z0FBQUFBQnFGdnVKZ2Z0U2s4cnpUN01mRVkzQUYyVm43NzZLOWJBODlvRlNFdTNGbzZHblNzUFJ2X0I3SXRFQTRXWlFYZjY1aUVVOTgxSU1KemZ4Wkl1NzFQb2JIcnM3bjg5bkRDYmpNVjVjTG55QmtSUVpZejEtckZTd20xRzd2NmhVSkJUQTFUZWk0dzhrUnJuNWZPa2NPSDR6QnQ1a0RCbWM4Y1h3Mmh2NmQ5SHFOR2FISndEMzF4Y29YcVlKaVNyNGM2VWFINUg4MjVMcHZJTUxVWXJNNUZVdW9GUkx0ZkZlZTJqRGI4ZkVuaklHMEotb3FyOEFka1c2WC1VclJZeHFucmJlRjhlUUhLNWdFX0xaRFp0ODNFNFZaWEdSTU5QbDcxbUxlclN0X2t0c1dpWXVJeFE9
|
||||||
Connector_AiAnthropic_API_SECRET = PROD_ENC:Z0FBQUFBQnFGcldVbkcybm1mbWcwNGFCQmFZbE41X0RJS0NhUEdOcXM0TDdRN3lhVnZMNC1XX0M2VWlYak5YWWtBWXBqNzNaWGlBOWRING1ZczVVcnFOVE9makE5TUJYREVKZ2hseDMwVy03dk9MTXhDSVhrQWZDY3NBSXJ0QUF5WkJCeGg2RTY5WkFDRmU3N0lvYlhYU3lacUJ5MVRwMVhVYUJqS1N1OVZVXzJSVkxaSEgxWEZxM0twVUdvUTE0TnU0VXE3Z25DaXRfQl8tWkJNMUdwQW0tQWZEdTZYVXJNdz09
|
Connector_AiAnthropic_API_SECRET = PROD_ENC:Z0FBQUFBQnFGd0hjRzFXSXhjMVZWOW40ZFRRREItclVxODVDdFdSa2tzVVJ2TWVZaVl5UE40YzgxR2d4RVdhVUFaQy1VZVRRMzFnZW1NcjlNY1h6ZVJta3F5STI5Y2taRVlXbFREb2paMTZpRVZpdEVBVnJrSjlvS1lSMzB3V3FkWW56WlNhQUFiby1Mb2RCb0VHQ2NYYmNOUGZ5UEdseGJic2ZSQk1ReXlTRnJITVY3SEdPb296eGNIdXNRME5LOTlZUlRvclJRX2R3ZHlxM0puXzlWRzY2eHliY1FUNmxSZz09
|
||||||
Connector_AiPerplexity_API_SECRET = PROD_ENC:Z0FBQUFBQnFGcldVaWNjSmVxbUJZWTh1andfU0ZKSzdIcDc5bUFkMnc2MU9PME5XVEhDREJYNmZRMk1XM3huYkFJQU5KbFd0WVlnaUJndUUxVlpybE4tVHZ0VndOYXBoWUI5MmV3VzNtd0lMbmdJYzdNQWMzTDNkSkQ1anV6emRJUC1RWUFDUFNTN1JEa25KWVRqUE5aekNXSkZjMWlncXRRPT0=
|
Connector_AiPerplexity_API_SECRET = PROD_ENC:Z0FBQUFBQnFGdnVLODRmYXo5T3BxSDJnZXgzRlFfR0oyWXVkeVRZbk14VkdDV3pTaWVfV3Y3R21LaWJpSC1laTg1T3NYREI2RzBBWWtraFJud0U2ZnhVQzJ0bnViVzJtOWh4dDZ3VUdoZUxaUzdhSkM4N3ZOOTFINmV1TGNmRE9RRmtfeTduVEV6QnYyRTZJaGxGb3ZFSmZmZ1JxUDdFSVBRPT0=
|
||||||
Connector_AiTavily_API_SECRET = PROD_ENC:Z0FBQUFBQnFGcldVMXlWdUM0NXhlNk02aWlyaDFtS05PR2ZlYzF2VlFVZ1kwbEp0akRBazIweEFLd2drY1ZzRFRpUHBsTUdkYmRZXzBIaUVEM2tfclVySVVQOFQ3SFdHYzZNNGU4RG9NSnNuN0pGQnRVRFRzdDZVZWI1dlZWM2lIMXR5TE1CU25mbEhaOGxiVnh6MFZOcEdRNDNfbFhvYUJBPT0=
|
Connector_AiTavily_API_SECRET = PROD_ENC:Z0FBQUFBQnFGdnVLcTlLSFJ5b0gwRmJLMFB5MzA3S3FYbmhKV2VzbHI0ZFUzOUJNdHVYQlQ0ckdicW1WWG5CNEkyWVlrR0gwQ0ZramJ1c19JS290MmlvWVhYWW92cEhIdmRTRXdPQzZpVFdDaU9MQzFlMEdPYUVnYy1HZlM1ODVuYnZGRnVZVFZpYzZBcUNRekVBZFFzVExQV254OUZ0aHVBPT0=
|
||||||
Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo=
|
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
|
Service_MSFT_TENANT_ID = common
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,25 +29,6 @@ _msg = apiRouteContext("oauthConnectTicket")
|
||||||
|
|
||||||
_CONNECT_TICKET_TTL_SEC = 600
|
_CONNECT_TICKET_TTL_SEC = 600
|
||||||
|
|
||||||
# OAuth providers sometimes redirect to the API root if the app redirect URL omits the path.
|
|
||||||
OAUTH_FLOW_CALLBACK_PATHS: Dict[str, str] = {
|
|
||||||
"clickup_connect": "/api/clickup/auth/connect/callback",
|
|
||||||
"msft_connect": "/api/msft/auth/connect/callback",
|
|
||||||
"google_connect": "/api/google/auth/connect/callback",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def oauth_callback_redirect_path(state: str) -> str | None:
|
|
||||||
"""Map connect-ticket JWT (OAuth ``state`` param) to the correct callback route."""
|
|
||||||
try:
|
|
||||||
data = jose_jwt.decode(state, SECRET_KEY, algorithms=[ALGORITHM])
|
|
||||||
except JWTError:
|
|
||||||
return None
|
|
||||||
flow = data.get("flow")
|
|
||||||
if not isinstance(flow, str):
|
|
||||||
return None
|
|
||||||
return OAUTH_FLOW_CALLBACK_PATHS.get(flow)
|
|
||||||
|
|
||||||
|
|
||||||
def issue_connect_ticket(flow: str, connection_id: str, user_id: str) -> str:
|
def issue_connect_ticket(flow: str, connection_id: str, user_id: str) -> str:
|
||||||
"""Issue a short-lived JWT for starting a data-connection OAuth popup."""
|
"""Issue a short-lived JWT for starting a data-connection OAuth popup."""
|
||||||
|
|
|
||||||
|
|
@ -43,31 +43,9 @@ class FeatureDataSource(PowerOnModel):
|
||||||
)
|
)
|
||||||
mandateId: str = Field(
|
mandateId: str = Field(
|
||||||
default="",
|
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"}},
|
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(
|
neutralize: Optional[bool] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description=(
|
description=(
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,6 @@
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import t
|
from modules.shared.i18nRegistry import t
|
||||||
|
|
||||||
from modules.features.graphicalEditor.nodeDefinitions.contextPickerHelp import (
|
|
||||||
CONTEXT_BUILDER_PARAM_DESCRIPTION,
|
|
||||||
)
|
|
||||||
from modules.features.graphicalEditor.nodeDefinitions.flow import (
|
from modules.features.graphicalEditor.nodeDefinitions.flow import (
|
||||||
CONTEXT_ENVELOPE_DATA_PICK_OPTIONS,
|
CONTEXT_ENVELOPE_DATA_PICK_OPTIONS,
|
||||||
CONTEXT_MERGE_ACTION_RESULT_DATA_PICK_OPTIONS,
|
CONTEXT_MERGE_ACTION_RESULT_DATA_PICK_OPTIONS,
|
||||||
|
|
@ -40,9 +37,9 @@ CONTEXT_NODES = [
|
||||||
),
|
),
|
||||||
"injectRunContext": True,
|
"injectRunContext": True,
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
|
{"name": "documentList", "type": "str", "required": True, "frontendType": "hidden",
|
||||||
"description": CONTEXT_BUILDER_PARAM_DESCRIPTION, "default": "",
|
"description": t("Dokumentenliste (via Wire oder DataRef)"), "default": "",
|
||||||
"graphInherit": {"port": 0, "kind": "primaryTextRef"}},
|
"graphInherit": {"port": 0, "kind": "documentListWire"}},
|
||||||
{
|
{
|
||||||
"name": "contentFilter",
|
"name": "contentFilter",
|
||||||
"type": "str",
|
"type": "str",
|
||||||
|
|
|
||||||
|
|
@ -986,7 +986,11 @@ async def listWorkspaceWorkflows(
|
||||||
"startedAt": getattr(wf, "startedAt", None),
|
"startedAt": getattr(wf, "startedAt", None),
|
||||||
"lastActivity": getattr(wf, "lastActivity", None),
|
"lastActivity": getattr(wf, "lastActivity", None),
|
||||||
"featureInstanceId": getattr(wf, "featureInstanceId", instanceId),
|
"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":
|
if not includeArchived and item.get("status") == "archived":
|
||||||
continue
|
continue
|
||||||
fiId = item.get("featureInstanceId") or instanceId
|
fiId = item.get("featureInstanceId") or instanceId
|
||||||
|
|
@ -1311,73 +1315,6 @@ async def listWorkspaceDataSources(
|
||||||
return JSONResponse({"dataSources": []})
|
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):
|
class CreateDataSourceRequest(BaseModel):
|
||||||
"""Request body for creating a DataSource."""
|
"""Request body for creating a DataSource."""
|
||||||
connectionId: str = Field(description="Connection ID")
|
connectionId: str = Field(description="Connection ID")
|
||||||
|
|
@ -1458,19 +1395,15 @@ async def createFeatureDataSource(
|
||||||
body: CreateFeatureDataSourceRequest = Body(...),
|
body: CreateFeatureDataSourceRequest = Body(...),
|
||||||
context: RequestContext = Depends(getRequestContext),
|
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
|
The FDS belongs to the FEATURE-INSTANCE (not to a workspace). Flag editing
|
||||||
matches how the tree (`allFds = recordset where workspaceInstanceId =
|
is governed by feature-admin RBAC on that feature instance (see the
|
||||||
instanceId`) and the PATCH endpoints scope these records — by workspace,
|
UDB reference page for the polymorphic node model). The `instanceId`
|
||||||
not by feature mandate. The user can legitimately reference a feature
|
in the URL path is the calling consumer's feature instance and is used
|
||||||
from another mandate they have access to (via the UDB mandate-group
|
only for access validation, not for FDS scoping.
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
wsMandateId, _ = _validateInstanceAccess(instanceId, context)
|
_validateInstanceAccess(instanceId, context)
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
|
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
|
||||||
|
|
||||||
|
|
@ -1478,8 +1411,10 @@ async def createFeatureDataSource(
|
||||||
if not rootIf.getFeatureAccess(str(context.user.id), body.featureInstanceId):
|
if not rootIf.getFeatureAccess(str(context.user.id), body.featureInstanceId):
|
||||||
raise HTTPException(status_code=403, detail=routeApiMsg("Access denied to this feature instance"))
|
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={
|
existing = rootIf.db.getRecordset(FeatureDataSource, recordFilter={
|
||||||
"workspaceInstanceId": instanceId,
|
|
||||||
"featureInstanceId": body.featureInstanceId,
|
"featureInstanceId": body.featureInstanceId,
|
||||||
"tableName": body.tableName,
|
"tableName": body.tableName,
|
||||||
}) or []
|
}) or []
|
||||||
|
|
@ -1494,9 +1429,7 @@ async def createFeatureDataSource(
|
||||||
tableName=body.tableName,
|
tableName=body.tableName,
|
||||||
objectKey=body.objectKey,
|
objectKey=body.objectKey,
|
||||||
label=body.label,
|
label=body.label,
|
||||||
mandateId=wsMandateId or "",
|
mandateId=fiMandateId,
|
||||||
userId=str(context.user.id),
|
|
||||||
workspaceInstanceId=instanceId,
|
|
||||||
recordFilter=body.recordFilter,
|
recordFilter=body.recordFilter,
|
||||||
)
|
)
|
||||||
created = rootIf.db.recordCreate(FeatureDataSource, fds.model_dump())
|
created = rootIf.db.recordCreate(FeatureDataSource, fds.model_dump())
|
||||||
|
|
@ -1510,27 +1443,28 @@ async def listFeatureDataSources(
|
||||||
instanceId: str = Path(...),
|
instanceId: str = Path(...),
|
||||||
context: RequestContext = Depends(getRequestContext),
|
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)
|
wsMandateId, _ = _validateInstanceAccess(instanceId, context)
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
|
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
|
||||||
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import buildEffectiveByWorkspaceFds
|
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import buildEffectiveByWorkspaceFds
|
||||||
|
|
||||||
rootIf = getRootInterface()
|
rootIf = getRootInterface()
|
||||||
recordFilter: dict = {"workspaceInstanceId": instanceId}
|
recordFilter: dict = {}
|
||||||
if wsMandateId:
|
if wsMandateId:
|
||||||
recordFilter["mandateId"] = wsMandateId
|
recordFilter["mandateId"] = wsMandateId
|
||||||
records = rootIf.db.getRecordset(FeatureDataSource, recordFilter=recordFilter)
|
records = rootIf.db.getRecordset(FeatureDataSource, recordFilter=recordFilter) or []
|
||||||
if not records:
|
if not records:
|
||||||
return JSONResponse({"featureDataSources": []})
|
return JSONResponse({"featureDataSources": []})
|
||||||
|
|
||||||
effNeutralize = buildEffectiveByWorkspaceFds(records, "neutralize", mode="aggregate")
|
effNeutralize = buildEffectiveByWorkspaceFds(records, "neutralize", mode="aggregate")
|
||||||
effScope = buildEffectiveByWorkspaceFds(records, "scope", mode="aggregate")
|
|
||||||
effRag = buildEffectiveByWorkspaceFds(records, "ragIndexEnabled", mode="aggregate")
|
effRag = buildEffectiveByWorkspaceFds(records, "ragIndexEnabled", mode="aggregate")
|
||||||
for fds in records:
|
for fds in records:
|
||||||
fdsId = fds.get("id", "")
|
fdsId = fds.get("id", "")
|
||||||
fds["effectiveNeutralize"] = effNeutralize.get(fdsId, False)
|
fds["effectiveNeutralize"] = effNeutralize.get(fdsId, False)
|
||||||
fds["effectiveScope"] = effScope.get(fdsId, "personal")
|
|
||||||
fds["effectiveRagIndexEnabled"] = effRag.get(fdsId, False)
|
fds["effectiveRagIndexEnabled"] = effRag.get(fdsId, False)
|
||||||
|
|
||||||
return JSONResponse({"featureDataSources": records})
|
return JSONResponse({"featureDataSources": records})
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
from fastapi import APIRouter, Response, Depends, Request, Body
|
from fastapi import APIRouter, Response, Depends, Request, Body
|
||||||
from fastapi.responses import FileResponse, RedirectResponse
|
from fastapi.responses import FileResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -11,7 +11,6 @@ from fastapi import HTTPException, status
|
||||||
|
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
from modules.auth import limiter, getCurrentUser
|
from modules.auth import limiter, getCurrentUser
|
||||||
from modules.auth.oauthConnectTicket import oauth_callback_redirect_path
|
|
||||||
from modules.datamodels.datamodelUam import User
|
from modules.datamodels.datamodelUam import User
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
from modules.shared.i18nRegistry import apiRouteContext
|
from modules.shared.i18nRegistry import apiRouteContext
|
||||||
|
|
@ -36,15 +35,8 @@ router.mount(
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("/")
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
def root(request: Request):
|
def root(request: Request) -> Dict[str, str]:
|
||||||
"""API status endpoint; forwards OAuth callbacks that land on ``/`` by mistake."""
|
"""API status endpoint"""
|
||||||
code = request.query_params.get("code")
|
|
||||||
state = request.query_params.get("state")
|
|
||||||
if code and state:
|
|
||||||
callback_path = oauth_callback_redirect_path(state)
|
|
||||||
if callback_path:
|
|
||||||
return RedirectResponse(url=f"{callback_path}?{request.url.query}", status_code=302)
|
|
||||||
|
|
||||||
# Validate required configuration values
|
# Validate required configuration values
|
||||||
allowedOrigins = APP_CONFIG.get("APP_ALLOWED_ORIGINS")
|
allowedOrigins = APP_CONFIG.get("APP_ALLOWED_ORIGINS")
|
||||||
if not allowedOrigins:
|
if not allowedOrigins:
|
||||||
|
|
|
||||||
|
|
@ -668,7 +668,6 @@ def get_files(
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||||
mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"),
|
mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"),
|
||||||
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
||||||
owner: str = Query("me", description="'all' | 'me' | 'shared'"),
|
|
||||||
currentUser: User = Depends(getCurrentUser),
|
currentUser: User = Depends(getCurrentUser),
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
):
|
):
|
||||||
|
|
@ -700,9 +699,8 @@ def get_files(
|
||||||
|
|
||||||
from modules.routes.routeHelpers import (
|
from modules.routes.routeHelpers import (
|
||||||
handleIdsMode,
|
handleIdsMode,
|
||||||
handleIdsInMemory,
|
|
||||||
handleFilterValuesInMemory,
|
handleFilterValuesInMemory,
|
||||||
resolveView, applyViewToParams, buildGroupLayout, effective_group_by_levels, paginateInMemory,
|
resolveView, applyViewToParams, buildGroupLayout, effective_group_by_levels,
|
||||||
)
|
)
|
||||||
import modules.interfaces.interfaceDbApp as _appIface
|
import modules.interfaces.interfaceDbApp as _appIface
|
||||||
from modules.datamodels.datamodelPagination import AppliedViewMeta
|
from modules.datamodels.datamodelPagination import AppliedViewMeta
|
||||||
|
|
@ -713,10 +711,6 @@ def get_files(
|
||||||
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
|
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
|
||||||
)
|
)
|
||||||
appInterface = _appIface.getInterface(currentUser)
|
appInterface = _appIface.getInterface(currentUser)
|
||||||
owner_mode = (owner or "me").strip().lower()
|
|
||||||
if owner_mode not in ("all", "me", "shared"):
|
|
||||||
raise HTTPException(status_code=400, detail="owner must be 'all', 'me', or 'shared'")
|
|
||||||
current_user_id = str(getattr(currentUser, "id", "") or "")
|
|
||||||
|
|
||||||
# Resolve view and merge config into params
|
# Resolve view and merge config into params
|
||||||
viewKey = paginationParams.viewKey if paginationParams else None
|
viewKey = paginationParams.viewKey if paginationParams else None
|
||||||
|
|
@ -728,17 +722,6 @@ def get_files(
|
||||||
def _filesToDicts(fileItems):
|
def _filesToDicts(fileItems):
|
||||||
return [f.model_dump() if hasattr(f, "model_dump") else (dict(f) if not isinstance(f, dict) else f) for f in fileItems]
|
return [f.model_dump() if hasattr(f, "model_dump") else (dict(f) if not isinstance(f, dict) else f) for f in fileItems]
|
||||||
|
|
||||||
def _apply_owner_filter(item_dicts):
|
|
||||||
if owner_mode == "all":
|
|
||||||
return item_dicts
|
|
||||||
if owner_mode == "me":
|
|
||||||
return [item for item in item_dicts if str(item.get("sysCreatedBy") or "") == current_user_id]
|
|
||||||
return [item for item in item_dicts if str(item.get("sysCreatedBy") or "") != current_user_id]
|
|
||||||
|
|
||||||
recordFilter = None
|
|
||||||
if owner_mode == "me":
|
|
||||||
recordFilter = {"sysCreatedBy": managementInterface.userId}
|
|
||||||
|
|
||||||
if mode == "groupSummary":
|
if mode == "groupSummary":
|
||||||
if not pagination:
|
if not pagination:
|
||||||
raise HTTPException(status_code=400, detail="pagination required for groupSummary")
|
raise HTTPException(status_code=400, detail="pagination required for groupSummary")
|
||||||
|
|
@ -753,12 +736,11 @@ def get_files(
|
||||||
)
|
)
|
||||||
field = groupByLevels[0]["field"]
|
field = groupByLevels[0]["field"]
|
||||||
null_label = str(groupByLevels[0].get("nullLabel") or "—")
|
null_label = str(groupByLevels[0].get("nullLabel") or "—")
|
||||||
allFiles = managementInterface.getAllFiles(recordFilter=recordFilter)
|
allFiles = managementInterface.getAllFiles()
|
||||||
allItems = enrichRowsWithFkLabels(
|
allItems = enrichRowsWithFkLabels(
|
||||||
_filesToDicts(allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])),
|
_filesToDicts(allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])),
|
||||||
FileItem,
|
FileItem,
|
||||||
)
|
)
|
||||||
allItems = _apply_owner_filter(allItems)
|
|
||||||
filtered = apply_strategy_b_filters_and_sort(allItems, paginationParams, currentUser)
|
filtered = apply_strategy_b_filters_and_sort(allItems, paginationParams, currentUser)
|
||||||
groups_out = build_group_summary_groups(filtered, field, null_label, groupByLevels=groupByLevels)
|
groups_out = build_group_summary_groups(filtered, field, null_label, groupByLevels=groupByLevels)
|
||||||
return JSONResponse(content={"groups": groups_out})
|
return JSONResponse(content={"groups": groups_out})
|
||||||
|
|
@ -766,57 +748,52 @@ def get_files(
|
||||||
if mode == "filterValues":
|
if mode == "filterValues":
|
||||||
if not column:
|
if not column:
|
||||||
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
||||||
allFiles = managementInterface.getAllFiles(recordFilter=recordFilter)
|
allFiles = managementInterface.getAllFiles()
|
||||||
items = allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])
|
items = allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])
|
||||||
itemDicts = _filesToDicts(items)
|
itemDicts = _filesToDicts(items)
|
||||||
itemDicts = _apply_owner_filter(itemDicts)
|
|
||||||
enrichRowsWithFkLabels(itemDicts, FileItem)
|
enrichRowsWithFkLabels(itemDicts, FileItem)
|
||||||
return handleFilterValuesInMemory(itemDicts, column, pagination)
|
return handleFilterValuesInMemory(itemDicts, column, pagination)
|
||||||
|
|
||||||
if mode == "ids":
|
if mode == "ids":
|
||||||
if owner_mode == "me":
|
recordFilter = {"sysCreatedBy": managementInterface.userId}
|
||||||
return handleIdsMode(managementInterface.db, FileItem, pagination, recordFilter)
|
return handleIdsMode(managementInterface.db, FileItem, pagination, recordFilter)
|
||||||
allFiles = managementInterface.getAllFiles(recordFilter=recordFilter)
|
|
||||||
items = allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])
|
|
||||||
itemDicts = _apply_owner_filter(_filesToDicts(items))
|
|
||||||
enrichRowsWithFkLabels(itemDicts, FileItem)
|
|
||||||
return handleIdsInMemory(itemDicts, pagination)
|
|
||||||
|
|
||||||
# Strategy B: load visible list first, then filter/sort/paginate in memory.
|
|
||||||
# This is required for files because internal workflow artefacts are
|
|
||||||
# suppressed after record loading; SQL-level COUNT/LIMIT would otherwise
|
|
||||||
# count hidden rows and produce pages with only a handful of visible items.
|
|
||||||
allFiles = managementInterface.getAllFiles(recordFilter=recordFilter)
|
|
||||||
allItems = enrichRowsWithFkLabels(
|
|
||||||
_filesToDicts(allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])),
|
|
||||||
FileItem,
|
|
||||||
)
|
|
||||||
allItems = _apply_owner_filter(allItems)
|
|
||||||
|
|
||||||
from modules.routes.routeHelpers import apply_strategy_b_filters_and_sort
|
|
||||||
if paginationParams and (paginationParams.filters or paginationParams.sort):
|
|
||||||
allItems = apply_strategy_b_filters_and_sort(allItems, paginationParams, currentUser)
|
|
||||||
|
|
||||||
if not paginationParams:
|
|
||||||
resp = {"items": allItems, "pagination": None}
|
|
||||||
if viewMeta:
|
|
||||||
resp["appliedView"] = viewMeta.model_dump()
|
|
||||||
return resp
|
|
||||||
|
|
||||||
if not groupByLevels:
|
if not groupByLevels:
|
||||||
page_items, totalItems = paginateInMemory(allItems, paginationParams)
|
# No grouping: let DB handle pagination directly (fastest path)
|
||||||
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
|
result = managementInterface.getAllFiles(pagination=paginationParams)
|
||||||
resp = {
|
if paginationParams and hasattr(result, 'items'):
|
||||||
"items": page_items,
|
enriched = enrichRowsWithFkLabels(_filesToDicts(result.items), FileItem)
|
||||||
|
resp: dict = {
|
||||||
|
"items": enriched,
|
||||||
"pagination": PaginationMetadata(
|
"pagination": PaginationMetadata(
|
||||||
currentPage=paginationParams.page,
|
currentPage=paginationParams.page,
|
||||||
pageSize=paginationParams.pageSize,
|
pageSize=paginationParams.pageSize,
|
||||||
totalItems=totalItems,
|
totalItems=result.totalItems,
|
||||||
totalPages=totalPages,
|
totalPages=result.totalPages,
|
||||||
sort=paginationParams.sort,
|
sort=paginationParams.sort,
|
||||||
filters=paginationParams.filters
|
filters=paginationParams.filters
|
||||||
).model_dump(),
|
).model_dump(),
|
||||||
}
|
}
|
||||||
|
else:
|
||||||
|
items = result if isinstance(result, list) else (result.items if hasattr(result, "items") else [result])
|
||||||
|
resp = {"items": enrichRowsWithFkLabels(_filesToDicts(items), FileItem), "pagination": None}
|
||||||
|
if viewMeta:
|
||||||
|
resp["appliedView"] = viewMeta.model_dump()
|
||||||
|
return resp
|
||||||
|
|
||||||
|
# Strategy B grouping: load full list, group, then slice
|
||||||
|
allFiles = managementInterface.getAllFiles()
|
||||||
|
allItems = enrichRowsWithFkLabels(
|
||||||
|
_filesToDicts(allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])),
|
||||||
|
FileItem,
|
||||||
|
)
|
||||||
|
|
||||||
|
from modules.routes.routeHelpers import apply_strategy_b_filters_and_sort
|
||||||
|
if paginationParams.filters or paginationParams.sort:
|
||||||
|
allItems = apply_strategy_b_filters_and_sort(allItems, paginationParams, currentUser)
|
||||||
|
|
||||||
|
if not paginationParams:
|
||||||
|
resp = {"items": allItems, "pagination": None}
|
||||||
if viewMeta:
|
if viewMeta:
|
||||||
resp["appliedView"] = viewMeta.model_dump()
|
resp["appliedView"] = viewMeta.model_dump()
|
||||||
return resp
|
return resp
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,11 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# 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
|
import logging
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
@ -43,49 +48,6 @@ def _ensureConnectionKnowledgeFlag(rootIf, connectionId: str) -> None:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Could not auto-enable knowledgeIngestionEnabled for connection %s: %s", connectionId, 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(
|
router = APIRouter(
|
||||||
prefix="/api/datasources",
|
prefix="/api/datasources",
|
||||||
tags=["Data Sources"],
|
tags=["Data Sources"],
|
||||||
|
|
@ -98,9 +60,6 @@ router = APIRouter(
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
_VALID_SCOPES = {"personal", "featureInstance", "mandate", "global"}
|
|
||||||
|
|
||||||
|
|
||||||
def _findSourceRecord(db, sourceId: str):
|
def _findSourceRecord(db, sourceId: str):
|
||||||
"""Look up a source by ID, checking DataSource first, then FeatureDataSource."""
|
"""Look up a source by ID, checking DataSource first, then FeatureDataSource."""
|
||||||
rec = db.getRecord(DataSource, sourceId)
|
rec = db.getRecord(DataSource, sourceId)
|
||||||
|
|
@ -112,250 +71,6 @@ def _findSourceRecord(db, sourceId: str):
|
||||||
return None, None
|
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"}
|
_CLICKUP_SOURCE_TYPES = {"clickup", "clickupList", "clickupSpace", "clickupFolder"}
|
||||||
_ALLOWED_RAG_LIMIT_KEYS = {
|
_ALLOWED_RAG_LIMIT_KEYS = {
|
||||||
"files": {"maxItems", "maxBytes", "maxFileSize", "maxDepth"},
|
"files": {"maxItems", "maxBytes", "maxFileSize", "maxDepth"},
|
||||||
|
|
@ -412,8 +127,9 @@ def _updateDataSourceSettings(
|
||||||
Currently supports `ragLimits` only. Unknown top-level keys in the body are
|
Currently supports `ragLimits` only. Unknown top-level keys in the body are
|
||||||
rejected to avoid silently storing garbage that no consumer reads.
|
rejected to avoid silently storing garbage that no consumer reads.
|
||||||
|
|
||||||
Owner-only for personal DataSources; mandate/feature scopes additionally
|
DataSource: owner-only (or sysadmin). For mandate/feature scopes the
|
||||||
accept the mandate or workspace admins of that scope.
|
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):
|
if not isinstance(settings, dict):
|
||||||
raise HTTPException(status_code=400, detail="settings must be an object")
|
raise HTTPException(status_code=400, detail="settings must be an object")
|
||||||
|
|
@ -428,23 +144,22 @@ def _updateDataSourceSettings(
|
||||||
if not rec:
|
if not rec:
|
||||||
raise HTTPException(status_code=404, detail=f"DataSource {sourceId} not found")
|
raise HTTPException(status_code=404, detail=f"DataSource {sourceId} not found")
|
||||||
|
|
||||||
ownerId = str(rec.get("userId") or "")
|
|
||||||
currentUserId = str(context.user.id)
|
currentUserId = str(context.user.id)
|
||||||
|
if model is DataSource:
|
||||||
|
ownerId = str(rec.get("userId") or "")
|
||||||
if ownerId and ownerId != currentUserId and not context.isSysAdmin:
|
if ownerId and ownerId != currentUserId and not context.isSysAdmin:
|
||||||
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlag
|
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlag
|
||||||
if model is DataSource:
|
|
||||||
connectionId = rec.get("connectionId", "")
|
connectionId = rec.get("connectionId", "")
|
||||||
allDs = rootIf.db.getRecordset(DataSource, recordFilter={"connectionId": connectionId})
|
allDs = rootIf.db.getRecordset(DataSource, recordFilter={"connectionId": connectionId})
|
||||||
scope = str(getEffectiveFlag(rec, "scope", allDs, mode="walk"))
|
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)
|
isMandateAdmin = getattr(context, "isMandateAdmin", False)
|
||||||
if scope == "personal" or not isMandateAdmin:
|
if scope == "personal" or not isMandateAdmin:
|
||||||
raise HTTPException(status_code=403, detail="Not allowed to modify this DataSource's settings")
|
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)
|
kind = _kindForSource(rec, model)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -265,7 +265,7 @@ def _buildFeatureInstanceInventory(featureInstanceIds, rootIf, knowledgeIf) -> L
|
||||||
st = (r.get("status") if isinstance(r, dict) else getattr(r, "status", "unknown")) or "unknown"
|
st = (r.get("status") if isinstance(r, dict) else getattr(r, "status", "unknown")) or "unknown"
|
||||||
statusCounts[st] = statusCounts.get(st, 0) + 1
|
statusCounts[st] = statusCounts.get(st, 0) + 1
|
||||||
|
|
||||||
allFds = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"workspaceInstanceId": fiId})
|
allFds = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"featureInstanceId": fiId})
|
||||||
dsItems = []
|
dsItems = []
|
||||||
anyRagEnabled = False
|
anyRagEnabled = False
|
||||||
for fds in allFds:
|
for fds in allFds:
|
||||||
|
|
@ -287,7 +287,7 @@ def _buildFeatureInstanceInventory(featureInstanceIds, rootIf, knowledgeIf) -> L
|
||||||
|
|
||||||
fiJobs = [
|
fiJobs = [
|
||||||
j for j in allFeatureJobs
|
j for j in allFeatureJobs
|
||||||
if (j.get("payload") or {}).get("workspaceInstanceId") == fiId
|
if (j.get("payload") or {}).get("featureInstanceId") == fiId
|
||||||
]
|
]
|
||||||
runningJobs = [
|
runningJobs = [
|
||||||
{
|
{
|
||||||
|
|
@ -572,17 +572,18 @@ async def _reindexConnection(
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.post("/reindex-feature/{workspaceInstanceId}")
|
@router.post("/reindex-feature/{featureInstanceId}")
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
async def _reindexFeature(
|
async def _reindexFeature(
|
||||||
request: Request,
|
request: Request,
|
||||||
workspaceInstanceId: str,
|
featureInstanceId: str,
|
||||||
currentUser: User = Depends(getCurrentUser),
|
currentUser: User = Depends(getCurrentUser),
|
||||||
) -> Dict[str, Any]:
|
) -> 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.
|
Indexes all RAG-enabled FeatureDataSource rows owned by this feature
|
||||||
Must be ``async def`` so ``await startJob(...)`` registers in the main loop.
|
instance into the knowledge store. Must be ``async def`` so
|
||||||
|
``await startJob(...)`` registers in the main loop.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
|
@ -592,7 +593,7 @@ async def _reindexFeature(
|
||||||
rootIf = getRootInterface()
|
rootIf = getRootInterface()
|
||||||
featureAccesses = rootIf.getFeatureAccessesForUser(str(currentUser.id))
|
featureAccesses = rootIf.getFeatureAccessesForUser(str(currentUser.id))
|
||||||
hasAccess = any(
|
hasAccess = any(
|
||||||
str(fa.featureInstanceId) == workspaceInstanceId and fa.enabled
|
str(fa.featureInstanceId) == featureInstanceId and fa.enabled
|
||||||
for fa in featureAccesses
|
for fa in featureAccesses
|
||||||
)
|
)
|
||||||
if not hasAccess and not getattr(currentUser, "isSysAdmin", False):
|
if not hasAccess and not getattr(currentUser, "isSysAdmin", False):
|
||||||
|
|
@ -600,12 +601,12 @@ async def _reindexFeature(
|
||||||
|
|
||||||
jobId = await startJob(
|
jobId = await startJob(
|
||||||
FEATURE_BOOTSTRAP_JOB_TYPE,
|
FEATURE_BOOTSTRAP_JOB_TYPE,
|
||||||
{"workspaceInstanceId": workspaceInstanceId},
|
{"featureInstanceId": featureInstanceId},
|
||||||
triggeredBy=str(currentUser.id),
|
triggeredBy=str(currentUser.id),
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("Feature reindex triggered for workspace %s (jobId=%s)", workspaceInstanceId, jobId)
|
logger.info("Feature reindex triggered for feature %s (jobId=%s)", featureInstanceId, jobId)
|
||||||
return {"status": "queued", "workspaceInstanceId": workspaceInstanceId, "jobId": jobId}
|
return {"status": "queued", "featureInstanceId": featureInstanceId, "jobId": jobId}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -257,8 +257,6 @@ async def auth_connect_callback(
|
||||||
except Exception as _cbErr:
|
except Exception as _cbErr:
|
||||||
logger.warning("connection.established callback failed for %s: %s", connection.id, _cbErr)
|
logger.warning("connection.established callback failed for %s: %s", connection.id, _cbErr)
|
||||||
|
|
||||||
allowed = (APP_CONFIG.get("APP_ALLOWED_ORIGINS") or "").split(",")[0].strip()
|
|
||||||
post_target = allowed if allowed else "*"
|
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
content=f"""
|
content=f"""
|
||||||
<html>
|
<html>
|
||||||
|
|
@ -275,7 +273,7 @@ async def auth_connect_callback(
|
||||||
lastChecked: {getUtcTimestamp()},
|
lastChecked: {getUtcTimestamp()},
|
||||||
expiresAt: {expires_at}
|
expiresAt: {expires_at}
|
||||||
}}
|
}}
|
||||||
}}, {json.dumps(post_target)});
|
}}, '*');
|
||||||
setTimeout(() => window.close(), 1000);
|
setTimeout(() => window.close(), 1000);
|
||||||
}} else {{
|
}} else {{
|
||||||
window.close();
|
window.close();
|
||||||
|
|
|
||||||
229
modules/routes/routeUdb.py
Normal file
229
modules/routes/routeUdb.py
Normal file
|
|
@ -0,0 +1,229 @@
|
||||||
|
# Copyright (c) 2026 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""Generic UDB (Unified Data Bar) router.
|
||||||
|
|
||||||
|
The UDB is feature-agnostic: it can render the user's accessible data
|
||||||
|
sources (personal + feature-owned) without being coupled to a specific
|
||||||
|
caller feature instance. This router owns two endpoints:
|
||||||
|
|
||||||
|
POST /api/udb/tree/children
|
||||||
|
Resolve the children for a list of parent tree keys (UI walks).
|
||||||
|
|
||||||
|
POST /api/udb/node/{nodeKey}/flag/{flag}
|
||||||
|
Persist a new value for a single flag on a single node.
|
||||||
|
|
||||||
|
Permission policy:
|
||||||
|
- DataSource-family nodes: owner-of-record (rec.userId == user).
|
||||||
|
- FdsRecord / FdsField nodes: feature-admin on the FDS's
|
||||||
|
featureInstanceId (a FeatureAccessRole whose Role.roleLabel ends
|
||||||
|
with '-admin').
|
||||||
|
- Synthetic containers (personalRoot, mgrp): never editable.
|
||||||
|
|
||||||
|
See wiki/b-reference/platform/unified-data-bar.md for the full domain
|
||||||
|
model and the rationale behind the hard cut from the previous
|
||||||
|
feature-instance-scoped endpoints.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Depends, HTTPException, Path, Request
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from modules.auth import getRequestContext, limiter, RequestContext
|
||||||
|
from modules.shared.i18nRegistry import apiRouteContext
|
||||||
|
|
||||||
|
routeApiMsg = apiRouteContext("routeUdb")
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
router = APIRouter(
|
||||||
|
prefix="/api/udb",
|
||||||
|
tags=["Unified Data Bar"],
|
||||||
|
responses={
|
||||||
|
400: {"description": "Bad request"},
|
||||||
|
401: {"description": "Unauthorized"},
|
||||||
|
403: {"description": "Forbidden"},
|
||||||
|
404: {"description": "Not found"},
|
||||||
|
500: {"description": "Internal server error"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_VALID_SCOPES = {"personal", "featureInstance", "mandate", "global"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/udb/tree/children
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _UdbTreeChildrenRequest(BaseModel):
|
||||||
|
"""Request body for the generic UDB tree children endpoint."""
|
||||||
|
parents: List[Optional[str]] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="List of parent keys to fetch children for. Use null for top-level.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tree/children")
|
||||||
|
@limiter.limit("300/minute")
|
||||||
|
async def _udbTreeChildren(
|
||||||
|
request: Request,
|
||||||
|
body: _UdbTreeChildrenRequest = Body(...),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Resolve children for the given parent keys.
|
||||||
|
|
||||||
|
The UDB is feature-agnostic; this endpoint requires only that the
|
||||||
|
user is authenticated. Visibility is driven by the user's accessible
|
||||||
|
mandates and feature instances inside `getChildrenForParents`.
|
||||||
|
"""
|
||||||
|
from modules.serviceCenter.services.serviceKnowledge._buildTree import getChildrenForParents
|
||||||
|
try:
|
||||||
|
nodesByParent = await getChildrenForParents(body.parents, context)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("UDB tree children build failed: %s", exc)
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc))
|
||||||
|
return {"nodesByParent": nodesByParent}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# POST /api/udb/node/{nodeKey}/flag/{flag}
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class _UdbFlagBody(BaseModel):
|
||||||
|
"""Generic flag-mutation body.
|
||||||
|
|
||||||
|
Exactly one of `value` / `neutralizeFields` is expected depending on
|
||||||
|
the flag (see `_extractFlagValue` for the mapping). `value` is typed
|
||||||
|
as Any because the legal type depends on the flag:
|
||||||
|
- neutralize/ragIndexEnabled : bool | null (null = inherit)
|
||||||
|
- scope : str | null (one of _VALID_SCOPES, null = inherit)
|
||||||
|
"""
|
||||||
|
value: Any = Field(default=None, description="New flag value or null to reset to inherit.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/node/{nodeKey:path}/flag/{flag}")
|
||||||
|
@limiter.limit("60/minute")
|
||||||
|
async def _udbNodeFlag(
|
||||||
|
request: Request,
|
||||||
|
nodeKey: str = Path(..., description="Tree key of the node to modify"),
|
||||||
|
flag: str = Path(..., description="One of: neutralize | scope | ragIndexEnabled"),
|
||||||
|
body: _UdbFlagBody = Body(default_factory=_UdbFlagBody),
|
||||||
|
context: RequestContext = Depends(getRequestContext),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Persist a new value for `flag` on the node identified by `nodeKey`.
|
||||||
|
|
||||||
|
`value=null` resets the node to inherit from its ancestor chain (no
|
||||||
|
cascade, no purge). `value=true/false` (or a scope string) writes
|
||||||
|
the explicit override and cascade-resets any explicit child
|
||||||
|
descendants so they re-inherit.
|
||||||
|
|
||||||
|
RBAC: `node.canEdit(context, rootIf)` decides; the route never
|
||||||
|
re-implements ownership rules.
|
||||||
|
"""
|
||||||
|
if flag not in ("neutralize", "scope", "ragIndexEnabled"):
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unknown flag: {flag}")
|
||||||
|
|
||||||
|
value = _validateFlagValue(flag, body.value, context)
|
||||||
|
|
||||||
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
from modules.serviceCenter.services.serviceKnowledge.udbNodes import buildNodeForKey
|
||||||
|
rootIf = getRootInterface()
|
||||||
|
|
||||||
|
node = buildNodeForKey(nodeKey, context, rootIf)
|
||||||
|
if node is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Unknown UDB node key: {nodeKey}")
|
||||||
|
|
||||||
|
if not node.supportsFlag(flag):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"{type(node).__name__} does not support flag '{flag}'",
|
||||||
|
)
|
||||||
|
if not node.canEdit(context, rootIf):
|
||||||
|
raise HTTPException(status_code=403, detail=routeApiMsg("Not allowed to edit this UDB node"))
|
||||||
|
|
||||||
|
try:
|
||||||
|
resetIds = node.setFlag(flag, value, rootIf)
|
||||||
|
except NotImplementedError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail=str(exc))
|
||||||
|
except RuntimeError as exc:
|
||||||
|
raise HTTPException(status_code=409, detail=str(exc))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("UDB setFlag failed: key=%s flag=%s: %s", nodeKey, flag, exc)
|
||||||
|
raise HTTPException(status_code=500, detail=str(exc))
|
||||||
|
|
||||||
|
effective = _computeEffectiveAfterWrite(rootIf, context, node, flag)
|
||||||
|
|
||||||
|
import json
|
||||||
|
from modules.shared.auditLogger import audit_logger
|
||||||
|
from modules.datamodels.datamodelAudit import AuditCategory
|
||||||
|
audit_logger.logEvent(
|
||||||
|
userId=str(context.user.id),
|
||||||
|
mandateId=context.mandateId,
|
||||||
|
category=AuditCategory.PERMISSION.value,
|
||||||
|
action="udb_flag_changed",
|
||||||
|
details=json.dumps({
|
||||||
|
"nodeKey": nodeKey,
|
||||||
|
"flag": flag,
|
||||||
|
"value": value,
|
||||||
|
"resetDescendants": len(resetIds),
|
||||||
|
"nodeKind": type(node).__name__,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"nodeKey": nodeKey,
|
||||||
|
"flag": flag,
|
||||||
|
"value": value,
|
||||||
|
"effective": effective,
|
||||||
|
"resetDescendantIds": resetIds,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _validateFlagValue(flag: str, value: Any, context: RequestContext) -> Any:
|
||||||
|
"""Validate the incoming value matches the flag's expected shape.
|
||||||
|
|
||||||
|
Returns the validated value (possibly normalised) or raises HTTPException.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if flag == "scope":
|
||||||
|
if not isinstance(value, str) or value not in _VALID_SCOPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid scope: {value!r}. Must be one of {sorted(_VALID_SCOPES)}",
|
||||||
|
)
|
||||||
|
if value == "global" and not context.isSysAdmin:
|
||||||
|
raise HTTPException(status_code=403, detail=routeApiMsg("Only sysadmins can set global scope"))
|
||||||
|
return value
|
||||||
|
# neutralize / ragIndexEnabled
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid value for flag {flag!r}: expected bool or null, got {type(value).__name__}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _computeEffectiveAfterWrite(rootIf: Any, context: RequestContext,
|
||||||
|
node: Any, flag: str) -> Any:
|
||||||
|
"""Recompute the node's effective value after the write.
|
||||||
|
|
||||||
|
Re-loads the relevant recordsets so the cascade resets are visible.
|
||||||
|
"""
|
||||||
|
from modules.datamodels.datamodelDataSource import DataSource
|
||||||
|
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
|
||||||
|
userId = str(context.user.id)
|
||||||
|
allDs = rootIf.db.getRecordset(DataSource, recordFilter={"userId": userId}) or []
|
||||||
|
fdsFilter: Dict[str, Any] = {}
|
||||||
|
featureInstanceId = getattr(node, "featureInstanceId", None)
|
||||||
|
if featureInstanceId:
|
||||||
|
fdsFilter["featureInstanceId"] = featureInstanceId
|
||||||
|
allFds = rootIf.db.getRecordset(FeatureDataSource, recordFilter=fdsFilter) or []
|
||||||
|
try:
|
||||||
|
return node.getEffectiveFlag(flag, allDs, allFds, mode="aggregate")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Effective-after-write failed for %s flag=%s: %s",
|
||||||
|
getattr(node, "key", "?"), flag, exc)
|
||||||
|
return None
|
||||||
|
|
@ -91,7 +91,6 @@ def _registerFeatureSubAgentTools(registry: ToolRegistry, services):
|
||||||
mandateId = instance.mandateId or ""
|
mandateId = instance.mandateId or ""
|
||||||
instanceLabel = instance.label or ""
|
instanceLabel = instance.label or ""
|
||||||
userId = context.get("userId", "")
|
userId = context.get("userId", "")
|
||||||
workspaceInstanceId = context.get("featureInstanceId", "")
|
|
||||||
requestLang = None
|
requestLang = None
|
||||||
if userId:
|
if userId:
|
||||||
langUser = rootIf.getUser(userId)
|
langUser = rootIf.getUser(userId)
|
||||||
|
|
@ -107,7 +106,7 @@ def _registerFeatureSubAgentTools(registry: ToolRegistry, services):
|
||||||
|
|
||||||
featureDataSources = rootDbConn.getRecordset(
|
featureDataSources = rootDbConn.getRecordset(
|
||||||
FeatureDataSource,
|
FeatureDataSource,
|
||||||
recordFilter={"featureInstanceId": featureInstanceId, "workspaceInstanceId": workspaceInstanceId},
|
recordFilter={"featureInstanceId": featureInstanceId},
|
||||||
)
|
)
|
||||||
|
|
||||||
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlagFds
|
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlagFds
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -28,7 +28,7 @@ from typing import Any, Dict, Iterable, List, Literal, Optional, Tuple
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_INHERITABLE_FLAGS = ("neutralize", "ragIndexEnabled", "scope")
|
_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
|
# Connection-root DataSources carry the authority as their sourceType
|
||||||
# (e.g. 'msft', 'google'). They sit one level above all service DataSources
|
# (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}")
|
raise ValueError(f"Unknown inheritable FDS flag: {flag}")
|
||||||
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
|
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
|
||||||
|
|
||||||
workspaceInstanceId = _getRecordValue(parentRec, "workspaceInstanceId")
|
featureInstanceId = _getRecordValue(parentRec, "featureInstanceId")
|
||||||
if not workspaceInstanceId:
|
if not featureInstanceId:
|
||||||
return []
|
return []
|
||||||
siblings = rootIf.db.getRecordset(
|
siblings = rootIf.db.getRecordset(
|
||||||
FeatureDataSource, recordFilter={"workspaceInstanceId": workspaceInstanceId}
|
FeatureDataSource, recordFilter={"featureInstanceId": featureInstanceId}
|
||||||
)
|
)
|
||||||
|
|
||||||
toReset: List[Tuple[int, str]] = []
|
toReset: List[Tuple[int, str]] = []
|
||||||
|
|
@ -475,7 +475,6 @@ def cascadeResetDescendantsFds(
|
||||||
sibId = _getRecordValue(sib, "id")
|
sibId = _getRecordValue(sib, "id")
|
||||||
toReset.append((_fdsDepth(sib), sibId))
|
toReset.append((_fdsDepth(sib), sibId))
|
||||||
|
|
||||||
# Sort deepest first (bottom-up)
|
|
||||||
toReset.sort(key=lambda x: x[0], reverse=True)
|
toReset.sort(key=lambda x: x[0], reverse=True)
|
||||||
|
|
||||||
resetIds: List[str] = []
|
resetIds: List[str] = []
|
||||||
|
|
@ -576,9 +575,9 @@ def resolveEffectiveForPath(
|
||||||
"ragIndexEnabled": None,
|
"ragIndexEnabled": None,
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"effectiveNeutralize": _resolveWalkValue(virtualRec, "neutralize", allDs),
|
"effectiveNeutralize": getEffectiveFlag(virtualRec, "neutralize", allDs, mode=mode),
|
||||||
"effectiveScope": _resolveWalkValue(virtualRec, "scope", allDs),
|
"effectiveScope": getEffectiveFlag(virtualRec, "scope", allDs, mode=mode),
|
||||||
"effectiveRagIndexEnabled": _resolveWalkValue(virtualRec, "ragIndexEnabled", allDs),
|
"effectiveRagIndexEnabled": getEffectiveFlag(virtualRec, "ragIndexEnabled", allDs, mode=mode),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -591,11 +590,11 @@ def resolveEffectiveForFds(
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Resolve effective flags for ANY FDS tuple (even without DB record).
|
"""Resolve effective flags for ANY FDS tuple (even without DB record).
|
||||||
|
|
||||||
`allFds` is pre-scoped to a single workspace (loaded with
|
`allFds` is pre-scoped (typically to a mandate). Within that set, the
|
||||||
workspaceInstanceId filter). Within that set, the coordinate is
|
coordinate is featureInstanceId + tableName + recordFilter.
|
||||||
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
|
exactRecord = None
|
||||||
for fds in allFds:
|
for fds in allFds:
|
||||||
|
|
@ -611,7 +610,6 @@ def resolveEffectiveForFds(
|
||||||
if exactRecord:
|
if exactRecord:
|
||||||
return {
|
return {
|
||||||
"effectiveNeutralize": getEffectiveFlagFds(exactRecord, "neutralize", allFds, mode=mode),
|
"effectiveNeutralize": getEffectiveFlagFds(exactRecord, "neutralize", allFds, mode=mode),
|
||||||
"effectiveScope": getEffectiveFlagFds(exactRecord, "scope", allFds, mode=mode),
|
|
||||||
"effectiveRagIndexEnabled": getEffectiveFlagFds(exactRecord, "ragIndexEnabled", allFds, mode=mode),
|
"effectiveRagIndexEnabled": getEffectiveFlagFds(exactRecord, "ragIndexEnabled", allFds, mode=mode),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -621,11 +619,9 @@ def resolveEffectiveForFds(
|
||||||
"tableName": tableName,
|
"tableName": tableName,
|
||||||
"recordFilter": recordFilter,
|
"recordFilter": recordFilter,
|
||||||
"neutralize": None,
|
"neutralize": None,
|
||||||
"scope": None,
|
|
||||||
"ragIndexEnabled": None,
|
"ragIndexEnabled": None,
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"effectiveNeutralize": _resolveWalkValueFds(virtualRec, "neutralize", allFds),
|
"effectiveNeutralize": getEffectiveFlagFds(virtualRec, "neutralize", allFds, mode=mode),
|
||||||
"effectiveScope": _resolveWalkValueFds(virtualRec, "scope", allFds),
|
"effectiveRagIndexEnabled": getEffectiveFlagFds(virtualRec, "ragIndexEnabled", allFds, mode=mode),
|
||||||
"effectiveRagIndexEnabled": _resolveWalkValueFds(virtualRec, "ragIndexEnabled", allFds),
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ text, and feeds it through KnowledgeService.requestIngestion so the data
|
||||||
appears in ContentChunk embeddings for semantic RAG search.
|
appears in ContentChunk embeddings for semantic RAG search.
|
||||||
|
|
||||||
Job type: ``feature.bootstrap``
|
Job type: ``feature.bootstrap``
|
||||||
Payload: ``{"workspaceInstanceId": "...", "featureDataSourceIds": [...] (optional)}``
|
Payload: ``{"featureInstanceId": "...", "featureDataSourceIds": [...] (optional)}``
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -23,7 +23,7 @@ logger = logging.getLogger(__name__)
|
||||||
FEATURE_BOOTSTRAP_JOB_TYPE = "feature.bootstrap"
|
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.
|
"""Load FeatureDataSource rows whose effective ragIndexEnabled is True.
|
||||||
|
|
||||||
Returns dicts with resolved flags so downstream code can read them directly.
|
Returns dicts with resolved flags so downstream code can read them directly.
|
||||||
|
|
@ -34,7 +34,7 @@ def _loadRagEnabledFds(workspaceInstanceId: str, featureDataSourceIds: Optional[
|
||||||
|
|
||||||
rootIf = getRootInterface()
|
rootIf = getRootInterface()
|
||||||
allFds = rootIf.db.getRecordset(
|
allFds = rootIf.db.getRecordset(
|
||||||
FeatureDataSource, recordFilter={"workspaceInstanceId": workspaceInstanceId}
|
FeatureDataSource, recordFilter={"featureInstanceId": featureInstanceId}
|
||||||
)
|
)
|
||||||
resolved = []
|
resolved = []
|
||||||
for fds in allFds:
|
for fds in allFds:
|
||||||
|
|
@ -47,7 +47,6 @@ def _loadRagEnabledFds(workspaceInstanceId: str, featureDataSourceIds: Optional[
|
||||||
continue
|
continue
|
||||||
row = dict(fds) if isinstance(fds, dict) else {**fds.__dict__}
|
row = dict(fds) if isinstance(fds, dict) else {**fds.__dict__}
|
||||||
row["_effectiveNeutralize"] = getEffectiveFlagFds(fds, "neutralize", allFds, mode="aggregate")
|
row["_effectiveNeutralize"] = getEffectiveFlagFds(fds, "neutralize", allFds, mode="aggregate")
|
||||||
row["_effectiveScope"] = getEffectiveFlagFds(fds, "scope", allFds, mode="aggregate") or "featureInstance"
|
|
||||||
row["ragIndexEnabled"] = True
|
row["ragIndexEnabled"] = True
|
||||||
resolved.append(row)
|
resolved.append(row)
|
||||||
|
|
||||||
|
|
@ -104,20 +103,20 @@ async def _featureBootstrapHandler(
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Walk RAG-enabled FeatureDataSources and index their rows."""
|
"""Walk RAG-enabled FeatureDataSources and index their rows."""
|
||||||
payload = job.get("payload") or {}
|
payload = job.get("payload") or {}
|
||||||
workspaceInstanceId = payload.get("workspaceInstanceId")
|
featureInstanceId = payload.get("featureInstanceId")
|
||||||
featureDataSourceIds = payload.get("featureDataSourceIds")
|
featureDataSourceIds = payload.get("featureDataSourceIds")
|
||||||
if not workspaceInstanceId:
|
if not featureInstanceId:
|
||||||
raise ValueError("feature.bootstrap requires payload.workspaceInstanceId")
|
raise ValueError("feature.bootstrap requires payload.featureInstanceId")
|
||||||
|
|
||||||
progressCb(5, messageKey="Feature-Datenquellen werden geladen...")
|
progressCb(5, messageKey="Feature-Datenquellen werden geladen...")
|
||||||
|
|
||||||
fdsList = _loadRagEnabledFds(workspaceInstanceId, featureDataSourceIds)
|
fdsList = _loadRagEnabledFds(featureInstanceId, featureDataSourceIds)
|
||||||
if not fdsList:
|
if not fdsList:
|
||||||
logger.info(
|
logger.info(
|
||||||
"feature.bootstrap.skipped — no rag-enabled FDS for workspace %s",
|
"feature.bootstrap.skipped — no rag-enabled FDS for feature %s",
|
||||||
workspaceInstanceId,
|
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.serviceAgent.featureDataProvider import FeatureDataProvider
|
||||||
from modules.serviceCenter.services.serviceKnowledge.mainServiceKnowledge import IngestionJob
|
from modules.serviceCenter.services.serviceKnowledge.mainServiceKnowledge import IngestionJob
|
||||||
|
|
@ -134,11 +133,10 @@ async def _featureBootstrapHandler(
|
||||||
fdsId = fds.get("id", "")
|
fdsId = fds.get("id", "")
|
||||||
featureCode = fds.get("featureCode", "")
|
featureCode = fds.get("featureCode", "")
|
||||||
tableName = fds.get("tableName", "")
|
tableName = fds.get("tableName", "")
|
||||||
featureInstanceId = fds.get("featureInstanceId", "")
|
fdsFeatureInstanceId = fds.get("featureInstanceId", "")
|
||||||
mandateId = fds.get("mandateId", "")
|
mandateId = fds.get("mandateId", "")
|
||||||
neutralizeFields = fds.get("neutralizeFields") or []
|
neutralizeFields = fds.get("neutralizeFields") or []
|
||||||
recordFilter = fds.get("recordFilter") or {}
|
recordFilter = fds.get("recordFilter") or {}
|
||||||
effectiveScope = fds.get("_effectiveScope", "featureInstance")
|
|
||||||
effectiveNeutralize = bool(fds.get("_effectiveNeutralize", False))
|
effectiveNeutralize = bool(fds.get("_effectiveNeutralize", False))
|
||||||
|
|
||||||
progressPct = 5 + int(90 * fdsIdx / len(fdsList))
|
progressPct = 5 + int(90 * fdsIdx / len(fdsList))
|
||||||
|
|
@ -148,7 +146,7 @@ async def _featureBootstrapHandler(
|
||||||
messageParams={"table": tableName, "n": fdsIdx + 1, "total": len(fdsList)},
|
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)
|
logger.warning("feature.bootstrap: skipping FDS %s — missing featureCode/tableName/fiId", fdsId)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -160,7 +158,7 @@ async def _featureBootstrapHandler(
|
||||||
ctx = ServiceCenterContext(
|
ctx = ServiceCenterContext(
|
||||||
user=rootUser,
|
user=rootUser,
|
||||||
mandate_id=mandateId,
|
mandate_id=mandateId,
|
||||||
feature_instance_id=workspaceInstanceId,
|
feature_instance_id=fdsFeatureInstanceId,
|
||||||
)
|
)
|
||||||
knowledgeService = getService("knowledge", ctx)
|
knowledgeService = getService("knowledge", ctx)
|
||||||
|
|
||||||
|
|
@ -178,7 +176,7 @@ async def _featureBootstrapHandler(
|
||||||
while True:
|
while True:
|
||||||
result = provider.browseTable(
|
result = provider.browseTable(
|
||||||
tableName=tableName,
|
tableName=tableName,
|
||||||
featureInstanceId=featureInstanceId,
|
featureInstanceId=fdsFeatureInstanceId,
|
||||||
mandateId=mandateId,
|
mandateId=mandateId,
|
||||||
limit=batchSize,
|
limit=batchSize,
|
||||||
offset=offset,
|
offset=offset,
|
||||||
|
|
@ -202,11 +200,11 @@ async def _featureBootstrapHandler(
|
||||||
|
|
||||||
ingestionJob = IngestionJob(
|
ingestionJob = IngestionJob(
|
||||||
sourceKind="feature_record",
|
sourceKind="feature_record",
|
||||||
sourceId=f"{workspaceInstanceId}:{tableName}:{rowId}",
|
sourceId=f"{fdsFeatureInstanceId}:{tableName}:{rowId}",
|
||||||
fileName=f"{tableName}-{rowId}",
|
fileName=f"{tableName}-{rowId}",
|
||||||
mimeType="application/vnd.poweron.feature-record+json",
|
mimeType="application/vnd.poweron.feature-record+json",
|
||||||
userId=fds.get("userId") or "system",
|
userId="system",
|
||||||
featureInstanceId=workspaceInstanceId,
|
featureInstanceId=fdsFeatureInstanceId,
|
||||||
mandateId=mandateId,
|
mandateId=mandateId,
|
||||||
contentObjects=[{
|
contentObjects=[{
|
||||||
"contentType": "text",
|
"contentType": "text",
|
||||||
|
|
@ -214,7 +212,7 @@ async def _featureBootstrapHandler(
|
||||||
"contextRef": {
|
"contextRef": {
|
||||||
"table": tableName,
|
"table": tableName,
|
||||||
"featureCode": featureCode,
|
"featureCode": featureCode,
|
||||||
"featureInstanceId": featureInstanceId,
|
"featureInstanceId": fdsFeatureInstanceId,
|
||||||
"rowId": rowId,
|
"rowId": rowId,
|
||||||
},
|
},
|
||||||
"contentObjectId": f"{tableName}:{rowId}",
|
"contentObjectId": f"{tableName}:{rowId}",
|
||||||
|
|
@ -225,7 +223,7 @@ async def _featureBootstrapHandler(
|
||||||
"featureDataSourceId": fdsId,
|
"featureDataSourceId": fdsId,
|
||||||
"tableName": tableName,
|
"tableName": tableName,
|
||||||
"featureCode": featureCode,
|
"featureCode": featureCode,
|
||||||
"featureInstanceId": featureInstanceId,
|
"featureInstanceId": fdsFeatureInstanceId,
|
||||||
},
|
},
|
||||||
neutralize=effectiveNeutralize,
|
neutralize=effectiveNeutralize,
|
||||||
)
|
)
|
||||||
|
|
@ -281,7 +279,7 @@ async def _featureBootstrapHandler(
|
||||||
progressCb(100, messageKey="Feature-Daten-Sync abgeschlossen.")
|
progressCb(100, messageKey="Feature-Daten-Sync abgeschlossen.")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"workspaceInstanceId": workspaceInstanceId,
|
"featureInstanceId": featureInstanceId,
|
||||||
"indexed": totalIndexed,
|
"indexed": totalIndexed,
|
||||||
"skippedDuplicate": totalSkipped,
|
"skippedDuplicate": totalSkipped,
|
||||||
"failed": totalFailed,
|
"failed": totalFailed,
|
||||||
|
|
|
||||||
1055
modules/serviceCenter/services/serviceKnowledge/udbNodes.py
Normal file
1055
modules/serviceCenter/services/serviceKnowledge/udbNodes.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -24,7 +24,7 @@ import time
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from modules.datamodels.datamodelChat import ActionResult
|
from modules.datamodels.datamodelChat import ActionResult
|
||||||
from modules.datamodels.datamodelDocref import DocumentReferenceList, coerceDocumentReferenceList
|
from modules.datamodels.datamodelDocref import coerceDocumentReferenceList
|
||||||
from modules.datamodels.datamodelExtraction import ContentExtracted, ExtractionOptions
|
from modules.datamodels.datamodelExtraction import ContentExtracted, ExtractionOptions
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -1751,62 +1751,6 @@ def presentation_envelopes_to_document_json(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _document_list_from_context(raw: Any, *, _depth: int = 0) -> DocumentReferenceList:
|
|
||||||
"""Best-effort extraction of document/file references from ``context`` payloads.
|
|
||||||
|
|
||||||
Supports direct DocumentList-like values plus nested shapes commonly produced
|
|
||||||
by DataPicker selections, ActionResult wrappers, and file/files containers.
|
|
||||||
"""
|
|
||||||
if _depth > 6 or raw is None or raw == "":
|
|
||||||
return DocumentReferenceList(references=[])
|
|
||||||
|
|
||||||
if isinstance(raw, dict) and "fileId" in raw and "id" not in raw and "documentId" not in raw:
|
|
||||||
direct = coerceDocumentReferenceList({
|
|
||||||
"id": raw.get("fileId"),
|
|
||||||
"name": raw.get("fileName") or raw.get("name"),
|
|
||||||
})
|
|
||||||
else:
|
|
||||||
direct = coerceDocumentReferenceList(raw)
|
|
||||||
if direct.references:
|
|
||||||
return direct
|
|
||||||
|
|
||||||
collected = []
|
|
||||||
|
|
||||||
def _extend_from(value: Any) -> None:
|
|
||||||
nested = _document_list_from_context(value, _depth=_depth + 1)
|
|
||||||
if nested.references:
|
|
||||||
collected.extend(nested.references)
|
|
||||||
|
|
||||||
if isinstance(raw, dict):
|
|
||||||
nested_files = raw.get("files")
|
|
||||||
if isinstance(nested_files, dict):
|
|
||||||
_extend_from(list(nested_files.values()))
|
|
||||||
for key in ("documents", "references", "items", "file", "document", "value", "data", "merged", "result", "context"):
|
|
||||||
nested = raw.get(key)
|
|
||||||
if nested is None or nested is raw:
|
|
||||||
continue
|
|
||||||
_extend_from(nested)
|
|
||||||
elif isinstance(raw, list):
|
|
||||||
for item in raw:
|
|
||||||
_extend_from(item)
|
|
||||||
|
|
||||||
if not collected:
|
|
||||||
return DocumentReferenceList(references=[])
|
|
||||||
|
|
||||||
deduped = []
|
|
||||||
seen = set()
|
|
||||||
for ref in collected:
|
|
||||||
try:
|
|
||||||
key = ref.to_string()
|
|
||||||
except Exception:
|
|
||||||
key = repr(ref)
|
|
||||||
if key in seen:
|
|
||||||
continue
|
|
||||||
seen.add(key)
|
|
||||||
deduped.append(ref)
|
|
||||||
return DocumentReferenceList(references=deduped)
|
|
||||||
|
|
||||||
|
|
||||||
async def extractContent(self, parameters: Dict[str, Any]) -> ActionResult:
|
async def extractContent(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
operation_id = None
|
operation_id = None
|
||||||
try:
|
try:
|
||||||
|
|
@ -1814,24 +1758,18 @@ async def extractContent(self, parameters: Dict[str, Any]) -> ActionResult:
|
||||||
operation_id = f"context_extract_{wf}_{int(time.time())}"
|
operation_id = f"context_extract_{wf}_{int(time.time())}"
|
||||||
|
|
||||||
document_list_param = parameters.get("documentList")
|
document_list_param = parameters.get("documentList")
|
||||||
if document_list_param:
|
if not document_list_param:
|
||||||
|
return ActionResult.isFailure(error="documentList is required")
|
||||||
|
|
||||||
dl = coerceDocumentReferenceList(document_list_param)
|
dl = coerceDocumentReferenceList(document_list_param)
|
||||||
source = "documentList"
|
|
||||||
else:
|
|
||||||
context_param = parameters.get("context")
|
|
||||||
dl = _document_list_from_context(context_param)
|
|
||||||
source = "context"
|
|
||||||
if not dl.references:
|
if not dl.references:
|
||||||
return ActionResult.isFailure(
|
return ActionResult.isFailure(
|
||||||
error=(
|
error=(
|
||||||
f"{source} could not be parsed into document references "
|
f"documentList could not be parsed (type={type(document_list_param).__name__}); "
|
||||||
f"(type={type((document_list_param if document_list_param else parameters.get('context'))).__name__}); "
|
"expected DocumentReferenceList, list of strings/dicts, or "
|
||||||
"expected DocumentReferenceList, list of string/dict refs, "
|
"a wrapper dict like {'documents': [...]}"
|
||||||
"or a context payload containing file/document refs under keys like "
|
|
||||||
"{documents, files, file, data, value}."
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
logger.info("extractContent resolved %d document reference(s) from %s", len(dl.references), source)
|
|
||||||
|
|
||||||
parent_operation_id = parameters.get("parentOperationId")
|
parent_operation_id = parameters.get("parentOperationId")
|
||||||
self.services.chat.progressLogStart(
|
self.services.chat.progressLogStart(
|
||||||
|
|
|
||||||
|
|
@ -64,22 +64,12 @@ class MethodContext(MethodBase):
|
||||||
dynamicMode=True,
|
dynamicMode=True,
|
||||||
outputType="UdmDocument",
|
outputType="UdmDocument",
|
||||||
parameters={
|
parameters={
|
||||||
"context": WorkflowActionParameter(
|
|
||||||
name="context",
|
|
||||||
type="Any",
|
|
||||||
frontendType=FrontendType.CONTEXT_BUILDER,
|
|
||||||
required=False,
|
|
||||||
description=(
|
|
||||||
"Optional context payload that may contain file/document references. "
|
|
||||||
"Preferred input for extractContent; documentList remains supported for compatibility."
|
|
||||||
),
|
|
||||||
),
|
|
||||||
"documentList": WorkflowActionParameter(
|
"documentList": WorkflowActionParameter(
|
||||||
name="documentList",
|
name="documentList",
|
||||||
type="DocumentList",
|
type="DocumentList",
|
||||||
frontendType=FrontendType.DOCUMENT_REFERENCE,
|
frontendType=FrontendType.DOCUMENT_REFERENCE,
|
||||||
required=False,
|
required=True,
|
||||||
description="Optional document reference(s) to extract content from. When omitted, extractContent also accepts refs via context.",
|
description="Document reference(s) to extract content from",
|
||||||
),
|
),
|
||||||
"contentFilter": WorkflowActionParameter(
|
"contentFilter": WorkflowActionParameter(
|
||||||
name="contentFilter",
|
name="contentFilter",
|
||||||
|
|
|
||||||
|
|
@ -308,7 +308,7 @@ def process_all_env_files(env_files: List[str] = None, dry_run: bool = False, cr
|
||||||
"""
|
"""
|
||||||
# Default environment files if none specified
|
# Default environment files if none specified
|
||||||
if env_files is None:
|
if env_files is None:
|
||||||
env_files = ['env-dev.env', 'env-int.env', 'env-prod.env']
|
env_files = ['env-gateway-dev.env', 'env-gateway-int.env', 'env-gateway-prod.env']
|
||||||
|
|
||||||
# Convert to Path objects and check if they exist
|
# Convert to Path objects and check if they exist
|
||||||
env_paths = []
|
env_paths = []
|
||||||
|
|
|
||||||
|
|
@ -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
|
Most node-level behavior moved into the polymorphic class hierarchy
|
||||||
existing handlers (top-level, conn, mgrp, feat) are produced with the
|
(`udbNodes.py`) and has its own dedicated tests in `test_udbNodes.py`.
|
||||||
correct effective-flag triplet.
|
This file covers the orchestrator (`getChildrenForParents`) and the
|
||||||
|
remaining lookup helpers.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
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"])
|
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):
|
class TestRecordLookup(unittest.TestCase):
|
||||||
def test_finds_ds_record_by_normalised_path(self):
|
def test_finds_ds_record_by_normalised_path(self):
|
||||||
rec = {"id": "x", "connectionId": "c", "sourceType": "msft", "path": "/folder"}
|
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"))
|
self.assertIsNone(_buildTree._findDsRecord([rec], "c", "msft", "/other"))
|
||||||
|
|
||||||
def test_finds_fds_record_with_matching_filter(self):
|
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.assertEqual(_buildTree._findFdsRecord([rec], "fi1", "Pos", {"id": "5"}).get("id"), "f")
|
||||||
self.assertIsNone(_buildTree._findFdsRecord([rec], "fi1", "Pos", {"id": "99"}))
|
self.assertIsNone(_buildTree._findFdsRecord([rec], "fi1", "Pos", {"id": "99"}))
|
||||||
|
|
||||||
def test_fds_record_with_none_filter_matches_only_none(self):
|
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.assertEqual(_buildTree._findFdsRecord([rec], "fi1", "*", None).get("id"), "f")
|
||||||
self.assertIsNone(_buildTree._findFdsRecord([rec], "fi1", "*", {"id": "1"}))
|
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):
|
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):
|
def _runAsync(self, coro):
|
||||||
return asyncio.run(coro)
|
return asyncio.run(coro)
|
||||||
|
|
@ -92,12 +122,11 @@ class TestGetChildrenForParents(unittest.TestCase):
|
||||||
ctx.mandateId = "m1"
|
ctx.mandateId = "m1"
|
||||||
|
|
||||||
result = self._runAsync(
|
result = self._runAsync(
|
||||||
_buildTree.getChildrenForParents("inst-1", ["bogus|key"], ctx)
|
_buildTree.getChildrenForParents(["bogus|key"], ctx)
|
||||||
)
|
)
|
||||||
self.assertEqual(result["bogus|key"], [])
|
self.assertEqual(result["bogus|key"], [])
|
||||||
|
|
||||||
def test_top_level_emits_personal_root_first(self):
|
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:
|
with patch("modules.interfaces.interfaceDbApp.getRootInterface") as mockRoot:
|
||||||
rootIf = MagicMock()
|
rootIf = MagicMock()
|
||||||
rootIf.db.getRecordset.return_value = []
|
rootIf.db.getRecordset.return_value = []
|
||||||
|
|
@ -109,7 +138,7 @@ class TestGetChildrenForParents(unittest.TestCase):
|
||||||
ctx.mandateId = "m1"
|
ctx.mandateId = "m1"
|
||||||
|
|
||||||
result = self._runAsync(
|
result = self._runAsync(
|
||||||
_buildTree.getChildrenForParents("inst-1", [None], ctx)
|
_buildTree.getChildrenForParents([None], ctx)
|
||||||
)
|
)
|
||||||
children = result["__root__"]
|
children = result["__root__"]
|
||||||
self.assertGreaterEqual(len(children), 1)
|
self.assertGreaterEqual(len(children), 1)
|
||||||
|
|
@ -120,92 +149,7 @@ class TestGetChildrenForParents(unittest.TestCase):
|
||||||
self.assertTrue(personalRoot["hasChildren"])
|
self.assertTrue(personalRoot["hasChildren"])
|
||||||
self.assertTrue(personalRoot["defaultExpanded"])
|
self.assertTrue(personalRoot["defaultExpanded"])
|
||||||
|
|
||||||
|
def test_top_level_emits_mandate_groups_inline(self):
|
||||||
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):
|
|
||||||
with patch("modules.interfaces.interfaceDbApp.getRootInterface") as mockRoot, \
|
with patch("modules.interfaces.interfaceDbApp.getRootInterface") as mockRoot, \
|
||||||
patch("modules.security.rbacCatalog.getCatalogService") as mockCatalog:
|
patch("modules.security.rbacCatalog.getCatalogService") as mockCatalog:
|
||||||
rootIf = MagicMock()
|
rootIf = MagicMock()
|
||||||
|
|
@ -218,8 +162,7 @@ class TestTopLevelLayout(unittest.TestCase):
|
||||||
featureInst.featureCode = "trustee"
|
featureInst.featureCode = "trustee"
|
||||||
featureInst.enabled = True
|
featureInst.enabled = True
|
||||||
rootIf.getFeatureInstancesByMandate.return_value = [featureInst]
|
rootIf.getFeatureInstancesByMandate.return_value = [featureInst]
|
||||||
featureAccess = MagicMock()
|
featureAccess = MagicMock(enabled=True)
|
||||||
featureAccess.enabled = True
|
|
||||||
rootIf.getFeatureAccess.return_value = featureAccess
|
rootIf.getFeatureAccess.return_value = featureAccess
|
||||||
mockRoot.return_value = rootIf
|
mockRoot.return_value = rootIf
|
||||||
|
|
||||||
|
|
@ -231,11 +174,8 @@ class TestTopLevelLayout(unittest.TestCase):
|
||||||
ctx.user.id = "u1"
|
ctx.user.id = "u1"
|
||||||
ctx.mandateId = None
|
ctx.mandateId = None
|
||||||
|
|
||||||
result = self._runAsync(
|
result = self._runAsync(_buildTree.getChildrenForParents([None], ctx))
|
||||||
_buildTree.getChildrenForParents("inst-1", [None], ctx)
|
byKey = {c["key"]: c for c in result["__root__"]}
|
||||||
)
|
|
||||||
children = result["__root__"]
|
|
||||||
byKey = {c["key"]: c for c in children}
|
|
||||||
self.assertIn("personalRoot", byKey)
|
self.assertIn("personalRoot", byKey)
|
||||||
self.assertIn("mgrp|m1", byKey)
|
self.assertIn("mgrp|m1", byKey)
|
||||||
mgroup = byKey["mgrp|m1"]
|
mgroup = byKey["mgrp|m1"]
|
||||||
|
|
@ -243,116 +183,6 @@ class TestTopLevelLayout(unittest.TestCase):
|
||||||
self.assertIsNone(mgroup["parentKey"])
|
self.assertIsNone(mgroup["parentKey"])
|
||||||
self.assertEqual(mgroup["mandateId"], "m1")
|
self.assertEqual(mgroup["mandateId"], "m1")
|
||||||
self.assertTrue(mgroup["defaultExpanded"])
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|
|
||||||
|
|
@ -34,15 +34,19 @@ def _ds(idVal: str, path: str, **flags) -> dict:
|
||||||
|
|
||||||
|
|
||||||
def _fds(idVal: str, *, tableName: str, recordFilter=None, featureInstanceId="fi-1", **flags) -> dict:
|
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 = {
|
base = {
|
||||||
"id": idVal,
|
"id": idVal,
|
||||||
"workspaceInstanceId": "ws-1",
|
|
||||||
"featureInstanceId": featureInstanceId,
|
"featureInstanceId": featureInstanceId,
|
||||||
"tableName": tableName,
|
"tableName": tableName,
|
||||||
"recordFilter": recordFilter,
|
"recordFilter": recordFilter,
|
||||||
"neutralize": None,
|
"neutralize": None,
|
||||||
"scope": None,
|
"ragIndexEnabled": None,
|
||||||
}
|
}
|
||||||
base.update(flags)
|
base.update(flags)
|
||||||
return base
|
return base
|
||||||
|
|
@ -473,6 +477,7 @@ class TestFdsCascadeReset(unittest.TestCase):
|
||||||
_inheritFlags.cascadeResetDescendantsFds(rootIf, ws, "doesNotExist")
|
_inheritFlags.cascadeResetDescendantsFds(rootIf, ws, "doesNotExist")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# FeatureDataSource: collectAncestorChainFds
|
# FeatureDataSource: collectAncestorChainFds
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
|
|
@ -572,28 +577,32 @@ class TestResolveEffectiveForPath(unittest.TestCase):
|
||||||
|
|
||||||
|
|
||||||
class TestResolveEffectiveForFds(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):
|
def test_with_exact_record(self):
|
||||||
ws = _fds("ws", tableName="*", neutralize=True, scope="mandate")
|
ws = _fds("ws", tableName="*", neutralize=True)
|
||||||
tbl = _fds("t", tableName="Pos", neutralize=False, scope="personal")
|
tbl = _fds("t", tableName="Pos", neutralize=False)
|
||||||
allFds = [ws, tbl]
|
allFds = [ws, tbl]
|
||||||
result = _inheritFlags.resolveEffectiveForFds("fi-1", "Pos", None, allFds)
|
result = _inheritFlags.resolveEffectiveForFds("fi-1", "Pos", None, allFds)
|
||||||
self.assertEqual(result["effectiveNeutralize"], False)
|
self.assertEqual(result["effectiveNeutralize"], False)
|
||||||
self.assertEqual(result["effectiveScope"], "personal")
|
|
||||||
self.assertEqual(result["effectiveRagIndexEnabled"], False)
|
self.assertEqual(result["effectiveRagIndexEnabled"], False)
|
||||||
|
self.assertNotIn("effectiveScope", result)
|
||||||
|
|
||||||
def test_without_record_inherits_from_workspace_wildcard(self):
|
def test_without_record_inherits_from_feature_wildcard(self):
|
||||||
ws = _fds("ws", tableName="*", neutralize=True, scope="mandate", ragIndexEnabled=True)
|
ws = _fds("ws", tableName="*", neutralize=True, ragIndexEnabled=True)
|
||||||
allFds = [ws]
|
allFds = [ws]
|
||||||
result = _inheritFlags.resolveEffectiveForFds("fi-1", "Unknown", None, allFds)
|
result = _inheritFlags.resolveEffectiveForFds("fi-1", "Unknown", None, allFds)
|
||||||
self.assertEqual(result["effectiveNeutralize"], True)
|
self.assertEqual(result["effectiveNeutralize"], True)
|
||||||
self.assertEqual(result["effectiveScope"], "mandate")
|
|
||||||
self.assertEqual(result["effectiveRagIndexEnabled"], True)
|
self.assertEqual(result["effectiveRagIndexEnabled"], True)
|
||||||
|
|
||||||
def test_without_record_no_ancestors_returns_defaults(self):
|
def test_without_record_no_ancestors_returns_defaults(self):
|
||||||
allFds: list = []
|
allFds: list = []
|
||||||
result = _inheritFlags.resolveEffectiveForFds("fi-1", "Pos", None, allFds)
|
result = _inheritFlags.resolveEffectiveForFds("fi-1", "Pos", None, allFds)
|
||||||
self.assertEqual(result["effectiveNeutralize"], False)
|
self.assertEqual(result["effectiveNeutralize"], False)
|
||||||
self.assertEqual(result["effectiveScope"], "personal")
|
|
||||||
self.assertEqual(result["effectiveRagIndexEnabled"], False)
|
self.assertEqual(result["effectiveRagIndexEnabled"], False)
|
||||||
|
|
||||||
def test_rag_inherits_when_table_overrides_neutralize_only(self):
|
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")
|
result = _inheritFlags.resolveEffectiveForFds("fi-1", "*", None, allFds, mode="aggregate")
|
||||||
self.assertEqual(result["effectiveRagIndexEnabled"], "mixed")
|
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("ragIndexEnabled", _inheritFlags._INHERITABLE_FDS_FLAGS)
|
||||||
self.assertIn("neutralize", _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")
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
449
tests/unit/services/test_udbNodes.py
Normal file
449
tests/unit/services/test_udbNodes.py
Normal file
|
|
@ -0,0 +1,449 @@
|
||||||
|
"""Unit tests for the polymorphic UDB node hierarchy (udbNodes.py).
|
||||||
|
|
||||||
|
Each concrete node class is exercised for:
|
||||||
|
- `supportsFlag` returns the right set per kind
|
||||||
|
- `canEdit` enforces DS-owner vs FDS-feature-admin
|
||||||
|
- `getEffectiveFlag` resolves walk + aggregate correctly
|
||||||
|
- `setFlag` writes the right record and (where applicable) cascades
|
||||||
|
- `toDict` produces the expected wire shape
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from modules.serviceCenter.services.serviceKnowledge.udbNodes import (
|
||||||
|
UdbNode,
|
||||||
|
SyntheticContainerNode,
|
||||||
|
MandateGroupNode,
|
||||||
|
ConnectionNode,
|
||||||
|
ServiceNode,
|
||||||
|
FolderNode,
|
||||||
|
FileNode,
|
||||||
|
FdsWorkspaceNode,
|
||||||
|
FdsTableNode,
|
||||||
|
FdsFieldNode,
|
||||||
|
_isFeatureAdmin,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeUser:
|
||||||
|
def __init__(self, userId: str = "user-1"):
|
||||||
|
self.id = userId
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeContext:
|
||||||
|
def __init__(self, userId: str = "user-1"):
|
||||||
|
self.user = _FakeUser(userId)
|
||||||
|
self.mandateId = "m1"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSupportsFlag(unittest.TestCase):
|
||||||
|
def test_synthetic_container_supports_nothing(self):
|
||||||
|
n = SyntheticContainerNode("k", "label", icon="x")
|
||||||
|
self.assertFalse(n.supportsFlag("neutralize"))
|
||||||
|
self.assertFalse(n.supportsFlag("scope"))
|
||||||
|
self.assertFalse(n.supportsFlag("ragIndexEnabled"))
|
||||||
|
|
||||||
|
def test_connection_supports_all_three(self):
|
||||||
|
n = ConnectionNode("c1", "msft", label="m", parentKey="personalRoot", rec=None)
|
||||||
|
self.assertTrue(n.supportsFlag("neutralize"))
|
||||||
|
self.assertTrue(n.supportsFlag("scope"))
|
||||||
|
self.assertTrue(n.supportsFlag("ragIndexEnabled"))
|
||||||
|
|
||||||
|
def test_fds_table_supports_neutralize_and_rag_but_not_scope(self):
|
||||||
|
n = FdsTableNode(
|
||||||
|
featureInstanceId="fi1", featureCode="trustee", tableName="Pos",
|
||||||
|
objectKey="data.feature.trustee.Pos", label="Positions",
|
||||||
|
parentKey="feat|m1|trustee|fi1", rec=None, hasFields=False,
|
||||||
|
)
|
||||||
|
self.assertTrue(n.supportsFlag("neutralize"))
|
||||||
|
self.assertTrue(n.supportsFlag("ragIndexEnabled"))
|
||||||
|
self.assertFalse(n.supportsFlag("scope"))
|
||||||
|
|
||||||
|
def test_fds_field_supports_only_neutralize(self):
|
||||||
|
n = FdsFieldNode(
|
||||||
|
featureInstanceId="fi1", tableName="Pos", fieldName="amount",
|
||||||
|
parentKey="fdstbl|fi1|Pos", tableRec=None, featureCode="trustee",
|
||||||
|
)
|
||||||
|
self.assertTrue(n.supportsFlag("neutralize"))
|
||||||
|
self.assertFalse(n.supportsFlag("scope"))
|
||||||
|
self.assertFalse(n.supportsFlag("ragIndexEnabled"))
|
||||||
|
|
||||||
|
|
||||||
|
class TestCanEditDataSourceOwner(unittest.TestCase):
|
||||||
|
def test_owner_can_edit(self):
|
||||||
|
rec = {"id": "ds1", "userId": "user-1"}
|
||||||
|
node = ConnectionNode("c1", "msft", "m", "personalRoot", rec=rec)
|
||||||
|
self.assertTrue(node.canEdit(_FakeContext("user-1"), MagicMock()))
|
||||||
|
|
||||||
|
def test_non_owner_cannot_edit(self):
|
||||||
|
rec = {"id": "ds1", "userId": "user-other"}
|
||||||
|
node = ConnectionNode("c1", "msft", "m", "personalRoot", rec=rec)
|
||||||
|
self.assertFalse(node.canEdit(_FakeContext("user-1"), MagicMock()))
|
||||||
|
|
||||||
|
def test_virtual_node_own_connection_can_edit(self):
|
||||||
|
rootIf = MagicMock()
|
||||||
|
rootIf.db.getRecord.return_value = {"id": "c1", "userId": "user-1"}
|
||||||
|
node = ConnectionNode("c1", "msft", "m", "personalRoot", rec=None)
|
||||||
|
self.assertTrue(node.canEdit(_FakeContext("user-1"), rootIf))
|
||||||
|
|
||||||
|
def test_virtual_node_other_connection_cannot_edit(self):
|
||||||
|
rootIf = MagicMock()
|
||||||
|
rootIf.db.getRecord.return_value = {"id": "c1", "userId": "user-other"}
|
||||||
|
node = ConnectionNode("c1", "msft", "m", "personalRoot", rec=None)
|
||||||
|
self.assertFalse(node.canEdit(_FakeContext("user-1"), rootIf))
|
||||||
|
|
||||||
|
def test_virtual_node_missing_connection_cannot_edit(self):
|
||||||
|
rootIf = MagicMock()
|
||||||
|
rootIf.db.getRecord.return_value = None
|
||||||
|
node = ConnectionNode("c1", "msft", "m", "personalRoot", rec=None)
|
||||||
|
self.assertFalse(node.canEdit(_FakeContext("user-1"), rootIf))
|
||||||
|
|
||||||
|
|
||||||
|
class TestCanEditFdsFeatureAdmin(unittest.TestCase):
|
||||||
|
def _buildRootIfWithAdminRole(self, hasAdmin: bool):
|
||||||
|
rootIf = MagicMock()
|
||||||
|
access = MagicMock(id="acc1", enabled=True)
|
||||||
|
rootIf.getFeatureAccess.return_value = access
|
||||||
|
rootIf.getRoleIdsForFeatureAccess.return_value = ["role-1"]
|
||||||
|
rootIf.db.getRecord.return_value = {
|
||||||
|
"id": "role-1",
|
||||||
|
"roleLabel": "trustee-admin" if hasAdmin else "trustee-user",
|
||||||
|
}
|
||||||
|
return rootIf
|
||||||
|
|
||||||
|
def test_admin_can_edit_fds_table(self):
|
||||||
|
rootIf = self._buildRootIfWithAdminRole(hasAdmin=True)
|
||||||
|
node = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
|
||||||
|
"feat|m1|trustee|fi1", rec={"id": "fds1"}, hasFields=False)
|
||||||
|
self.assertTrue(node.canEdit(_FakeContext(), rootIf))
|
||||||
|
|
||||||
|
def test_non_admin_cannot_edit_fds_table(self):
|
||||||
|
rootIf = self._buildRootIfWithAdminRole(hasAdmin=False)
|
||||||
|
node = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
|
||||||
|
"feat|m1|trustee|fi1", rec={"id": "fds1"}, hasFields=False)
|
||||||
|
self.assertFalse(node.canEdit(_FakeContext(), rootIf))
|
||||||
|
|
||||||
|
def test_fds_field_uses_feature_admin_check(self):
|
||||||
|
rootIf = self._buildRootIfWithAdminRole(hasAdmin=True)
|
||||||
|
field = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
|
||||||
|
tableRec={"id": "fds1"}, featureCode="trustee")
|
||||||
|
self.assertTrue(field.canEdit(_FakeContext(), rootIf))
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetEffectiveFlag(unittest.TestCase):
|
||||||
|
def test_ds_walk_inherits_from_authority_root(self):
|
||||||
|
root = {
|
||||||
|
"id": "r", "connectionId": "c", "sourceType": "msft", "path": "/",
|
||||||
|
"userId": "user-1", "neutralize": True, "scope": None, "ragIndexEnabled": None,
|
||||||
|
}
|
||||||
|
node = FolderNode(
|
||||||
|
connectionId="c", service="sharepoint", sourceType="sharepointFolder",
|
||||||
|
path="/sites/x", label="x", parentKey="svc|c|sharepoint",
|
||||||
|
rec=None, hasChildren=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(node.getEffectiveFlag("neutralize", [root], [], "walk"))
|
||||||
|
|
||||||
|
def test_fds_field_neutralize_from_neutralize_fields(self):
|
||||||
|
rec = {
|
||||||
|
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||||
|
"recordFilter": None, "neutralizeFields": ["amount"],
|
||||||
|
}
|
||||||
|
node = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
|
||||||
|
tableRec=rec, featureCode="trustee")
|
||||||
|
self.assertTrue(node.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
|
||||||
|
|
||||||
|
other = FdsFieldNode("fi1", "Pos", "currency", "fdstbl|fi1|Pos",
|
||||||
|
tableRec=rec, featureCode="trustee")
|
||||||
|
# currency is not in the override list and the table has no
|
||||||
|
# explicit neutralize -> inherits the default (False).
|
||||||
|
self.assertFalse(other.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
|
||||||
|
|
||||||
|
def test_fds_field_inherits_true_from_table(self):
|
||||||
|
"""Field without explicit override inherits the table's explicit
|
||||||
|
neutralize. Regression: previously fell through to False, so
|
||||||
|
toggling the table left the field icon unchanged."""
|
||||||
|
rec = {
|
||||||
|
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||||
|
"recordFilter": None, "neutralize": True, "neutralizeFields": None,
|
||||||
|
}
|
||||||
|
node = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
|
||||||
|
tableRec=rec, featureCode="trustee")
|
||||||
|
self.assertTrue(node.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
|
||||||
|
|
||||||
|
def test_fds_field_inherits_from_workspace_via_table(self):
|
||||||
|
"""Field walks the whole FDS ancestor chain: table -> workspace."""
|
||||||
|
ws = {
|
||||||
|
"id": "fds-ws", "featureInstanceId": "fi1", "tableName": "*",
|
||||||
|
"recordFilter": None, "neutralize": True,
|
||||||
|
}
|
||||||
|
tbl = {
|
||||||
|
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||||
|
"recordFilter": None, "neutralize": None, "neutralizeFields": None,
|
||||||
|
}
|
||||||
|
node = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
|
||||||
|
tableRec=tbl, featureCode="trustee")
|
||||||
|
self.assertTrue(node.getEffectiveFlag("neutralize", [], [ws, tbl], "aggregate"))
|
||||||
|
|
||||||
|
def test_fds_field_explicit_override_beats_table_false(self):
|
||||||
|
"""Per-column override (True via list entry) beats an explicit
|
||||||
|
table False -- this is the one case where the two-source model
|
||||||
|
diverges intentionally and produces the 'mixed' aggregate."""
|
||||||
|
rec = {
|
||||||
|
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||||
|
"recordFilter": None, "neutralize": False, "neutralizeFields": ["amount"],
|
||||||
|
}
|
||||||
|
amount = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
|
||||||
|
tableRec=rec, featureCode="trustee")
|
||||||
|
other = FdsFieldNode("fi1", "Pos", "currency", "fdstbl|fi1|Pos",
|
||||||
|
tableRec=rec, featureCode="trustee")
|
||||||
|
self.assertTrue(amount.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
|
||||||
|
# currency inherits from table -> False
|
||||||
|
self.assertFalse(other.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
|
||||||
|
|
||||||
|
def test_fds_table_mixed_when_field_and_table_disagree(self):
|
||||||
|
# table.neutralize=False, field "amount" is in neutralizeFields => mixed
|
||||||
|
rec = {
|
||||||
|
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||||
|
"recordFilter": None, "neutralize": False, "ragIndexEnabled": None,
|
||||||
|
"neutralizeFields": ["amount"],
|
||||||
|
}
|
||||||
|
table = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
|
||||||
|
"feat|m1|trustee|fi1", rec=rec, hasFields=True)
|
||||||
|
# FdsTableNode.getEffectiveFlag itself only consults FDS records,
|
||||||
|
# not field nodes. The aggregation across field nodes is wired
|
||||||
|
# in _buildTree via `_wireTableFieldsAsLogicalChildren`. So we
|
||||||
|
# exercise the explicit FDS walk here:
|
||||||
|
val = table.getEffectiveFlag("neutralize", [], [rec], "walk")
|
||||||
|
self.assertFalse(val)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetFlag(unittest.TestCase):
|
||||||
|
def test_setflag_writes_value_on_ds(self):
|
||||||
|
rec = {"id": "ds1", "connectionId": "c", "sourceType": "msft", "path": "/",
|
||||||
|
"userId": "user-1"}
|
||||||
|
node = ConnectionNode("c", "msft", "m", "personalRoot", rec=rec)
|
||||||
|
rootIf = MagicMock()
|
||||||
|
rootIf.db.getRecordset.return_value = [] # no siblings -> no cascade
|
||||||
|
node.setFlag("neutralize", True, rootIf)
|
||||||
|
rootIf.db.recordModify.assert_called()
|
||||||
|
args = rootIf.db.recordModify.call_args[0]
|
||||||
|
self.assertEqual(args[1], "ds1")
|
||||||
|
self.assertEqual(args[2], {"neutralize": True})
|
||||||
|
|
||||||
|
def test_setflag_virtual_ds_auto_creates_record(self):
|
||||||
|
"""Toggling a flag on a virtual DS node must auto-create the
|
||||||
|
DataSource record so the flag can be persisted."""
|
||||||
|
node = FolderNode(
|
||||||
|
connectionId="c1", service="sharepoint",
|
||||||
|
sourceType="sharepointFolder", path="/sites/x/docs",
|
||||||
|
label="docs", parentKey="svc|c1|sharepoint",
|
||||||
|
rec=None, hasChildren=True,
|
||||||
|
)
|
||||||
|
rootIf = MagicMock()
|
||||||
|
rootIf.db.getRecordset.return_value = []
|
||||||
|
rootIf.db.getRecord.return_value = {"id": "c1", "userId": "user-1"}
|
||||||
|
createdRec = {"id": "ds-new", "connectionId": "c1",
|
||||||
|
"sourceType": "sharepointFolder", "path": "/sites/x/docs",
|
||||||
|
"userId": "user-1"}
|
||||||
|
rootIf.db.recordCreate.return_value = createdRec
|
||||||
|
node.setFlag("neutralize", True, rootIf)
|
||||||
|
rootIf.db.recordCreate.assert_called_once()
|
||||||
|
rootIf.db.recordModify.assert_called()
|
||||||
|
args = rootIf.db.recordModify.call_args[0]
|
||||||
|
self.assertEqual(args[1], "ds-new")
|
||||||
|
self.assertEqual(args[2], {"neutralize": True})
|
||||||
|
|
||||||
|
def test_fds_field_setflag_mutates_neutralize_fields(self):
|
||||||
|
rec = {
|
||||||
|
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||||
|
"recordFilter": None, "neutralize": False, "neutralizeFields": None,
|
||||||
|
}
|
||||||
|
node = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
|
||||||
|
tableRec=rec, featureCode="trustee")
|
||||||
|
rootIf = MagicMock()
|
||||||
|
node.setFlag("neutralize", True, rootIf)
|
||||||
|
rootIf.db.recordModify.assert_called()
|
||||||
|
# last call: set neutralizeFields to ["amount"]
|
||||||
|
args = rootIf.db.recordModify.call_args[0]
|
||||||
|
self.assertEqual(args[1], "fds-tbl")
|
||||||
|
self.assertEqual(args[2], {"neutralizeFields": ["amount"]})
|
||||||
|
|
||||||
|
def test_fds_field_setflag_removes_field_when_toggled_off(self):
|
||||||
|
rec = {
|
||||||
|
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||||
|
"recordFilter": None, "neutralizeFields": ["amount", "currency"],
|
||||||
|
}
|
||||||
|
node = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
|
||||||
|
tableRec=rec, featureCode="trustee")
|
||||||
|
rootIf = MagicMock()
|
||||||
|
node.setFlag("neutralize", False, rootIf)
|
||||||
|
args = rootIf.db.recordModify.call_args[0]
|
||||||
|
self.assertEqual(args[2], {"neutralizeFields": ["currency"]})
|
||||||
|
|
||||||
|
def test_fds_field_setflag_roundtrip(self):
|
||||||
|
rec = {
|
||||||
|
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||||
|
"recordFilter": None, "neutralizeFields": None,
|
||||||
|
}
|
||||||
|
node = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
|
||||||
|
tableRec=rec, featureCode="trustee")
|
||||||
|
rootIf = MagicMock()
|
||||||
|
node.setFlag("neutralize", True, rootIf)
|
||||||
|
self.assertTrue(node.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
|
||||||
|
node.setFlag("neutralize", False, rootIf)
|
||||||
|
self.assertFalse(node.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
|
||||||
|
|
||||||
|
def test_fds_table_explicit_neutralize_wipes_own_neutralize_fields(self):
|
||||||
|
"""Setting an explicit neutralize on a table must clear its own
|
||||||
|
`neutralizeFields` list. Otherwise the table's aggregate stays
|
||||||
|
'mixed' because field children walk to True via that list and the
|
||||||
|
UI shows no change after the toggle."""
|
||||||
|
rec = {
|
||||||
|
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||||
|
"recordFilter": None, "neutralize": None,
|
||||||
|
"neutralizeFields": ["amount", "currency"],
|
||||||
|
}
|
||||||
|
node = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
|
||||||
|
"feat|m1|trustee|fi1", rec=rec, hasFields=True)
|
||||||
|
rootIf = MagicMock()
|
||||||
|
rootIf.db.getRecordset.return_value = [rec] # no descendants
|
||||||
|
node.setFlag("neutralize", False, rootIf)
|
||||||
|
rootIf.db.recordModify.assert_called()
|
||||||
|
args = rootIf.db.recordModify.call_args[0]
|
||||||
|
self.assertEqual(args[1], "fds-tbl")
|
||||||
|
self.assertEqual(
|
||||||
|
args[2], {"neutralize": False, "neutralizeFields": None},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_fds_table_setflag_inherit_keeps_neutralize_fields(self):
|
||||||
|
"""`value=None` (reset to inherit) must NOT cascade and must NOT
|
||||||
|
wipe `neutralizeFields`; that matches the cascade-reset spec
|
||||||
|
(only explicit toggles clear descendants)."""
|
||||||
|
rec = {
|
||||||
|
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||||
|
"recordFilter": None, "neutralize": True,
|
||||||
|
"neutralizeFields": ["amount"],
|
||||||
|
}
|
||||||
|
node = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
|
||||||
|
"feat|m1|trustee|fi1", rec=rec, hasFields=True)
|
||||||
|
rootIf = MagicMock()
|
||||||
|
rootIf.db.getRecordset.return_value = [rec]
|
||||||
|
node.setFlag("neutralize", None, rootIf)
|
||||||
|
args = rootIf.db.recordModify.call_args[0]
|
||||||
|
self.assertEqual(args[2], {"neutralize": None})
|
||||||
|
|
||||||
|
def test_fds_table_setflag_rag_does_not_touch_neutralize_fields(self):
|
||||||
|
"""A RAG toggle on the table must leave `neutralizeFields` alone
|
||||||
|
(it is neutralize-only field state)."""
|
||||||
|
rec = {
|
||||||
|
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||||
|
"recordFilter": None, "ragIndexEnabled": None,
|
||||||
|
"neutralizeFields": ["amount"],
|
||||||
|
}
|
||||||
|
node = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
|
||||||
|
"feat|m1|trustee|fi1", rec=rec, hasFields=True)
|
||||||
|
rootIf = MagicMock()
|
||||||
|
rootIf.db.getRecordset.return_value = [rec]
|
||||||
|
node.setFlag("ragIndexEnabled", True, rootIf)
|
||||||
|
args = rootIf.db.recordModify.call_args[0]
|
||||||
|
self.assertEqual(args[2], {"ragIndexEnabled": True})
|
||||||
|
|
||||||
|
def test_fds_workspace_neutralize_clears_descendant_neutralize_fields(self):
|
||||||
|
"""Workspace toggle must clear per-column overrides on descendant
|
||||||
|
tables; otherwise the table aggregate stays 'mixed' because some
|
||||||
|
field children still read True from the list."""
|
||||||
|
wsRec = {
|
||||||
|
"id": "fds-ws", "featureInstanceId": "fi1", "tableName": "*",
|
||||||
|
"recordFilter": None, "neutralize": None,
|
||||||
|
}
|
||||||
|
tblRec = {
|
||||||
|
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||||
|
"recordFilter": None, "neutralize": None,
|
||||||
|
"neutralizeFields": ["amount", "currency"],
|
||||||
|
}
|
||||||
|
node = FdsWorkspaceNode("m1", "trustee", "fi1", label="Trustee",
|
||||||
|
icon="trustee", parentKey="mgrp|m1", rec=wsRec)
|
||||||
|
rootIf = MagicMock()
|
||||||
|
rootIf.db.getRecordset.return_value = [wsRec, tblRec]
|
||||||
|
node.setFlag("neutralize", True, rootIf)
|
||||||
|
calls = rootIf.db.recordModify.call_args_list
|
||||||
|
modifyMap = {c[0][1]: c[0][2] for c in calls}
|
||||||
|
self.assertEqual(modifyMap["fds-tbl"], {"neutralizeFields": None})
|
||||||
|
self.assertEqual(modifyMap["fds-ws"], {"neutralize": True})
|
||||||
|
|
||||||
|
def test_fds_workspace_rag_does_not_clear_descendant_neutralize_fields(self):
|
||||||
|
"""A RAG toggle on the workspace must not touch descendant
|
||||||
|
`neutralizeFields`."""
|
||||||
|
wsRec = {
|
||||||
|
"id": "fds-ws", "featureInstanceId": "fi1", "tableName": "*",
|
||||||
|
"recordFilter": None, "ragIndexEnabled": None,
|
||||||
|
}
|
||||||
|
tblRec = {
|
||||||
|
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||||
|
"recordFilter": None, "ragIndexEnabled": None,
|
||||||
|
"neutralizeFields": ["amount"],
|
||||||
|
}
|
||||||
|
node = FdsWorkspaceNode("m1", "trustee", "fi1", label="Trustee",
|
||||||
|
icon="trustee", parentKey="mgrp|m1", rec=wsRec)
|
||||||
|
rootIf = MagicMock()
|
||||||
|
rootIf.db.getRecordset.return_value = [wsRec, tblRec]
|
||||||
|
node.setFlag("ragIndexEnabled", True, rootIf)
|
||||||
|
calls = rootIf.db.recordModify.call_args_list
|
||||||
|
modifyIds = [c[0][1] for c in calls]
|
||||||
|
self.assertNotIn("fds-tbl", modifyIds)
|
||||||
|
|
||||||
|
|
||||||
|
class TestToDict(unittest.TestCase):
|
||||||
|
def test_fds_table_dict_has_neutralize_fields(self):
|
||||||
|
rec = {
|
||||||
|
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||||
|
"neutralizeFields": ["amount"],
|
||||||
|
}
|
||||||
|
node = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
|
||||||
|
"feat|m1|trustee|fi1", rec=rec, hasFields=True)
|
||||||
|
out = node.toDict([], [rec])
|
||||||
|
self.assertEqual(out["neutralizeFields"], ["amount"])
|
||||||
|
self.assertEqual(out["kind"], "fdsTable")
|
||||||
|
self.assertEqual(out["modelType"], "FeatureDataSource")
|
||||||
|
self.assertEqual(out["effectiveScope"], "personal") # FDS has no scope
|
||||||
|
|
||||||
|
def test_synthetic_container_has_no_dataSourceId(self):
|
||||||
|
n = SyntheticContainerNode("personalRoot", "Personal", icon="person",
|
||||||
|
defaultExpanded=True)
|
||||||
|
d = n.toDict([], [])
|
||||||
|
self.assertIsNone(d["dataSourceId"])
|
||||||
|
self.assertEqual(d["effectiveNeutralize"], False)
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsFeatureAdmin(unittest.TestCase):
|
||||||
|
def test_no_access_returns_false(self):
|
||||||
|
rootIf = MagicMock()
|
||||||
|
rootIf.getFeatureAccess.return_value = None
|
||||||
|
self.assertFalse(_isFeatureAdmin(rootIf, "user-1", "fi1"))
|
||||||
|
|
||||||
|
def test_no_roles_returns_false(self):
|
||||||
|
rootIf = MagicMock()
|
||||||
|
rootIf.getFeatureAccess.return_value = MagicMock(id="acc1", enabled=True)
|
||||||
|
rootIf.getRoleIdsForFeatureAccess.return_value = []
|
||||||
|
self.assertFalse(_isFeatureAdmin(rootIf, "user-1", "fi1"))
|
||||||
|
|
||||||
|
def test_non_admin_role_returns_false(self):
|
||||||
|
rootIf = MagicMock()
|
||||||
|
rootIf.getFeatureAccess.return_value = MagicMock(id="acc1", enabled=True)
|
||||||
|
rootIf.getRoleIdsForFeatureAccess.return_value = ["r1"]
|
||||||
|
rootIf.db.getRecord.return_value = {"id": "r1", "roleLabel": "trustee-user"}
|
||||||
|
self.assertFalse(_isFeatureAdmin(rootIf, "user-1", "fi1"))
|
||||||
|
|
||||||
|
def test_admin_role_returns_true(self):
|
||||||
|
rootIf = MagicMock()
|
||||||
|
rootIf.getFeatureAccess.return_value = MagicMock(id="acc1", enabled=True)
|
||||||
|
rootIf.getRoleIdsForFeatureAccess.return_value = ["r1"]
|
||||||
|
rootIf.db.getRecord.return_value = {"id": "r1", "roleLabel": "workspace-admin"}
|
||||||
|
self.assertTrue(_isFeatureAdmin(rootIf, "user-1", "fi1"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -27,7 +27,7 @@ def test_context_extractContent_node_shape():
|
||||||
assert "LoopItem" in node["inputPorts"][0]["accepts"]
|
assert "LoopItem" in node["inputPorts"][0]["accepts"]
|
||||||
names = [p["name"] for p in node["parameters"]]
|
names = [p["name"] for p in node["parameters"]]
|
||||||
assert names == [
|
assert names == [
|
||||||
"context",
|
"documentList",
|
||||||
"contentFilter",
|
"contentFilter",
|
||||||
"outputMode",
|
"outputMode",
|
||||||
"splitBy",
|
"splitBy",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue