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
|
||||
app.include_router(dataSourceRouter)
|
||||
|
||||
from modules.routes.routeUdb import router as udbRouter
|
||||
app.include_router(udbRouter)
|
||||
|
||||
from modules.routes.routeDataPrompts import router as promptRouter
|
||||
app.include_router(promptRouter)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
# AI configuration
|
||||
Connector_AiOpenai_API_SECRET = DEV_ENC:Z0FBQUFBQnFGcldVVnVRcFo4MGUxaDU3Tkl3R2EwUHhibm1BTVkzMmhBYVNDMGRoQUozQkVCNmVqTXpvZ3V2ZkNwLWVDLWZFZ3dBaWJKdVY3OGJTUTdhVnZ4bW9CMjBKclhSQldTYm1Bb2RDb3ZtYVg1UXEtMHdaelROajBYTGZzc3NEeDk2V09ndlRTdTFHZWFOQjdBbUNxTnJ5SHYxcUJ3NEN6QzZnVllCakJVazRHc2h4Q2FyRWlVZ3lNam9GMm1TQmxJTkhlOHVvSHllMVEwWDYyYktGdllyTEFmUzFVRWp3LWdxVklWcFZ3SXZQcEwwempSem5oWFlWYmk3MVdhNldqZEhPWmxfczdYTnRHcElld1IySjY2SkxxRzZrMFVKWTJyVzJkNmJWa0VOSTgtbW83NmM9
|
||||
Connector_AiAnthropic_API_SECRET = DEV_ENC:Z0FBQUFBQnFCdlFlbXEzdGc2NFExb3AzVUw0cEhkZzlNRjZxTVlJMV81LTZhVXhoNXBpYUlMN0FxUUJHQlJnS0N3OV85Uk9sa1J3M1lyZExSMWVsbzVSdzdWQUVsUVp2dzhfLThmY2lNb1NhVGlvbnhLR0NnSmhsOVp2RkxfODc2SFpDYlBkcWp4aFFtNldtZGQ2LUhBZVM5VXk4RTNHNzQyV3FnMVNJMW9yOGpRRkQxUC1hZ3NOOHhqV3Y4LWJSNjFYQ3dwQmhrRWJRRzhaX1N4aFlWLTVsaEJmOWxkTjZMZz09
|
||||
Connector_AiOpenai_API_SECRET = DEV_ENC:Z0FBQUFBQnFGdnVHZlpWWWVaV1dERUItVjRfNWFMRXZUVjY5Ulp1ZXkyMmVZWUJPNzJ5anRucGNlOUFYSVNzZ1FaWlZLemVNbk5pbDgwOEZxbkxxM3U3UU1EdDJzczBaYmRDbld1N0hHdjROQUFmMUJmbDRMS1JWc3c4ZTFPMHY3OWVublBsUjFxNjhDSGRCaE9PR1JUN29iQjRqVFRINHB5dnJXeGxiQ2FTdnNnN283b3o1MnV3X09uc1pXeXZUclNTbjN4YWZyb2tGVmtGVmRnQmNlczI0WlZKRGZSYWgycnB0R2RfR1Fvbkt5bXN1UVBwS202SkZyRmJGTEU3MkxpcTk2d0QtOGxRckNLaTFLRnJBYlVSZDAydlpjMDVqYmktdHhuV3FLa2xrYkh4cGc3S3FGcnM9
|
||||
Connector_AiAnthropic_API_SECRET = DEV_ENC:Z0FBQUFBQnFGd0hadGRhRFBOaXJSN2E1UnpLcHlkUHhoQS1FWFJBQlJ5cGsxcHNNMDlRM1JVbEJkWWE1ZXQzTThFQkRmQTBOeVNvUERXTVRTQ3Y4ekt5NU1XR3E2cWw4UlFNUXlGSmZmZU1JZXlwT3lUVGw0aWF4V1d6cVR6LTFsbGRjWWx5dVlodWNEUFJkZ0tUa3hSbjk3WjZ1Z3RPczZMYzA5QTlMbkVudFNVcG1xaTJuM3g3dDdSSFczbWJnODQ1S1J2djBnS3lQc2NFd0ttUThRVnFma3NOVlFIZm1ZZz09
|
||||
Connector_AiPerplexity_API_SECRET = DEV_ENC:Z0FBQUFBQnFCdlFlNV9felVPcHVyMU9kVGhGZEt0MG9iRzRrTVM4TFJvSHhGOVo0U1ROWkdEMzRSWjhtMnFrZUhHTHNXelpLZ014RzRkMlIxZDJwcjEwc1dRamY5ekJMR1VLb2w4eEZqZENBRnFaZlRhb1h5VE05Tml1ZlVBWHBaTkJaZUE5NWprVklva0ZFZnB4cFFudGdkalpmTlBhdV9nPT0=
|
||||
Connector_AiTavily_API_SECRET = DEV_ENC:Z0FBQUFBQnFCdlFlY1R2WGpuazk5M05SeDIyLWd3bHpKN3lUdlVFdjhvZEJXdlM4bGlBdTB1TjRia051YllDQ2lwM0V3R3dPd2lKVWxoSm9BNWl1ZFFlVkZ5cXh4TFRVU0Z4NVU5WVRjSUJPc01La3JyaVZSNkhYWU9PR00yMENEb0dRT3l5enEwSFlWZVVzTVR0UWQ4eUxvRmZvWHl0c0xRPT0=
|
||||
Connector_AiPrivateLlm_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGRHM5eFdUVmVZU1R1cHBwN1RlMUx4T0NlLTJLUFFVX3J2OElDWFpuZmJHVmp4Z3BNNWMwZUVVZUd2TFhRSjVmVkVlcFlVRWtybXh0ZHloZ01ZcnVvX195YjdlWVdEcjZSWFFTTlNBWUlaTlNoLWhqVFBIb0thVlBiaWhjYjFQOFY=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
# AI configuration
|
||||
Connector_AiOpenai_API_SECRET = INT_ENC:Z0FBQUFBQnFGcldVdG9pOUFMRzFHOEM2WDJVYU5fWEVQcjVjUlFuUWl4bS05TVlPSTJUX1pVWEIwRi12VU1tT2E3LTIyblpieTlJWTZZRXdGU2xNajJWc0JrcDZac19iYUJKS19GSWgzRld1V01LczJrYTUzYkxITjkxNUpBNk9UWnRHY2JBZnRzNk5aQ0VNNUZodTV2c2FXbnhZR2dERGstUUxJNW92Uld0Q0hwaWxYRGhLY3Rtb1B0Ynl5clR4ZGF1SFllc09Ka0puMS1EM1FHREFRMFB5bVpqVE0tNXJaTDBhSjNCeGNTVk1UVDM3M2hwQW5mVFVNVkhESHVldEhSSlBSalB4aXYxeXZ4RGtLMk9EM2hJNk9QNmNoREZBMXNGUmRPczZ6elAyQXBOc0F3WnJha289
|
||||
Connector_AiAnthropic_API_SECRET = INT_ENC:Z0FBQUFBQnFCdlFlRHFpNThJb3g3UU05cUw4SVJpOXBTblU5QzU1WFItZ2JkNXVILVN4VHp0Umh2RjJyZXJMNVp5OWFxLWhjRjhub3cxajkxMVRQMnZQdVBGT21obWN0Q0NlOU80MVhMMXRWb1l3cWNpR2Ytc1d0WnVlRUN1TTZ4NjFQcDd0Wll4cFN6dzk1OU5SZGNJck54WmNoeElITzEzejJrczVSQnp6ZTBINGtENHFiT3NnWjdUME9xXzJ5Y0N3dHk5QnpBRkpyVTgxOE0xTVllR2JMUC0yTkwyWWxHQT09
|
||||
Connector_AiOpenai_API_SECRET = INT_ENC:Z0FBQUFBQnFGdnVIcUxPOUVXT0NlUlJNVENjRi1iLXdsR1ZuOXU3Sk1qbmVZOThYdUZrNGlDREJmMkttRVNyWlNsMHlDc2pnQ1VyZ0lzYXVkc0hHNm95bjFrejNRWVVGUWZOVTVYOGpKcF9QNGttc001TE9VdFdKa2FyUEYxY1VYOE1RenBObmNMbVFHeTdHbGpORVAwOWc3Rng1dWtlUEphZmVKV1otSE03a2FTVHVvMlNONWZ3N3hMR2FmdTEtdkdWOHV5d0RYWlZ3dVV1SEpKNHBjWG1QTEZ6SE5oS1VESEJ2MmVSRmh4azd3d1RmQ3FjMDVsbWxNc2EzZDdWMXYyaFBOMTZFSUltUkk2ZTEtZ0FHRW5pZkhON1Rna3lYX1Z5TWRQNkZEX3NXVHlPYkVuMW5zcE09
|
||||
Connector_AiAnthropic_API_SECRET = INT_ENC:Z0FBQUFBQnFGd0hhbGxMRUlZc1A2d1RvOWdFX3NkQXM0RG5LOTZQYWpOc21tTTJWU09nbS12M29YLVVzVk8zWGdrWXIzb05meW56dkRtTElVN1ZndkZ5eHdWMGxGVjBPTlRvTGxpTzVzcFlzdnVhTTh0R0gtM2M2Mk9ac3dnc0RYYkx2c3BDdnoxVXJLX2tPMTVpZXdmQll3cHF3dEhGWGRlb3JLZjlNTVJpZTN1TzFtMU5yZmdXTnZuZ1lXN0p5VUdsVXBDUXJoY1Y3aFBkUW1HbmJJZmZaR1cwTVNQR0VZUT09
|
||||
Connector_AiPerplexity_API_SECRET = INT_ENC:Z0FBQUFBQnFCdlFla1h1R1M3QlQ5XzJhS0x4eXFpTkZ3WHpLMWVZZldRMGpMX2psMFZ2RmpETTZMZ3ZXblo2MnhyemxYWXRsMHN1LXdZU3k5ampEMjMtdzcyb1J4Ri1rTmxPOWhJMF9MMEtzZ3d5dFZxSFY3TjNac3ZpTVJxUFFmUVpXeHEtbVBTUmtiR0lhQjhVcjM3U1NNX1ZHY1NxUFJ3PT0=
|
||||
Connector_AiTavily_API_SECRET = INT_ENC:Z0FBQUFBQnFCdlFlbmRSZVRjTzVKRklFbFgwdVZJaE5jNVoyX3dVTVlRUFVUenc4X1JOX2laOHRoTU9mN1lTUVRzb2xNZjJXVjhEYnVIaXdkSWN4NEpJbTFJZFN2cmkwUkJ0ZXNKT2NidktjdDFJX1BkZ3QwU3dQRzg0aG9aNmtxc1FZZ1ZBRjQyM3lOSS1EYkpqWmxoV0xWWE1Fc01uN3RnPT0=
|
||||
Connector_AiPrivateLlm_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGSjZ1NWh0aWc1R3Z4MHNaeS1HamtUbndhcUZFZDlqUDhjSmg5eHFfdlVkU0RsVkJ2UVRaMWs3aWhraG5jSlc0YkxNWHVmR2JoSW5ENFFCdkJBM0VienlKSnhzNnBKbTJOUTFKczRfWlQ3bWpmUkRTT1I1OGNUSTlQdExacGRpeXg=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Connector_AiOpenai_API_SECRET = PROD_ENC:Z0FBQUFBQnFGcldVM1phS2F4TUdmSDBUVUtfZFZoOHZLTWpzSjg4aVhkNTdnemRCenQ4Wi1HYjB1RTZ1YVUzMzVBUlg0STQybFhiQmF5VnRRd2NIZ1JCR1BwMXU5b0dycDVlVjkzWHNfYWdsV0dVZmRxa2JGU3BULXBubjJvcFdRYUdzMWR5dXRRY2JXazNtVTQ1cGFjTk1QbS1WUVo5YzlIZGJCS09GQTl2QkJXYW5oZlJJdVdxbWV4aGlyQXFUM1lUU3hGZ0NEeXoyMEpPMVp3VFpZbnhwdGN0T2pHOFVsYnhhZ1FlT0ZuVTBkbjMzUGpoZEhxSzdZdUdtVXZkd2FMUm9VYlNESHljTF9qZlZSS0tjN0o0MWNCazZ6SFc4SF83WHVHa2VabkN3dnk4RU9ER2xpR0k9
|
||||
Connector_AiAnthropic_API_SECRET = PROD_ENC:Z0FBQUFBQnFGcldVbkcybm1mbWcwNGFCQmFZbE41X0RJS0NhUEdOcXM0TDdRN3lhVnZMNC1XX0M2VWlYak5YWWtBWXBqNzNaWGlBOWRING1ZczVVcnFOVE9makE5TUJYREVKZ2hseDMwVy03dk9MTXhDSVhrQWZDY3NBSXJ0QUF5WkJCeGg2RTY5WkFDRmU3N0lvYlhYU3lacUJ5MVRwMVhVYUJqS1N1OVZVXzJSVkxaSEgxWEZxM0twVUdvUTE0TnU0VXE3Z25DaXRfQl8tWkJNMUdwQW0tQWZEdTZYVXJNdz09
|
||||
Connector_AiPerplexity_API_SECRET = PROD_ENC:Z0FBQUFBQnFGcldVaWNjSmVxbUJZWTh1andfU0ZKSzdIcDc5bUFkMnc2MU9PME5XVEhDREJYNmZRMk1XM3huYkFJQU5KbFd0WVlnaUJndUUxVlpybE4tVHZ0VndOYXBoWUI5MmV3VzNtd0lMbmdJYzdNQWMzTDNkSkQ1anV6emRJUC1RWUFDUFNTN1JEa25KWVRqUE5aekNXSkZjMWlncXRRPT0=
|
||||
Connector_AiTavily_API_SECRET = PROD_ENC:Z0FBQUFBQnFGcldVMXlWdUM0NXhlNk02aWlyaDFtS05PR2ZlYzF2VlFVZ1kwbEp0akRBazIweEFLd2drY1ZzRFRpUHBsTUdkYmRZXzBIaUVEM2tfclVySVVQOFQ3SFdHYzZNNGU4RG9NSnNuN0pGQnRVRFRzdDZVZWI1dlZWM2lIMXR5TE1CU25mbEhaOGxiVnh6MFZOcEdRNDNfbFhvYUJBPT0=
|
||||
Connector_AiOpenai_API_SECRET = PROD_ENC:Z0FBQUFBQnFGdnVKZ2Z0U2s4cnpUN01mRVkzQUYyVm43NzZLOWJBODlvRlNFdTNGbzZHblNzUFJ2X0I3SXRFQTRXWlFYZjY1aUVVOTgxSU1KemZ4Wkl1NzFQb2JIcnM3bjg5bkRDYmpNVjVjTG55QmtSUVpZejEtckZTd20xRzd2NmhVSkJUQTFUZWk0dzhrUnJuNWZPa2NPSDR6QnQ1a0RCbWM4Y1h3Mmh2NmQ5SHFOR2FISndEMzF4Y29YcVlKaVNyNGM2VWFINUg4MjVMcHZJTUxVWXJNNUZVdW9GUkx0ZkZlZTJqRGI4ZkVuaklHMEotb3FyOEFka1c2WC1VclJZeHFucmJlRjhlUUhLNWdFX0xaRFp0ODNFNFZaWEdSTU5QbDcxbUxlclN0X2t0c1dpWXVJeFE9
|
||||
Connector_AiAnthropic_API_SECRET = PROD_ENC:Z0FBQUFBQnFGd0hjRzFXSXhjMVZWOW40ZFRRREItclVxODVDdFdSa2tzVVJ2TWVZaVl5UE40YzgxR2d4RVdhVUFaQy1VZVRRMzFnZW1NcjlNY1h6ZVJta3F5STI5Y2taRVlXbFREb2paMTZpRVZpdEVBVnJrSjlvS1lSMzB3V3FkWW56WlNhQUFiby1Mb2RCb0VHQ2NYYmNOUGZ5UEdseGJic2ZSQk1ReXlTRnJITVY3SEdPb296eGNIdXNRME5LOTlZUlRvclJRX2R3ZHlxM0puXzlWRzY2eHliY1FUNmxSZz09
|
||||
Connector_AiPerplexity_API_SECRET = PROD_ENC:Z0FBQUFBQnFGdnVLODRmYXo5T3BxSDJnZXgzRlFfR0oyWXVkeVRZbk14VkdDV3pTaWVfV3Y3R21LaWJpSC1laTg1T3NYREI2RzBBWWtraFJud0U2ZnhVQzJ0bnViVzJtOWh4dDZ3VUdoZUxaUzdhSkM4N3ZOOTFINmV1TGNmRE9RRmtfeTduVEV6QnYyRTZJaGxGb3ZFSmZmZ1JxUDdFSVBRPT0=
|
||||
Connector_AiTavily_API_SECRET = PROD_ENC:Z0FBQUFBQnFGdnVLcTlLSFJ5b0gwRmJLMFB5MzA3S3FYbmhKV2VzbHI0ZFUzOUJNdHVYQlQ0ckdicW1WWG5CNEkyWVlrR0gwQ0ZramJ1c19JS290MmlvWVhYWW92cEhIdmRTRXdPQzZpVFdDaU9MQzFlMEdPYUVnYy1HZlM1ODVuYnZGRnVZVFZpYzZBcUNRekVBZFFzVExQV254OUZ0aHVBPT0=
|
||||
Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo=
|
||||
Connector_AiMistral_API_SECRET = PROD_ENC:Z0FBQUFBQnFGcldVWThJZGQtSFcwTTlnZTAwOTBoSHFhYzQ4eTVYdEhmSEdpSW5VQTlQbE1KakMzMkNZZXBNRnhtRDNYMHBHV0lSSVVTOXZwYXA4VkltTjVfcmRhQnhkSTNzVVFaUnZxMFNHeThQZ1ZGeEo4a0ZsQjNUdXVyRm1ORXc0QkpLWEk1cV8=
|
||||
Connector_AiMistral_API_SECRET = PROD_ENC:Z0FBQUFBQnFGdnVLRHplbzNheDhIdndsU0xUeGlBYVVXWDRzOF9Tek41WjEtSmNqbnVHRXFaZ0dramlfZWlQelpJWVh5T0F2azBaQWU3ajU0TWljaGpMeTlra0g0LVhKeTRKNGxKY0ZqSkxwdTJLdWM5cWdMVC1TVkpLb2lPdHhyeWtieFJFOHdkVy0=
|
||||
|
||||
Service_MSFT_TENANT_ID = common
|
||||
|
||||
|
|
|
|||
|
|
@ -29,25 +29,6 @@ _msg = apiRouteContext("oauthConnectTicket")
|
|||
|
||||
_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:
|
||||
"""Issue a short-lived JWT for starting a data-connection OAuth popup."""
|
||||
|
|
|
|||
|
|
@ -43,31 +43,9 @@ class FeatureDataSource(PowerOnModel):
|
|||
)
|
||||
mandateId: str = Field(
|
||||
default="",
|
||||
description="Mandate scope",
|
||||
description="Mandate scope (set automatically from featureInstance.mandateId on create).",
|
||||
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
|
||||
)
|
||||
userId: str = Field(
|
||||
default="",
|
||||
description="Owner user ID",
|
||||
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
|
||||
)
|
||||
workspaceInstanceId: str = Field(
|
||||
description="Workspace feature instance where this source is used",
|
||||
json_schema_extra={"label": "Workspace", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
|
||||
)
|
||||
scope: Optional[str] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Data visibility scope with inherit semantics. "
|
||||
"None = inherit; values: personal, featureInstance, mandate, global."
|
||||
),
|
||||
json_schema_extra={"label": "Sichtbarkeit", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
|
||||
{"value": "personal", "label": "Persönlich"},
|
||||
{"value": "featureInstance", "label": "Feature-Instanz"},
|
||||
{"value": "mandate", "label": "Mandant"},
|
||||
{"value": "global", "label": "Global"},
|
||||
]},
|
||||
)
|
||||
neutralize: Optional[bool] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
|
|
|
|||
|
|
@ -4,9 +4,6 @@
|
|||
|
||||
from modules.shared.i18nRegistry import t
|
||||
|
||||
from modules.features.graphicalEditor.nodeDefinitions.contextPickerHelp import (
|
||||
CONTEXT_BUILDER_PARAM_DESCRIPTION,
|
||||
)
|
||||
from modules.features.graphicalEditor.nodeDefinitions.flow import (
|
||||
CONTEXT_ENVELOPE_DATA_PICK_OPTIONS,
|
||||
CONTEXT_MERGE_ACTION_RESULT_DATA_PICK_OPTIONS,
|
||||
|
|
@ -40,9 +37,9 @@ CONTEXT_NODES = [
|
|||
),
|
||||
"injectRunContext": True,
|
||||
"parameters": [
|
||||
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
|
||||
"description": CONTEXT_BUILDER_PARAM_DESCRIPTION, "default": "",
|
||||
"graphInherit": {"port": 0, "kind": "primaryTextRef"}},
|
||||
{"name": "documentList", "type": "str", "required": True, "frontendType": "hidden",
|
||||
"description": t("Dokumentenliste (via Wire oder DataRef)"), "default": "",
|
||||
"graphInherit": {"port": 0, "kind": "documentListWire"}},
|
||||
{
|
||||
"name": "contentFilter",
|
||||
"type": "str",
|
||||
|
|
|
|||
|
|
@ -986,7 +986,11 @@ async def listWorkspaceWorkflows(
|
|||
"startedAt": getattr(wf, "startedAt", None),
|
||||
"lastActivity": getattr(wf, "lastActivity", None),
|
||||
"featureInstanceId": getattr(wf, "featureInstanceId", instanceId),
|
||||
"workflowMode": getattr(wf, "workflowMode", None),
|
||||
"linkedWorkflowId": getattr(wf, "linkedWorkflowId", None),
|
||||
}
|
||||
if item.get("workflowMode") == "Automation" or item.get("linkedWorkflowId"):
|
||||
continue
|
||||
if not includeArchived and item.get("status") == "archived":
|
||||
continue
|
||||
fiId = item.get("featureInstanceId") or instanceId
|
||||
|
|
@ -1311,73 +1315,6 @@ async def listWorkspaceDataSources(
|
|||
return JSONResponse({"dataSources": []})
|
||||
|
||||
|
||||
class _TreeChildrenRequest(BaseModel):
|
||||
"""Request body for the generic tree children endpoint."""
|
||||
parents: List[Optional[str]] = Field(
|
||||
default_factory=list,
|
||||
description="List of parent keys to fetch children for. Use null for top-level.",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{instanceId}/tree/children")
|
||||
@limiter.limit("300/minute")
|
||||
async def getTreeChildren(
|
||||
request: Request,
|
||||
instanceId: str = Path(...),
|
||||
body: _TreeChildrenRequest = Body(...),
|
||||
context: RequestContext = Depends(getRequestContext),
|
||||
):
|
||||
"""Generic UDB tree children resolver.
|
||||
|
||||
The UI sends a list of parent keys (or null for top-level). The backend
|
||||
returns children for each requested parent, with all effective flag
|
||||
values pre-computed. The UI builds the visible tree from the resulting
|
||||
flat per-parent map.
|
||||
"""
|
||||
_validateInstanceAccess(instanceId, context)
|
||||
from modules.serviceCenter.services.serviceKnowledge._buildTree import getChildrenForParents
|
||||
|
||||
try:
|
||||
nodesByParent = await getChildrenForParents(instanceId, body.parents, context)
|
||||
except Exception as exc:
|
||||
logger.exception("Tree children build failed: %s", exc)
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
return JSONResponse({"nodesByParent": nodesByParent})
|
||||
|
||||
|
||||
class _TreeAttributesRequest(BaseModel):
|
||||
"""Request body for the attribute-refresh endpoint."""
|
||||
keys: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="List of node keys to fetch current attributes for.",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{instanceId}/tree/attributes")
|
||||
@limiter.limit("300/minute")
|
||||
async def getTreeAttributes(
|
||||
request: Request,
|
||||
instanceId: str = Path(...),
|
||||
body: _TreeAttributesRequest = Body(...),
|
||||
context: RequestContext = Depends(getRequestContext),
|
||||
):
|
||||
"""Return current effective attribute values (neutralize, scope,
|
||||
ragIndexEnabled) for a list of node keys. Used after a toggle action
|
||||
to refresh only the visible nodes without reloading tree structure."""
|
||||
_validateInstanceAccess(instanceId, context)
|
||||
from modules.serviceCenter.services.serviceKnowledge._buildTree import getAttributesForKeys
|
||||
|
||||
if len(body.keys) > 500:
|
||||
raise HTTPException(status_code=400, detail="Max 500 keys per request")
|
||||
|
||||
try:
|
||||
attrs = await getAttributesForKeys(instanceId, body.keys, context)
|
||||
except Exception as exc:
|
||||
logger.exception("Tree attributes failed: %s", exc)
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
return JSONResponse({"attributes": attrs})
|
||||
|
||||
|
||||
class CreateDataSourceRequest(BaseModel):
|
||||
"""Request body for creating a DataSource."""
|
||||
connectionId: str = Field(description="Connection ID")
|
||||
|
|
@ -1458,19 +1395,15 @@ async def createFeatureDataSource(
|
|||
body: CreateFeatureDataSourceRequest = Body(...),
|
||||
context: RequestContext = Depends(getRequestContext),
|
||||
):
|
||||
"""Create a FeatureDataSource for this workspace instance.
|
||||
"""Create a FeatureDataSource for the referenced feature instance.
|
||||
|
||||
The FDS lives under the WORKSPACE's mandate (not the feature's): that
|
||||
matches how the tree (`allFds = recordset where workspaceInstanceId =
|
||||
instanceId`) and the PATCH endpoints scope these records — by workspace,
|
||||
not by feature mandate. The user can legitimately reference a feature
|
||||
from another mandate they have access to (via the UDB mandate-group
|
||||
nodes), and a hard cross-mandate block here would silently 403 those
|
||||
toggles. Access to the referenced feature is verified by the user's
|
||||
`FeatureAccess` and the existing tree-children RBAC, which run before
|
||||
the user can ever click on this node.
|
||||
The FDS belongs to the FEATURE-INSTANCE (not to a workspace). Flag editing
|
||||
is governed by feature-admin RBAC on that feature instance (see the
|
||||
UDB reference page for the polymorphic node model). The `instanceId`
|
||||
in the URL path is the calling consumer's feature instance and is used
|
||||
only for access validation, not for FDS scoping.
|
||||
"""
|
||||
wsMandateId, _ = _validateInstanceAccess(instanceId, context)
|
||||
_validateInstanceAccess(instanceId, context)
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
|
||||
|
||||
|
|
@ -1478,8 +1411,10 @@ async def createFeatureDataSource(
|
|||
if not rootIf.getFeatureAccess(str(context.user.id), body.featureInstanceId):
|
||||
raise HTTPException(status_code=403, detail=routeApiMsg("Access denied to this feature instance"))
|
||||
|
||||
fi = rootIf.getFeatureInstance(body.featureInstanceId)
|
||||
fiMandateId = str(fi.mandateId) if fi and getattr(fi, "mandateId", None) else ""
|
||||
|
||||
existing = rootIf.db.getRecordset(FeatureDataSource, recordFilter={
|
||||
"workspaceInstanceId": instanceId,
|
||||
"featureInstanceId": body.featureInstanceId,
|
||||
"tableName": body.tableName,
|
||||
}) or []
|
||||
|
|
@ -1494,9 +1429,7 @@ async def createFeatureDataSource(
|
|||
tableName=body.tableName,
|
||||
objectKey=body.objectKey,
|
||||
label=body.label,
|
||||
mandateId=wsMandateId or "",
|
||||
userId=str(context.user.id),
|
||||
workspaceInstanceId=instanceId,
|
||||
mandateId=fiMandateId,
|
||||
recordFilter=body.recordFilter,
|
||||
)
|
||||
created = rootIf.db.recordCreate(FeatureDataSource, fds.model_dump())
|
||||
|
|
@ -1510,27 +1443,28 @@ async def listFeatureDataSources(
|
|||
instanceId: str = Path(...),
|
||||
context: RequestContext = Depends(getRequestContext),
|
||||
):
|
||||
"""List active FeatureDataSources for this workspace instance, scoped to mandate."""
|
||||
"""List FeatureDataSources visible to this caller. Filters by mandate of
|
||||
the calling feature-instance (RBAC). FDS records are now feature-owned;
|
||||
visibility is governed by the user's accessible feature instances within
|
||||
the mandate."""
|
||||
wsMandateId, _ = _validateInstanceAccess(instanceId, context)
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
|
||||
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import buildEffectiveByWorkspaceFds
|
||||
|
||||
rootIf = getRootInterface()
|
||||
recordFilter: dict = {"workspaceInstanceId": instanceId}
|
||||
recordFilter: dict = {}
|
||||
if wsMandateId:
|
||||
recordFilter["mandateId"] = wsMandateId
|
||||
records = rootIf.db.getRecordset(FeatureDataSource, recordFilter=recordFilter)
|
||||
records = rootIf.db.getRecordset(FeatureDataSource, recordFilter=recordFilter) or []
|
||||
if not records:
|
||||
return JSONResponse({"featureDataSources": []})
|
||||
|
||||
effNeutralize = buildEffectiveByWorkspaceFds(records, "neutralize", mode="aggregate")
|
||||
effScope = buildEffectiveByWorkspaceFds(records, "scope", mode="aggregate")
|
||||
effRag = buildEffectiveByWorkspaceFds(records, "ragIndexEnabled", mode="aggregate")
|
||||
for fds in records:
|
||||
fdsId = fds.get("id", "")
|
||||
fds["effectiveNeutralize"] = effNeutralize.get(fdsId, False)
|
||||
fds["effectiveScope"] = effScope.get(fdsId, "personal")
|
||||
fds["effectiveRagIndexEnabled"] = effRag.get(fdsId, False)
|
||||
|
||||
return JSONResponse({"featureDataSources": records})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
from fastapi import APIRouter, Response, Depends, Request, Body
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
import os
|
||||
import logging
|
||||
|
|
@ -11,7 +11,6 @@ from fastapi import HTTPException, status
|
|||
|
||||
from modules.shared.configuration import APP_CONFIG
|
||||
from modules.auth import limiter, getCurrentUser
|
||||
from modules.auth.oauthConnectTicket import oauth_callback_redirect_path
|
||||
from modules.datamodels.datamodelUam import User
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
from modules.shared.i18nRegistry import apiRouteContext
|
||||
|
|
@ -36,15 +35,8 @@ router.mount(
|
|||
|
||||
@router.get("/")
|
||||
@limiter.limit("30/minute")
|
||||
def root(request: Request):
|
||||
"""API status endpoint; forwards OAuth callbacks that land on ``/`` by mistake."""
|
||||
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)
|
||||
|
||||
def root(request: Request) -> Dict[str, str]:
|
||||
"""API status endpoint"""
|
||||
# Validate required configuration values
|
||||
allowedOrigins = APP_CONFIG.get("APP_ALLOWED_ORIGINS")
|
||||
if not allowedOrigins:
|
||||
|
|
|
|||
|
|
@ -668,7 +668,6 @@ def get_files(
|
|||
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"),
|
||||
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
||||
owner: str = Query("me", description="'all' | 'me' | 'shared'"),
|
||||
currentUser: User = Depends(getCurrentUser),
|
||||
context: RequestContext = Depends(getRequestContext)
|
||||
):
|
||||
|
|
@ -700,9 +699,8 @@ def get_files(
|
|||
|
||||
from modules.routes.routeHelpers import (
|
||||
handleIdsMode,
|
||||
handleIdsInMemory,
|
||||
handleFilterValuesInMemory,
|
||||
resolveView, applyViewToParams, buildGroupLayout, effective_group_by_levels, paginateInMemory,
|
||||
resolveView, applyViewToParams, buildGroupLayout, effective_group_by_levels,
|
||||
)
|
||||
import modules.interfaces.interfaceDbApp as _appIface
|
||||
from modules.datamodels.datamodelPagination import AppliedViewMeta
|
||||
|
|
@ -713,10 +711,6 @@ def get_files(
|
|||
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
|
||||
)
|
||||
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
|
||||
viewKey = paginationParams.viewKey if paginationParams else None
|
||||
|
|
@ -728,17 +722,6 @@ def get_files(
|
|||
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]
|
||||
|
||||
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 not pagination:
|
||||
raise HTTPException(status_code=400, detail="pagination required for groupSummary")
|
||||
|
|
@ -753,12 +736,11 @@ def get_files(
|
|||
)
|
||||
field = groupByLevels[0]["field"]
|
||||
null_label = str(groupByLevels[0].get("nullLabel") or "—")
|
||||
allFiles = managementInterface.getAllFiles(recordFilter=recordFilter)
|
||||
allFiles = managementInterface.getAllFiles()
|
||||
allItems = enrichRowsWithFkLabels(
|
||||
_filesToDicts(allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])),
|
||||
FileItem,
|
||||
)
|
||||
allItems = _apply_owner_filter(allItems)
|
||||
filtered = apply_strategy_b_filters_and_sort(allItems, paginationParams, currentUser)
|
||||
groups_out = build_group_summary_groups(filtered, field, null_label, groupByLevels=groupByLevels)
|
||||
return JSONResponse(content={"groups": groups_out})
|
||||
|
|
@ -766,57 +748,52 @@ def get_files(
|
|||
if mode == "filterValues":
|
||||
if not column:
|
||||
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 [])
|
||||
itemDicts = _filesToDicts(items)
|
||||
itemDicts = _apply_owner_filter(itemDicts)
|
||||
enrichRowsWithFkLabels(itemDicts, FileItem)
|
||||
return handleFilterValuesInMemory(itemDicts, column, pagination)
|
||||
|
||||
if mode == "ids":
|
||||
if owner_mode == "me":
|
||||
recordFilter = {"sysCreatedBy": managementInterface.userId}
|
||||
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:
|
||||
page_items, totalItems = paginateInMemory(allItems, paginationParams)
|
||||
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
|
||||
resp = {
|
||||
"items": page_items,
|
||||
# No grouping: let DB handle pagination directly (fastest path)
|
||||
result = managementInterface.getAllFiles(pagination=paginationParams)
|
||||
if paginationParams and hasattr(result, 'items'):
|
||||
enriched = enrichRowsWithFkLabels(_filesToDicts(result.items), FileItem)
|
||||
resp: dict = {
|
||||
"items": enriched,
|
||||
"pagination": PaginationMetadata(
|
||||
currentPage=paginationParams.page,
|
||||
pageSize=paginationParams.pageSize,
|
||||
totalItems=totalItems,
|
||||
totalPages=totalPages,
|
||||
totalItems=result.totalItems,
|
||||
totalPages=result.totalPages,
|
||||
sort=paginationParams.sort,
|
||||
filters=paginationParams.filters
|
||||
).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:
|
||||
resp["appliedView"] = viewMeta.model_dump()
|
||||
return resp
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""PATCH endpoints for DataSource and FeatureDataSource scope/neutralize/rag-index tagging."""
|
||||
"""DataSource auxiliary endpoints: settings (ragLimits) and cost estimate.
|
||||
|
||||
Flag toggles (neutralize / scope / ragIndexEnabled) have moved to the
|
||||
generic UDB router (`POST /api/udb/node/{key}/flag/{flag}`); see
|
||||
`modules/routes/routeUdb.py` and the wiki UDB reference page.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
|
@ -43,49 +48,6 @@ def _ensureConnectionKnowledgeFlag(rootIf, connectionId: str) -> None:
|
|||
except Exception as e:
|
||||
logger.warning("Could not auto-enable knowledgeIngestionEnabled for connection %s: %s", connectionId, e)
|
||||
|
||||
def _computeOwnEffective(rootIf, rec, model, sourceId: str, flag: str) -> Any:
|
||||
"""Re-load the record after modification and compute its aggregate effective value."""
|
||||
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import (
|
||||
getEffectiveFlag, getEffectiveFlagFds,
|
||||
)
|
||||
freshRec = rootIf.db.getRecord(model, sourceId)
|
||||
if not freshRec:
|
||||
return None
|
||||
if model is DataSource:
|
||||
connectionId = freshRec.get("connectionId", "")
|
||||
allDs = rootIf.db.getRecordset(DataSource, recordFilter={"connectionId": connectionId})
|
||||
return getEffectiveFlag(freshRec, flag, allDs, mode="aggregate")
|
||||
else:
|
||||
wsId = freshRec.get("workspaceInstanceId", "")
|
||||
allFds = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"workspaceInstanceId": wsId})
|
||||
return getEffectiveFlagFds(freshRec, flag, allFds, mode="aggregate")
|
||||
|
||||
|
||||
def _computeAncestorEffectives(rootIf, rec, model, flag: str) -> List[Dict[str, Any]]:
|
||||
"""Compute the aggregate effective value for all ancestors of `rec`."""
|
||||
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import (
|
||||
collectAncestorChain, collectAncestorChainFds,
|
||||
getEffectiveFlag, getEffectiveFlagFds,
|
||||
)
|
||||
effectiveKey = f"effective{flag[0].upper()}{flag[1:]}"
|
||||
if model is DataSource:
|
||||
connectionId = rec.get("connectionId", "")
|
||||
allDs = rootIf.db.getRecordset(DataSource, recordFilter={"connectionId": connectionId})
|
||||
ancestors = collectAncestorChain(rec, allDs)
|
||||
return [
|
||||
{"id": a.get("id") or getattr(a, "id", ""), effectiveKey: getEffectiveFlag(a, flag, allDs, mode="aggregate")}
|
||||
for a in ancestors
|
||||
]
|
||||
else:
|
||||
wsId = rec.get("workspaceInstanceId", "")
|
||||
allFds = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"workspaceInstanceId": wsId})
|
||||
ancestors = collectAncestorChainFds(rec, allFds)
|
||||
return [
|
||||
{"id": a.get("id") or getattr(a, "id", ""), effectiveKey: getEffectiveFlagFds(a, flag, allFds, mode="aggregate")}
|
||||
for a in ancestors
|
||||
]
|
||||
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/datasources",
|
||||
tags=["Data Sources"],
|
||||
|
|
@ -98,9 +60,6 @@ router = APIRouter(
|
|||
},
|
||||
)
|
||||
|
||||
_VALID_SCOPES = {"personal", "featureInstance", "mandate", "global"}
|
||||
|
||||
|
||||
def _findSourceRecord(db, sourceId: str):
|
||||
"""Look up a source by ID, checking DataSource first, then FeatureDataSource."""
|
||||
rec = db.getRecord(DataSource, sourceId)
|
||||
|
|
@ -112,250 +71,6 @@ def _findSourceRecord(db, sourceId: str):
|
|||
return None, None
|
||||
|
||||
|
||||
@router.patch("/{sourceId}/scope")
|
||||
@limiter.limit("30/minute")
|
||||
def _updateDataSourceScope(
|
||||
request: Request,
|
||||
sourceId: str = Path(..., description="ID of the DataSource or FeatureDataSource"),
|
||||
scope: Optional[str] = Body(None, embed=True),
|
||||
context: RequestContext = Depends(getRequestContext),
|
||||
) -> Dict[str, Any]:
|
||||
"""Update the scope of a DataSource. Cascade-resets explicit descendants.
|
||||
|
||||
`scope=None` resets this node to inherit (no cascade). Global scope
|
||||
requires sysAdmin.
|
||||
"""
|
||||
if scope is not None:
|
||||
if scope not in _VALID_SCOPES:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid scope: {scope}. Must be one of {_VALID_SCOPES}")
|
||||
if scope == "global" and not context.isSysAdmin:
|
||||
raise HTTPException(status_code=403, detail=routeApiMsg("Only sysadmins can set global scope"))
|
||||
|
||||
try:
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import (
|
||||
cascadeResetDescendants, cascadeResetDescendantsFds,
|
||||
getEffectiveFlag, getEffectiveFlagFds,
|
||||
collectAncestorChain, collectAncestorChainFds,
|
||||
)
|
||||
rootIf = getRootInterface()
|
||||
rec, model = _findSourceRecord(rootIf.db, sourceId)
|
||||
if not rec:
|
||||
raise HTTPException(status_code=404, detail=f"DataSource {sourceId} not found")
|
||||
|
||||
# 1. Cascade reset descendants bottom-up (before modifying master)
|
||||
resetIds: List[str] = []
|
||||
if scope is not None:
|
||||
if model is DataSource:
|
||||
resetIds = cascadeResetDescendants(rootIf, rec, "scope")
|
||||
else:
|
||||
resetIds = cascadeResetDescendantsFds(rootIf, rec, "scope")
|
||||
|
||||
# 2. Set master value last (crash-safe)
|
||||
rootIf.db.recordModify(model, sourceId, {"scope": scope})
|
||||
|
||||
# 3. Compute effective + ancestor chain for response
|
||||
updatedAncestors = _computeAncestorEffectives(rootIf, rec, model, "scope")
|
||||
effectiveScope = _computeOwnEffective(rootIf, rec, model, sourceId, "scope")
|
||||
|
||||
logger.info(
|
||||
"Updated scope=%s for %s %s (cascade-reset %d descendants)",
|
||||
scope, model.__name__, sourceId, len(resetIds),
|
||||
)
|
||||
return {
|
||||
"sourceId": sourceId,
|
||||
"scope": scope,
|
||||
"effectiveScope": effectiveScope,
|
||||
"resetDescendantIds": resetIds,
|
||||
"updatedAncestors": updatedAncestors,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error updating datasource scope: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/{sourceId}/neutralize")
|
||||
@limiter.limit("30/minute")
|
||||
def _updateDataSourceNeutralize(
|
||||
request: Request,
|
||||
sourceId: str = Path(..., description="ID of the DataSource or FeatureDataSource"),
|
||||
neutralize: Optional[bool] = Body(None, embed=True),
|
||||
context: RequestContext = Depends(getRequestContext),
|
||||
) -> Dict[str, Any]:
|
||||
"""Set neutralize flag on a DataSource. Cascade-resets explicit descendants.
|
||||
|
||||
`neutralize=None` resets this node to inherit (no cascade).
|
||||
"""
|
||||
try:
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import (
|
||||
cascadeResetDescendants, cascadeResetDescendantsFds,
|
||||
)
|
||||
rootIf = getRootInterface()
|
||||
rec, model = _findSourceRecord(rootIf.db, sourceId)
|
||||
if not rec:
|
||||
raise HTTPException(status_code=404, detail=f"DataSource {sourceId} not found")
|
||||
|
||||
# 1. Cascade reset descendants bottom-up (before modifying master)
|
||||
resetIds: List[str] = []
|
||||
if neutralize is not None:
|
||||
if model is DataSource:
|
||||
resetIds = cascadeResetDescendants(rootIf, rec, "neutralize")
|
||||
else:
|
||||
resetIds = cascadeResetDescendantsFds(rootIf, rec, "neutralize")
|
||||
|
||||
# 2. Set master value last (crash-safe)
|
||||
rootIf.db.recordModify(model, sourceId, {"neutralize": neutralize})
|
||||
|
||||
# 3. Compute effective + ancestor chain for response
|
||||
updatedAncestors = _computeAncestorEffectives(rootIf, rec, model, "neutralize")
|
||||
effectiveNeutralize = _computeOwnEffective(rootIf, rec, model, sourceId, "neutralize")
|
||||
|
||||
logger.info(
|
||||
"Updated neutralize=%s for %s %s (cascade-reset %d descendants)",
|
||||
neutralize, model.__name__, sourceId, len(resetIds),
|
||||
)
|
||||
return {
|
||||
"sourceId": sourceId,
|
||||
"neutralize": neutralize,
|
||||
"effectiveNeutralize": effectiveNeutralize,
|
||||
"resetDescendantIds": resetIds,
|
||||
"updatedAncestors": updatedAncestors,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error updating datasource neutralize: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/{sourceId}/neutralize-fields")
|
||||
@limiter.limit("30/minute")
|
||||
def _updateNeutralizeFields(
|
||||
request: Request,
|
||||
sourceId: str = Path(..., description="ID of the FeatureDataSource"),
|
||||
neutralizeFields: List[str] = Body(..., embed=True),
|
||||
context: RequestContext = Depends(getRequestContext),
|
||||
) -> Dict[str, Any]:
|
||||
"""Update the list of field names to neutralize on a FeatureDataSource."""
|
||||
try:
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
rootIf = getRootInterface()
|
||||
rec = rootIf.db.getRecord(FeatureDataSource, sourceId)
|
||||
if not rec:
|
||||
raise HTTPException(status_code=404, detail=f"FeatureDataSource {sourceId} not found")
|
||||
|
||||
cleanFields = [f for f in neutralizeFields if f and isinstance(f, str)] if neutralizeFields else []
|
||||
rootIf.db.recordModify(FeatureDataSource, sourceId, {
|
||||
"neutralizeFields": cleanFields if cleanFields else None,
|
||||
})
|
||||
logger.info("Updated neutralizeFields=%s for FeatureDataSource %s", cleanFields, sourceId)
|
||||
return {"sourceId": sourceId, "neutralizeFields": cleanFields, "updated": True}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error updating neutralizeFields: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/{sourceId}/rag-index")
|
||||
@limiter.limit("30/minute")
|
||||
async def _updateDataSourceRagIndex(
|
||||
request: Request,
|
||||
sourceId: str = Path(..., description="ID of the DataSource"),
|
||||
ragIndexEnabled: Optional[bool] = Body(None, embed=True),
|
||||
context: RequestContext = Depends(getRequestContext),
|
||||
) -> Dict[str, Any]:
|
||||
"""Set RAG indexing flag on a DataSource. Cascade-resets explicit descendants.
|
||||
|
||||
`ragIndexEnabled=None` resets this node to inherit (no cascade, no purge,
|
||||
no bootstrap — the node simply follows its ancestor chain afterwards).
|
||||
`True` enqueues a mini-bootstrap. `False` synchronously purges chunks.
|
||||
|
||||
Must be `async def` so `await startJob(...)` registers `_runJob` in the
|
||||
main event loop.
|
||||
"""
|
||||
try:
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import (
|
||||
cascadeResetDescendants, cascadeResetDescendantsFds,
|
||||
)
|
||||
rootIf = getRootInterface()
|
||||
rec, model = _findSourceRecord(rootIf.db, sourceId)
|
||||
if not rec:
|
||||
raise HTTPException(status_code=404, detail=f"DataSource {sourceId} not found")
|
||||
|
||||
# 1. Cascade reset descendants bottom-up (before modifying master)
|
||||
resetIds: List[str] = []
|
||||
if ragIndexEnabled is not None:
|
||||
if model is DataSource:
|
||||
resetIds = cascadeResetDescendants(rootIf, rec, "ragIndexEnabled")
|
||||
else:
|
||||
resetIds = cascadeResetDescendantsFds(rootIf, rec, "ragIndexEnabled")
|
||||
|
||||
# 2. Set master value last (crash-safe)
|
||||
rootIf.db.recordModify(model, sourceId, {"ragIndexEnabled": ragIndexEnabled})
|
||||
|
||||
logger.info(
|
||||
"Updated ragIndexEnabled=%s for %s %s (cascade-reset %d descendants)",
|
||||
ragIndexEnabled, model.__name__, sourceId, len(resetIds),
|
||||
)
|
||||
|
||||
# Bootstrap / purge only for personal DataSource (file/folder-based RAG).
|
||||
# FDS RAG is handled by the feature pipeline; the flag alone is enough.
|
||||
if model is DataSource:
|
||||
connectionId = rec.get("connectionId") or rec.get("connection_id") or ""
|
||||
if ragIndexEnabled is True:
|
||||
_ensureConnectionKnowledgeFlag(rootIf, connectionId)
|
||||
from modules.serviceCenter.services.serviceBackgroundJobs import startJob
|
||||
|
||||
conn = rootIf.getUserConnectionById(connectionId) if connectionId else None
|
||||
authority = ""
|
||||
if conn:
|
||||
authority = conn.authority.value if hasattr(conn.authority, "value") else str(conn.authority or "")
|
||||
|
||||
await startJob(
|
||||
"connection.bootstrap",
|
||||
{"connectionId": connectionId, "authority": authority.lower(), "dataSourceIds": [sourceId]},
|
||||
triggeredBy=str(context.user.id),
|
||||
)
|
||||
elif ragIndexEnabled is False:
|
||||
from modules.interfaces.interfaceDbKnowledge import getInterface as getKnowledgeInterface
|
||||
purgeResult = getKnowledgeInterface(None).deleteFileContentIndexByDataSource(sourceId)
|
||||
logger.info("Purged %d index rows / %d chunks for DataSource %s",
|
||||
purgeResult.get("indexRows", 0), purgeResult.get("chunks", 0), sourceId)
|
||||
|
||||
import json
|
||||
from modules.shared.auditLogger import audit_logger
|
||||
from modules.datamodels.datamodelAudit import AuditCategory
|
||||
audit_logger.logEvent(
|
||||
userId=str(context.user.id),
|
||||
mandateId=context.mandateId,
|
||||
category=AuditCategory.PERMISSION.value,
|
||||
action="rag_index_toggled",
|
||||
details=json.dumps({"sourceId": sourceId, "ragIndexEnabled": ragIndexEnabled, "resetDescendants": len(resetIds), "model": model.__name__}),
|
||||
)
|
||||
|
||||
# 3. Compute effective + ancestors for response
|
||||
updatedAncestors = _computeAncestorEffectives(rootIf, rec, model, "ragIndexEnabled")
|
||||
effectiveRag = _computeOwnEffective(rootIf, rec, model, sourceId, "ragIndexEnabled")
|
||||
|
||||
return {
|
||||
"sourceId": sourceId,
|
||||
"ragIndexEnabled": ragIndexEnabled,
|
||||
"effectiveRagIndexEnabled": effectiveRag,
|
||||
"resetDescendantIds": resetIds,
|
||||
"updatedAncestors": updatedAncestors,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Error updating datasource ragIndexEnabled: %s", e)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
_CLICKUP_SOURCE_TYPES = {"clickup", "clickupList", "clickupSpace", "clickupFolder"}
|
||||
_ALLOWED_RAG_LIMIT_KEYS = {
|
||||
"files": {"maxItems", "maxBytes", "maxFileSize", "maxDepth"},
|
||||
|
|
@ -412,8 +127,9 @@ def _updateDataSourceSettings(
|
|||
Currently supports `ragLimits` only. Unknown top-level keys in the body are
|
||||
rejected to avoid silently storing garbage that no consumer reads.
|
||||
|
||||
Owner-only for personal DataSources; mandate/feature scopes additionally
|
||||
accept the mandate or workspace admins of that scope.
|
||||
DataSource: owner-only (or sysadmin). For mandate/feature scopes the
|
||||
mandateAdmin also passes. FeatureDataSource has no userId/scope; for
|
||||
those we require a feature-admin role on the FDS's featureInstanceId.
|
||||
"""
|
||||
if not isinstance(settings, dict):
|
||||
raise HTTPException(status_code=400, detail="settings must be an object")
|
||||
|
|
@ -428,23 +144,22 @@ def _updateDataSourceSettings(
|
|||
if not rec:
|
||||
raise HTTPException(status_code=404, detail=f"DataSource {sourceId} not found")
|
||||
|
||||
ownerId = str(rec.get("userId") or "")
|
||||
currentUserId = str(context.user.id)
|
||||
if model is DataSource:
|
||||
ownerId = str(rec.get("userId") or "")
|
||||
if ownerId and ownerId != currentUserId and not context.isSysAdmin:
|
||||
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlag
|
||||
if model is DataSource:
|
||||
connectionId = rec.get("connectionId", "")
|
||||
allDs = rootIf.db.getRecordset(DataSource, recordFilter={"connectionId": connectionId})
|
||||
scope = str(getEffectiveFlag(rec, "scope", allDs, mode="walk"))
|
||||
else:
|
||||
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource as FDS
|
||||
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlagFds
|
||||
wsId = rec.get("workspaceInstanceId", "")
|
||||
allFds = rootIf.db.getRecordset(FDS, recordFilter={"workspaceInstanceId": wsId})
|
||||
scope = str(getEffectiveFlagFds(rec, "scope", allFds, mode="walk"))
|
||||
isMandateAdmin = getattr(context, "isMandateAdmin", False)
|
||||
if scope == "personal" or not isMandateAdmin:
|
||||
raise HTTPException(status_code=403, detail="Not allowed to modify this DataSource's settings")
|
||||
else:
|
||||
from modules.serviceCenter.services.serviceKnowledge.udbNodes import _isFeatureAdmin
|
||||
featureInstanceId = str(rec.get("featureInstanceId") or "")
|
||||
if not (context.isSysAdmin or _isFeatureAdmin(rootIf, currentUserId, featureInstanceId)):
|
||||
raise HTTPException(status_code=403, detail="Not allowed to modify this FeatureDataSource's settings")
|
||||
|
||||
kind = _kindForSource(rec, model)
|
||||
|
||||
|
|
|
|||
|
|
@ -265,7 +265,7 @@ def _buildFeatureInstanceInventory(featureInstanceIds, rootIf, knowledgeIf) -> L
|
|||
st = (r.get("status") if isinstance(r, dict) else getattr(r, "status", "unknown")) or "unknown"
|
||||
statusCounts[st] = statusCounts.get(st, 0) + 1
|
||||
|
||||
allFds = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"workspaceInstanceId": fiId})
|
||||
allFds = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"featureInstanceId": fiId})
|
||||
dsItems = []
|
||||
anyRagEnabled = False
|
||||
for fds in allFds:
|
||||
|
|
@ -287,7 +287,7 @@ def _buildFeatureInstanceInventory(featureInstanceIds, rootIf, knowledgeIf) -> L
|
|||
|
||||
fiJobs = [
|
||||
j for j in allFeatureJobs
|
||||
if (j.get("payload") or {}).get("workspaceInstanceId") == fiId
|
||||
if (j.get("payload") or {}).get("featureInstanceId") == fiId
|
||||
]
|
||||
runningJobs = [
|
||||
{
|
||||
|
|
@ -572,17 +572,18 @@ async def _reindexConnection(
|
|||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/reindex-feature/{workspaceInstanceId}")
|
||||
@router.post("/reindex-feature/{featureInstanceId}")
|
||||
@limiter.limit("10/minute")
|
||||
async def _reindexFeature(
|
||||
request: Request,
|
||||
workspaceInstanceId: str,
|
||||
featureInstanceId: str,
|
||||
currentUser: User = Depends(getCurrentUser),
|
||||
) -> Dict[str, Any]:
|
||||
"""Re-trigger feature data bootstrap for a workspace instance.
|
||||
"""Re-trigger feature data bootstrap for a feature instance.
|
||||
|
||||
Indexes all RAG-enabled FeatureDataSource rows into the knowledge store.
|
||||
Must be ``async def`` so ``await startJob(...)`` registers in the main loop.
|
||||
Indexes all RAG-enabled FeatureDataSource rows owned by this feature
|
||||
instance into the knowledge store. Must be ``async def`` so
|
||||
``await startJob(...)`` registers in the main loop.
|
||||
"""
|
||||
try:
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
|
|
@ -592,7 +593,7 @@ async def _reindexFeature(
|
|||
rootIf = getRootInterface()
|
||||
featureAccesses = rootIf.getFeatureAccessesForUser(str(currentUser.id))
|
||||
hasAccess = any(
|
||||
str(fa.featureInstanceId) == workspaceInstanceId and fa.enabled
|
||||
str(fa.featureInstanceId) == featureInstanceId and fa.enabled
|
||||
for fa in featureAccesses
|
||||
)
|
||||
if not hasAccess and not getattr(currentUser, "isSysAdmin", False):
|
||||
|
|
@ -600,12 +601,12 @@ async def _reindexFeature(
|
|||
|
||||
jobId = await startJob(
|
||||
FEATURE_BOOTSTRAP_JOB_TYPE,
|
||||
{"workspaceInstanceId": workspaceInstanceId},
|
||||
{"featureInstanceId": featureInstanceId},
|
||||
triggeredBy=str(currentUser.id),
|
||||
)
|
||||
|
||||
logger.info("Feature reindex triggered for workspace %s (jobId=%s)", workspaceInstanceId, jobId)
|
||||
return {"status": "queued", "workspaceInstanceId": workspaceInstanceId, "jobId": jobId}
|
||||
logger.info("Feature reindex triggered for feature %s (jobId=%s)", featureInstanceId, jobId)
|
||||
return {"status": "queued", "featureInstanceId": featureInstanceId, "jobId": jobId}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -257,8 +257,6 @@ async def auth_connect_callback(
|
|||
except Exception as _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(
|
||||
content=f"""
|
||||
<html>
|
||||
|
|
@ -275,7 +273,7 @@ async def auth_connect_callback(
|
|||
lastChecked: {getUtcTimestamp()},
|
||||
expiresAt: {expires_at}
|
||||
}}
|
||||
}}, {json.dumps(post_target)});
|
||||
}}, '*');
|
||||
setTimeout(() => window.close(), 1000);
|
||||
}} else {{
|
||||
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 ""
|
||||
instanceLabel = instance.label or ""
|
||||
userId = context.get("userId", "")
|
||||
workspaceInstanceId = context.get("featureInstanceId", "")
|
||||
requestLang = None
|
||||
if userId:
|
||||
langUser = rootIf.getUser(userId)
|
||||
|
|
@ -107,7 +106,7 @@ def _registerFeatureSubAgentTools(registry: ToolRegistry, services):
|
|||
|
||||
featureDataSources = rootDbConn.getRecordset(
|
||||
FeatureDataSource,
|
||||
recordFilter={"featureInstanceId": featureInstanceId, "workspaceInstanceId": workspaceInstanceId},
|
||||
recordFilter={"featureInstanceId": featureInstanceId},
|
||||
)
|
||||
|
||||
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlagFds
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -28,7 +28,7 @@ from typing import Any, Dict, Iterable, List, Literal, Optional, Tuple
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
_INHERITABLE_FLAGS = ("neutralize", "ragIndexEnabled", "scope")
|
||||
_INHERITABLE_FDS_FLAGS = ("neutralize", "ragIndexEnabled", "scope")
|
||||
_INHERITABLE_FDS_FLAGS = ("neutralize", "ragIndexEnabled")
|
||||
|
||||
# Connection-root DataSources carry the authority as their sourceType
|
||||
# (e.g. 'msft', 'google'). They sit one level above all service DataSources
|
||||
|
|
@ -458,11 +458,11 @@ def cascadeResetDescendantsFds(
|
|||
raise ValueError(f"Unknown inheritable FDS flag: {flag}")
|
||||
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
|
||||
|
||||
workspaceInstanceId = _getRecordValue(parentRec, "workspaceInstanceId")
|
||||
if not workspaceInstanceId:
|
||||
featureInstanceId = _getRecordValue(parentRec, "featureInstanceId")
|
||||
if not featureInstanceId:
|
||||
return []
|
||||
siblings = rootIf.db.getRecordset(
|
||||
FeatureDataSource, recordFilter={"workspaceInstanceId": workspaceInstanceId}
|
||||
FeatureDataSource, recordFilter={"featureInstanceId": featureInstanceId}
|
||||
)
|
||||
|
||||
toReset: List[Tuple[int, str]] = []
|
||||
|
|
@ -475,7 +475,6 @@ def cascadeResetDescendantsFds(
|
|||
sibId = _getRecordValue(sib, "id")
|
||||
toReset.append((_fdsDepth(sib), sibId))
|
||||
|
||||
# Sort deepest first (bottom-up)
|
||||
toReset.sort(key=lambda x: x[0], reverse=True)
|
||||
|
||||
resetIds: List[str] = []
|
||||
|
|
@ -576,9 +575,9 @@ def resolveEffectiveForPath(
|
|||
"ragIndexEnabled": None,
|
||||
}
|
||||
return {
|
||||
"effectiveNeutralize": _resolveWalkValue(virtualRec, "neutralize", allDs),
|
||||
"effectiveScope": _resolveWalkValue(virtualRec, "scope", allDs),
|
||||
"effectiveRagIndexEnabled": _resolveWalkValue(virtualRec, "ragIndexEnabled", allDs),
|
||||
"effectiveNeutralize": getEffectiveFlag(virtualRec, "neutralize", allDs, mode=mode),
|
||||
"effectiveScope": getEffectiveFlag(virtualRec, "scope", allDs, mode=mode),
|
||||
"effectiveRagIndexEnabled": getEffectiveFlag(virtualRec, "ragIndexEnabled", allDs, mode=mode),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -591,11 +590,11 @@ def resolveEffectiveForFds(
|
|||
) -> Dict[str, Any]:
|
||||
"""Resolve effective flags for ANY FDS tuple (even without DB record).
|
||||
|
||||
`allFds` is pre-scoped to a single workspace (loaded with
|
||||
workspaceInstanceId filter). Within that set, the coordinate is
|
||||
featureInstanceId + tableName + recordFilter.
|
||||
`allFds` is pre-scoped (typically to a mandate). Within that set, the
|
||||
coordinate is featureInstanceId + tableName + recordFilter.
|
||||
|
||||
Returns dict with effectiveNeutralize, effectiveScope, effectiveRagIndexEnabled.
|
||||
Returns dict with effectiveNeutralize, effectiveRagIndexEnabled.
|
||||
FDS has no `scope` attribute (visibility is governed by feature RBAC).
|
||||
"""
|
||||
exactRecord = None
|
||||
for fds in allFds:
|
||||
|
|
@ -611,7 +610,6 @@ def resolveEffectiveForFds(
|
|||
if exactRecord:
|
||||
return {
|
||||
"effectiveNeutralize": getEffectiveFlagFds(exactRecord, "neutralize", allFds, mode=mode),
|
||||
"effectiveScope": getEffectiveFlagFds(exactRecord, "scope", allFds, mode=mode),
|
||||
"effectiveRagIndexEnabled": getEffectiveFlagFds(exactRecord, "ragIndexEnabled", allFds, mode=mode),
|
||||
}
|
||||
|
||||
|
|
@ -621,11 +619,9 @@ def resolveEffectiveForFds(
|
|||
"tableName": tableName,
|
||||
"recordFilter": recordFilter,
|
||||
"neutralize": None,
|
||||
"scope": None,
|
||||
"ragIndexEnabled": None,
|
||||
}
|
||||
return {
|
||||
"effectiveNeutralize": _resolveWalkValueFds(virtualRec, "neutralize", allFds),
|
||||
"effectiveScope": _resolveWalkValueFds(virtualRec, "scope", allFds),
|
||||
"effectiveRagIndexEnabled": _resolveWalkValueFds(virtualRec, "ragIndexEnabled", allFds),
|
||||
"effectiveNeutralize": getEffectiveFlagFds(virtualRec, "neutralize", allFds, mode=mode),
|
||||
"effectiveRagIndexEnabled": getEffectiveFlagFds(virtualRec, "ragIndexEnabled", allFds, mode=mode),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ text, and feeds it through KnowledgeService.requestIngestion so the data
|
|||
appears in ContentChunk embeddings for semantic RAG search.
|
||||
|
||||
Job type: ``feature.bootstrap``
|
||||
Payload: ``{"workspaceInstanceId": "...", "featureDataSourceIds": [...] (optional)}``
|
||||
Payload: ``{"featureInstanceId": "...", "featureDataSourceIds": [...] (optional)}``
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -23,7 +23,7 @@ logger = logging.getLogger(__name__)
|
|||
FEATURE_BOOTSTRAP_JOB_TYPE = "feature.bootstrap"
|
||||
|
||||
|
||||
def _loadRagEnabledFds(workspaceInstanceId: str, featureDataSourceIds: Optional[List[str]] = None):
|
||||
def _loadRagEnabledFds(featureInstanceId: str, featureDataSourceIds: Optional[List[str]] = None):
|
||||
"""Load FeatureDataSource rows whose effective ragIndexEnabled is True.
|
||||
|
||||
Returns dicts with resolved flags so downstream code can read them directly.
|
||||
|
|
@ -34,7 +34,7 @@ def _loadRagEnabledFds(workspaceInstanceId: str, featureDataSourceIds: Optional[
|
|||
|
||||
rootIf = getRootInterface()
|
||||
allFds = rootIf.db.getRecordset(
|
||||
FeatureDataSource, recordFilter={"workspaceInstanceId": workspaceInstanceId}
|
||||
FeatureDataSource, recordFilter={"featureInstanceId": featureInstanceId}
|
||||
)
|
||||
resolved = []
|
||||
for fds in allFds:
|
||||
|
|
@ -47,7 +47,6 @@ def _loadRagEnabledFds(workspaceInstanceId: str, featureDataSourceIds: Optional[
|
|||
continue
|
||||
row = dict(fds) if isinstance(fds, dict) else {**fds.__dict__}
|
||||
row["_effectiveNeutralize"] = getEffectiveFlagFds(fds, "neutralize", allFds, mode="aggregate")
|
||||
row["_effectiveScope"] = getEffectiveFlagFds(fds, "scope", allFds, mode="aggregate") or "featureInstance"
|
||||
row["ragIndexEnabled"] = True
|
||||
resolved.append(row)
|
||||
|
||||
|
|
@ -104,20 +103,20 @@ async def _featureBootstrapHandler(
|
|||
) -> Dict[str, Any]:
|
||||
"""Walk RAG-enabled FeatureDataSources and index their rows."""
|
||||
payload = job.get("payload") or {}
|
||||
workspaceInstanceId = payload.get("workspaceInstanceId")
|
||||
featureInstanceId = payload.get("featureInstanceId")
|
||||
featureDataSourceIds = payload.get("featureDataSourceIds")
|
||||
if not workspaceInstanceId:
|
||||
raise ValueError("feature.bootstrap requires payload.workspaceInstanceId")
|
||||
if not featureInstanceId:
|
||||
raise ValueError("feature.bootstrap requires payload.featureInstanceId")
|
||||
|
||||
progressCb(5, messageKey="Feature-Datenquellen werden geladen...")
|
||||
|
||||
fdsList = _loadRagEnabledFds(workspaceInstanceId, featureDataSourceIds)
|
||||
fdsList = _loadRagEnabledFds(featureInstanceId, featureDataSourceIds)
|
||||
if not fdsList:
|
||||
logger.info(
|
||||
"feature.bootstrap.skipped — no rag-enabled FDS for workspace %s",
|
||||
workspaceInstanceId,
|
||||
"feature.bootstrap.skipped — no rag-enabled FDS for feature %s",
|
||||
featureInstanceId,
|
||||
)
|
||||
return {"workspaceInstanceId": workspaceInstanceId, "skipped": True, "reason": "no_rag_enabled_fds"}
|
||||
return {"featureInstanceId": featureInstanceId, "skipped": True, "reason": "no_rag_enabled_fds"}
|
||||
|
||||
from modules.serviceCenter.services.serviceAgent.featureDataProvider import FeatureDataProvider
|
||||
from modules.serviceCenter.services.serviceKnowledge.mainServiceKnowledge import IngestionJob
|
||||
|
|
@ -134,11 +133,10 @@ async def _featureBootstrapHandler(
|
|||
fdsId = fds.get("id", "")
|
||||
featureCode = fds.get("featureCode", "")
|
||||
tableName = fds.get("tableName", "")
|
||||
featureInstanceId = fds.get("featureInstanceId", "")
|
||||
fdsFeatureInstanceId = fds.get("featureInstanceId", "")
|
||||
mandateId = fds.get("mandateId", "")
|
||||
neutralizeFields = fds.get("neutralizeFields") or []
|
||||
recordFilter = fds.get("recordFilter") or {}
|
||||
effectiveScope = fds.get("_effectiveScope", "featureInstance")
|
||||
effectiveNeutralize = bool(fds.get("_effectiveNeutralize", False))
|
||||
|
||||
progressPct = 5 + int(90 * fdsIdx / len(fdsList))
|
||||
|
|
@ -148,7 +146,7 @@ async def _featureBootstrapHandler(
|
|||
messageParams={"table": tableName, "n": fdsIdx + 1, "total": len(fdsList)},
|
||||
)
|
||||
|
||||
if not featureCode or not tableName or not featureInstanceId:
|
||||
if not featureCode or not tableName or not fdsFeatureInstanceId:
|
||||
logger.warning("feature.bootstrap: skipping FDS %s — missing featureCode/tableName/fiId", fdsId)
|
||||
continue
|
||||
|
||||
|
|
@ -160,7 +158,7 @@ async def _featureBootstrapHandler(
|
|||
ctx = ServiceCenterContext(
|
||||
user=rootUser,
|
||||
mandate_id=mandateId,
|
||||
feature_instance_id=workspaceInstanceId,
|
||||
feature_instance_id=fdsFeatureInstanceId,
|
||||
)
|
||||
knowledgeService = getService("knowledge", ctx)
|
||||
|
||||
|
|
@ -178,7 +176,7 @@ async def _featureBootstrapHandler(
|
|||
while True:
|
||||
result = provider.browseTable(
|
||||
tableName=tableName,
|
||||
featureInstanceId=featureInstanceId,
|
||||
featureInstanceId=fdsFeatureInstanceId,
|
||||
mandateId=mandateId,
|
||||
limit=batchSize,
|
||||
offset=offset,
|
||||
|
|
@ -202,11 +200,11 @@ async def _featureBootstrapHandler(
|
|||
|
||||
ingestionJob = IngestionJob(
|
||||
sourceKind="feature_record",
|
||||
sourceId=f"{workspaceInstanceId}:{tableName}:{rowId}",
|
||||
sourceId=f"{fdsFeatureInstanceId}:{tableName}:{rowId}",
|
||||
fileName=f"{tableName}-{rowId}",
|
||||
mimeType="application/vnd.poweron.feature-record+json",
|
||||
userId=fds.get("userId") or "system",
|
||||
featureInstanceId=workspaceInstanceId,
|
||||
userId="system",
|
||||
featureInstanceId=fdsFeatureInstanceId,
|
||||
mandateId=mandateId,
|
||||
contentObjects=[{
|
||||
"contentType": "text",
|
||||
|
|
@ -214,7 +212,7 @@ async def _featureBootstrapHandler(
|
|||
"contextRef": {
|
||||
"table": tableName,
|
||||
"featureCode": featureCode,
|
||||
"featureInstanceId": featureInstanceId,
|
||||
"featureInstanceId": fdsFeatureInstanceId,
|
||||
"rowId": rowId,
|
||||
},
|
||||
"contentObjectId": f"{tableName}:{rowId}",
|
||||
|
|
@ -225,7 +223,7 @@ async def _featureBootstrapHandler(
|
|||
"featureDataSourceId": fdsId,
|
||||
"tableName": tableName,
|
||||
"featureCode": featureCode,
|
||||
"featureInstanceId": featureInstanceId,
|
||||
"featureInstanceId": fdsFeatureInstanceId,
|
||||
},
|
||||
neutralize=effectiveNeutralize,
|
||||
)
|
||||
|
|
@ -281,7 +279,7 @@ async def _featureBootstrapHandler(
|
|||
progressCb(100, messageKey="Feature-Daten-Sync abgeschlossen.")
|
||||
|
||||
return {
|
||||
"workspaceInstanceId": workspaceInstanceId,
|
||||
"featureInstanceId": featureInstanceId,
|
||||
"indexed": totalIndexed,
|
||||
"skippedDuplicate": totalSkipped,
|
||||
"failed": totalFailed,
|
||||
|
|
|
|||
1055
modules/serviceCenter/services/serviceKnowledge/udbNodes.py
Normal file
1055
modules/serviceCenter/services/serviceKnowledge/udbNodes.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -24,7 +24,7 @@ import time
|
|||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
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
|
||||
|
||||
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:
|
||||
operation_id = None
|
||||
try:
|
||||
|
|
@ -1814,24 +1758,18 @@ async def extractContent(self, parameters: Dict[str, Any]) -> ActionResult:
|
|||
operation_id = f"context_extract_{wf}_{int(time.time())}"
|
||||
|
||||
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)
|
||||
source = "documentList"
|
||||
else:
|
||||
context_param = parameters.get("context")
|
||||
dl = _document_list_from_context(context_param)
|
||||
source = "context"
|
||||
if not dl.references:
|
||||
return ActionResult.isFailure(
|
||||
error=(
|
||||
f"{source} could not be parsed into document references "
|
||||
f"(type={type((document_list_param if document_list_param else parameters.get('context'))).__name__}); "
|
||||
"expected DocumentReferenceList, list of string/dict refs, "
|
||||
"or a context payload containing file/document refs under keys like "
|
||||
"{documents, files, file, data, value}."
|
||||
f"documentList could not be parsed (type={type(document_list_param).__name__}); "
|
||||
"expected DocumentReferenceList, list of strings/dicts, or "
|
||||
"a wrapper dict like {'documents': [...]}"
|
||||
),
|
||||
)
|
||||
logger.info("extractContent resolved %d document reference(s) from %s", len(dl.references), source)
|
||||
|
||||
parent_operation_id = parameters.get("parentOperationId")
|
||||
self.services.chat.progressLogStart(
|
||||
|
|
|
|||
|
|
@ -64,22 +64,12 @@ class MethodContext(MethodBase):
|
|||
dynamicMode=True,
|
||||
outputType="UdmDocument",
|
||||
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(
|
||||
name="documentList",
|
||||
type="DocumentList",
|
||||
frontendType=FrontendType.DOCUMENT_REFERENCE,
|
||||
required=False,
|
||||
description="Optional document reference(s) to extract content from. When omitted, extractContent also accepts refs via context.",
|
||||
required=True,
|
||||
description="Document reference(s) to extract content from",
|
||||
),
|
||||
"contentFilter": WorkflowActionParameter(
|
||||
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
|
||||
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
|
||||
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
|
||||
existing handlers (top-level, conn, mgrp, feat) are produced with the
|
||||
correct effective-flag triplet.
|
||||
Most node-level behavior moved into the polymorphic class hierarchy
|
||||
(`udbNodes.py`) and has its own dedicated tests in `test_udbNodes.py`.
|
||||
This file covers the orchestrator (`getChildrenForParents`) and the
|
||||
remaining lookup helpers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -27,37 +28,6 @@ class TestKeyCoding(unittest.TestCase):
|
|||
self.assertEqual(_buildTree._decode("feat|m1|trustee|fi-1")[1], ["m1", "trustee", "fi-1"])
|
||||
|
||||
|
||||
class TestEffectiveTriplets(unittest.TestCase):
|
||||
def test_ds_triplet_no_record_returns_defaults(self):
|
||||
result = _buildTree._effectiveTripletDs("c", "msft", "/", [])
|
||||
self.assertEqual(result, {
|
||||
"effectiveNeutralize": False,
|
||||
"effectiveScope": "personal",
|
||||
"effectiveRagIndexEnabled": False,
|
||||
})
|
||||
|
||||
def test_ds_triplet_inherits_from_root(self):
|
||||
root = {
|
||||
"id": "r", "connectionId": "c", "sourceType": "msft", "path": "/",
|
||||
"neutralize": True, "scope": "mandate", "ragIndexEnabled": True,
|
||||
}
|
||||
result = _buildTree._effectiveTripletDs("c", "sharepointFolder", "/sites/x", [root])
|
||||
self.assertEqual(result["effectiveNeutralize"], True)
|
||||
self.assertEqual(result["effectiveScope"], "mandate")
|
||||
self.assertEqual(result["effectiveRagIndexEnabled"], True)
|
||||
|
||||
def test_fds_triplet_inherits_from_workspace_wildcard(self):
|
||||
ws = {
|
||||
"id": "ws", "workspaceInstanceId": "ws-inst", "featureInstanceId": "fi1",
|
||||
"tableName": "*", "recordFilter": None, "neutralize": True,
|
||||
"scope": "mandate", "ragIndexEnabled": True,
|
||||
}
|
||||
result = _buildTree._effectiveTripletFds("fi1", "Pos", None, [ws])
|
||||
self.assertEqual(result["effectiveNeutralize"], True)
|
||||
self.assertEqual(result["effectiveScope"], "mandate")
|
||||
self.assertEqual(result["effectiveRagIndexEnabled"], True)
|
||||
|
||||
|
||||
class TestRecordLookup(unittest.TestCase):
|
||||
def test_finds_ds_record_by_normalised_path(self):
|
||||
rec = {"id": "x", "connectionId": "c", "sourceType": "msft", "path": "/folder"}
|
||||
|
|
@ -65,18 +35,78 @@ class TestRecordLookup(unittest.TestCase):
|
|||
self.assertIsNone(_buildTree._findDsRecord([rec], "c", "msft", "/other"))
|
||||
|
||||
def test_finds_fds_record_with_matching_filter(self):
|
||||
rec = {"id": "f", "workspaceInstanceId": "ws", "featureInstanceId": "fi1", "tableName": "Pos", "recordFilter": {"id": "5"}}
|
||||
rec = {"id": "f", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||
"recordFilter": {"id": "5"}}
|
||||
self.assertEqual(_buildTree._findFdsRecord([rec], "fi1", "Pos", {"id": "5"}).get("id"), "f")
|
||||
self.assertIsNone(_buildTree._findFdsRecord([rec], "fi1", "Pos", {"id": "99"}))
|
||||
|
||||
def test_fds_record_with_none_filter_matches_only_none(self):
|
||||
rec = {"id": "f", "workspaceInstanceId": "ws", "featureInstanceId": "fi1", "tableName": "*", "recordFilter": None}
|
||||
rec = {"id": "f", "featureInstanceId": "fi1", "tableName": "*", "recordFilter": None}
|
||||
self.assertEqual(_buildTree._findFdsRecord([rec], "fi1", "*", None).get("id"), "f")
|
||||
self.assertIsNone(_buildTree._findFdsRecord([rec], "fi1", "*", {"id": "1"}))
|
||||
|
||||
|
||||
class TestWiredTableFieldAggregation(unittest.TestCase):
|
||||
"""`_wireTableFieldsAsLogicalChildren` rewraps `FdsTableNode.getEffectiveFlag`
|
||||
so the table aggregates with its declared field children. The aggregate
|
||||
must respect the new FdsField inheritance: if all fields inherit from the
|
||||
table (no list entries), the table stays non-mixed."""
|
||||
|
||||
def _buildTableWithFields(self, *, tableNeutralize, neutralizeFields, fieldNames):
|
||||
from modules.serviceCenter.services.serviceKnowledge.udbNodes import (
|
||||
FdsTableNode, FdsFieldNode,
|
||||
)
|
||||
rec = {
|
||||
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||
"recordFilter": None,
|
||||
"neutralize": tableNeutralize,
|
||||
"neutralizeFields": neutralizeFields,
|
||||
}
|
||||
tableNode = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
|
||||
"feat|m1|trustee|fi1", rec=rec, hasFields=True)
|
||||
fields = [
|
||||
FdsFieldNode("fi1", "Pos", name, tableNode.key,
|
||||
tableRec=rec, featureCode="trustee")
|
||||
for name in fieldNames
|
||||
]
|
||||
tableNode._logicalFieldChildren = fields # type: ignore[attr-defined]
|
||||
_buildTree._wireTableFieldsAsLogicalChildren(tableNode)
|
||||
return tableNode, [rec]
|
||||
|
||||
def test_table_true_no_overrides_stays_true(self):
|
||||
"""Regression: toggling a table to True must NOT leave it 'mixed'
|
||||
because the declared field children should inherit the table value."""
|
||||
tableNode, allFds = self._buildTableWithFields(
|
||||
tableNeutralize=True, neutralizeFields=None,
|
||||
fieldNames=["amount", "currency"],
|
||||
)
|
||||
self.assertTrue(tableNode.getEffectiveFlag("neutralize", [], allFds, "aggregate"))
|
||||
|
||||
def test_table_false_with_override_is_mixed(self):
|
||||
tableNode, allFds = self._buildTableWithFields(
|
||||
tableNeutralize=False, neutralizeFields=["amount"],
|
||||
fieldNames=["amount", "currency"],
|
||||
)
|
||||
self.assertEqual(
|
||||
tableNode.getEffectiveFlag("neutralize", [], allFds, "aggregate"),
|
||||
"mixed",
|
||||
)
|
||||
|
||||
def test_table_inherit_no_overrides_walks_default(self):
|
||||
"""Implicit table + no overrides + no workspace -> default False."""
|
||||
tableNode, allFds = self._buildTableWithFields(
|
||||
tableNeutralize=None, neutralizeFields=None,
|
||||
fieldNames=["amount", "currency"],
|
||||
)
|
||||
self.assertFalse(tableNode.getEffectiveFlag("neutralize", [], allFds, "aggregate"))
|
||||
|
||||
|
||||
class TestGetChildrenForParents(unittest.TestCase):
|
||||
"""End-to-end orchestrator test with mocked dependencies."""
|
||||
"""End-to-end orchestrator tests with mocked dependencies. The
|
||||
orchestrator returns serialised node dicts produced by
|
||||
`UdbNode.toDict(...)`, so the keys/kinds/parentKey wiring is what
|
||||
matters here -- not the inheritance arithmetic (covered in
|
||||
test_udbNodes.py)."""
|
||||
|
||||
def _runAsync(self, coro):
|
||||
return asyncio.run(coro)
|
||||
|
|
@ -92,12 +122,11 @@ class TestGetChildrenForParents(unittest.TestCase):
|
|||
ctx.mandateId = "m1"
|
||||
|
||||
result = self._runAsync(
|
||||
_buildTree.getChildrenForParents("inst-1", ["bogus|key"], ctx)
|
||||
_buildTree.getChildrenForParents(["bogus|key"], ctx)
|
||||
)
|
||||
self.assertEqual(result["bogus|key"], [])
|
||||
|
||||
def test_top_level_emits_personal_root_first(self):
|
||||
"""Top-level emits personalRoot first, then mandate-group nodes inline."""
|
||||
with patch("modules.interfaces.interfaceDbApp.getRootInterface") as mockRoot:
|
||||
rootIf = MagicMock()
|
||||
rootIf.db.getRecordset.return_value = []
|
||||
|
|
@ -109,7 +138,7 @@ class TestGetChildrenForParents(unittest.TestCase):
|
|||
ctx.mandateId = "m1"
|
||||
|
||||
result = self._runAsync(
|
||||
_buildTree.getChildrenForParents("inst-1", [None], ctx)
|
||||
_buildTree.getChildrenForParents([None], ctx)
|
||||
)
|
||||
children = result["__root__"]
|
||||
self.assertGreaterEqual(len(children), 1)
|
||||
|
|
@ -120,92 +149,7 @@ class TestGetChildrenForParents(unittest.TestCase):
|
|||
self.assertTrue(personalRoot["hasChildren"])
|
||||
self.assertTrue(personalRoot["defaultExpanded"])
|
||||
|
||||
|
||||
class TestTopLevelLayout(unittest.TestCase):
|
||||
"""Tests for the flat top-level layout (personalRoot + mandate groups)."""
|
||||
|
||||
def _runAsync(self, coro):
|
||||
return asyncio.run(coro)
|
||||
|
||||
def test_personal_root_carries_neutral_default_triplet(self):
|
||||
with patch("modules.interfaces.interfaceDbApp.getRootInterface") as mockRoot:
|
||||
rootIf = MagicMock()
|
||||
rootIf.db.getRecordset.return_value = []
|
||||
rootIf.getUserMandates.return_value = []
|
||||
mockRoot.return_value = rootIf
|
||||
|
||||
ctx = MagicMock()
|
||||
ctx.user.id = "u1"
|
||||
ctx.mandateId = "m1"
|
||||
|
||||
result = self._runAsync(
|
||||
_buildTree.getChildrenForParents("inst-1", [None], ctx)
|
||||
)
|
||||
personalRoot = result["__root__"][0]
|
||||
self.assertFalse(personalRoot["effectiveNeutralize"])
|
||||
self.assertEqual(personalRoot["effectiveScope"], "personal")
|
||||
self.assertFalse(personalRoot["effectiveRagIndexEnabled"])
|
||||
self.assertFalse(personalRoot["supportsRag"])
|
||||
self.assertFalse(personalRoot["canBeAdded"])
|
||||
self.assertIsNone(personalRoot["dataSourceId"])
|
||||
self.assertIsNone(personalRoot["modelType"])
|
||||
|
||||
def test_personal_root_emits_active_connection_with_correct_parent(self):
|
||||
with patch("modules.interfaces.interfaceDbApp.getRootInterface") as mockRoot, \
|
||||
patch("modules.serviceCenter.getService") as mockGetService:
|
||||
rootIf = MagicMock()
|
||||
rootIf.db.getRecordset.return_value = []
|
||||
mockRoot.return_value = rootIf
|
||||
|
||||
chatService = MagicMock()
|
||||
chatService.getUserConnections.return_value = [{
|
||||
"id": "conn-1",
|
||||
"status": "active",
|
||||
"authority": "msft",
|
||||
"externalEmail": "user@example.com",
|
||||
}]
|
||||
mockGetService.return_value = chatService
|
||||
|
||||
ctx = MagicMock()
|
||||
ctx.user.id = "u1"
|
||||
ctx.mandateId = "m1"
|
||||
|
||||
result = self._runAsync(
|
||||
_buildTree.getChildrenForParents("inst-1", ["personalRoot"], ctx)
|
||||
)
|
||||
children = result["personalRoot"]
|
||||
self.assertEqual(len(children), 1)
|
||||
self.assertEqual(children[0]["key"], "conn|conn-1")
|
||||
self.assertEqual(children[0]["kind"], "connection")
|
||||
self.assertEqual(children[0]["parentKey"], "personalRoot")
|
||||
self.assertEqual(children[0]["label"], "user@example.com")
|
||||
self.assertTrue(children[0]["supportsRag"])
|
||||
|
||||
def test_personal_root_skips_inactive_connection(self):
|
||||
with patch("modules.interfaces.interfaceDbApp.getRootInterface") as mockRoot, \
|
||||
patch("modules.serviceCenter.getService") as mockGetService:
|
||||
rootIf = MagicMock()
|
||||
rootIf.db.getRecordset.return_value = []
|
||||
mockRoot.return_value = rootIf
|
||||
|
||||
chatService = MagicMock()
|
||||
chatService.getUserConnections.return_value = [
|
||||
{"id": "c1", "status": "active", "authority": "msft", "externalEmail": "a"},
|
||||
{"id": "c2", "status": "expired", "authority": "google", "externalEmail": "b"},
|
||||
]
|
||||
mockGetService.return_value = chatService
|
||||
|
||||
ctx = MagicMock()
|
||||
ctx.user.id = "u1"
|
||||
ctx.mandateId = "m1"
|
||||
|
||||
result = self._runAsync(
|
||||
_buildTree.getChildrenForParents("inst-1", ["personalRoot"], ctx)
|
||||
)
|
||||
self.assertEqual(len(result["personalRoot"]), 1)
|
||||
self.assertEqual(result["personalRoot"][0]["connectionId"], "c1")
|
||||
|
||||
def test_mandate_groups_emitted_inline_at_top_level(self):
|
||||
def test_top_level_emits_mandate_groups_inline(self):
|
||||
with patch("modules.interfaces.interfaceDbApp.getRootInterface") as mockRoot, \
|
||||
patch("modules.security.rbacCatalog.getCatalogService") as mockCatalog:
|
||||
rootIf = MagicMock()
|
||||
|
|
@ -218,8 +162,7 @@ class TestTopLevelLayout(unittest.TestCase):
|
|||
featureInst.featureCode = "trustee"
|
||||
featureInst.enabled = True
|
||||
rootIf.getFeatureInstancesByMandate.return_value = [featureInst]
|
||||
featureAccess = MagicMock()
|
||||
featureAccess.enabled = True
|
||||
featureAccess = MagicMock(enabled=True)
|
||||
rootIf.getFeatureAccess.return_value = featureAccess
|
||||
mockRoot.return_value = rootIf
|
||||
|
||||
|
|
@ -231,11 +174,8 @@ class TestTopLevelLayout(unittest.TestCase):
|
|||
ctx.user.id = "u1"
|
||||
ctx.mandateId = None
|
||||
|
||||
result = self._runAsync(
|
||||
_buildTree.getChildrenForParents("inst-1", [None], ctx)
|
||||
)
|
||||
children = result["__root__"]
|
||||
byKey = {c["key"]: c for c in children}
|
||||
result = self._runAsync(_buildTree.getChildrenForParents([None], ctx))
|
||||
byKey = {c["key"]: c for c in result["__root__"]}
|
||||
self.assertIn("personalRoot", byKey)
|
||||
self.assertIn("mgrp|m1", byKey)
|
||||
mgroup = byKey["mgrp|m1"]
|
||||
|
|
@ -243,116 +183,6 @@ class TestTopLevelLayout(unittest.TestCase):
|
|||
self.assertIsNone(mgroup["parentKey"])
|
||||
self.assertEqual(mgroup["mandateId"], "m1")
|
||||
self.assertTrue(mgroup["defaultExpanded"])
|
||||
self.assertFalse(mgroup["supportsRag"])
|
||||
|
||||
def test_top_level_omits_mandates_without_data_features(self):
|
||||
with patch("modules.interfaces.interfaceDbApp.getRootInterface") as mockRoot, \
|
||||
patch("modules.security.rbacCatalog.getCatalogService") as mockCatalog:
|
||||
rootIf = MagicMock()
|
||||
rootIf.db.getRecordset.return_value = []
|
||||
userMandate = MagicMock()
|
||||
userMandate.mandateId = "m1"
|
||||
rootIf.getUserMandates.return_value = [userMandate]
|
||||
rootIf.getFeatureInstancesByMandate.return_value = []
|
||||
mockRoot.return_value = rootIf
|
||||
|
||||
catalog = MagicMock()
|
||||
catalog.getFeaturesWithDataObjects.return_value = ["trustee"]
|
||||
mockCatalog.return_value = catalog
|
||||
|
||||
ctx = MagicMock()
|
||||
ctx.user.id = "u1"
|
||||
ctx.mandateId = None
|
||||
|
||||
result = self._runAsync(
|
||||
_buildTree.getChildrenForParents("inst-1", [None], ctx)
|
||||
)
|
||||
keys = [c["key"] for c in result["__root__"]]
|
||||
self.assertEqual(keys, ["personalRoot"])
|
||||
|
||||
def test_personal_root_listed_first_via_display_order(self):
|
||||
with patch("modules.interfaces.interfaceDbApp.getRootInterface") as mockRoot, \
|
||||
patch("modules.security.rbacCatalog.getCatalogService") as mockCatalog:
|
||||
rootIf = MagicMock()
|
||||
rootIf.db.getRecordset.return_value = []
|
||||
userMandate = MagicMock()
|
||||
userMandate.mandateId = "m1"
|
||||
rootIf.getUserMandates.return_value = [userMandate]
|
||||
featureInst = MagicMock()
|
||||
featureInst.id = "fi-1"
|
||||
featureInst.featureCode = "trustee"
|
||||
featureInst.enabled = True
|
||||
rootIf.getFeatureInstancesByMandate.return_value = [featureInst]
|
||||
featureAccess = MagicMock()
|
||||
featureAccess.enabled = True
|
||||
rootIf.getFeatureAccess.return_value = featureAccess
|
||||
mockRoot.return_value = rootIf
|
||||
|
||||
catalog = MagicMock()
|
||||
catalog.getFeaturesWithDataObjects.return_value = ["trustee"]
|
||||
mockCatalog.return_value = catalog
|
||||
|
||||
ctx = MagicMock()
|
||||
ctx.user.id = "u1"
|
||||
ctx.mandateId = None
|
||||
|
||||
result = self._runAsync(
|
||||
_buildTree.getChildrenForParents("inst-1", [None], ctx)
|
||||
)
|
||||
children = result["__root__"]
|
||||
self.assertEqual(children[0]["key"], "personalRoot")
|
||||
self.assertEqual(children[0]["displayOrder"], 0)
|
||||
|
||||
|
||||
class TestFeatureTableFields(unittest.TestCase):
|
||||
"""Per-column field expansion under a feature data-source table."""
|
||||
|
||||
def test_emits_one_node_per_field(self):
|
||||
nodes = _buildTree._featureTableFields(
|
||||
parentKey="fdstbl|fi-1|TrusteePosition",
|
||||
featureInstanceId="fi-1",
|
||||
tableName="TrusteePosition",
|
||||
fieldNames=["id", "valuta", "company"],
|
||||
allFds=[],
|
||||
)
|
||||
self.assertEqual(len(nodes), 3)
|
||||
self.assertEqual(nodes[0]["kind"], "fdsField")
|
||||
self.assertEqual(nodes[0]["fieldName"], "id")
|
||||
self.assertEqual(nodes[0]["parentKey"], "fdstbl|fi-1|TrusteePosition")
|
||||
self.assertEqual(nodes[0]["key"], "fdsfld|fi-1|TrusteePosition|id")
|
||||
self.assertFalse(nodes[0]["hasChildren"])
|
||||
self.assertFalse(nodes[0]["supportsRag"])
|
||||
|
||||
def test_field_neutralize_inherits_from_table_blanket(self):
|
||||
rec = {"id": "f", "workspaceInstanceId": "ws-1", "featureInstanceId": "fi-1",
|
||||
"tableName": "TrusteePosition", "recordFilter": None,
|
||||
"neutralize": True, "neutralizeFields": None,
|
||||
"scope": None, "ragIndexEnabled": False}
|
||||
nodes = _buildTree._featureTableFields(
|
||||
parentKey="fdstbl|fi-1|TrusteePosition",
|
||||
featureInstanceId="fi-1",
|
||||
tableName="TrusteePosition",
|
||||
fieldNames=["email", "company"],
|
||||
allFds=[rec],
|
||||
)
|
||||
self.assertTrue(nodes[0]["effectiveNeutralize"])
|
||||
self.assertTrue(nodes[1]["effectiveNeutralize"])
|
||||
|
||||
def test_field_neutralize_explicit_via_neutralize_fields(self):
|
||||
rec = {"id": "f", "workspaceInstanceId": "ws-1", "featureInstanceId": "fi-1",
|
||||
"tableName": "TrusteePosition", "recordFilter": None,
|
||||
"neutralize": False, "neutralizeFields": ["email"],
|
||||
"scope": None, "ragIndexEnabled": False}
|
||||
nodes = _buildTree._featureTableFields(
|
||||
parentKey="fdstbl|fi-1|TrusteePosition",
|
||||
featureInstanceId="fi-1",
|
||||
tableName="TrusteePosition",
|
||||
fieldNames=["email", "company"],
|
||||
allFds=[rec],
|
||||
)
|
||||
byField = {n["fieldName"]: n for n in nodes}
|
||||
self.assertTrue(byField["email"]["effectiveNeutralize"])
|
||||
self.assertFalse(byField["company"]["effectiveNeutralize"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -34,15 +34,19 @@ def _ds(idVal: str, path: str, **flags) -> dict:
|
|||
|
||||
|
||||
def _fds(idVal: str, *, tableName: str, recordFilter=None, featureInstanceId="fi-1", **flags) -> dict:
|
||||
"""Build a FeatureDataSource dict fixture."""
|
||||
"""Build a FeatureDataSource dict fixture.
|
||||
|
||||
FDS records no longer carry `userId`, `workspaceInstanceId`, or
|
||||
`scope`; visibility/edit-permission live on the feature instance
|
||||
via RBAC. Tests should only set neutralize/ragIndexEnabled.
|
||||
"""
|
||||
base = {
|
||||
"id": idVal,
|
||||
"workspaceInstanceId": "ws-1",
|
||||
"featureInstanceId": featureInstanceId,
|
||||
"tableName": tableName,
|
||||
"recordFilter": recordFilter,
|
||||
"neutralize": None,
|
||||
"scope": None,
|
||||
"ragIndexEnabled": None,
|
||||
}
|
||||
base.update(flags)
|
||||
return base
|
||||
|
|
@ -473,6 +477,7 @@ class TestFdsCascadeReset(unittest.TestCase):
|
|||
_inheritFlags.cascadeResetDescendantsFds(rootIf, ws, "doesNotExist")
|
||||
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# FeatureDataSource: collectAncestorChainFds
|
||||
# ===========================================================================
|
||||
|
|
@ -572,28 +577,32 @@ class TestResolveEffectiveForPath(unittest.TestCase):
|
|||
|
||||
|
||||
class TestResolveEffectiveForFds(unittest.TestCase):
|
||||
"""FDS records carry only `neutralize` + `ragIndexEnabled`. No scope.
|
||||
|
||||
`resolveEffectiveForFds` therefore returns a two-key dict; tests
|
||||
must not assert anything about `effectiveScope` on FDS results.
|
||||
"""
|
||||
|
||||
def test_with_exact_record(self):
|
||||
ws = _fds("ws", tableName="*", neutralize=True, scope="mandate")
|
||||
tbl = _fds("t", tableName="Pos", neutralize=False, scope="personal")
|
||||
ws = _fds("ws", tableName="*", neutralize=True)
|
||||
tbl = _fds("t", tableName="Pos", neutralize=False)
|
||||
allFds = [ws, tbl]
|
||||
result = _inheritFlags.resolveEffectiveForFds("fi-1", "Pos", None, allFds)
|
||||
self.assertEqual(result["effectiveNeutralize"], False)
|
||||
self.assertEqual(result["effectiveScope"], "personal")
|
||||
self.assertEqual(result["effectiveRagIndexEnabled"], False)
|
||||
self.assertNotIn("effectiveScope", result)
|
||||
|
||||
def test_without_record_inherits_from_workspace_wildcard(self):
|
||||
ws = _fds("ws", tableName="*", neutralize=True, scope="mandate", ragIndexEnabled=True)
|
||||
def test_without_record_inherits_from_feature_wildcard(self):
|
||||
ws = _fds("ws", tableName="*", neutralize=True, ragIndexEnabled=True)
|
||||
allFds = [ws]
|
||||
result = _inheritFlags.resolveEffectiveForFds("fi-1", "Unknown", None, allFds)
|
||||
self.assertEqual(result["effectiveNeutralize"], True)
|
||||
self.assertEqual(result["effectiveScope"], "mandate")
|
||||
self.assertEqual(result["effectiveRagIndexEnabled"], True)
|
||||
|
||||
def test_without_record_no_ancestors_returns_defaults(self):
|
||||
allFds: list = []
|
||||
result = _inheritFlags.resolveEffectiveForFds("fi-1", "Pos", None, allFds)
|
||||
self.assertEqual(result["effectiveNeutralize"], False)
|
||||
self.assertEqual(result["effectiveScope"], "personal")
|
||||
self.assertEqual(result["effectiveRagIndexEnabled"], False)
|
||||
|
||||
def test_rag_inherits_when_table_overrides_neutralize_only(self):
|
||||
|
|
@ -611,10 +620,10 @@ class TestResolveEffectiveForFds(unittest.TestCase):
|
|||
result = _inheritFlags.resolveEffectiveForFds("fi-1", "*", None, allFds, mode="aggregate")
|
||||
self.assertEqual(result["effectiveRagIndexEnabled"], "mixed")
|
||||
|
||||
def test_inheritable_fds_flags_includes_rag(self):
|
||||
def test_inheritable_fds_flags_excludes_scope(self):
|
||||
self.assertIn("ragIndexEnabled", _inheritFlags._INHERITABLE_FDS_FLAGS)
|
||||
self.assertIn("neutralize", _inheritFlags._INHERITABLE_FDS_FLAGS)
|
||||
self.assertIn("scope", _inheritFlags._INHERITABLE_FDS_FLAGS)
|
||||
self.assertNotIn("scope", _inheritFlags._INHERITABLE_FDS_FLAGS)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
|
|
@ -651,5 +660,80 @@ class TestPathNormalization(unittest.TestCase):
|
|||
self.assertEqual(_inheritFlags._normalisePath("foo/bar"), "/foo/bar")
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Virtual coordinates (no DB record) must support aggregate mode (mixed)
|
||||
# ===========================================================================
|
||||
|
||||
class TestVirtualCoordAggregate(unittest.TestCase):
|
||||
"""After the spec-recovery fix, resolveEffectiveForPath/Fds with
|
||||
mode='aggregate' must return 'mixed' for coordinates that have no DB
|
||||
record but whose descendants in the DB diverge."""
|
||||
|
||||
def test_virtual_folder_mixed_neutralize(self):
|
||||
child1 = _ds("c1", "/virtual/a", neutralize=True)
|
||||
child2 = _ds("c2", "/virtual/b", neutralize=False)
|
||||
allDs = [child1, child2]
|
||||
result = _inheritFlags.resolveEffectiveForPath(
|
||||
"conn-1", "sharepointFolder", "/virtual", allDs, mode="aggregate",
|
||||
)
|
||||
self.assertEqual(result["effectiveNeutralize"], "mixed")
|
||||
|
||||
def test_virtual_folder_mixed_scope(self):
|
||||
child1 = _ds("c1", "/virtual/a", scope="mandate")
|
||||
child2 = _ds("c2", "/virtual/b", scope="personal")
|
||||
allDs = [child1, child2]
|
||||
result = _inheritFlags.resolveEffectiveForPath(
|
||||
"conn-1", "sharepointFolder", "/virtual", allDs, mode="aggregate",
|
||||
)
|
||||
self.assertEqual(result["effectiveScope"], "mixed")
|
||||
|
||||
def test_virtual_folder_mixed_rag(self):
|
||||
child1 = _ds("c1", "/virtual/a", ragIndexEnabled=True)
|
||||
child2 = _ds("c2", "/virtual/b", ragIndexEnabled=False)
|
||||
allDs = [child1, child2]
|
||||
result = _inheritFlags.resolveEffectiveForPath(
|
||||
"conn-1", "sharepointFolder", "/virtual", allDs, mode="aggregate",
|
||||
)
|
||||
self.assertEqual(result["effectiveRagIndexEnabled"], "mixed")
|
||||
|
||||
def test_virtual_folder_uniform_returns_concrete(self):
|
||||
child1 = _ds("c1", "/virtual/a", neutralize=True)
|
||||
child2 = _ds("c2", "/virtual/b", neutralize=True)
|
||||
allDs = [child1, child2]
|
||||
result = _inheritFlags.resolveEffectiveForPath(
|
||||
"conn-1", "sharepointFolder", "/virtual", allDs, mode="aggregate",
|
||||
)
|
||||
self.assertTrue(result["effectiveNeutralize"])
|
||||
|
||||
def test_virtual_fds_workspace_mixed_neutralize(self):
|
||||
tblA = _fds("tA", tableName="A", neutralize=True)
|
||||
tblB = _fds("tB", tableName="B", neutralize=False)
|
||||
allFds = [tblA, tblB]
|
||||
result = _inheritFlags.resolveEffectiveForFds(
|
||||
"fi-1", "*", None, allFds, mode="aggregate",
|
||||
)
|
||||
self.assertEqual(result["effectiveNeutralize"], "mixed")
|
||||
|
||||
def test_virtual_fds_workspace_uniform_returns_concrete(self):
|
||||
tblA = _fds("tA", tableName="A", neutralize=True)
|
||||
tblB = _fds("tB", tableName="B", neutralize=True)
|
||||
allFds = [tblA, tblB]
|
||||
result = _inheritFlags.resolveEffectiveForFds(
|
||||
"fi-1", "*", None, allFds, mode="aggregate",
|
||||
)
|
||||
self.assertTrue(result["effectiveNeutralize"])
|
||||
|
||||
def test_virtual_connection_root_mixed_via_services(self):
|
||||
"""Connection root (authority sourceType, path='/') with no DB record
|
||||
but services that diverge must return 'mixed'."""
|
||||
spRecord = _ds("sp", "/", sourceType="sharepointFolder", neutralize=True)
|
||||
olRecord = _ds("ol", "/", sourceType="outlookFolder", neutralize=False)
|
||||
allDs = [spRecord, olRecord]
|
||||
result = _inheritFlags.resolveEffectiveForPath(
|
||||
"conn-1", "msft", "/", allDs, mode="aggregate",
|
||||
)
|
||||
self.assertEqual(result["effectiveNeutralize"], "mixed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
|
|||
449
tests/unit/services/test_udbNodes.py
Normal file
449
tests/unit/services/test_udbNodes.py
Normal file
|
|
@ -0,0 +1,449 @@
|
|||
"""Unit tests for the polymorphic UDB node hierarchy (udbNodes.py).
|
||||
|
||||
Each concrete node class is exercised for:
|
||||
- `supportsFlag` returns the right set per kind
|
||||
- `canEdit` enforces DS-owner vs FDS-feature-admin
|
||||
- `getEffectiveFlag` resolves walk + aggregate correctly
|
||||
- `setFlag` writes the right record and (where applicable) cascades
|
||||
- `toDict` produces the expected wire shape
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from modules.serviceCenter.services.serviceKnowledge.udbNodes import (
|
||||
UdbNode,
|
||||
SyntheticContainerNode,
|
||||
MandateGroupNode,
|
||||
ConnectionNode,
|
||||
ServiceNode,
|
||||
FolderNode,
|
||||
FileNode,
|
||||
FdsWorkspaceNode,
|
||||
FdsTableNode,
|
||||
FdsFieldNode,
|
||||
_isFeatureAdmin,
|
||||
)
|
||||
|
||||
|
||||
class _FakeUser:
|
||||
def __init__(self, userId: str = "user-1"):
|
||||
self.id = userId
|
||||
|
||||
|
||||
class _FakeContext:
|
||||
def __init__(self, userId: str = "user-1"):
|
||||
self.user = _FakeUser(userId)
|
||||
self.mandateId = "m1"
|
||||
|
||||
|
||||
class TestSupportsFlag(unittest.TestCase):
|
||||
def test_synthetic_container_supports_nothing(self):
|
||||
n = SyntheticContainerNode("k", "label", icon="x")
|
||||
self.assertFalse(n.supportsFlag("neutralize"))
|
||||
self.assertFalse(n.supportsFlag("scope"))
|
||||
self.assertFalse(n.supportsFlag("ragIndexEnabled"))
|
||||
|
||||
def test_connection_supports_all_three(self):
|
||||
n = ConnectionNode("c1", "msft", label="m", parentKey="personalRoot", rec=None)
|
||||
self.assertTrue(n.supportsFlag("neutralize"))
|
||||
self.assertTrue(n.supportsFlag("scope"))
|
||||
self.assertTrue(n.supportsFlag("ragIndexEnabled"))
|
||||
|
||||
def test_fds_table_supports_neutralize_and_rag_but_not_scope(self):
|
||||
n = FdsTableNode(
|
||||
featureInstanceId="fi1", featureCode="trustee", tableName="Pos",
|
||||
objectKey="data.feature.trustee.Pos", label="Positions",
|
||||
parentKey="feat|m1|trustee|fi1", rec=None, hasFields=False,
|
||||
)
|
||||
self.assertTrue(n.supportsFlag("neutralize"))
|
||||
self.assertTrue(n.supportsFlag("ragIndexEnabled"))
|
||||
self.assertFalse(n.supportsFlag("scope"))
|
||||
|
||||
def test_fds_field_supports_only_neutralize(self):
|
||||
n = FdsFieldNode(
|
||||
featureInstanceId="fi1", tableName="Pos", fieldName="amount",
|
||||
parentKey="fdstbl|fi1|Pos", tableRec=None, featureCode="trustee",
|
||||
)
|
||||
self.assertTrue(n.supportsFlag("neutralize"))
|
||||
self.assertFalse(n.supportsFlag("scope"))
|
||||
self.assertFalse(n.supportsFlag("ragIndexEnabled"))
|
||||
|
||||
|
||||
class TestCanEditDataSourceOwner(unittest.TestCase):
|
||||
def test_owner_can_edit(self):
|
||||
rec = {"id": "ds1", "userId": "user-1"}
|
||||
node = ConnectionNode("c1", "msft", "m", "personalRoot", rec=rec)
|
||||
self.assertTrue(node.canEdit(_FakeContext("user-1"), MagicMock()))
|
||||
|
||||
def test_non_owner_cannot_edit(self):
|
||||
rec = {"id": "ds1", "userId": "user-other"}
|
||||
node = ConnectionNode("c1", "msft", "m", "personalRoot", rec=rec)
|
||||
self.assertFalse(node.canEdit(_FakeContext("user-1"), MagicMock()))
|
||||
|
||||
def test_virtual_node_own_connection_can_edit(self):
|
||||
rootIf = MagicMock()
|
||||
rootIf.db.getRecord.return_value = {"id": "c1", "userId": "user-1"}
|
||||
node = ConnectionNode("c1", "msft", "m", "personalRoot", rec=None)
|
||||
self.assertTrue(node.canEdit(_FakeContext("user-1"), rootIf))
|
||||
|
||||
def test_virtual_node_other_connection_cannot_edit(self):
|
||||
rootIf = MagicMock()
|
||||
rootIf.db.getRecord.return_value = {"id": "c1", "userId": "user-other"}
|
||||
node = ConnectionNode("c1", "msft", "m", "personalRoot", rec=None)
|
||||
self.assertFalse(node.canEdit(_FakeContext("user-1"), rootIf))
|
||||
|
||||
def test_virtual_node_missing_connection_cannot_edit(self):
|
||||
rootIf = MagicMock()
|
||||
rootIf.db.getRecord.return_value = None
|
||||
node = ConnectionNode("c1", "msft", "m", "personalRoot", rec=None)
|
||||
self.assertFalse(node.canEdit(_FakeContext("user-1"), rootIf))
|
||||
|
||||
|
||||
class TestCanEditFdsFeatureAdmin(unittest.TestCase):
|
||||
def _buildRootIfWithAdminRole(self, hasAdmin: bool):
|
||||
rootIf = MagicMock()
|
||||
access = MagicMock(id="acc1", enabled=True)
|
||||
rootIf.getFeatureAccess.return_value = access
|
||||
rootIf.getRoleIdsForFeatureAccess.return_value = ["role-1"]
|
||||
rootIf.db.getRecord.return_value = {
|
||||
"id": "role-1",
|
||||
"roleLabel": "trustee-admin" if hasAdmin else "trustee-user",
|
||||
}
|
||||
return rootIf
|
||||
|
||||
def test_admin_can_edit_fds_table(self):
|
||||
rootIf = self._buildRootIfWithAdminRole(hasAdmin=True)
|
||||
node = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
|
||||
"feat|m1|trustee|fi1", rec={"id": "fds1"}, hasFields=False)
|
||||
self.assertTrue(node.canEdit(_FakeContext(), rootIf))
|
||||
|
||||
def test_non_admin_cannot_edit_fds_table(self):
|
||||
rootIf = self._buildRootIfWithAdminRole(hasAdmin=False)
|
||||
node = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
|
||||
"feat|m1|trustee|fi1", rec={"id": "fds1"}, hasFields=False)
|
||||
self.assertFalse(node.canEdit(_FakeContext(), rootIf))
|
||||
|
||||
def test_fds_field_uses_feature_admin_check(self):
|
||||
rootIf = self._buildRootIfWithAdminRole(hasAdmin=True)
|
||||
field = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
|
||||
tableRec={"id": "fds1"}, featureCode="trustee")
|
||||
self.assertTrue(field.canEdit(_FakeContext(), rootIf))
|
||||
|
||||
|
||||
class TestGetEffectiveFlag(unittest.TestCase):
|
||||
def test_ds_walk_inherits_from_authority_root(self):
|
||||
root = {
|
||||
"id": "r", "connectionId": "c", "sourceType": "msft", "path": "/",
|
||||
"userId": "user-1", "neutralize": True, "scope": None, "ragIndexEnabled": None,
|
||||
}
|
||||
node = FolderNode(
|
||||
connectionId="c", service="sharepoint", sourceType="sharepointFolder",
|
||||
path="/sites/x", label="x", parentKey="svc|c|sharepoint",
|
||||
rec=None, hasChildren=True,
|
||||
)
|
||||
self.assertTrue(node.getEffectiveFlag("neutralize", [root], [], "walk"))
|
||||
|
||||
def test_fds_field_neutralize_from_neutralize_fields(self):
|
||||
rec = {
|
||||
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||
"recordFilter": None, "neutralizeFields": ["amount"],
|
||||
}
|
||||
node = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
|
||||
tableRec=rec, featureCode="trustee")
|
||||
self.assertTrue(node.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
|
||||
|
||||
other = FdsFieldNode("fi1", "Pos", "currency", "fdstbl|fi1|Pos",
|
||||
tableRec=rec, featureCode="trustee")
|
||||
# currency is not in the override list and the table has no
|
||||
# explicit neutralize -> inherits the default (False).
|
||||
self.assertFalse(other.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
|
||||
|
||||
def test_fds_field_inherits_true_from_table(self):
|
||||
"""Field without explicit override inherits the table's explicit
|
||||
neutralize. Regression: previously fell through to False, so
|
||||
toggling the table left the field icon unchanged."""
|
||||
rec = {
|
||||
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||
"recordFilter": None, "neutralize": True, "neutralizeFields": None,
|
||||
}
|
||||
node = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
|
||||
tableRec=rec, featureCode="trustee")
|
||||
self.assertTrue(node.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
|
||||
|
||||
def test_fds_field_inherits_from_workspace_via_table(self):
|
||||
"""Field walks the whole FDS ancestor chain: table -> workspace."""
|
||||
ws = {
|
||||
"id": "fds-ws", "featureInstanceId": "fi1", "tableName": "*",
|
||||
"recordFilter": None, "neutralize": True,
|
||||
}
|
||||
tbl = {
|
||||
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||
"recordFilter": None, "neutralize": None, "neutralizeFields": None,
|
||||
}
|
||||
node = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
|
||||
tableRec=tbl, featureCode="trustee")
|
||||
self.assertTrue(node.getEffectiveFlag("neutralize", [], [ws, tbl], "aggregate"))
|
||||
|
||||
def test_fds_field_explicit_override_beats_table_false(self):
|
||||
"""Per-column override (True via list entry) beats an explicit
|
||||
table False -- this is the one case where the two-source model
|
||||
diverges intentionally and produces the 'mixed' aggregate."""
|
||||
rec = {
|
||||
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||
"recordFilter": None, "neutralize": False, "neutralizeFields": ["amount"],
|
||||
}
|
||||
amount = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
|
||||
tableRec=rec, featureCode="trustee")
|
||||
other = FdsFieldNode("fi1", "Pos", "currency", "fdstbl|fi1|Pos",
|
||||
tableRec=rec, featureCode="trustee")
|
||||
self.assertTrue(amount.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
|
||||
# currency inherits from table -> False
|
||||
self.assertFalse(other.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
|
||||
|
||||
def test_fds_table_mixed_when_field_and_table_disagree(self):
|
||||
# table.neutralize=False, field "amount" is in neutralizeFields => mixed
|
||||
rec = {
|
||||
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||
"recordFilter": None, "neutralize": False, "ragIndexEnabled": None,
|
||||
"neutralizeFields": ["amount"],
|
||||
}
|
||||
table = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
|
||||
"feat|m1|trustee|fi1", rec=rec, hasFields=True)
|
||||
# FdsTableNode.getEffectiveFlag itself only consults FDS records,
|
||||
# not field nodes. The aggregation across field nodes is wired
|
||||
# in _buildTree via `_wireTableFieldsAsLogicalChildren`. So we
|
||||
# exercise the explicit FDS walk here:
|
||||
val = table.getEffectiveFlag("neutralize", [], [rec], "walk")
|
||||
self.assertFalse(val)
|
||||
|
||||
|
||||
class TestSetFlag(unittest.TestCase):
|
||||
def test_setflag_writes_value_on_ds(self):
|
||||
rec = {"id": "ds1", "connectionId": "c", "sourceType": "msft", "path": "/",
|
||||
"userId": "user-1"}
|
||||
node = ConnectionNode("c", "msft", "m", "personalRoot", rec=rec)
|
||||
rootIf = MagicMock()
|
||||
rootIf.db.getRecordset.return_value = [] # no siblings -> no cascade
|
||||
node.setFlag("neutralize", True, rootIf)
|
||||
rootIf.db.recordModify.assert_called()
|
||||
args = rootIf.db.recordModify.call_args[0]
|
||||
self.assertEqual(args[1], "ds1")
|
||||
self.assertEqual(args[2], {"neutralize": True})
|
||||
|
||||
def test_setflag_virtual_ds_auto_creates_record(self):
|
||||
"""Toggling a flag on a virtual DS node must auto-create the
|
||||
DataSource record so the flag can be persisted."""
|
||||
node = FolderNode(
|
||||
connectionId="c1", service="sharepoint",
|
||||
sourceType="sharepointFolder", path="/sites/x/docs",
|
||||
label="docs", parentKey="svc|c1|sharepoint",
|
||||
rec=None, hasChildren=True,
|
||||
)
|
||||
rootIf = MagicMock()
|
||||
rootIf.db.getRecordset.return_value = []
|
||||
rootIf.db.getRecord.return_value = {"id": "c1", "userId": "user-1"}
|
||||
createdRec = {"id": "ds-new", "connectionId": "c1",
|
||||
"sourceType": "sharepointFolder", "path": "/sites/x/docs",
|
||||
"userId": "user-1"}
|
||||
rootIf.db.recordCreate.return_value = createdRec
|
||||
node.setFlag("neutralize", True, rootIf)
|
||||
rootIf.db.recordCreate.assert_called_once()
|
||||
rootIf.db.recordModify.assert_called()
|
||||
args = rootIf.db.recordModify.call_args[0]
|
||||
self.assertEqual(args[1], "ds-new")
|
||||
self.assertEqual(args[2], {"neutralize": True})
|
||||
|
||||
def test_fds_field_setflag_mutates_neutralize_fields(self):
|
||||
rec = {
|
||||
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||
"recordFilter": None, "neutralize": False, "neutralizeFields": None,
|
||||
}
|
||||
node = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
|
||||
tableRec=rec, featureCode="trustee")
|
||||
rootIf = MagicMock()
|
||||
node.setFlag("neutralize", True, rootIf)
|
||||
rootIf.db.recordModify.assert_called()
|
||||
# last call: set neutralizeFields to ["amount"]
|
||||
args = rootIf.db.recordModify.call_args[0]
|
||||
self.assertEqual(args[1], "fds-tbl")
|
||||
self.assertEqual(args[2], {"neutralizeFields": ["amount"]})
|
||||
|
||||
def test_fds_field_setflag_removes_field_when_toggled_off(self):
|
||||
rec = {
|
||||
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||
"recordFilter": None, "neutralizeFields": ["amount", "currency"],
|
||||
}
|
||||
node = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
|
||||
tableRec=rec, featureCode="trustee")
|
||||
rootIf = MagicMock()
|
||||
node.setFlag("neutralize", False, rootIf)
|
||||
args = rootIf.db.recordModify.call_args[0]
|
||||
self.assertEqual(args[2], {"neutralizeFields": ["currency"]})
|
||||
|
||||
def test_fds_field_setflag_roundtrip(self):
|
||||
rec = {
|
||||
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||
"recordFilter": None, "neutralizeFields": None,
|
||||
}
|
||||
node = FdsFieldNode("fi1", "Pos", "amount", "fdstbl|fi1|Pos",
|
||||
tableRec=rec, featureCode="trustee")
|
||||
rootIf = MagicMock()
|
||||
node.setFlag("neutralize", True, rootIf)
|
||||
self.assertTrue(node.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
|
||||
node.setFlag("neutralize", False, rootIf)
|
||||
self.assertFalse(node.getEffectiveFlag("neutralize", [], [rec], "aggregate"))
|
||||
|
||||
def test_fds_table_explicit_neutralize_wipes_own_neutralize_fields(self):
|
||||
"""Setting an explicit neutralize on a table must clear its own
|
||||
`neutralizeFields` list. Otherwise the table's aggregate stays
|
||||
'mixed' because field children walk to True via that list and the
|
||||
UI shows no change after the toggle."""
|
||||
rec = {
|
||||
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||
"recordFilter": None, "neutralize": None,
|
||||
"neutralizeFields": ["amount", "currency"],
|
||||
}
|
||||
node = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
|
||||
"feat|m1|trustee|fi1", rec=rec, hasFields=True)
|
||||
rootIf = MagicMock()
|
||||
rootIf.db.getRecordset.return_value = [rec] # no descendants
|
||||
node.setFlag("neutralize", False, rootIf)
|
||||
rootIf.db.recordModify.assert_called()
|
||||
args = rootIf.db.recordModify.call_args[0]
|
||||
self.assertEqual(args[1], "fds-tbl")
|
||||
self.assertEqual(
|
||||
args[2], {"neutralize": False, "neutralizeFields": None},
|
||||
)
|
||||
|
||||
def test_fds_table_setflag_inherit_keeps_neutralize_fields(self):
|
||||
"""`value=None` (reset to inherit) must NOT cascade and must NOT
|
||||
wipe `neutralizeFields`; that matches the cascade-reset spec
|
||||
(only explicit toggles clear descendants)."""
|
||||
rec = {
|
||||
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||
"recordFilter": None, "neutralize": True,
|
||||
"neutralizeFields": ["amount"],
|
||||
}
|
||||
node = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
|
||||
"feat|m1|trustee|fi1", rec=rec, hasFields=True)
|
||||
rootIf = MagicMock()
|
||||
rootIf.db.getRecordset.return_value = [rec]
|
||||
node.setFlag("neutralize", None, rootIf)
|
||||
args = rootIf.db.recordModify.call_args[0]
|
||||
self.assertEqual(args[2], {"neutralize": None})
|
||||
|
||||
def test_fds_table_setflag_rag_does_not_touch_neutralize_fields(self):
|
||||
"""A RAG toggle on the table must leave `neutralizeFields` alone
|
||||
(it is neutralize-only field state)."""
|
||||
rec = {
|
||||
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||
"recordFilter": None, "ragIndexEnabled": None,
|
||||
"neutralizeFields": ["amount"],
|
||||
}
|
||||
node = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
|
||||
"feat|m1|trustee|fi1", rec=rec, hasFields=True)
|
||||
rootIf = MagicMock()
|
||||
rootIf.db.getRecordset.return_value = [rec]
|
||||
node.setFlag("ragIndexEnabled", True, rootIf)
|
||||
args = rootIf.db.recordModify.call_args[0]
|
||||
self.assertEqual(args[2], {"ragIndexEnabled": True})
|
||||
|
||||
def test_fds_workspace_neutralize_clears_descendant_neutralize_fields(self):
|
||||
"""Workspace toggle must clear per-column overrides on descendant
|
||||
tables; otherwise the table aggregate stays 'mixed' because some
|
||||
field children still read True from the list."""
|
||||
wsRec = {
|
||||
"id": "fds-ws", "featureInstanceId": "fi1", "tableName": "*",
|
||||
"recordFilter": None, "neutralize": None,
|
||||
}
|
||||
tblRec = {
|
||||
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||
"recordFilter": None, "neutralize": None,
|
||||
"neutralizeFields": ["amount", "currency"],
|
||||
}
|
||||
node = FdsWorkspaceNode("m1", "trustee", "fi1", label="Trustee",
|
||||
icon="trustee", parentKey="mgrp|m1", rec=wsRec)
|
||||
rootIf = MagicMock()
|
||||
rootIf.db.getRecordset.return_value = [wsRec, tblRec]
|
||||
node.setFlag("neutralize", True, rootIf)
|
||||
calls = rootIf.db.recordModify.call_args_list
|
||||
modifyMap = {c[0][1]: c[0][2] for c in calls}
|
||||
self.assertEqual(modifyMap["fds-tbl"], {"neutralizeFields": None})
|
||||
self.assertEqual(modifyMap["fds-ws"], {"neutralize": True})
|
||||
|
||||
def test_fds_workspace_rag_does_not_clear_descendant_neutralize_fields(self):
|
||||
"""A RAG toggle on the workspace must not touch descendant
|
||||
`neutralizeFields`."""
|
||||
wsRec = {
|
||||
"id": "fds-ws", "featureInstanceId": "fi1", "tableName": "*",
|
||||
"recordFilter": None, "ragIndexEnabled": None,
|
||||
}
|
||||
tblRec = {
|
||||
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||
"recordFilter": None, "ragIndexEnabled": None,
|
||||
"neutralizeFields": ["amount"],
|
||||
}
|
||||
node = FdsWorkspaceNode("m1", "trustee", "fi1", label="Trustee",
|
||||
icon="trustee", parentKey="mgrp|m1", rec=wsRec)
|
||||
rootIf = MagicMock()
|
||||
rootIf.db.getRecordset.return_value = [wsRec, tblRec]
|
||||
node.setFlag("ragIndexEnabled", True, rootIf)
|
||||
calls = rootIf.db.recordModify.call_args_list
|
||||
modifyIds = [c[0][1] for c in calls]
|
||||
self.assertNotIn("fds-tbl", modifyIds)
|
||||
|
||||
|
||||
class TestToDict(unittest.TestCase):
|
||||
def test_fds_table_dict_has_neutralize_fields(self):
|
||||
rec = {
|
||||
"id": "fds-tbl", "featureInstanceId": "fi1", "tableName": "Pos",
|
||||
"neutralizeFields": ["amount"],
|
||||
}
|
||||
node = FdsTableNode("fi1", "trustee", "Pos", "key", "Positions",
|
||||
"feat|m1|trustee|fi1", rec=rec, hasFields=True)
|
||||
out = node.toDict([], [rec])
|
||||
self.assertEqual(out["neutralizeFields"], ["amount"])
|
||||
self.assertEqual(out["kind"], "fdsTable")
|
||||
self.assertEqual(out["modelType"], "FeatureDataSource")
|
||||
self.assertEqual(out["effectiveScope"], "personal") # FDS has no scope
|
||||
|
||||
def test_synthetic_container_has_no_dataSourceId(self):
|
||||
n = SyntheticContainerNode("personalRoot", "Personal", icon="person",
|
||||
defaultExpanded=True)
|
||||
d = n.toDict([], [])
|
||||
self.assertIsNone(d["dataSourceId"])
|
||||
self.assertEqual(d["effectiveNeutralize"], False)
|
||||
|
||||
|
||||
class TestIsFeatureAdmin(unittest.TestCase):
|
||||
def test_no_access_returns_false(self):
|
||||
rootIf = MagicMock()
|
||||
rootIf.getFeatureAccess.return_value = None
|
||||
self.assertFalse(_isFeatureAdmin(rootIf, "user-1", "fi1"))
|
||||
|
||||
def test_no_roles_returns_false(self):
|
||||
rootIf = MagicMock()
|
||||
rootIf.getFeatureAccess.return_value = MagicMock(id="acc1", enabled=True)
|
||||
rootIf.getRoleIdsForFeatureAccess.return_value = []
|
||||
self.assertFalse(_isFeatureAdmin(rootIf, "user-1", "fi1"))
|
||||
|
||||
def test_non_admin_role_returns_false(self):
|
||||
rootIf = MagicMock()
|
||||
rootIf.getFeatureAccess.return_value = MagicMock(id="acc1", enabled=True)
|
||||
rootIf.getRoleIdsForFeatureAccess.return_value = ["r1"]
|
||||
rootIf.db.getRecord.return_value = {"id": "r1", "roleLabel": "trustee-user"}
|
||||
self.assertFalse(_isFeatureAdmin(rootIf, "user-1", "fi1"))
|
||||
|
||||
def test_admin_role_returns_true(self):
|
||||
rootIf = MagicMock()
|
||||
rootIf.getFeatureAccess.return_value = MagicMock(id="acc1", enabled=True)
|
||||
rootIf.getRoleIdsForFeatureAccess.return_value = ["r1"]
|
||||
rootIf.db.getRecord.return_value = {"id": "r1", "roleLabel": "workspace-admin"}
|
||||
self.assertTrue(_isFeatureAdmin(rootIf, "user-1", "fi1"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -27,7 +27,7 @@ def test_context_extractContent_node_shape():
|
|||
assert "LoopItem" in node["inputPorts"][0]["accepts"]
|
||||
names = [p["name"] for p in node["parameters"]]
|
||||
assert names == [
|
||||
"context",
|
||||
"documentList",
|
||||
"contentFilter",
|
||||
"outputMode",
|
||||
"splitBy",
|
||||
|
|
|
|||
Loading…
Reference in a new issue