Merge pull request #159 from valueonag/int

Int
This commit is contained in:
Patrick Motsch 2026-05-10 22:21:28 +02:00 committed by GitHub
commit 57a9257047
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
120 changed files with 4973 additions and 2377 deletions

View file

@ -29,10 +29,10 @@ ENV
*.swo
*~
# Environment files (env_gcp.env will be copied as .env by workflow)
env_*.env
# Environment files (env-gateway-*.env will be copied as .env by workflow)
env-*.env
.env.local
# Note: .env is NOT ignored - it will be created from env_gcp.env by the workflow
# Note: .env is NOT ignored - it will be created from env-gateway-*.env by the workflow
# Logs
*.log

View file

@ -22,8 +22,8 @@ jobs:
cd /srv/gateway/current &&
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/gateway.git &&
git pull &&
cp env_prod_forgejo.env .env &&
rm -f env_*.env &&
cp env-gateway-prod-forgejo.env .env &&
rm -f env-*.env &&
source .venv/bin/activate &&
pip install -r requirements.txt --no-cache-dir &&
sudo systemctl restart gateway

View file

@ -30,7 +30,7 @@ ENV
*~
# Environment files (will be handled separately)
env_*.env
env-*.env
.env.local
# Logs

View file

@ -11,11 +11,11 @@
# 2. Create secret "CONFIG_KEY" in Secret Manager with your master key
# 3. Grant the service account access to Secret Manager secrets
# 4. Create Cloud SQL instance (if not exists)
# 5. Create env_prod.env and env_int.env files with your configuration
# 5. Create env-gateway-prod.env and env-gateway-int.env files with your configuration
#
# Environment Selection:
# - Push to 'main' branch → uses env_prod.env (production)
# - Push to 'int' branch → uses env_int.env (integration)
# - Push to 'main' branch → uses env-gateway-prod.env (production)
# - Push to 'int' branch → uses env-gateway-int.env (integration)
# - Manual dispatch → select environment (prod/int) to use corresponding env file
name: Deploy Gateway to Google Cloud Run
@ -70,10 +70,10 @@ jobs:
fi
echo "env_type=$ENV_TYPE" >> $GITHUB_OUTPUT
echo "service_name=gateway-$ENV_TYPE" >> $GITHUB_OUTPUT
echo "env_file=env_${ENV_TYPE}.env" >> $GITHUB_OUTPUT
echo "env_file=env-gateway-${ENV_TYPE}.env" >> $GITHUB_OUTPUT
echo "Determined environment: $ENV_TYPE"
echo "Service name: gateway-$ENV_TYPE"
echo "Env file: env_${ENV_TYPE}.env"
echo "Env file: env-gateway-${ENV_TYPE}.env"
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
@ -98,11 +98,11 @@ jobs:
echo "Using $ENV_FILE"
cp "$ENV_FILE" .env
else
echo "Warning: $ENV_FILE not found, using env_prod.env as fallback"
cp env_prod.env .env
echo "Warning: $ENV_FILE not found, using env-gateway-prod.env as fallback"
cp env-gateway-prod.env .env
fi
# Clean up other env files (optional, for security)
rm -f env_*.env
rm -f env-*.env
- name: Build and push container image
working-directory: ./gateway

View file

@ -74,10 +74,10 @@ jobs:
run: unzip release.zip
- name: Set productive environment
run: cp env_int.env .env
run: cp env-gateway-int.env .env
- name: Clean up environment files
run: rm -f env_*.env
run: rm -f env-*.env
- name: 'Deploy to Azure Web App'
uses: azure/webapps-deploy@v3

View file

@ -74,10 +74,10 @@ jobs:
run: unzip release.zip
- name: Set productive environment
run: cp env_prod.env .env
run: cp env-gateway-prod.env .env
- name: Clean up environment files
run: rm -f env_*.env
run: rm -f env-*.env
- name: 'Deploy to Azure Web App'
uses: azure/webapps-deploy@v3

2
.gitignore vendored
View file

@ -131,7 +131,7 @@ env.bak/
venv.bak/
# Don't ignore environment templates
!env*.env
!env-*.env
# Spyder project settings
.spyderproject

View file

@ -28,13 +28,13 @@ COPY requirements.lock .
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir -r requirements.lock
# Copy application code (includes .env file created by workflow from env_gcp.env)
# Copy application code (includes .env file created by workflow from env-gateway-*.env)
COPY . .
# Create directories for logs (Cloud Run uses /tmp for writable storage)
RUN mkdir -p /tmp/logs /tmp/debug
# Note: .env file (created from env_gcp.env by workflow) contains encrypted secrets
# Note: .env file (created from env-gateway-*.env by workflow) contains encrypted secrets
# These are decrypted at runtime using the master key from Secret Manager
# (mounted as CONFIG_KEY environment variable in Cloud Run)

7
app.py
View file

@ -396,6 +396,10 @@ async def lifespan(app: FastAPI):
from modules.shared.auditLogger import registerAuditLogCleanupScheduler
registerAuditLogCleanupScheduler()
# Register enterprise subscription auto-renewal scheduler
from modules.serviceCenter.services.serviceSubscription.enterpriseRenewalScheduler import registerEnterpriseRenewalScheduler
registerEnterpriseRenewalScheduler()
# Recover background jobs that were RUNNING when the previous worker died
try:
from modules.serviceCenter.services.serviceBackgroundJobs.mainBackgroundJobService import (
@ -600,6 +604,9 @@ app.include_router(promptRouter)
from modules.routes.routeDataConnections import router as connectionsRouter
app.include_router(connectionsRouter)
from modules.routes.routeTableViews import router as tableViewsRouter
app.include_router(tableViewsRouter)
from modules.routes.routeSecurityLocal import router as localRouter
app.include_router(localRouter)

View file

@ -45,11 +45,6 @@ Connector_StacSwisstopo_MAX_RETRIES = 3
Connector_StacSwisstopo_RETRY_DELAY = 1.0
Connector_StacSwisstopo_ENABLE_CACHE = True
# Demo RMA credentials (same for all demo trustee instances)
Demo_RMA_ApiBaseUrl = https://service.int.runmyaccounts.com/api/latest/clients/
Demo_RMA_ClientName = poweronag
Demo_RMA_ApiKey = pat_tipTbnHU26CrMzAnLSjCR_uzHJv4CDNa7obaQGHIA-4
# Operator company information (shown on invoice emails)
Operator_CompanyName = PowerOn AG
Operator_Address = Birmensdorferstrasse 94, 8003 Zürich

View file

@ -19,7 +19,7 @@ APP_JWT_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERjlrSktmZHVuQnJ1VVJDdndLaUcxZGJsT2Z
APP_TOKEN_EXPIRY=300
# CORS Configuration
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://playground.poweron-center.net
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://playground.poweron.swiss
# Logging configuration
APP_LOGGING_LOG_LEVEL = DEBUG

View file

@ -3,7 +3,9 @@
# System Configuration
APP_ENV_TYPE = int
APP_ENV_LABEL = Integration Instance
APP_API_URL = https://gateway-int.poweron-center.net
APP_API_URL = https://gateway-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 = CONFIG_KEY
APP_INIT_PASS_ADMIN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjWm41MWZ4TUZGaVlrX3pWZWNwakJsY3Facm0wLVZDd1VKeTFoZEVZQnItcEdUUnVJS1NXeDBpM2xKbGRsYmxOSmRhc29PZjJSU2txQjdLbUVrTTE1NEJjUXBHbV9NOVJWZUR3QlJkQnJvTEU9
APP_INIT_PASS_EVENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjdmtrakgxa0djekZVNGtTZV8wM2I5UUpCZllveVBMWXROYk5yS3BiV3JEelJSM09VYTRONHpnY3VtMGxDRk5JTEZSRFhtcDZ0RVRmZ1RicTFhb3c5dVZRQ1o4SmlkLVpPTW5MMTU2eTQ0Vkk9
@ -19,7 +21,7 @@ APP_JWT_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNUctb2RwU25iR3ZnanBOdHZhWUtIajZ1RnZ
APP_TOKEN_EXPIRY=300
# CORS Configuration
APP_ALLOWED_ORIGINS=http://localhost:8080,https://playground.poweron-center.net,https://playground-int.poweron-center.net,http://localhost:5176,https://nyla.poweron-center.net, https://nyla-int.poweron-center.net
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://playground.poweron.swiss,https://playground-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
@ -34,22 +36,22 @@ APP_LOGGING_BACKUP_COUNT = 5
# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm83T29rMDZvcV9qTG5xb1FzUkdqS1llbzRxSEJXbmpONFFtcUtfZXdtZjQybmJSMjBjMEpnRVhiOGRuczZvVFBFdVVTQV80SG9PSnRQTEpLdVViNm5wc2E5aGRLWjZ4TGF1QjVkNmdRSzBpNWNkYXVublFYclVEdEM5TVBBZWVVMW5RVWk=
Service_MSFT_AUTH_REDIRECT_URI = https://gateway-int.poweron-center.net/api/msft/auth/login/callback
Service_MSFT_AUTH_REDIRECT_URI = https://gateway-int.poweron.swiss/api/msft/auth/login/callback
Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm83T29rMDZvcV9qTG5xb1FzUkdqS1llbzRxSEJXbmpONFFtcUtfZXdtZjQybmJSMjBjMEpnRVhiOGRuczZvVFBFdVVTQV80SG9PSnRQTEpLdVViNm5wc2E5aGRLWjZ4TGF1QjVkNmdRSzBpNWNkYXVublFYclVEdEM5TVBBZWVVMW5RVWk=
Service_MSFT_DATA_REDIRECT_URI = https://gateway-int.poweron-center.net/api/msft/auth/connect/callback
Service_MSFT_DATA_REDIRECT_URI = https://gateway-int.poweron.swiss/api/msft/auth/connect/callback
Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNThGeVRNd3hacThtRnE0bzlDa0JPUWQyaEd6QjlFckdsMGZjRlRfUks2bXV3aDdVRTF3LVRlZVY5WjVzSXV4ZGNnX002RDl3dkNYdGFzZkxVUW01My1wTHRCanVCLUozZEx4TlduQlB5MnpvNTR2SGlvbFl1YkhzTEtsSi1SOEo=
Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-int.poweron-center.net/api/google/auth/login/callback
Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-int.poweron.swiss/api/google/auth/login/callback
Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNThGeVRNd3hacThtRnE0bzlDa0JPUWQyaEd6QjlFckdsMGZjRlRfUks2bXV3aDdVRTF3LVRlZVY5WjVzSXV4ZGNnX002RDl3dkNYdGFzZkxVUW01My1wTHRCanVCLUozZEx4TlduQlB5MnpvNTR2SGlvbFl1YkhzTEtsSi1SOEo=
Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-int.poweron-center.net/api/google/auth/connect/callback
Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-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://gateway-int.poweron-center.net/api/clickup/auth/connect/callback
Service_CLICKUP_OAUTH_REDIRECT_URI = https://gateway-int.poweron.swiss/api/clickup/auth/connect/callback
# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.

View file

@ -6,7 +6,8 @@ APP_ENV_LABEL = Production Instance
APP_KEY_SYSVAR = CONFIG_KEY
APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW1WbUt6bGtQYkdrNkZDOXNOLXFua1hqeFF2RHJnRXJ5VlVGV3hOZm41QjZOMlNTb0duYXNxZi05dXVTc2xDVkx0SVBFLUhncVo5T0VUZHE0UTZLWWw3ck09
APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9
APP_API_URL = https://gateway-prod.poweron-center.net
APP_API_URL = https://gateway-prod.poweron.swiss
APP_COOKIE_SECURE = true
# PostgreSQL DB Host
DB_HOST=gateway-prod-server.postgres.database.azure.com
@ -19,7 +20,7 @@ APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUl
APP_TOKEN_EXPIRY=300
# CORS Configuration
APP_ALLOWED_ORIGINS=http://localhost:8080,https://playground.poweron-center.net,https://playground-int.poweron-center.net,http://localhost:5176,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://playground.poweron.swiss,https://playground-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
@ -34,22 +35,22 @@ APP_LOGGING_BACKUP_COUNT = 5
# OAuth: Auth app (login/JWT) vs Data app (Graph / Google APIs)
Service_MSFT_AUTH_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4=
Service_MSFT_AUTH_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/msft/auth/login/callback
Service_MSFT_AUTH_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/msft/auth/login/callback
Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4=
Service_MSFT_DATA_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/msft/auth/connect/callback
Service_MSFT_DATA_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/msft/auth/connect/callback
Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o=
Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/google/auth/login/callback
Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/google/auth/login/callback
Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o=
Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/google/auth/connect/callback
Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-prod.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://gateway-prod.poweron-center.net/api/clickup/auth/connect/callback
Service_CLICKUP_OAUTH_REDIRECT_URI = https://gateway-prod.poweron.swiss/api/clickup/auth/connect/callback
# Infomaniak: no OAuth client. Users paste a Personal Access Token (kdrive + mail) per UI.

View file

@ -1,107 +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://playground.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 = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_AUTH_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm83T29rV1pQelMtc1p1MXR4NTFpa19CTEhHQ0xfNmdPUmZqcWp5UHBMS0hYTGl4c1pPdmhTNTJVWUl5WnlnUUZhV0VTRzVCb0d5YjR1NnZPZk5CZ0dGazNGdUJVbjkxeVdrYlNiVjJUYzF2aVFtQnVxTHFqTTJqZlF0RTFGNmE1OGN1TEk=
Service_MSFT_AUTH_REDIRECT_URI = http://localhost:8000/api/msft/auth/login/callback
Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm83T29rV1pQelMtc1p1MXR4NTFpa19CTEhHQ0xfNmdPUmZqcWp5UHBMS0hYTGl4c1pPdmhTNTJVWUl5WnlnUUZhV0VTRzVCb0d5YjR1NnZPZk5CZ0dGazNGdUJVbjkxeVdrYlNiVjJUYzF2aVFtQnVxTHFqTTJqZlF0RTFGNmE1OGN1TEk=
Service_MSFT_DATA_REDIRECT_URI = http://localhost:8000/api/msft/auth/connect/callback
Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETDJhbGVQMHlFQzNPVFI1ZzBMa3pNMGlQUHhaQm10eVl1bFlSeTBybzlTOWE2MURXQ0hkRlo0NlNGbHQxWEl1OVkxQnVKYlhhOXR1cUF4T3k0WDdscktkY1oyYllRTmdDTWpfbUdwWGtSd1JvNlYxeTBJdEtaaS1vYnItcW0yaFM=
Service_GOOGLE_AUTH_REDIRECT_URI = http://localhost:8000/api/google/auth/login/callback
Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETDJhbGVQMHlFQzNPVFI1ZzBMa3pNMGlQUHhaQm10eVl1bFlSeTBybzlTOWE2MURXQ0hkRlo0NlNGbHQxWEl1OVkxQnVKYlhhOXR1cUF4T3k0WDdscktkY1oyYllRTmdDTWpfbUdwWGtSd1JvNlYxeTBJdEtaaS1vYnItcW0yaFM=
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 OAuth -- Data App (kDrive + Mail)
Service_INFOMANIAK_DATA_CLIENT_ID = abd71a95-7c67-465a-b7ab-963cc5eccb4b
Service_INFOMANIAK_DATA_CLIENT_SECRET = jwaEZza0VnmAHA1vIQJcpaCC1O4ND6IS0mkQ0GGiVlmof7XHxUcl9YMl7TbtEINz
Service_INFOMANIAK_OAUTH_REDIRECT_URI = http://localhost:8000/api/infomaniak/auth/connect/callback
# 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 = DEV_ENC:Z0FBQUFBQnBaSnM4TWFRRmxVQmNQblVIYmc1Y0Q3aW9zZUtDWlNWdGZjbFpncGp2NHN2QjkxMWxibUJnZDBId252MWk5TXN3Yk14ajFIdi1CTkx2ZWx2QzF5OFR6LUx5azQ3dnNLaXJBOHNxc0tlWmtZcTFVelF4eXBSM2JkbHd2eTM0VHNXdHNtVUprZWtPVzctNlJsZHNmM20tU1N6Q1Q2cHFYSi1tNlhZNDNabTVuaEVGWmIydEhadTcyMlBURmw2aUJxOF9GTzR0dTZiNGZfOFlHaVpPZ1A1LXhhOEFtN1J5TEVNNWtMcGpyNkMzSl8xRnZsaTF1WTZrOUZmb0cxVURjSGFLS2dIYTQyZEJtTm90bEYxVWxNNXVPdTVjaVhYbXhxT3JsVDM5VjZMVFZKSE1tZnM9
Connector_AiAnthropic_API_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpENmFBWG16STFQUVZxNzZZRzRLYTA4X3lRanF1VkF4cU45OExNMzlsQmdISGFxTUxud1dXODBKcFhMVG9KNjdWVnlTTFFROVc3NDlsdlNHLUJXeG41NDBHaXhHR0VHVWl5UW9RNkVWbmlhakRKVW5pM0R4VHk0LUw0TV9LdkljNHdBLXJua21NQkl2b3l4UkVkMGN1YjBrMmJEeWtMay1jbmxrYWJNbUV0aktCXzU1djR2d2RSQXZORTNwcG92ZUVvVGMtQzQzTTVncEZTRGRtZUFIZWQ0dz09
Connector_AiPerplexity_API_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5ZmdDZ3hrSElrMnQzNFAtel9wX191VjVzN2g1LWZoa0V1YklubEdmMEJDdEZiR1RWeVZrM3V3enBHX3p6WUtTS0kwYkFyVEF0Nm8zX05CelVQcFJUc0lwVW5iNFczc1p1WWJ2WFBmd0lpLUxxWndEeUh0b2hGUHVpN19vb19nMTBnV1A1VmNpWERVX05lQ29VS20wTjZ3PT0=
Connector_AiTavily_API_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEQTdnUHMwd2pIaXNtMmtCTFREd0pyQXRKb1F5eGtHSnkyOGZiUnlBOFc0b3Vzcndrc3ViRm1nMDJIOEZKYWxqdWNkZGh5N0Z4R0JlQmxXSG5pVnJUR2VYckZhMWNMZ1FNeXJ3enJLVlpiblhOZTNleUg3ZzZyUzRZanFSeDlVMkI=
Connector_AiPrivateLlm_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGRHM5eFdUVmVZU1R1cHBwN1RlMUx4T0NlLTJLUFFVX3J2OElDWFpuZmJHVmp4Z3BNNWMwZUVVZUd2TFhRSjVmVkVlcFlVRWtybXh0ZHloZ01ZcnVvX195YjdlWVdEcjZSWFFTTlNBWUlaTlNoLWhqVFBIb0thVlBiaWhjYjFQOFY=
Connector_AiMistral_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGeEQxYUIxOHhia0JlQWpWQ2dWQWZzY3l6SWwyUnJoR1hRQWloX2lxb2lGNkc4UnA4U2tWNjJaYzB1d1hvNG9fWUp1N3V4OW9FMGhaWVhjSlVwWEc1X2loVDBSZDEtdHdfcTA5QkcxQTR4OHc4RkRzclJrU2d1RFZpNDJkRDRURlE=
Service_MSFT_TENANT_ID = common
# Google Cloud Speech Services configuration
Connector_GoogleSpeech_API_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETk5FWWM3Q0JKMzhIYTlyMkhuNjA4NlF4dk82U2NScHhTVGY3UG83NkhfX3RrcWVtWWcyLXRjU1dTT21zWEl6YWRMMUFndXpsUnJOeHh3QThsNDZKRXROTzdXRUdsT0JZajZJNVlfb0gtMXkwWm9DOERPVnpjU0pyUEZfOGJsUnprT3ltMVVhalUyUm9hMUFtZEtHUnJqOGZ4dEZjZm5SWVVTckVCWnY1UkdVSHVmUlgwbnAyc0xDQW84R3ViSko5OHVCVWZRUVNiaG1pVFB6X3EwS0FPd2dUYjhiSmRjcXh2WEZiXzI4SFZqT21tbDduUWRyVWdFZXpmcVM5ZDR0VWtzZnF5UER6cGwwS2JlLV9CSTZ0Z0IyQ1h0YW9TcmhRTXZEckp4bWhmTkt6UTNYMk4zVkpnbUJmaDIxZnoyR2dWTEYwTUFEV0w2eUdUUGpoZk9XRkt4RVF1Z1NPdUpBeTcyWV9PY1Ffd2s0ZEdVekxGekhoeEl4TmNqaXYtbUJuSVdycFducERWdWtZajZnX011Q2w4eE9VMTBqQ1ZxRmdScWhXY1E3WWhzX1JZcHhxam9FbDVPN3Q1MWtrMUZuTUg3LVFQVHp1T1hpQWNDMzEzekVJWk9ybl91YUVjSkFob1VaMi1ONEtuMnRSOEg1S3QybUMwbVZDejItajBLTjM2Zy1hNzZQMW5LLVVDVGdFWm5BZUxNeEFnUkZzU3dxV0lCUlc0LWo4b05GczVpOGZSV2ZxbFBwUml6OU5tYjdnTks3Y3hrVEZVTHlmc1NPdFh4WE5pWldEZklOQUxBbjBpMTlkX3FFQVJ6c2NSZGdzTThycE92VW82enZKamhiRGFnU25aZGlHZHhZd2lUUmhuTVptNjhoWVlJQkxIOEkzbzJNMjZCZFJyM25tdXBnQ2ZWaHV3b2p6UWJpdk9xUEhBc1dyTlNmeF9wbm5yYUhHV01UZnVXWDFlNzBkdXlWUWhvcmJpSmljbmE3LUpUZEg4VzRwZ2JVSjdYUm1sODViQXVxUzdGTmZFbVpiN2V1YW5XV3U4b2VRWmxldGVGVHZsSldoekhVLU9wZ2V0cGZIYkNqM2pXVGctQVAyUm4xTHhpd1VVLXFhcnVEV21Rby1hbTlqTl84TjVveHdYTExUVkhHQ0ltaTB2WXJnY1NQVE5PbWg3ejgySElYc1JSTlQ3NDlFUWR6STZVUjVqaXFRN200NF9LY1ljQ0R2UldlWUtKY1NQVnJ4QXRyYTBGSWVuenhyM0Z0cWtndTd1eG8xRzY5a2dNZ1hkQm5MV3BHVzA2N1QwUkd6WlRGYTZQOUhnVWQ2S0Y5U0s1dXFNVXh5Q2pLWVUxSUQ2MlR1ak52NmRIZ2hlYTk1SGZGWS1RV3hWVU9rR3d1Rk9MLS11REZXbzhqMHpsSm1HYW1jMUNLT29YOHZsRWNaLTVvOFpmT3l3MHVwaERTT0dNLWFjcGRYZ25qT2szTkVFUnRFR3JWYS1aNXFIRnMyalozTlQzNFF2NXJLVHVPVF9zdTF6ZjlkbzJ4RFc2ZENmNFFxZDZzTzhfMUl0bW96V0lPZkh1dXFYZlEteFBlSG84Si1FNS1TTi1OMkFnX2pOYW8xY3MxMVJnVC02MDUyaXZfMEVHWDQtVlRpcENmV0h3V0dCWEFRS2prQXdNRlQ5dnRFVHU0Q1dNTmh0SlBCaU55bFMydWM1TTFFLW96ODBnV3dNZHFZTWZhRURYSHlrdzF3RlRuWDBoQUhSOUJWemtRM3pxcDJFbGJoaTJ3ZktRTlJxbXltaHBoZXVJVDlxS3cxNWo2c0ZBV0NzaUstRWdsMW1xLXFkanZGYUFiU0tSLXFQa0tkcDFoMV9kak41ZjQ0R214UmtOR1ZBanRuemY3Mmw1SkZ5aDZodGIzT3N2aV85MW9kcld6c0g0ZDgtTWo3b3Y3VjJCRnR2U2tMVm9rUXNVRnVHbzZXVTZ6RmI2RkNmajBfMWVnODVFbnpkT0oyci15czJHU0p1cUowTGZJMzVnd3hIRjQyTVhKOGRkcFRKdVpyQ3Yzd01Jb1lSajFmV0paeEV0cjk1SmpmdWpDVFJMUmMtUFctOGhaTmlKQXNRVlVUNlhJemxudHZCR056SVlBb3NOTEYxRTRLaFlVd2d3TWtxVlB6ZEtQLTkxOGMyY3N0a2pYRFUweDBNaGhja2xSSklPOUZla1dKTWRNbG8tUGdSNEV5cW90OWlOZFlIUExBd3U2b2hyS1owbXVMM3p0Qm41cUtzWUxYNzB1N3JpUTNBSGdsT0NuamNTb1lIbXR4MG1sakNPVkxBUXRLVE1xX0YxWDhOcERIY1lTQVFqS01CaXZKNllFaXlIR0JsM1pKMmV1OUo3TGI1WkRaVnYxUTl1LTM0SU1qN1V1b0RCT0x0VHNLTmNLZnk1S0MxYnBBcm03WnVua0xqaEhGUzhOU253ZkppRzdudXBSVlMxeFVOSWxtZ1o2RVBSQUhEUEFuQ1hxSVZMME4yWUtaU3VyRGo3RkUyRUNjT0pNcE1BdE1ZRzdXVl8ydUtXZjdMdHdEVW4teHUtTi1HSGliLUxud21TX0NtcGVkRFBHNkZ1WTlNczR4OUJfUVluc1BoV09oWS1scUdsNnB5d1U5M1huX3k4QzAyNldtb2hybktYN2xKZ1NTNWFsaWwzV3pCRVhkaGR5eTNlV1d6ZzFfaFZTT0E4UjRpQ3pKdEZxUlJ6UFZXM3laUndyWEk2NlBXLUpoajVhZzVwQXpWVzUtVjVNZFBwdWdQa3AxZC1KdGdqNnhibjN4dmFYb2cxcEVwc1g5R09zRUdINUZtOE5QRjVUU0dpZy1QVl9odnFtVDNuWFZLSURtMXlSMlhRNTBWSVFJbEdOOWpfVWV0SmdRWDdlUXZZWE8xRUxDN1I0aEN6MHYwNzM1cmpJS0ZpMnBYWkxfb3FsbEV1VnlqWGxqdVJ6SHlwSjAzRlMycTBaQ295NXNnZERpUnJQcjhrUUd3bkI4bDVzRmxQblhkaFJPTTdISnVUQmhET3BOMTM4bjVvUEc2VmZhb2lrR1FyTUl2RWNEeGg0U0dsNnV6eU5zOUxiNDY5SXBxR0hBS00wOTgyWTFnWkQyaEtLVUloT3ZxZGh0RWVGRmJzenFsaUtfZENQM0JzdkVVeTdXR3hUSmJST1NBMUI1NkVFWncwNW5JZVVLX1p1RXdqVnFfQWpvQ08yQjZhN1NkTkpTSnUxOVRXZXE0WFEtZWxhZW1NNXYtQ2sya0VGLURmS01lMkctNVY3c2ZhN0ZGRFgwWHlabTFkeS1hcUZ1dDZ3cnpPQ3hha2IzVE11M0pqbklmU0diczBqTFBNZC1QZGp6VzNTSnJVSjJoWkJUQjVORG4tYUJmMEJtSUNUdVpEaGt6OTM3TjFOdVhXUHItZjRtZ25nU3NhZC1sVTVXNTRDTmxZbnlfeHNsdkpuMXhUYnE1MnpVQ0ZOclRWM1M4eHdXTzRXbFRZZVQtTS1iRVdXVWZMSGotcWg3MUxUYTFnSEEtanBCRHlZRUNIdGdpUFhsYjdYUndCZnRITzhMZVJ1dHFoVlVNb0duVjlxd0U4OGRuQVV3MG90R0hiYW5MWkxWVklzbWFRNzBfSUNrdzc5bVdtTXg0dExEYnRCaDI3c1I4TWFwLXZKR0wxSjRZYjZIV3ZqZjNqTWhFT0RGSDVMc1A1UzY2bDBiMGFSUy1fNVRQRzRJWDVydUpqb1ZfSHNVbldVeUN2YlAxSW5WVDdxVzJ1WHpLeUdmb0xWMDNHN05oQzY3YnhvUUdhS2xaOHNidkVvbTZtSHFlblhOYmwyR3NQdVJDRUdxREhWdF9ZcXhwUWxHc2hyLW5vUGhIUVhJNUNhY0hFU0ptVnI0TFVhZDE1TFBBUEstSkRoZWJ5MHJhUmZrR1ZrRlFtRGpxS1pOMmFMQjBsdjluY3FiYUU4eGJVVXlZVEpuNWdHVVhJMGtwaTdZR2NDbXd2eHpOQ09SeTV6N1BaVUpsR1pQVDBZcElJUUt6VnVpQmxSYnE4Y1BCWV9IRWdVV0p3enBGVHItdnBGN3NyNWFBWmkySnByWThsbDliSlExQmp3LVlBaDIyZXp6UnR6cU9rTzJmTDBlSVpON0tiWllMdm1oME1zTFl2S2ZYYllhQlY2VHNZRGtHUDY4U1lIVExLZTU4VzZxSTZrZHl1ZTBDc0g4SjI4WGYyZHV1bm9wQ3R2Z09ld1ZmUkN5alJGeHZKSHl1bWhQVXpNMzdjblpLcUhfSm02Qlh5S1FVN3lIcHl0NnlRPT0=
# Feature SyncDelta JIRA configuration
Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEbm0yRUJ6VUJKbUwyRW5kMnRaNW4wM2YxMkJUTXVXZUdmdVRCaUZIVHU2TTV2RWZLRmUtZkcwZE4yRUNlNDQ0aUJWYjNfdVg5YjV5c2JwMHhoUUYxZWdkeS11bXR0eGxRLWRVaVU3cUVQZWJlNDRtY1lWUDdqeDVFSlpXS0VFX21WajlRS3lHQjc0bS11akkybWV3QUFlR2hNWUNYLUdiRjZuN2dQODdDSExXWG1Dd2ZGclI2aUhlSWhETVZuY3hYdnhkb2c2LU1JTFBvWFpTNmZtMkNVOTZTejJwbDI2eGE0OS1xUlIwQnlCSmFxRFNCeVJNVzlOMDhTR1VUamx4RDRyV3p6Tk9qVHBrWWdySUM3TVRaYjd3N0JHMFhpdzFhZTNDLTFkRVQ2RVE4U19COXRhRWtNc0NVOHRqUS1CRDFpZ19xQmtFLU9YSDU3TXBZQXpVcld3PT0=
# 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
# Manadate Pre-Processing Servers
PREPROCESS_ALTHAUS_CHAT_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGbEphQ3ZUMlFMQ2EwSGpoSE9NNzRJNTJtaGk1N0RGakdIYnVVeVFHZmF5OXB3QTVWLVNaZk9wNkhfQkZWRnVwRGRxem9iRzJIWXdpX1NIN2FwSExfT3c9PQ==
# Preprocessor API Configuration
PP_QUERY_API_KEY=ouho02j0rj2oijroi3rj2oijro23jr0990
PP_QUERY_BASE_URL=https://poweron-althaus-preprocess-prod-e3fegaatc7faency.switzerlandnorth-01.azurewebsites.net/api/v1/dataquery/query
# 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

View file

@ -1,100 +0,0 @@
# Integration Environment Configuration
# System Configuration
APP_ENV_TYPE = int
APP_ENV_LABEL = Integration Instance
APP_API_URL = https://gateway-int.poweron-center.net
APP_KEY_SYSVAR = CONFIG_KEY
APP_INIT_PASS_ADMIN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjWm41MWZ4TUZGaVlrX3pWZWNwakJsY3Facm0wLVZDd1VKeTFoZEVZQnItcEdUUnVJS1NXeDBpM2xKbGRsYmxOSmRhc29PZjJSU2txQjdLbUVrTTE1NEJjUXBHbV9NOVJWZUR3QlJkQnJvTEU9
APP_INIT_PASS_EVENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjdmtrakgxa0djekZVNGtTZV8wM2I5UUpCZllveVBMWXROYk5yS3BiV3JEelJSM09VYTRONHpnY3VtMGxDRk5JTEZSRFhtcDZ0RVRmZ1RicTFhb3c5dVZRQ1o4SmlkLVpPTW5MMTU2eTQ0Vkk9
# PostgreSQL DB Host
DB_HOST=gateway-int-server.postgres.database.azure.com
DB_USER=heeshkdlby
DB_PASSWORD_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjczYzOUtTa21MMGJVTUQ5UmFfdWc3YlhCbWZOeXFaNEE1QzdJV3BLVjhnalBkLVVCMm5BZzdxdlFXQXc2RHYzLWtPSFZkZE1iWG9rQ1NkVWlpRnF5TURVbnl1cm9iYXlSMGYxd1BGYVc0VDA9
DB_PORT=5432
# Security Configuration
APP_JWT_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNUctb2RwU25iR3ZnanBOdHZhWUtIajZ1RnZzTEp4aDR0MktWRjNoeVBrY1Npd1R0VE9YVHp3M2w1cXRzbUxNaU82QUJvaDNFeVQyN05KblRWblBvbWtoT0VXbkNBbDQ5OHhwSUFnaDZGRG10Vmgtdm1YUkRsYUhFMzRVZURmSFlDTFIzVWg4MXNueDZyMGc5aVpFdWRxY3dkTExGM093ZTVUZVl5LUhGWnlRPQ==
APP_TOKEN_EXPIRY=300
# CORS Configuration
APP_ALLOWED_ORIGINS=http://localhost:8080,https://playground.poweron-center.net,https://playground-int.poweron-center.net,http://localhost:5176,https://nyla.poweron-center.net, https://nyla-int.poweron-center.net
# Logging configuration
APP_LOGGING_LOG_LEVEL = DEBUG
APP_LOGGING_LOG_DIR = /home/site/wwwroot/
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 = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm83T29rMDZvcV9qTG5xb1FzUkdqS1llbzRxSEJXbmpONFFtcUtfZXdtZjQybmJSMjBjMEpnRVhiOGRuczZvVFBFdVVTQV80SG9PSnRQTEpLdVViNm5wc2E5aGRLWjZ4TGF1QjVkNmdRSzBpNWNkYXVublFYclVEdEM5TVBBZWVVMW5RVWk=
Service_MSFT_AUTH_REDIRECT_URI = https://gateway-int.poweron-center.net/api/msft/auth/login/callback
Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm83T29rMDZvcV9qTG5xb1FzUkdqS1llbzRxSEJXbmpONFFtcUtfZXdtZjQybmJSMjBjMEpnRVhiOGRuczZvVFBFdVVTQV80SG9PSnRQTEpLdVViNm5wc2E5aGRLWjZ4TGF1QjVkNmdRSzBpNWNkYXVublFYclVEdEM5TVBBZWVVMW5RVWk=
Service_MSFT_DATA_REDIRECT_URI = https://gateway-int.poweron-center.net/api/msft/auth/connect/callback
Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNThGeVRNd3hacThtRnE0bzlDa0JPUWQyaEd6QjlFckdsMGZjRlRfUks2bXV3aDdVRTF3LVRlZVY5WjVzSXV4ZGNnX002RDl3dkNYdGFzZkxVUW01My1wTHRCanVCLUozZEx4TlduQlB5MnpvNTR2SGlvbFl1YkhzTEtsSi1SOEo=
Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-int.poweron-center.net/api/google/auth/login/callback
Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNThGeVRNd3hacThtRnE0bzlDa0JPUWQyaEd6QjlFckdsMGZjRlRfUks2bXV3aDdVRTF3LVRlZVY5WjVzSXV4ZGNnX002RDl3dkNYdGFzZkxVUW01My1wTHRCanVCLUozZEx4TlduQlB5MnpvNTR2SGlvbFl1YkhzTEtsSi1SOEo=
Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-int.poweron-center.net/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://gateway-int.poweron-center.net/api/clickup/auth/connect/callback
# Infomaniak OAuth -- Data App (kDrive + Mail)
Service_INFOMANIAK_DATA_CLIENT_ID = abd71a95-7c67-465a-b7ab-963cc5eccb4b
Service_INFOMANIAK_DATA_CLIENT_SECRET = jwaEZza0VnmAHA1vIQJcpaCC1O4ND6IS0mkQ0GGiVlmof7XHxUcl9YMl7TbtEINz
Service_INFOMANIAK_OAUTH_REDIRECT_URI = https://gateway-int.poweron-center.net/api/infomaniak/auth/connect/callback
# Stripe Billing (both end with _SECRET for encryption script)
STRIPE_SECRET_KEY_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5ekdBaGNGVUlOQUpncTlzLWlTV0V5OWZzQkpDczhCUGw4U1JpTHZ0d3pfYlFNWElLRlNiNlNsaDRYTGZUTkg2OUFrTW1GZXpOUjBVbmRQWjN6ekhHd2ZSQ195OHlaeWh1TmxrUm10V2R3YmdncmFLbFMzVjdqcWJMSUJPR2xuSEozclNoZG1rZVBTaWg3OFQ1Qzdxb0wyQ2RKazc2dG1aZXBUTXlvbDZqLS1KOVI5M3BGc3NQZkZRbnFpRjIwWmh2ZHlVNlpxZVo2dWNmMjQ5eW02QmtzUT09
STRIPE_WEBHOOK_SECRET = whsec_2agCQEbDPSOn2C40EJcwoPCqlvaPLF7M
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 = INT_ENC:Z0FBQUFBQnBaSnM4MENkQ2xJVmE5WFZKUkh2SHJFby1YVXN3ZmVxRkptS3ZWRmlwdU93ZEJjSjlMV2NGbU5mS3NCdmFfcmFYTEJNZXFIQ3ozTWE4ZC1pemlQNk9wbjU1d3BPS0ZCTTZfOF8yWmVXMWx0TU1DamlJLVFhSTJXclZsY3hMVWlPcXVqQWtMdER4T252NHZUWEhUOTdIN1VGR3ltazEweXFqQ0lvb0hYWmxQQnpxb0JwcFNhRDNGWXdoRTVJWm9FalZpTUF5b1RqZlRaYnVKYkp0NWR5Vko1WWJ0Wmg2VWJzYXZ0Z3Q4UkpsTldDX2dsekhKMmM4YjRoa2RwemMwYVQwM2cyMFlvaU5mOTVTWGlROU8xY2ZVRXlxZzJqWkxURWlGZGI2STZNb0NpdEtWUnM9
Connector_AiAnthropic_API_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjT1ZlRWVJdVZMT3ljSFJDcFdxRFBRVkZhS204NnN5RDBlQ0tpenhTM0FFVktuWW9mWHNwRWx2dHB0eDBSZ0JFQnZKWlp6c01pVGREWHd1eGpERnU0Q2xhaks1clQ1ZXVsdnd2ZzhpNXNQS1BhY3FjSkdkVEhHalNaRGR4emhpakZncnpDQUVxOHVXQzVUWmtQc0FsYmFwTF9TSG5FOUFtWk5Ick1NcHFvY2s1T1c2WXlRUFFJZnh6TWhuaVpMYmppcDR0QUx0a0R6RXlwbGRYb1R4dzJkUT09
Connector_AiPerplexity_API_SECRET = INT_ENC:Z0FBQUFBQnB5dkd6UkhtU3lhYmZMSlo0bklQZ2s3UTFBSkprZTNwWkg5Q2lVa0wtenhxWXpva21xVDVMRjdKSmhpTmxWS05IUTRoRHdCbktSRVVjcVFnY1RfV0N2S2dyV0dTMlhxQlRFVm41RkFTWVQzQThuVkZwdlNuVC05QlVRVXB6Qjk3akNpYmY1MFR6R1ByMzlIMllRZlRRYVVRN2ZBPT0=
Connector_AiTavily_API_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkdkJMTDY0akhXNzZDWHVYSEt1cDZoOWEzSktneHZEV2JndTNmWlNSMV9KbFNIZmQzeVlrNE5qUEIwcUlBSGM1a0hOZ3J6djIyOVhnZzI3M1dIUkdicl9FVXF3RGktMmlEYmhnaHJfWTdGUkktSXVUSGdQMC1vSEV6VE8zR2F1SVk=
Connector_AiPrivateLlm_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGSjZ1NWh0aWc1R3Z4MHNaeS1HamtUbndhcUZFZDlqUDhjSmg5eHFfdlVkU0RsVkJ2UVRaMWs3aWhraG5jSlc0YkxNWHVmR2JoSW5ENFFCdkJBM0VienlKSnhzNnBKbTJOUTFKczRfWlQ3bWpmUkRTT1I1OGNUSTlQdExacGRpeXg=
Connector_AiMistral_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGZTNtZ1E4TWIxSEU1OUlreUpxZkJIR0Vxcm9xRHRUbnBxbTQ1cXlkbnltWkJVdTdMYWZ4c3Fsam42TERWUTVhNzZFMU9xVjdyRGFCYml6bmZsZFd2YmJzemlrSWN6Q3o3X0NXX2xXNUQteTNONHdKYzJ5YVpLLWdhU2JhSTJQZnI=
Service_MSFT_TENANT_ID = common
# Google Cloud Speech Services configuration
Connector_GoogleSpeech_API_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkNmVXZ1pWcHcydTF2MXF0ZGJoWHBydF85bTczTktiaEJ3Wk1vMW1mZVhDSG1yd0ZxR2ZuSGJTX0N3MWptWXFJTkNTWjh1SUVVTXI4UDVzcGdLMkU5SHJ2TUpkRlRoRWdnSldtYjNTQkh4UDJHY2xmdTdZQ1ZiMTZZcGZxS3RzaHdjV3dtVkZUcEpJcWx0b2xuQVR6ZmpoVFZPY1hNMTV2SnhDaC1IZEh4UUpLTy1ILXA4RG1zamJTbUJ4X0t2M2NkdzJPbEJxSmFpRzV3WC0wZThoVzlxcmpHZ3ZkLVlVY3REZk1vV19WQ05BOWN6cnJ4MWNYYnNiQ0FQSUVnUlpfM3BhMnlsVlZUOG5wM3pzM1lSN1UzWlZKUXRLczlHbjI1LTFvSUJ4SlVXMy1BNk43bE5Hb0RfTTVlWk9oZnFIaVg0SW5pbm9EcXRTTzU1RFlYY3dTcnpKWWNyNjN5T1BGZ0FmX253cEFncmhvZVRuM05KYzhkOEhFMFJsc2NBSEwzZVZ1R0JMOGxsekVwUE55alZaRXFrdzNWWVNGWXNmbnhKeWhQSFo2VXBTUlRPeHdvdVdncEFuOWgydEtsSUFneUN6cGVaTnBSdjNCdVJseGJFdmlMc203UFhLVlYyTENkaGg2dVN6Z2xwT1ZmTmN5bVZGUkM3ZWcyVkt2ckFUVVd3WFFwYnJjNVRobEh2SkVJbXRwUUpEOFJKQ1NUc0Q4NHNqUFhPSDh5cTV6MEcwSDEwRUJCQ2JiTTJlOE5nd3pMMkJaQ1dVYjMwZVVWWnlETmp2dkZ3aXEtQ29WNkxZTFkzYUkxdTlQUU1OTnhWWU12YU9MVnJQa1d2ZjRtUlhneTNubEMxTmp1eUNPOThSMlB3Y1F0T2tCdFNsNFlKalZPV25yR2QycVBUb096RmZ1V0FTaGsxLV9FWDBmenBIOXpMdGpLcUc0TWRoY2hlMFhYTzlET1ZRekw0ZHNwUVBQdVJBX2h6Q2ZzWVZJWTNybTJiekp3WmhmWF9SUFBXQzlqUjctcVlHWWVMZWVQallzR0JGTVF0WmtnWlg1aTM1bFprNVExZXY5dnNvWF93UjhwbkJ3RzNXaVJ2d2RRU3JJVlBvaVh4eTlBRUtqWkJia3dJQVVBV2Nqdm9FUTRUVW1TaHp2ZUwxT0N2ZndxQ2Nka1RYWXF0LWxIWFE0dTFQcVhncFFPM0hFdUUtYlFnemx3WkF4bjA1aDFULUdrZlVZbEJtRGRCdjJyVkdJSXozd0I0dF9zbWhOeHFqRDA4T1NVaWR5cjBwSVgwbllPU294NjZGTnM1bFhIdGpNQUxFOENWd3FCbGpSRFRmRXotQnU0N2lCVEU5RGF6Qi10S2U2NGdadDlrRjZtVE5oZkw5ZWFjXzhCTmxXQzNFTFgxRXVYY3J3YkxnbnlBSm9PY3h4MlM1NVFQbVNDRW5Ld1dvNWMxSmdoTXJuaE1pT2VFeXYwWXBHZ29MZDVlN2lwUUNIeGNCVVdQVi1rRXdJMWFncUlPTXR0MmZVQ1l0d09mZTdzWGFBWUJMUFd3b0RSOU8zeER2UWpNdzAxS0ZJWnB5S3FJdU9wUDJnTTNwMWw3VFVqVXQ3ZGZnU1RkUktkc0NhUHJ0SGFxZ0lVWDEzYjNtU2JfMGNWM1Y0dHlCTzNESEdENC1jUWF5MVppRzR1QlBNSUJySjFfRi1ENHEwcmJ4S3hQUFpXVHA0TG9DZWdoUlo5WnNSM1lCZm1KbEs2ak1yUUU4Wk9JcVJGUkJwc0NvUkMyTjhoTWxtZmVQeDREZVRKZkhYN2duLVNTeGZzdFdBVnhEandJSXB5QjM0azF0ckI3Tk1wSzFhNGVOUVRrNjU0cG9JQ29pN09xOFkwR1lMTlktaGp4TktxdTVtTnNEcldsV2pEZm5nQWpJc2hxY0hjQnVSWUR5VVdaUXBHWUloTzFZUC1oNzJ4UjZ1dnpLcDJxWEZtQlNIMWkzZ0hXWXdKeC1iLXdZWVJhcU04VFlpMU5pd2ZIdTdCdkVWVFVBdmJuRk16bEFFQTh4alBrcTV2RzliT2hGdTVPOXlRMjFuZktiRTZIamQ1VFVqS0hRTXhxcU1mdkgyQ1NjQmZfcjl4c3NJd0RIeDVMZUFBbHJqdEJxWWl3aWdGUEQxR3ZnMkNGdVB4RUxkZi1xOVlFQXh1NjRfbkFEaEJ5TVZlUGFrWVhSTVRPeGxqNlJDTHNsRWRrei1pYjhnUmZrb3BvWkQ2QXBzYjFHNXZoWU1LSExhLWtlYlJTZlJmYUM5Y1Rhb1pkMVYyWTByM3NTS0VXMG1ybm1BTVN2QXRYaXZqX2dKSkZrajZSS2cyVlNOQnd5Y29zMlVyaWlNbTJEb3FuUFFtbWNTNVpZTktUenFZSl91cVFXZjRkQUZyYmtPczU2S1RKQ19ONGFOTHlwX2hOOEE1UHZEVjhnT0xxRjMxTEE4SHhRbmlmTkZwVXJBdlJDbU5oZS05SzI4QVhEWDZaN2ZiSlFwUGRXSnB5TE9MZV9ia3pYcmZVa1dicG5FMHRXUFZXMWJQVDAwOEdDQzJmZEl0ZDhUOEFpZXZWWXl5Q2xwSmFienNCMldlb2NKb2ZRYV9KbUdHRzNUcjU1VUFhMzk1a2J6dDVuNTl6NTdpM0hGa3k0UWVtbF9pdDVsQVp2cndDLUU5dnNYOF9CLS0ySXhBSFdCSnpqV010bllBb3U0cEZZYVF5R2tSNFM5NlRhdS1fb1NqbDBKMkw0V2N0VEZhNExtQlR3ckZ3cVlCeHVXdXJ6X0s4cEtsaG5rVUxCN2RRbHQxTmcyVFBqYUxyOHJzeFBXVUJaRHpXbUoxdHZzMFBzQk1UTUFvX1pGNFNMNDFvZWdTdEUtMUNKMXNIeVlvQk1CeEdpZVdmN0tsSDVZZHJXSGt5c2o2MHdwSTZIMVBhRzM1eU43Q2FtcVNidExxczNJeUx5U2RuUG5EeHpCTlg2SV9WNk1ET3BRNXFuc0pNWlVvZUYtY21oRGtJSmwxQ09QbHBUV3BuS3B5NE9RVkhfellqZjJUQ0diSV94QlhQWmdaaC1TRWxsMUVWSXB0aE1McFZDZDNwQUVKZ2t5cXRTXzlRZVJwN0pZSnJSV21XMlh0TzFRVEl0c2I4QjBxOGRCYkNxek04a011X1lrb2poQ3h2LUhKTGJiUlhneHp5QWFBcE5nMElkNTVzM3JGOWtUQ19wNVBTaVVHUHFDNFJnNXJaWDNBSkMwbi1WbTdtSnFySkhNQl9ZQjZrR2xDcXhTRExhMmNHcGlyWjR3ZU9SSjRZd1l4ZjVPeHNiYk53SW5SYnZPTzNkd1lnZmFseV9tQ3BxM3lNYVBHT0J0elJnMTByZ3VHemxta0tVQzZZRllmQ2VLZ1ZCNDhUUTc3LWNCZXBMekFwWW1fQkQ1NktzNGFMYUdYTU0xbXprY1FONUNlUHNMY3h2NFJMMmhNa3VNdzF4TVFWQk9odnJUMjFJMVd3Z2N6Sms5aEM2SWlWZFViZ0JWTEpUWWM5NmIzOS1oQmRqdkt1NUUycFlVcUxERUZGbnZqTUxIYnJmMDBHZDEzbnJsWEEzSUo3UmNPUDg1dnRUU1FzcWtjTWZwUG9zM0JTY3RqMDdST2UxcXFTM0d0bGkwdFhnMk5LaUlxNWx3V1pLaVlLUFJXZzBzVl9Ia1V1OHdYUEFWOU50UndycGtCdzM0Q0NQamp2VTNqbFBLaGhsbUk5dUI5MjU5OHVySk1oY0drUWtXUloyVVRvOWJmbUVYRzFVeWNQczh2NXJCeVppRlZiWDNJaDhOSmRmX2lURTNVS3NXQXFZT1QtUmdvMWJoVWYxU3lqUUJhbzEyX3I3TXhwbm9wc1FoQ1ZUTlNBRjMyQTBTY2tzbHZ3RFUtTjVxQ0o1QXRTVks2WENwMGZCRGstNU1jN3FhUFJCQThyaFhhMVRsbnlSRXNGRmt3Yk01X21ldmV3bTItWm1JaGpZQWZROEFtT1d1UUtPQlhYVVFqT2NxLUxQenJHX3JfMEdscDRiMXcyZ1ZmU3NFMzVoelZJaDlvT0ZoRGQ2bmtlM0M5ZHlCd2ZMbnRZRkZUWHVBUEx4czNfTmtMckh5eXZrZFBzOEItOGRYOEhsMzBhZ0xlOWFjZzgteVBsdnpPT1pYdUxnbFNXYnhKaVB6QUxVdUJCOFpvU2x2c1FHZV94MDBOVWJhYkxISkswc0U5UmdPWFJLXzZNYklHTjN1QzRKaldKdEVHb0pOU284N3c2LXZGMGVleEZ5NGZ6OGV1dm1tM0J0aTQ3VFlNOEJrdEh3PT0=
# Feature SyncDelta JIRA configuration
Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkTUNsWm4wX0p6eXFDZmJ4dFdHNEs1MV9MUzdrb3RzeC1jVWVYZ0REWHRyZkFiaGZLcUQtTXFBZzZkNzRmQ0gxbEhGbUNlVVFfR1JEQTc0aldkZkgyWnBOcjdlUlZxR0tDTEdKRExULXAyUEtsVmNTMkRKU1BJNnFiM0hlMXo4YndMcHlRMExtZDQ3Zm9vNFhMcEZCcHpBPT0=
# Teamsbot Browser Bot Service
TEAMSBOT_BROWSER_BOT_URL = https://cae-poweron-shared.redwater-53d21339.switzerlandnorth.azurecontainerapps.io
# 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
# Manadate Pre-Processing Servers
PREPROCESS_ALTHAUS_CHAT_SECRET = INT_ENC:Z0FBQUFBQnBaSnM4UkNBelhvckxCQUVjZm94N3BZUDcxaEMyckE2dm1lRVhqODhrWU1SUjNXZ3dQZlVJOWhveXFkZXpobW5xT0NneGZ2SkNUblFmYXd0WTBYNTl3UmRnSWc9PQ==
# Preprocessor API Configuration
PP_QUERY_API_KEY=ouho02j0rj2oijroi3rj2oijro23jr0990
PP_QUERY_BASE_URL=https://poweron-althaus-preprocess-prod-e3fegaatc7faency.switzerlandnorth-01.azurewebsites.net/api/v1/dataquery/query
# 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

View file

@ -1,101 +0,0 @@
# Production Environment Configuration
# System Configuration
APP_ENV_TYPE = prod
APP_ENV_LABEL = Production Instance
APP_KEY_SYSVAR = CONFIG_KEY
APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW1WbUt6bGtQYkdrNkZDOXNOLXFua1hqeFF2RHJnRXJ5VlVGV3hOZm41QjZOMlNTb0duYXNxZi05dXVTc2xDVkx0SVBFLUhncVo5T0VUZHE0UTZLWWw3ck09
APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9
APP_API_URL = https://gateway-prod.poweron-center.net
# PostgreSQL DB Host
DB_HOST=gateway-prod-server.postgres.database.azure.com
DB_USER=gzxxmcrdhn
DB_PASSWORD_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3Y1JScGxjZG9TdUkwaHRzSHZhRHpNcDV3N1U2TnIwZ21PRG5TWFFfR1k0N3BiRk5WelVadjlnXzVSTDZ6NXFQNFpqbnJ1R3dNVkJocm1zVEgtSk0xaDRiR19zNDBEbVIzSk51ekNlQ0Z3b0U9
DB_PORT=5432
# Security Configuration
APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUlV5SVpaWXBNX1hpa2xPZGdMSWpnN2ZINHQxeGZnNHJweU5pZjlyYlY5Qm9zOUZEbl9wUEgtZHZXd1NhR19JSG9kbFU4MnFGQnllbFhRQVphRGQyNHlFVWR5VHQyUUpqN0stUmRuY2QyTi1oalczRHpLTEJqWURjZWs4YjZvT2U5YnFqcXEwdEpxV05fX05QMmtrPQ==
APP_TOKEN_EXPIRY=300
# CORS Configuration
APP_ALLOWED_ORIGINS=http://localhost:8080,https://playground.poweron-center.net,https://playground-int.poweron-center.net,http://localhost:5176,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
# Logging configuration
APP_LOGGING_LOG_LEVEL = DEBUG
APP_LOGGING_LOG_DIR = /home/site/wwwroot/
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 = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4=
Service_MSFT_AUTH_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/msft/auth/login/callback
Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4=
Service_MSFT_DATA_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/msft/auth/connect/callback
Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o=
Service_GOOGLE_AUTH_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/google/auth/login/callback
Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o=
Service_GOOGLE_DATA_REDIRECT_URI = https://gateway-prod.poweron-center.net/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://gateway-prod.poweron-center.net/api/clickup/auth/connect/callback
# Infomaniak OAuth -- Data App (kDrive + Mail)
Service_INFOMANIAK_DATA_CLIENT_ID = abd71a95-7c67-465a-b7ab-963cc5eccb4b
Service_INFOMANIAK_DATA_CLIENT_SECRET = jwaEZza0VnmAHA1vIQJcpaCC1O4ND6IS0mkQ0GGiVlmof7XHxUcl9YMl7TbtEINz
Service_INFOMANIAK_OAUTH_REDIRECT_URI = https://gateway-prod.poweron-center.net/api/infomaniak/auth/connect/callback
# 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 = PROD_ENC:Z0FBQUFBQnBaSnM4TWJOVm4xVkx6azRlNDdxN3UxLUdwY2hhdGYxRGp4VFJqYXZIcmkxM1ZyOWV2M0Z4MHdFNkVYQ0ROb1d6LUZFUEdvMHhLMEtXYVBCRzM5TlYyY3ROYWtJRk41cDZxd0tYYi00MjVqMTh4QVcyTXl0bmVocEFHbXQwREpwNi1vODdBNmwzazE5bkpNelE2WXpvblIzWlQwbGdEelI2WXFqT1RibXVHcjNWbVhwYzBOM25XTzNmTDAwUjRvYk4yNjIyZHc5c2RSZzREQUFCdUwyb0ZuOXN1dzI2c2FKdXI4NGxEbk92czZWamJXU3ZSbUlLejZjRklRRk4tLV9aVUFZekI2bTU4OHYxNTUybDg3RVo0ZTh6dXNKRW5GNXVackZvcm9laGI0X3R6V3M9
Connector_AiAnthropic_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3TnhYdlhSLW5RbXJyMHFXX0V0bHhuTDlTaFJsRDl2dTdIUTFtVFAwTE8tY3hLbzNSMnVTLXd3RUZualN3MGNzc1kwOTIxVUN2WW1rYi1TendFRVVBSVNqRFVjckEzNExyTGNaUkJLMmozazUwemI1cnhrcEtZVXJrWkdaVFFramp3MWZ6RmY2aGlRMXVEYjM2M3ZlbmxMdnNCRDM1QWR0Wmd6MWVnS1I1c01nV3hRLXg3d2NTZXVfTi1Wdm16UnRyNGsyRTZ0bG9TQ1g1OFB5Z002bmQ3QT09
Connector_AiPerplexity_API_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6NG5CTm9QOFZRV1BIVC0tV2RKTGtCQWFOUXlpRnhEdjN1U2x3VUdDamtIZV9CQzQ5ZmRmcUh3ZUVUa0NxbGhlenVVdWtaYjdpcnhvUlNFLXZfOWh2dWFZai0xUGU5cWpuYmpnRVRWakh0RVNUUTFyX0w5V0NXVWFrQlZuOTd5TkI0eVRoQ0ZBSm9HYUlYamoyY1FCMmlBPT0=
Connector_AiTavily_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3NmItcDh6V0JpcE5Jc0NlUWZqcmllRHB5eDlNZmVnUlNVenhNTm5xWExzbjJqdE1GZ0hTSUYtb2dvdWNhTnlQNmVWQ2NGVDgwZ0MwMWZBMlNKWEhzdlF3TlZzTXhCZWM4Z1Uwb18tSTRoU1JBVTVkSkJHOTJwX291b3dPaVphVFg=
Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo=
Connector_AiMistral_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGc2tQc2lvMk1YZk01Q1dob1U5cnR0dG03WWE3WkpoOWo0SEpvLU9Rc2lCNDExdy1wZExaN3lpT2FEQkxnaHRmWmZUUUZUUUJmblZreGlpaFpOdnFhbzlEd1RsVVJtX216cmhxTm5BcTN2eUZ2T054cDE5bmlEamJ3NGR6MVpFQnA=
Service_MSFT_TENANT_ID = common
# Google Cloud Speech Services configuration
Connector_GoogleSpeech_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4NFQxaF9uN3h1cVB6dnZid1c1R1VfNDlSQ1NHMEVDZWtKanpMQ29CLXc1MXBqRm1hQ0YtWVhaejBMY1ZTOEFEVlpWQ3hrYkFza1E2RDNsYkdMMndNR0VGNTMwVDRGdURJY3hyaVFxVjEtSEYwNHJzeWM3WmlpZW9jU2E3NTgycEV2allqQ3dJRTNyRFAzaDJ6dklKeXpNRkJhYjFzUkptN2dpbkNpMklrcGxuZl9vTkt3T0JvNm1YTXd5UlkwZWptUXdWVFpnV2J4X3J2WUhIUlFkSElFVnlqMnlJRnNHTnlpMWs2R1dZc2ROWjNYZG85cndmd1E5cUZnVmZRYnVjTG43dXFmSWd2bGFfVWFWSmtpWkpndWNlSUNwcnFNU2NqZXFaV0xsY3l3SElLRkVHcHZGZERKV1ltcGhTS0dhTko1VTJLYzNoZjRkSGVEX3dTMWVVTmdDczV5cE1JQUdSbUJGUm11eFhTVjJHbkt0SzB4UG1Dc2xmbnp1Y041Y2RTeWRuWGdmQy1sTGx0MGtnM2VJQ3EyLXViRlNhTU9ybzZkR1N1bXE5SXhlZENWRFpWSGlYOWx4SUQ3UlR0ZEVxQkxNakRUVFRiUmFnbklOalphLUZkRFVVaXBRUk5NZW5PaUZydTFmQkNPSTdTVTNZd0plWXllNVFJdmN4MVcyTGlwMGFtVjBzOGRxR1FjbzhfYW5zdTB0ZEZBTTJhakltazh1dktNMUZsOUItdFdTb1pIaUxySllXNkdlY20zUS0wTnpFNTB2SU5acG1VcXhyaHBmME8takw3RDh5T043T2VGOV92TzNya2pWSlpYVjZDdXlZcjM3a0hPTlhkaW9oQmxqQlpGRFYyTTY4WmZmT3k4Tk1tdXRuSGdTUVpNT2NKenhXb05PdXBfSEdhMTNxNjdpNXlKUUI2YUgydFFPX1VvXzVJb0UxWTU2YVNiNDQ0QndZanhMMHR1cGdHWGhvcEg1QXEtSXZJdTdZUE12ZEVVWkF4QmtsQS1GYnY3SFIxSHlsOGVfcEpGS1A4QUVEQWNEOFZYYlljQ3ByTU03YU16Y0UzUnJQZEprSWNjT1ZXVEtDWi03Y3ZzRVdYUTlabXJISEo5THRHVXVuM0xqbzA4bGVlZVpOMk1QMmptb21tV0pTMlVoOXdWVU95UW1iQmttc2w1RG9mMWwxXzg1T2IxYUVmTUJEZkpUdTFDTzZ3RlBFeUFiX01iRTZNWkNaSG45TkFOM2pzbUJRZ2N0VFpoejJUTG1RODY3TzZpSzVkYUQzaEpfY2pSTkRzU0VpanlkdXVQQmJ2WU5peno4QWNLTDVxZTlhSHI3NnNiM0k0Y3JkQ0xaOU05bGtsQl8zQklvaktWSDZ4aVp2MHlYelJuUDJyTU9CZC1OZjJxNFc1dDcwSUlxaVh1LTMyWWFwU0IwUU9kOUFpMWpnOERtLTh1VmJiNGVwcXBMbU5fMjVZc0hFbmxQT2puSFd1ZGpyTkphLU5sVlBZWWxrWEZrWGJQWmVkN19tZFZfZ1l1V3pSWlA0V0ZxM2lrWnl2NU9WeTdCbDROSmhfeENKTFhMVXk1d195S2JMUFJoRXZjcVo4V2g0MTNKRnZhUE1wRkNPM3FZOGdVazJPeW5PSGpuZnFGTTdJMkRnam5rUlV6NFlqODlIelRYaEN5VjdJNnVwbllNODNCTFRHMWlXbmM1VlRxbXB3Wm9LRjVrQUpjYzRNMThUMWwwSVhBMUlyamtPZnE4R0o4bEdHay1zMjR5RDJkZ1lYRHZaNHVHU2otR3ZpN25LZlEySEU0UmdTNzJGVHNWQXMyb0dVMV9WUE13ODhZWUFaakxGOWZieGNXZkNYRnV5djEyWTZLcmdrajRBLU1rS1Z0VVRkOWlDMU9fMGVmYXFhZXJGMUhpNkdmb2hkbzZ1OWV6VlNmVzNISjVYTFh6SjJNdWR5MWZidE8yVEo2dnRrZXhMRXBPczUwTG13OGhNUVpIQm0zQmRKRnJ0Nl8wNW1Ob0dHRDVpU0NWREV3TkY2SjktdVBkMFU1ZXBmSFpHQ3FHNTRZdTJvaExpZVEtLTU4YTVyeFBpNDdEajZtWUc4c1dBeUJqQ3NIY1NLS0FIMUxGZzZxNFNkOG9ORGNHWWJCVnZuNnJVTEtoQi1mRTZyUl81ZWJJMi1KOGdERzBhNVRZeHRYUUlqY2JvMFlaNHhWMU9pWFFiZjdaLUhkaG15TTBPZVlkS2R5UVdENTI4QVFiY1RJV0ZNZnlpVWxfZmlnN1BXbGdrbjFGUkhzYl9qeHBxVVJacUE4bjZETENHVFpSamh0NVpOM2hMYTZjYzBuS3J0a3hhZGxSM1V5UHd2OTU3ZHY0Yy1xWDBkWUk0Ymp0MWVrS3YzSktKODhQZnY3QTZ1Wm1VZkZJbS1jamdreks1ZlhpQjFOUDFiOHJ2Nm9NcmdTdU5LQXV2RkZWZEFNZnVKUjVwcVY3dDdhQnpmRVJ6SmlvVXpDM0ZiYXh5bGE2X04tTE9qZ3BiTnN3TF9ZaFRxSUpjNjB1dXZBcy1TZHRHTjFjSUR3WUl4cE9VNzB5Rkk4U3Z1SVZYTl9sYXlZVk83UnFrMlVmcnBpam9lRUlCY19DdVJwOXl2TVVDV1pMRFZTZk9MY3Z1eXA0MnhGazc5YllQaWtOeTc4NjlOa2lGY05RRzY1cG9nbGpYelc4c3FicWxWRkg0YzRSamFlQ19zOU14YWJreU9pNDREZVJ3a0REMUxGTzF1XzI1bEF3VXVZRjlBeWFiLXJsOXgza3VZem1WckhWSnVNbDBNcldadU8xQ3RwOTl5NGgtVlR0QklCLWl5WkE4V1FlQTBCOVU1RE9sQlRrYUNZOGdfUmEwbEZvUTFGUEFWVmQ4V1FhOU9VNjZqemRpZm1sUDhZQTJ0YVBRbWZldkF5THV4QXpfdUtNZ0tlcGdSRFM3c0lDOTNQbnBxdmxYYWNpTmI3MW9BMlZIdTQ5RldudHpNQWQ5NDNPLVVTLXVVNzdHZXh4UXpZa3dVa2J4dTFDV1RkYjRnWXU2M3lJekRYWGNMcWU5OVh6U2xZWDh6MmpqcnpiOHlnMjA5S3RFQm1NZjNSM21adkVnTUpSYVhkTzNkNnJCTmljY0x1cl9kMkx3UHhySjZEdHREanZERzNEUTFlTkR0NWlBczAtdmFGTjdZNVpTMlkxV2czYW5RN2lqemg4eUViZDV6RjdKNXdFcUlvcVhoNkJ6eVJkR1pua1hnNzQwOEs2TXJYSlpGcW9qRDU2QjBOWFFtdXBJRkRKbmdZUF9ZSmRPVEtvUjVhLTV1NjdXQjRhS0duaEtJb2FrQnNjUTRvdFMxdkdTNk1NYlFHUFhhYTJ1eUN3WHN4UlJ4UjdrZjY0SzFGYWVFN1k0cGJnc1RjNmFUenR4NHljbVhablZSWHZmUVN3cXRHNjhsX1BSZWEzdTJUZFA0S2pTaU9YMnZIQ1ZPcGhWMFJqZkVEMWRMR1h3SnU0Z2FzZ3VGM3puNzdhVjhaQXNIWHFsbjB0TDVYSFdSNV9rdWhUUUhSZHBGYkJIVDB5SDdlMC13QTVnS0g5Qkg5RGNxSGJlelVndUhPcEQ0QkRKMTJTZUM1OXJhVm0zYjU0OVY2dk9MQVBheklIQXpVNW9Yc0ROVjEzaFZTWmVxYlBWMlNlSzladzJ6TmNuMG5FVVZkN1VZN1pfS2ZHa0lQcE80S24wSnQtVlJVV09OVWJ3M09YMkZpV2ktVF9ENHhKU2dfYUQ2aUVyamk0VHJHQmVfVHU4clpUTFoteW5aSWRPV1M0RDRMTms4NGRoYmJfVE82aUl2X3VieVJOdDhBQmRwdzdnRTVBNzZwaW93dUlZb3ZRYUtOeG9ULWxvNVp5a0haSjdkcUhRb3d6UGIxRUpCVkVYX2d6TkRqQVozUWxkNGFoc1FXYVd2YWNkME9Qclo0bjYxMFRWTy1nbnI5NTBJNzRMMDluUXRKYTFqQUN4d0d5aHVlamN3Tkk3NWJXeXR0TW9BeUg5Vnp4Q2RnZUY3b3AtMDlrNmlrSGR0eGRtbUdUd2lFRWg4MklEeWJHN2wwZEpVSXMxNDNOWjRFS0tPdWxhMmFCckhfRENIY184aEFDZXNrRDl2dHQtQW12UnRuQXJjaDJoTUpiYkNWQUtfRG9GMUZoNWM4UnBYZ29RWWs2NHcyUm5kdTF3Vk1GeFpiRUJLaVZ2UGFjbi1jV3lMV0N2ZDl4VERPN295X01NNG56ZjZkRzZoYUtmY1E5NlVXemx2SnVfb19iSXg0R2M3Mjd1a2JRPT0=
# Feature SyncDelta JIRA configuration
Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4d3Z4d2x6N1FhUktMU0RKbkxfY2pTQkRzXzJ6UXVEbDNCaFM3UHMtQVFGYzNmYWs4N0lMM1R2SFJuZTVFVmx6MGVEbXc5U3NOTnY1TWN0ZDNaamlHQWloalM3VldmREJNSHQ1TlVkSVFJMTVhQWVGSVRMTGw4UTBqNGlQZFVuaHp4WUlKemR5UnBXZlh0REJFLXJ4ejR3PT0=
# Teamsbot Browser Bot Service
TEAMSBOT_BROWSER_BOT_URL = https://cae-poweron-shared.redwater-53d21339.switzerlandnorth.azurecontainerapps.io
# 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
# Manadate Pre-Processing Servers
PREPROCESS_ALTHAUS_CHAT_SECRET = PROD_ENC:Z0FBQUFBQnBaSnM4RVRmYW5IelNIbklTUDZIMEoycEN4ZFF0YUJoWWlUTUh2M0dhSXpYRXcwVkRGd1VieDNsYkdCRlpxMUR5Rjk1RDhPRkE5bmVtc2VDMURfLW9QNkxMVHN0M1JhbU9sa3JHWmdDZnlHS3BQRVBGTERVMHhXOVdDOWVqNkhfSUQyOHo=
# Preprocessor API Configuration
PP_QUERY_API_KEY=ouho02j0rj2oijroi3rj2oijro23jr0990
PP_QUERY_BASE_URL=https://poweron-althaus-preprocess-prod-e3fegaatc7faency.switzerlandnorth-01.azurewebsites.net/api/v1/dataquery/query
# 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

View file

@ -1,101 +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
DB_HOST=10.20.0.21
DB_USER=poweron_dev
DB_PASSWORD_SECRET = mypassword
DB_PORT=5432
# Security Configuration
APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUlV5SVpaWXBNX1hpa2xPZGdMSWpnN2ZINHQxeGZnNHJweU5pZjlyYlY5Qm9zOUZEbl9wUEgtZHZXd1NhR19JSG9kbFU4MnFGQnllbFhRQVphRGQyNHlFVWR5VHQyUUpqN0stUmRuY2QyTi1oalczRHpLTEJqWURjZWs4YjZvT2U5YnFqcXEwdEpxV05fX05QMmtrPQ==
APP_TOKEN_EXPIRY=300
# CORS Configuration
APP_ALLOWED_ORIGINS=https://porta.poweron.swiss
# 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 = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4=
Service_MSFT_AUTH_REDIRECT_URI=https://api.poweron.swiss/api/msft/auth/login/callback
Service_MSFT_DATA_CLIENT_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
Service_MSFT_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4=
Service_MSFT_DATA_REDIRECT_URI = https://api.poweron.swiss/api/msft/auth/connect/callback
Service_GOOGLE_AUTH_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o=
Service_GOOGLE_AUTH_REDIRECT_URI =
Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
Service_GOOGLE_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o=
Service_GOOGLE_DATA_REDIRECT_URI =
# 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 OAuth -- Data App (kDrive + Mail)
Service_INFOMANIAK_DATA_CLIENT_ID = abd71a95-7c67-465a-b7ab-963cc5eccb4b
Service_INFOMANIAK_DATA_CLIENT_SECRET = jwaEZza0VnmAHA1vIQJcpaCC1O4ND6IS0mkQ0GGiVlmof7XHxUcl9YMl7TbtEINz
Service_INFOMANIAK_OAUTH_REDIRECT_URI = https://api.poweron.swiss/api/infomaniak/auth/connect/callback
# 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 = PROD_ENC:Z0FBQUFBQnBaSnM4TWJOVm4xVkx6azRlNDdxN3UxLUdwY2hhdGYxRGp4VFJqYXZIcmkxM1ZyOWV2M0Z4MHdFNkVYQ0ROb1d6LUZFUEdvMHhLMEtXYVBCRzM5TlYyY3ROYWtJRk41cDZxd0tYYi00MjVqMTh4QVcyTXl0bmVocEFHbXQwREpwNi1vODdBNmwzazE5bkpNelE2WXpvblIzWlQwbGdEelI2WXFqT1RibXVHcjNWbVhwYzBOM25XTzNmTDAwUjRvYk4yNjIyZHc5c2RSZzREQUFCdUwyb0ZuOXN1dzI2c2FKdXI4NGxEbk92czZWamJXU3ZSbUlLejZjRklRRk4tLV9aVUFZekI2bTU4OHYxNTUybDg3RVo0ZTh6dXNKRW5GNXVackZvcm9laGI0X3R6V3M9
Connector_AiAnthropic_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3TnhYdlhSLW5RbXJyMHFXX0V0bHhuTDlTaFJsRDl2dTdIUTFtVFAwTE8tY3hLbzNSMnVTLXd3RUZualN3MGNzc1kwOTIxVUN2WW1rYi1TendFRVVBSVNqRFVjckEzNExyTGNaUkJLMmozazUwemI1cnhrcEtZVXJrWkdaVFFramp3MWZ6RmY2aGlRMXVEYjM2M3ZlbmxMdnNCRDM1QWR0Wmd6MWVnS1I1c01nV3hRLXg3d2NTZXVfTi1Wdm16UnRyNGsyRTZ0bG9TQ1g1OFB5Z002bmQ3QT09
Connector_AiPerplexity_API_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6NG5CTm9QOFZRV1BIVC0tV2RKTGtCQWFOUXlpRnhEdjN1U2x3VUdDamtIZV9CQzQ5ZmRmcUh3ZUVUa0NxbGhlenVVdWtaYjdpcnhvUlNFLXZfOWh2dWFZai0xUGU5cWpuYmpnRVRWakh0RVNUUTFyX0w5V0NXVWFrQlZuOTd5TkI0eVRoQ0ZBSm9HYUlYamoyY1FCMmlBPT0=
Connector_AiTavily_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3NmItcDh6V0JpcE5Jc0NlUWZqcmllRHB5eDlNZmVnUlNVenhNTm5xWExzbjJqdE1GZ0hTSUYtb2dvdWNhTnlQNmVWQ2NGVDgwZ0MwMWZBMlNKWEhzdlF3TlZzTXhCZWM4Z1Uwb18tSTRoU1JBVTVkSkJHOTJwX291b3dPaVphVFg=
Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo=
Connector_AiMistral_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGc2tQc2lvMk1YZk01Q1dob1U5cnR0dG03WWE3WkpoOWo0SEpvLU9Rc2lCNDExdy1wZExaN3lpT2FEQkxnaHRmWmZUUUZUUUJmblZreGlpaFpOdnFhbzlEd1RsVVJtX216cmhxTm5BcTN2eUZ2T054cDE5bmlEamJ3NGR6MVpFQnA=
Service_MSFT_TENANT_ID = common
# Google Cloud Speech Services configuration
Connector_GoogleSpeech_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4NFQxaF9uN3h1cVB6dnZid1c1R1VfNDlSQ1NHMEVDZWtKanpMQ29CLXc1MXBqRm1hQ0YtWVhaejBMY1ZTOEFEVlpWQ3hrYkFza1E2RDNsYkdMMndNR0VGNTMwVDRGdURJY3hyaVFxVjEtSEYwNHJzeWM3WmlpZW9jU2E3NTgycEV2allqQ3dJRTNyRFAzaDJ6dklKeXpNRkJhYjFzUkptN2dpbkNpMklrcGxuZl9vTkt3T0JvNm1YTXd5UlkwZWptUXdWVFpnV2J4X3J2WUhIUlFkSElFVnlqMnlJRnNHTnlpMWs2R1dZc2ROWjNYZG85cndmd1E5cUZnVmZRYnVjTG43dXFmSWd2bGFfVWFWSmtpWkpndWNlSUNwcnFNU2NqZXFaV0xsY3l3SElLRkVHcHZGZERKV1ltcGhTS0dhTko1VTJLYzNoZjRkSGVEX3dTMWVVTmdDczV5cE1JQUdSbUJGUm11eFhTVjJHbkt0SzB4UG1Dc2xmbnp1Y041Y2RTeWRuWGdmQy1sTGx0MGtnM2VJQ3EyLXViRlNhTU9ybzZkR1N1bXE5SXhlZENWRFpWSGlYOWx4SUQ3UlR0ZEVxQkxNakRUVFRiUmFnbklOalphLUZkRFVVaXBRUk5NZW5PaUZydTFmQkNPSTdTVTNZd0plWXllNVFJdmN4MVcyTGlwMGFtVjBzOGRxR1FjbzhfYW5zdTB0ZEZBTTJhakltazh1dktNMUZsOUItdFdTb1pIaUxySllXNkdlY20zUS0wTnpFNTB2SU5acG1VcXhyaHBmME8takw3RDh5T043T2VGOV92TzNya2pWSlpYVjZDdXlZcjM3a0hPTlhkaW9oQmxqQlpGRFYyTTY4WmZmT3k4Tk1tdXRuSGdTUVpNT2NKenhXb05PdXBfSEdhMTNxNjdpNXlKUUI2YUgydFFPX1VvXzVJb0UxWTU2YVNiNDQ0QndZanhMMHR1cGdHWGhvcEg1QXEtSXZJdTdZUE12ZEVVWkF4QmtsQS1GYnY3SFIxSHlsOGVfcEpGS1A4QUVEQWNEOFZYYlljQ3ByTU03YU16Y0UzUnJQZEprSWNjT1ZXVEtDWi03Y3ZzRVdYUTlabXJISEo5THRHVXVuM0xqbzA4bGVlZVpOMk1QMmptb21tV0pTMlVoOXdWVU95UW1iQmttc2w1RG9mMWwxXzg1T2IxYUVmTUJEZkpUdTFDTzZ3RlBFeUFiX01iRTZNWkNaSG45TkFOM2pzbUJRZ2N0VFpoejJUTG1RODY3TzZpSzVkYUQzaEpfY2pSTkRzU0VpanlkdXVQQmJ2WU5peno4QWNLTDVxZTlhSHI3NnNiM0k0Y3JkQ0xaOU05bGtsQl8zQklvaktWSDZ4aVp2MHlYelJuUDJyTU9CZC1OZjJxNFc1dDcwSUlxaVh1LTMyWWFwU0IwUU9kOUFpMWpnOERtLTh1VmJiNGVwcXBMbU5fMjVZc0hFbmxQT2puSFd1ZGpyTkphLU5sVlBZWWxrWEZrWGJQWmVkN19tZFZfZ1l1V3pSWlA0V0ZxM2lrWnl2NU9WeTdCbDROSmhfeENKTFhMVXk1d195S2JMUFJoRXZjcVo4V2g0MTNKRnZhUE1wRkNPM3FZOGdVazJPeW5PSGpuZnFGTTdJMkRnam5rUlV6NFlqODlIelRYaEN5VjdJNnVwbllNODNCTFRHMWlXbmM1VlRxbXB3Wm9LRjVrQUpjYzRNMThUMWwwSVhBMUlyamtPZnE4R0o4bEdHay1zMjR5RDJkZ1lYRHZaNHVHU2otR3ZpN25LZlEySEU0UmdTNzJGVHNWQXMyb0dVMV9WUE13ODhZWUFaakxGOWZieGNXZkNYRnV5djEyWTZLcmdrajRBLU1rS1Z0VVRkOWlDMU9fMGVmYXFhZXJGMUhpNkdmb2hkbzZ1OWV6VlNmVzNISjVYTFh6SjJNdWR5MWZidE8yVEo2dnRrZXhMRXBPczUwTG13OGhNUVpIQm0zQmRKRnJ0Nl8wNW1Ob0dHRDVpU0NWREV3TkY2SjktdVBkMFU1ZXBmSFpHQ3FHNTRZdTJvaExpZVEtLTU4YTVyeFBpNDdEajZtWUc4c1dBeUJqQ3NIY1NLS0FIMUxGZzZxNFNkOG9ORGNHWWJCVnZuNnJVTEtoQi1mRTZyUl81ZWJJMi1KOGdERzBhNVRZeHRYUUlqY2JvMFlaNHhWMU9pWFFiZjdaLUhkaG15TTBPZVlkS2R5UVdENTI4QVFiY1RJV0ZNZnlpVWxfZmlnN1BXbGdrbjFGUkhzYl9qeHBxVVJacUE4bjZETENHVFpSamh0NVpOM2hMYTZjYzBuS3J0a3hhZGxSM1V5UHd2OTU3ZHY0Yy1xWDBkWUk0Ymp0MWVrS3YzSktKODhQZnY3QTZ1Wm1VZkZJbS1jamdreks1ZlhpQjFOUDFiOHJ2Nm9NcmdTdU5LQXV2RkZWZEFNZnVKUjVwcVY3dDdhQnpmRVJ6SmlvVXpDM0ZiYXh5bGE2X04tTE9qZ3BiTnN3TF9ZaFRxSUpjNjB1dXZBcy1TZHRHTjFjSUR3WUl4cE9VNzB5Rkk4U3Z1SVZYTl9sYXlZVk83UnFrMlVmcnBpam9lRUlCY19DdVJwOXl2TVVDV1pMRFZTZk9MY3Z1eXA0MnhGazc5YllQaWtOeTc4NjlOa2lGY05RRzY1cG9nbGpYelc4c3FicWxWRkg0YzRSamFlQ19zOU14YWJreU9pNDREZVJ3a0REMUxGTzF1XzI1bEF3VXVZRjlBeWFiLXJsOXgza3VZem1WckhWSnVNbDBNcldadU8xQ3RwOTl5NGgtVlR0QklCLWl5WkE4V1FlQTBCOVU1RE9sQlRrYUNZOGdfUmEwbEZvUTFGUEFWVmQ4V1FhOU9VNjZqemRpZm1sUDhZQTJ0YVBRbWZldkF5THV4QXpfdUtNZ0tlcGdSRFM3c0lDOTNQbnBxdmxYYWNpTmI3MW9BMlZIdTQ5RldudHpNQWQ5NDNPLVVTLXVVNzdHZXh4UXpZa3dVa2J4dTFDV1RkYjRnWXU2M3lJekRYWGNMcWU5OVh6U2xZWDh6MmpqcnpiOHlnMjA5S3RFQm1NZjNSM21adkVnTUpSYVhkTzNkNnJCTmljY0x1cl9kMkx3UHhySjZEdHREanZERzNEUTFlTkR0NWlBczAtdmFGTjdZNVpTMlkxV2czYW5RN2lqemg4eUViZDV6RjdKNXdFcUlvcVhoNkJ6eVJkR1pua1hnNzQwOEs2TXJYSlpGcW9qRDU2QjBOWFFtdXBJRkRKbmdZUF9ZSmRPVEtvUjVhLTV1NjdXQjRhS0duaEtJb2FrQnNjUTRvdFMxdkdTNk1NYlFHUFhhYTJ1eUN3WHN4UlJ4UjdrZjY0SzFGYWVFN1k0cGJnc1RjNmFUenR4NHljbVhablZSWHZmUVN3cXRHNjhsX1BSZWEzdTJUZFA0S2pTaU9YMnZIQ1ZPcGhWMFJqZkVEMWRMR1h3SnU0Z2FzZ3VGM3puNzdhVjhaQXNIWHFsbjB0TDVYSFdSNV9rdWhUUUhSZHBGYkJIVDB5SDdlMC13QTVnS0g5Qkg5RGNxSGJlelVndUhPcEQ0QkRKMTJTZUM1OXJhVm0zYjU0OVY2dk9MQVBheklIQXpVNW9Yc0ROVjEzaFZTWmVxYlBWMlNlSzladzJ6TmNuMG5FVVZkN1VZN1pfS2ZHa0lQcE80S24wSnQtVlJVV09OVWJ3M09YMkZpV2ktVF9ENHhKU2dfYUQ2aUVyamk0VHJHQmVfVHU4clpUTFoteW5aSWRPV1M0RDRMTms4NGRoYmJfVE82aUl2X3VieVJOdDhBQmRwdzdnRTVBNzZwaW93dUlZb3ZRYUtOeG9ULWxvNVp5a0haSjdkcUhRb3d6UGIxRUpCVkVYX2d6TkRqQVozUWxkNGFoc1FXYVd2YWNkME9Qclo0bjYxMFRWTy1nbnI5NTBJNzRMMDluUXRKYTFqQUN4d0d5aHVlamN3Tkk3NWJXeXR0TW9BeUg5Vnp4Q2RnZUY3b3AtMDlrNmlrSGR0eGRtbUdUd2lFRWg4MklEeWJHN2wwZEpVSXMxNDNOWjRFS0tPdWxhMmFCckhfRENIY184aEFDZXNrRDl2dHQtQW12UnRuQXJjaDJoTUpiYkNWQUtfRG9GMUZoNWM4UnBYZ29RWWs2NHcyUm5kdTF3Vk1GeFpiRUJLaVZ2UGFjbi1jV3lMV0N2ZDl4VERPN295X01NNG56ZjZkRzZoYUtmY1E5NlVXemx2SnVfb19iSXg0R2M3Mjd1a2JRPT0=
# Feature SyncDelta JIRA configuration
Feature_SyncDelta_JIRA_DELTA_TOKEN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z4d3Z4d2x6N1FhUktMU0RKbkxfY2pTQkRzXzJ6UXVEbDNCaFM3UHMtQVFGYzNmYWs4N0lMM1R2SFJuZTVFVmx6MGVEbXc5U3NOTnY1TWN0ZDNaamlHQWloalM3VldmREJNSHQ1TlVkSVFJMTVhQWVGSVRMTGw4UTBqNGlQZFVuaHp4WUlKemR5UnBXZlh0REJFLXJ4ejR3PT0=
# Teamsbot Browser Bot Service
TEAMSBOT_BROWSER_BOT_URL = https://cae-poweron-shared.redwater-53d21339.switzerlandnorth.azurecontainerapps.io
# 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
# Manadate Pre-Processing Servers
PREPROCESS_ALTHAUS_CHAT_SECRET = PROD_ENC:Z0FBQUFBQnBaSnM4RVRmYW5IelNIbklTUDZIMEoycEN4ZFF0YUJoWWlUTUh2M0dhSXpYRXcwVkRGd1VieDNsYkdCRlpxMUR5Rjk1RDhPRkE5bmVtc2VDMURfLW9QNkxMVHN0M1JhbU9sa3JHWmdDZnlHS3BQRVBGTERVMHhXOVdDOWVqNkhfSUQyOHo=
# Preprocessor API Configuration
PP_QUERY_API_KEY=ouho02j0rj2oijroi3rj2oijro23jr0990
PP_QUERY_BASE_URL=https://poweron-althaus-preprocess-prod-e3fegaatc7faency.switzerlandnorth-01.azurewebsites.net/api/v1/dataquery/query
# 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

View file

@ -272,7 +272,9 @@ class ModelSelector:
return 1.0
elif requestedPriority == PriorityEnum.SPEED:
return model.speedRating / 10.0
# Scale to same magnitude as operation type (x1000) so speed
# can meaningfully influence model ranking across tiers.
return model.speedRating * 100.0
elif requestedPriority == PriorityEnum.QUALITY:
return model.qualityRating / 10.0

View file

@ -351,6 +351,7 @@ class AiAnthropic(BaseConnectorAi):
# Parse response
anthropicResponse = response.json()
stop_reason = anthropicResponse.get("stop_reason")
# Extract content and tool_use blocks from response
content = ""
@ -374,9 +375,25 @@ class AiAnthropic(BaseConnectorAi):
if not content and not toolCalls:
logger.warning(f"Anthropic API returned empty content. Full response: {anthropicResponse}")
content = "[Anthropic API returned empty response]"
err = (
"Anthropic refused the request (content policy) — try another model or adjust the prompt."
if stop_reason == "refusal"
else f"Anthropic returned no assistant text (stop_reason={stop_reason or 'unknown'})."
)
return AiModelResponse(
content="",
success=False,
error=err,
modelId=model.name,
metadata={
"response_id": anthropicResponse.get("id", ""),
"stop_reason": stop_reason,
},
)
metadata = {"response_id": anthropicResponse.get("id", "")}
if stop_reason:
metadata["stop_reason"] = stop_reason
if toolCalls:
metadata["toolCalls"] = toolCalls
@ -492,6 +509,19 @@ class AiAnthropic(BaseConnectorAi):
f"Anthropic stream returned empty response: model={model.name}, "
f"stopReason={stopReason}"
)
err = (
"Anthropic refused the request (content policy) — try another model or adjust the prompt."
if stopReason == "refusal"
else f"Anthropic returned no assistant text (stop_reason={stopReason or 'unknown'})."
)
yield AiModelResponse(
content="",
success=False,
error=err,
modelId=model.name,
metadata={"stopReason": stopReason} if stopReason else {},
)
return
metadata: Dict[str, Any] = {}
if stopReason:

View file

@ -19,10 +19,28 @@ ALGORITHM = APP_CONFIG.get("Auth_ALGORITHM")
ACCESS_TOKEN_EXPIRE_MINUTES = int(APP_CONFIG.get("APP_TOKEN_EXPIRY"))
REFRESH_TOKEN_EXPIRE_DAYS = int(APP_CONFIG.get("APP_REFRESH_TOKEN_EXPIRY", "7"))
# Cookie security settings - use secure cookies based on whether API uses HTTPS
# Cookies must have secure=True on HTTPS sites, secure=False on HTTP sites
APP_API_URL = APP_CONFIG.get("APP_API_URL", "http://localhost:8000")
USE_SECURE_COOKIES = APP_API_URL.startswith("https://") if APP_API_URL else False
def _cookiePolicy() -> Tuple[bool, str, str]:
"""
Return (useSecure, samesiteStarlette, samesiteSetCookieHeader).
Evaluated on each Set-Cookie so policy is not frozen at module import (config refresh / load order).
Cross-origin SPA + API: SameSite=None and Secure=True so credentialed fetch sends cookies.
HTTP dev: Lax + Secure=False.
APP_COOKIE_SECURE: explicit true/false (1/0, yes/no) overrides the APP_API_URL heuristic.
"""
explicit = (APP_CONFIG.get("APP_COOKIE_SECURE") or "").strip().lower()
if explicit in ("1", "true", "yes"):
useSecure = True
elif explicit in ("0", "false", "no"):
useSecure = False
else:
apiUrl = (APP_CONFIG.get("APP_API_URL") or "").strip()
useSecure = apiUrl.startswith("https://")
samesite = "none" if useSecure else "lax"
samesiteHeader = "None" if useSecure else "Lax"
return useSecure, samesite, samesiteHeader
def createAccessToken(data: dict, expiresDelta: Optional[timedelta] = None) -> Tuple[str, "datetime"]:
@ -54,13 +72,14 @@ def createRefreshToken(data: dict) -> Tuple[str, "datetime"]:
def setAccessTokenCookie(response: Response, token: str, expiresDelta: Optional[timedelta] = None) -> None:
"""Set access token as httpOnly cookie."""
useSecure, samesite, _ = _cookiePolicy()
maxAge = int(expiresDelta.total_seconds()) if expiresDelta else ACCESS_TOKEN_EXPIRE_MINUTES * 60
response.set_cookie(
key="auth_token",
value=token,
httponly=True,
secure=USE_SECURE_COOKIES, # Only secure in production (HTTPS)
samesite="strict",
secure=useSecure,
samesite=samesite,
path="/",
max_age=maxAge
)
@ -68,12 +87,13 @@ def setAccessTokenCookie(response: Response, token: str, expiresDelta: Optional[
def setRefreshTokenCookie(response: Response, token: str) -> None:
"""Set refresh token as httpOnly cookie."""
useSecure, samesite, _ = _cookiePolicy()
response.set_cookie(
key="refresh_token",
value=token,
httponly=True,
secure=USE_SECURE_COOKIES, # Only secure in production (HTTPS)
samesite="strict",
secure=useSecure,
samesite=samesite,
path="/",
max_age=REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
)
@ -84,17 +104,23 @@ def clearAccessTokenCookie(response: Response) -> None:
Clear access token cookie by setting it to expire immediately.
Uses both raw header manipulation and FastAPI's delete_cookie for maximum browser compatibility.
"""
# Build secure flag based on environment
secure_flag = "; Secure" if USE_SECURE_COOKIES else ""
useSecure, samesite, samesiteHeader = _cookiePolicy()
secure_flag = "; Secure" if useSecure else ""
# Primary method: Raw Set-Cookie header for guaranteed deletion
response.headers.append(
"Set-Cookie",
f"auth_token=deleted; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly{secure_flag}; SameSite=Strict"
f"auth_token=deleted; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly{secure_flag}; SameSite={samesiteHeader}"
)
# Fallback: Also use FastAPI's built-in method (match SameSite/Secure for invalidation)
response.delete_cookie(
key="auth_token",
path="/",
secure=useSecure,
httponly=True,
samesite=samesite,
)
# Fallback: Also use FastAPI's built-in method
response.delete_cookie(key="auth_token", path="/")
def clearRefreshTokenCookie(response: Response) -> None:
@ -102,16 +128,22 @@ def clearRefreshTokenCookie(response: Response) -> None:
Clear refresh token cookie by setting it to expire immediately.
Uses both raw header manipulation and FastAPI's delete_cookie for maximum browser compatibility.
"""
# Build secure flag based on environment
secure_flag = "; Secure" if USE_SECURE_COOKIES else ""
useSecure, samesite, samesiteHeader = _cookiePolicy()
secure_flag = "; Secure" if useSecure else ""
# Primary method: Raw Set-Cookie header for guaranteed deletion
response.headers.append(
"Set-Cookie",
f"refresh_token=deleted; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly{secure_flag}; SameSite=Strict"
f"refresh_token=deleted; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly{secure_flag}; SameSite={samesiteHeader}"
)
# Fallback: Also use FastAPI's built-in method (match SameSite/Secure for invalidation)
response.delete_cookie(
key="refresh_token",
path="/",
secure=useSecure,
httponly=True,
samesite=samesite,
)
# Fallback: Also use FastAPI's built-in method
response.delete_cookie(key="refresh_token", path="/")

View file

@ -834,7 +834,10 @@ class DatabaseConnector:
createdTs = record.get("sysCreatedAt")
if createdTs is None or createdTs == 0 or createdTs == 0.0:
record["sysCreatedAt"] = currentTime
if effective_user_id:
# Do not wipe caller-provided sysCreatedBy (e.g. FileItem from createFile with
# real user). ContextVar can be "system" for the DB pool while the business
# user is set on the record from model_dump().
if effective_user_id and not record.get("sysCreatedBy"):
record["sysCreatedBy"] = effective_user_id
elif not record.get("sysCreatedBy"):
if effective_user_id:
@ -1531,7 +1534,7 @@ class DatabaseConnector:
createdTs = rec.get("sysCreatedAt")
if createdTs is None or createdTs == 0 or createdTs == 0.0:
rec["sysCreatedAt"] = currentTime
if effectiveUserId:
if effectiveUserId and not rec.get("sysCreatedBy"):
rec["sysCreatedBy"] = effectiveUserId
elif not rec.get("sysCreatedBy") and effectiveUserId:
rec["sysCreatedBy"] = effectiveUserId

View file

@ -9,50 +9,95 @@ All models use camelStyle naming convention for consistency with frontend.
from typing import List, Dict, Any, Optional, Generic, TypeVar
from pydantic import BaseModel, Field, ConfigDict
import math
import uuid
T = TypeVar('T')
# ---------------------------------------------------------------------------
# Table Grouping models
# Group layout models (Strategy B — derived from Views, purely presentational)
# ---------------------------------------------------------------------------
class TableGroupNode(BaseModel):
class GroupByLevel(BaseModel):
"""One level of a multi-level grouping definition, stored inside a TableListView config."""
field: str = Field(..., description="Field key to group by")
nullLabel: str = Field(default="", description="Display label for null/empty values")
direction: str = Field(
default="asc",
description="Order of group bands at this level: 'asc' or 'desc'",
)
class GroupBand(BaseModel):
"""
A single node in a user-defined group tree for a FormGeneratorTable.
A contiguous block of rows that share the same group path, intersecting the current page.
Items belong to exactly one group (no multi-membership).
Groups can be nested to arbitrary depth via subGroups.
startRowIndex and rowCount are 0-based indices relative to the current page's items[].
"""
id: str
name: str
itemIds: List[str] = Field(default_factory=list)
subGroups: List['TableGroupNode'] = Field(default_factory=list)
order: int = 0
isExpanded: bool = True
TableGroupNode.model_rebuild()
path: List[str] = Field(..., description="Hierarchical group key (one entry per level)")
label: str = Field(..., description="Display label for this band (last path element)")
startRowIndex: int = Field(..., description="0-based start index within items[] on this page")
rowCount: int = Field(..., description="Number of items in this band on this page")
class TableGrouping(BaseModel):
class GroupLayout(BaseModel):
"""
Persisted grouping configuration for one (user, contextKey) pair.
Stored in table_groupings in poweron_app (auto-created).
Grouping structure for the current response page.
Included only when the effective view has groupByLevels configured.
The frontend renders group header rows by iterating bands and inserting
headers before each startRowIndex.
"""
levels: List[str] = Field(..., description="Ordered field keys that define the grouping hierarchy")
bands: List[GroupBand] = Field(..., description="Bands intersecting the current page, in order")
class AppliedViewMeta(BaseModel):
"""Minimal metadata about the view that was applied to this response."""
viewKey: Optional[str] = None
displayName: Optional[str] = None
# ---------------------------------------------------------------------------
# Persisted view model
# ---------------------------------------------------------------------------
class TableListView(BaseModel):
"""
A saved table view for one (userId, contextKey) pair.
config schema (schemaVersion=1):
{
"schemaVersion": 1,
"filters": {}, # same structure as PaginationParams.filters
"sort": [], # same structure as PaginationParams.sort
"groupByLevels": [ # ordered grouping levels
{"field": "scope", "nullLabel": "", "direction": "asc"}
],
"collapsedSectionKeys": [], # optional: section UI (stable group keys)
"collapsedGroupKeys": [], # optional: inline group bands (path.join('///'))
}
contextKey convention: API path without /api/ prefix and without trailing slash.
Examples: "connections", "prompts", "admin/users", "trustee/{instanceId}/documents"
Examples: "connections", "prompts", "admin/users", "files/list"
viewKey is a user-defined slug, unique per (userId, mandateId, contextKey).
"""
id: str
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
userId: str
mandateId: Optional[str] = None
contextKey: str
rootGroups: List[TableGroupNode] = Field(default_factory=list)
viewKey: str
displayName: str
config: Dict[str, Any] = Field(default_factory=dict)
updatedAt: Optional[float] = None
# ---------------------------------------------------------------------------
# Sort and pagination models
# ---------------------------------------------------------------------------
class SortField(BaseModel):
"""
Single sort field configuration.
"""
"""Single sort field configuration."""
field: str = Field(..., description="Field name to sort by")
direction: str = Field(..., description="Sort direction: 'asc' or 'desc'")
@ -61,16 +106,13 @@ class PaginationParams(BaseModel):
"""
Complete pagination state including page, sorting, and filters.
Grouping extensions (both optional omit when not using grouping):
groupId Scope the request to items belonging to this group.
The backend resolves it to an itemIds IN-filter before
applying normal pagination/search/filter logic.
Also applied for mode=ids and mode=filterValues so that
bulk-select and filter-dropdowns respect the group scope.
saveGroupTree If present the backend persists this tree for the current
(user, contextKey) pair *before* fetching, then returns
the confirmed tree in the response groupTree field.
Omit on every request that does not change the group tree.
View extension (optional):
viewKey Slug of a saved TableListView for this (user, contextKey) pair.
The server loads the view, merges its filters/sort/groupByLevels
into the effective query (request fields take priority over view
defaults for explicitly provided fields), and returns groupLayout
in the response when groupByLevels is non-empty.
Omit or set to None for the default (ungrouped) view.
"""
page: int = Field(ge=1, description="Current page number (1-based)")
pageSize: int = Field(ge=1, le=1000, description="Number of items per page")
@ -85,13 +127,16 @@ class PaginationParams(BaseModel):
- Supported operators: equals/eq, contains, startsWith, endsWith, gt, gte, lt, lte, in, notIn
- Multiple filters are combined with AND logic"""
)
groupId: Optional[str] = Field(
viewKey: Optional[str] = Field(
default=None,
description="Scope request to items of this group (resolved server-side to itemIds IN-filter)",
description="Slug of a saved view to load; server merges view config into effective query",
)
saveGroupTree: Optional[List[Dict[str, Any]]] = Field(
groupByLevels: Optional[List[GroupByLevel]] = Field(
default=None,
description="If set, persist this group tree before fetching (optimistic save)",
description=(
"When set (including an empty list), replaces the saved view's groupByLevels for this request. "
"Omit entirely to use grouping from the view only."
),
)
@ -130,16 +175,22 @@ class PaginatedResponse(BaseModel, Generic[T]):
"""
Response containing paginated data and metadata.
groupTree is included when the endpoint supports table grouping and the
current user has a saved group tree for the requested contextKey.
It is None when grouping is not configured for the endpoint or the user
has not created any groups yet. Frontend must treat None as an empty tree.
groupLayout is included when the effective view has groupByLevels configured.
It describes how to render group header rows in the current page's items[].
Omitted (None) when no grouping is active.
appliedView describes which saved view was merged into this response,
allowing the frontend to synchronise its view selector.
"""
items: List[T] = Field(..., description="Array of items for current page")
pagination: Optional[PaginationMetadata] = Field(..., description="Pagination metadata (None if pagination not applied)")
groupTree: Optional[List[TableGroupNode]] = Field(
groupLayout: Optional[GroupLayout] = Field(
default=None,
description="Current group tree for this (user, contextKey) pair — None if no grouping configured",
description="Group band structure for this page (None if no grouping active)",
)
appliedView: Optional[AppliedViewMeta] = Field(
default=None,
description="Metadata about the view applied to this response",
)
model_config = ConfigDict(arbitrary_types_allowed=True)
@ -148,34 +199,30 @@ class PaginatedResponse(BaseModel, Generic[T]):
def normalize_pagination_dict(pagination_dict: Dict[str, Any]) -> Dict[str, Any]:
"""
Normalize pagination dictionary to handle frontend variations.
Moves top-level "search" field into filters if present.
Grouping fields (groupId, saveGroupTree) are passed through as-is.
Args:
pagination_dict: Raw pagination dictionary from frontend
Returns:
Normalized pagination dictionary ready for PaginationParams parsing
- Moves top-level "search" field into filters if present.
- Silently drops legacy fields (groupId, saveGroupTree) that were part of the
old tree-grouping implementation so old clients do not cause validation errors.
- Passes viewKey through unchanged.
"""
if not pagination_dict:
return pagination_dict
# Create a copy to avoid modifying the original
normalized = dict(pagination_dict)
# Ensure required fields have sensible defaults
if "page" not in normalized:
normalized["page"] = 1
if "pageSize" not in normalized:
normalized["pageSize"] = 25
# Move top-level "search" into filters if present
# Move top-level "search" into filters
if "search" in normalized:
if "filters" not in normalized or normalized["filters"] is None:
normalized["filters"] = {}
normalized["filters"]["search"] = normalized.pop("search")
# groupId / saveGroupTree are valid PaginationParams fields — pass through unchanged.
# No transformation needed; Pydantic will validate them.
# Drop legacy tree-grouping fields — harmless if already absent
normalized.pop("groupId", None)
normalized.pop("saveGroupTree", None)
return normalized

View file

@ -6,7 +6,7 @@ StripePlanPrice (persisted Stripe IDs per plan).
State Machine: see wiki/concepts/Subscription-State-Machine.md
"""
from typing import Dict, List, Optional
from typing import Any, Dict, List, Optional
from enum import Enum
from datetime import datetime, timezone
from pydantic import BaseModel, Field
@ -284,12 +284,63 @@ class MandateSubscription(PowerOnModel):
json_schema_extra={"label": "Stripe-Item (Instanzen)"},
)
# Enterprise subscription fields (custom limits, no Stripe billing)
isEnterprise: bool = Field(
default=False,
description="True for enterprise subscriptions managed by sysadmin with flat pricing",
json_schema_extra={"label": "Enterprise-Abo"},
)
enterpriseFlatPriceCHF: Optional[float] = Field(
None,
description="Flat price per period (CHF) for enterprise subscriptions",
json_schema_extra={"label": "Pauschale (CHF)"},
)
enterpriseMaxUsers: Optional[int] = Field(
None,
description="Custom user limit for enterprise (None = unlimited)",
json_schema_extra={"label": "Enterprise Max. Benutzer"},
)
enterpriseMaxFeatureInstances: Optional[int] = Field(
None,
description="Custom feature instance limit for enterprise (None = unlimited)",
json_schema_extra={"label": "Enterprise Max. Module"},
)
enterpriseMaxDataVolumeMB: Optional[int] = Field(
None,
description="Custom storage limit in MB for enterprise (None = unlimited)",
json_schema_extra={"label": "Enterprise Datenvolumen (MB)"},
)
enterpriseBudgetAiCHF: Optional[float] = Field(
None,
description="Fixed AI budget per period (CHF) for enterprise subscriptions",
json_schema_extra={"label": "Enterprise AI-Budget (CHF)"},
)
enterpriseNote: Optional[str] = Field(
None,
description="Free-text note (e.g. contract reference) for enterprise subscriptions",
json_schema_extra={"label": "Enterprise Notiz"},
)
# ============================================================================
# Built-in plan catalog (static, no env dependency)
# ============================================================================
BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
"ENTERPRISE": SubscriptionPlan(
planKey="ENTERPRISE",
selectableByUser=False,
title=t("Enterprise"),
description=t("Individuelles Pauschalen-Abonnement — Limiten und Preis vom Sysadmin festgelegt."),
billingPeriod=BillingPeriodEnum.NONE,
autoRenew=False,
maxUsers=None,
maxFeatureInstances=None,
includedModules=0,
maxDataVolumeMB=None,
budgetAiCHF=0.0,
budgetAiPerUserCHF=0.0,
),
"ROOT": SubscriptionPlan(
planKey="ROOT",
selectableByUser=False,
@ -415,3 +466,35 @@ def getPlan(planKey: str) -> Optional[SubscriptionPlan]:
def _getSelectablePlans() -> List[SubscriptionPlan]:
"""Return plans that users can choose in the UI."""
return [p for p in BUILTIN_PLANS.values() if p.selectableByUser]
def getEffectiveLimits(sub: Dict[str, Any], plan: Optional[SubscriptionPlan] = None) -> Dict[str, Any]:
"""Resolve effective limits for a subscription.
For enterprise subscriptions the custom enterprise* fields on the subscription
record take precedence. For standard subscriptions the plan catalog values are
returned. Falls back to unlimited (None / 0) when neither source provides a
value."""
if sub.get("isEnterprise"):
return {
"maxUsers": sub.get("enterpriseMaxUsers"),
"maxFeatureInstances": sub.get("enterpriseMaxFeatureInstances"),
"maxDataVolumeMB": sub.get("enterpriseMaxDataVolumeMB"),
"budgetAiCHF": sub.get("enterpriseBudgetAiCHF") or 0.0,
"includedModules": sub.get("enterpriseMaxFeatureInstances") or 0,
}
if plan:
return {
"maxUsers": plan.maxUsers,
"maxFeatureInstances": plan.maxFeatureInstances,
"maxDataVolumeMB": plan.maxDataVolumeMB,
"budgetAiCHF": plan.budgetAiCHF,
"includedModules": plan.includedModules,
}
return {
"maxUsers": None,
"maxFeatureInstances": None,
"maxDataVolumeMB": None,
"budgetAiCHF": 0.0,
"includedModules": 0,
}

View file

@ -2,7 +2,7 @@
# All rights reserved.
"""
CommCoach Feature - Data Models.
Pydantic models for coaching contexts, sessions, messages, tasks, scores, and user profiles.
Pydantic models for training modules, sessions, messages, tasks, scores, and user profiles.
"""
from typing import Optional, List, Dict, Any
from pydantic import BaseModel, Field
@ -16,22 +16,18 @@ import uuid
# Enums
# ============================================================================
class CoachingContextStatus(str, Enum):
class TrainingModuleStatus(str, Enum):
ACTIVE = "active"
PAUSED = "paused"
ARCHIVED = "archived"
COMPLETED = "completed"
class CoachingContextCategory(str, Enum):
LEADERSHIP = "leadership"
CONFLICT = "conflict"
NEGOTIATION = "negotiation"
PRESENTATION = "presentation"
FEEDBACK = "feedback"
DELEGATION = "delegation"
CHANGE_MANAGEMENT = "changeManagement"
CUSTOM = "custom"
class TrainingModuleType(str, Enum):
COACHING = "coaching"
TRAINING = "training"
EXAM = "exam"
ELEARNING = "elearning"
class CoachingSessionStatus(str, Enum):
@ -75,19 +71,21 @@ class CoachingScoreTrend(str, Enum):
# Database Models
# ============================================================================
class CoachingContext(PowerOnModel):
"""A coaching context/dossier representing a topic the user is working on."""
class TrainingModule(PowerOnModel):
"""A training module representing a topic the user is working on."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
userId: str = Field(description="Owner user ID (strict ownership)")
mandateId: str = Field(description="Mandate ID")
instanceId: str = Field(description="Feature instance ID")
title: str = Field(description="Context title, e.g. 'Conflict with team lead'")
title: str = Field(description="Module title, e.g. 'Conflict with team lead'")
description: Optional[str] = Field(default=None, description="Short description")
category: CoachingContextCategory = Field(default=CoachingContextCategory.CUSTOM)
status: CoachingContextStatus = Field(default=CoachingContextStatus.ACTIVE)
goals: Optional[str] = Field(default=None, description="JSON array of goals [{id, text, status, createdAt}]")
moduleType: TrainingModuleType = Field(default=TrainingModuleType.COACHING)
status: TrainingModuleStatus = Field(default=TrainingModuleStatus.ACTIVE)
goals: Optional[str] = Field(default=None, description="Free-text goal description")
insights: Optional[str] = Field(default=None, description="JSON array of AI insights [{id, text, sessionId, createdAt}]")
metadata: Optional[str] = Field(default=None, description="JSON object with flexible metadata")
personaId: Optional[str] = Field(default=None, description="Default persona for sessions")
kpiTargets: Optional[str] = Field(default=None, description="JSON object with structured KPI targets")
sessionCount: int = Field(default=0)
taskCount: int = Field(default=0)
lastSessionAt: Optional[float] = Field(default=None, json_schema_extra={"frontend_type": "timestamp"})
@ -96,9 +94,9 @@ class CoachingContext(PowerOnModel):
class CoachingSession(PowerOnModel):
"""A single coaching conversation session within a context."""
"""A single coaching conversation session within a module."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
contextId: str = Field(description="FK to CoachingContext")
moduleId: str = Field(description="FK to TrainingModule")
userId: str = Field(description="Owner user ID")
mandateId: str = Field(description="Mandate ID")
instanceId: str = Field(description="Feature instance ID")
@ -121,7 +119,7 @@ class CoachingMessage(PowerOnModel):
"""A single message in a coaching session."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
sessionId: str = Field(description="FK to CoachingSession")
contextId: str = Field(description="FK to CoachingContext")
moduleId: str = Field(description="FK to TrainingModule")
userId: str = Field(description="Owner user ID")
role: CoachingMessageRole = Field(description="Message author role")
content: str = Field(description="Message content (Markdown)")
@ -131,9 +129,9 @@ class CoachingMessage(PowerOnModel):
class CoachingTask(PowerOnModel):
"""A task/checklist item assigned within a coaching context."""
"""A task/checklist item assigned within a training module."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
contextId: str = Field(description="FK to CoachingContext")
moduleId: str = Field(description="FK to TrainingModule")
sessionId: Optional[str] = Field(default=None, description="FK to originating session")
userId: str = Field(description="Owner user ID")
mandateId: str = Field(description="Mandate ID")
@ -148,7 +146,7 @@ class CoachingTask(PowerOnModel):
class CoachingScore(PowerOnModel):
"""A competence score for a dimension, recorded after a session."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
contextId: str = Field(description="FK to CoachingContext")
moduleId: str = Field(description="FK to TrainingModule")
sessionId: str = Field(description="FK to CoachingSession")
userId: str = Field(description="Owner user ID")
mandateId: str = Field(description="Mandate ID")
@ -193,6 +191,22 @@ class CoachingPersona(PowerOnModel):
isActive: bool = Field(default=True)
# ============================================================================
# Module-Persona Mapping (M:N)
# ============================================================================
class ModulePersonaMapping(PowerOnModel):
"""Maps which personas are available for a specific training module."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
moduleId: str = Field(description="FK to TrainingModule")
personaId: str = Field(description="FK to CoachingPersona")
instanceId: str = Field(description="Feature instance ID")
class SetModulePersonasRequest(BaseModel):
personaIds: List[str] = Field(description="List of persona IDs to assign to this module")
# ============================================================================
# Iteration 2: Badges / Gamification
# ============================================================================
@ -211,18 +225,22 @@ class CoachingBadge(PowerOnModel):
# API Request/Response Models
# ============================================================================
class CreateContextRequest(BaseModel):
title: str = Field(description="Context title")
class CreateModuleRequest(BaseModel):
title: str = Field(description="Module title")
description: Optional[str] = None
category: Optional[CoachingContextCategory] = CoachingContextCategory.CUSTOM
goals: Optional[List[str]] = None
moduleType: Optional[TrainingModuleType] = TrainingModuleType.COACHING
goals: Optional[str] = None
personaId: Optional[str] = None
kpiTargets: Optional[str] = None
class UpdateContextRequest(BaseModel):
class UpdateModuleRequest(BaseModel):
title: Optional[str] = None
description: Optional[str] = None
category: Optional[CoachingContextCategory] = None
moduleType: Optional[TrainingModuleType] = None
goals: Optional[str] = None
personaId: Optional[str] = None
kpiTargets: Optional[str] = None
class SendMessageRequest(BaseModel):
@ -279,8 +297,8 @@ class UpdatePersonaRequest(BaseModel):
class DashboardData(BaseModel):
"""Aggregated dashboard data for the user."""
totalContexts: int = 0
activeContexts: int = 0
totalModules: int = 0
activeModules: int = 0
totalSessions: int = 0
totalMinutes: int = 0
streakDays: int = 0
@ -289,4 +307,4 @@ class DashboardData(BaseModel):
recentScores: List[Dict[str, Any]] = Field(default_factory=list)
openTasks: int = 0
completedTasks: int = 0
contexts: List[Dict[str, Any]] = Field(default_factory=list)
modules: List[Dict[str, Any]] = Field(default_factory=list)

View file

@ -17,7 +17,7 @@ from modules.shared.configuration import APP_CONFIG
from modules.shared.i18nRegistry import resolveText, t
from .datamodelCommcoach import (
CoachingContext, CoachingContextStatus,
TrainingModule, TrainingModuleStatus,
CoachingSession, CoachingSessionStatus,
CoachingMessage,
CoachingTask, CoachingTaskStatus,
@ -70,47 +70,60 @@ class CommcoachObjects:
)
# =========================================================================
# Contexts
# Modules (formerly Contexts)
# =========================================================================
def getContexts(self, instanceId: str, userId: str, includeArchived: bool = False) -> List[Dict[str, Any]]:
"""Get all coaching contexts for a user. Strict ownership."""
def getModules(self, instanceId: str, userId: str, includeArchived: bool = False) -> List[Dict[str, Any]]:
"""Get all training modules for a user. Enriches with live sessionCount from sessions table."""
records = self.db.getRecordset(
CoachingContext,
TrainingModule,
recordFilter={"instanceId": instanceId, "userId": userId},
)
if not includeArchived:
records = [r for r in records if r.get("status") != CoachingContextStatus.ARCHIVED.value]
records = [r for r in records if r.get("status") != TrainingModuleStatus.ARCHIVED.value]
allSessions = self.db.getRecordset(
CoachingSession,
recordFilter={"instanceId": instanceId, "userId": userId},
)
countByModule: Dict[str, int] = {}
for s in allSessions:
mid = s.get("moduleId")
if mid:
countByModule[mid] = countByModule.get(mid, 0) + 1
for r in records:
r["sessionCount"] = countByModule.get(r.get("id", ""), 0)
records.sort(key=lambda r: r.get("updatedAt") or r.get("createdAt") or "", reverse=True)
return records
def getContext(self, contextId: str) -> Optional[Dict[str, Any]]:
records = self.db.getRecordset(CoachingContext, recordFilter={"id": contextId})
def getModule(self, moduleId: str) -> Optional[Dict[str, Any]]:
records = self.db.getRecordset(TrainingModule, recordFilter={"id": moduleId})
return records[0] if records else None
def createContext(self, data: Dict[str, Any]) -> Dict[str, Any]:
def createModule(self, data: Dict[str, Any]) -> Dict[str, Any]:
data["createdAt"] = getIsoTimestamp()
data["updatedAt"] = getIsoTimestamp()
return self.db.recordCreate(CoachingContext, data)
return self.db.recordCreate(TrainingModule, data)
def updateContext(self, contextId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
def updateModule(self, moduleId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
updates["updatedAt"] = getIsoTimestamp()
return self.db.recordModify(CoachingContext, contextId, updates)
return self.db.recordModify(TrainingModule, moduleId, updates)
def deleteContext(self, contextId: str) -> bool:
self._deleteSessionsByContext(contextId)
self._deleteTasksByContext(contextId)
self._deleteScoresByContext(contextId)
return self.db.recordDelete(CoachingContext, contextId)
def deleteModule(self, moduleId: str) -> bool:
self._deleteSessionsByModule(moduleId)
self._deleteTasksByModule(moduleId)
self._deleteScoresByModule(moduleId)
return self.db.recordDelete(TrainingModule, moduleId)
# =========================================================================
# Sessions
# =========================================================================
def getSessions(self, contextId: str, userId: str) -> List[Dict[str, Any]]:
def getSessions(self, moduleId: str, userId: str) -> List[Dict[str, Any]]:
records = self.db.getRecordset(
CoachingSession,
recordFilter={"contextId": contextId, "userId": userId},
recordFilter={"moduleId": moduleId, "userId": userId},
)
records.sort(key=lambda r: r.get("startedAt") or 0, reverse=True)
return records
@ -119,10 +132,10 @@ class CommcoachObjects:
records = self.db.getRecordset(CoachingSession, recordFilter={"id": sessionId})
return records[0] if records else None
def getActiveSession(self, contextId: str, userId: str) -> Optional[Dict[str, Any]]:
def getActiveSession(self, moduleId: str, userId: str) -> Optional[Dict[str, Any]]:
records = self.db.getRecordset(
CoachingSession,
recordFilter={"contextId": contextId, "userId": userId, "status": CoachingSessionStatus.ACTIVE.value},
recordFilter={"moduleId": moduleId, "userId": userId, "status": CoachingSessionStatus.ACTIVE.value},
)
return records[0] if records else None
@ -136,8 +149,8 @@ class CommcoachObjects:
updates["updatedAt"] = getIsoTimestamp()
return self.db.recordModify(CoachingSession, sessionId, updates)
def _deleteSessionsByContext(self, contextId: str) -> int:
records = self.db.getRecordset(CoachingSession, recordFilter={"contextId": contextId})
def _deleteSessionsByModule(self, moduleId: str) -> int:
records = self.db.getRecordset(CoachingSession, recordFilter={"moduleId": moduleId})
count = 0
for record in records:
self._deleteMessagesBySession(record.get("id"))
@ -174,10 +187,10 @@ class CommcoachObjects:
# Tasks
# =========================================================================
def getTasks(self, contextId: str, userId: str) -> List[Dict[str, Any]]:
def getTasks(self, moduleId: str, userId: str) -> List[Dict[str, Any]]:
records = self.db.getRecordset(
CoachingTask,
recordFilter={"contextId": contextId, "userId": userId},
recordFilter={"moduleId": moduleId, "userId": userId},
)
records.sort(key=lambda r: r.get("createdAt") or "", reverse=True)
return records
@ -198,8 +211,8 @@ class CommcoachObjects:
def deleteTask(self, taskId: str) -> bool:
return self.db.recordDelete(CoachingTask, taskId)
def _deleteTasksByContext(self, contextId: str) -> int:
records = self.db.getRecordset(CoachingTask, recordFilter={"contextId": contextId})
def _deleteTasksByModule(self, moduleId: str) -> int:
records = self.db.getRecordset(CoachingTask, recordFilter={"moduleId": moduleId})
count = 0
for record in records:
self.db.recordDelete(CoachingTask, record.get("id"))
@ -218,10 +231,10 @@ class CommcoachObjects:
# Scores
# =========================================================================
def getScores(self, contextId: str, userId: str) -> List[Dict[str, Any]]:
def getScores(self, moduleId: str, userId: str) -> List[Dict[str, Any]]:
records = self.db.getRecordset(
CoachingScore,
recordFilter={"contextId": contextId, "userId": userId},
recordFilter={"moduleId": moduleId, "userId": userId},
)
records.sort(key=lambda r: r.get("createdAt") or "")
return records
@ -235,8 +248,8 @@ class CommcoachObjects:
data["createdAt"] = getIsoTimestamp()
return self.db.recordCreate(CoachingScore, data)
def _deleteScoresByContext(self, contextId: str) -> int:
records = self.db.getRecordset(CoachingScore, recordFilter={"contextId": contextId})
def _deleteScoresByModule(self, moduleId: str) -> int:
records = self.db.getRecordset(CoachingScore, recordFilter={"moduleId": moduleId})
count = 0
for record in records:
self.db.recordDelete(CoachingScore, record.get("id"))
@ -274,6 +287,39 @@ class CommcoachObjects:
from .datamodelCommcoach import CoachingPersona
return self.db.recordDelete(CoachingPersona, personaId)
def getAllPersonas(self, instanceId: str) -> List[Dict[str, Any]]:
"""All personas (builtin + custom for this instance), including inactive."""
from .datamodelCommcoach import CoachingPersona
builtins = self.db.getRecordset(CoachingPersona, recordFilter={"userId": "system"})
custom = self.db.getRecordset(CoachingPersona, recordFilter={"instanceId": instanceId})
custom = [p for p in custom if p.get("userId") != "system"]
return builtins + custom
# =========================================================================
# Module-Persona Mapping
# =========================================================================
def getModulePersonas(self, moduleId: str) -> List[Dict[str, Any]]:
from .datamodelCommcoach import ModulePersonaMapping
return self.db.getRecordset(ModulePersonaMapping, recordFilter={"moduleId": moduleId})
def setModulePersonas(self, moduleId: str, personaIds: List[str], instanceId: str) -> List[Dict[str, Any]]:
from .datamodelCommcoach import ModulePersonaMapping
existing = self.db.getRecordset(ModulePersonaMapping, recordFilter={"moduleId": moduleId})
for rec in existing:
self.db.recordDelete(ModulePersonaMapping, rec["id"])
created = []
for pId in personaIds:
data = ModulePersonaMapping(
moduleId=moduleId,
personaId=pId,
instanceId=instanceId,
).model_dump()
data["createdAt"] = getIsoTimestamp()
data["updatedAt"] = getIsoTimestamp()
created.append(self.db.recordCreate(ModulePersonaMapping, data))
return created
# =========================================================================
# Badges
# =========================================================================
@ -299,8 +345,8 @@ class CommcoachObjects:
# Score History
# =========================================================================
def getScoreHistory(self, contextId: str, userId: str) -> Dict[str, List[Dict[str, Any]]]:
scores = self.getScores(contextId, userId)
def getScoreHistory(self, moduleId: str, userId: str) -> Dict[str, List[Dict[str, Any]]]:
scores = self.getScores(moduleId, userId)
history: Dict[str, List[Dict[str, Any]]] = {}
for s in scores:
dim = s.get("dimension", "unknown")
@ -344,16 +390,15 @@ class CommcoachObjects:
# =========================================================================
def getDashboardData(self, userId: str, instanceId: str) -> Dict[str, Any]:
contexts = self.db.getRecordset(CoachingContext, recordFilter={"userId": userId, "instanceId": instanceId})
modules = self.db.getRecordset(TrainingModule, recordFilter={"userId": userId, "instanceId": instanceId})
sessions = self.db.getRecordset(CoachingSession, recordFilter={"userId": userId, "instanceId": instanceId})
profile = self.getProfile(userId, instanceId)
activeContexts = [c for c in contexts if c.get("status") == CoachingContextStatus.ACTIVE.value]
completedSessions = [s for s in sessions if s.get("status") == CoachingSessionStatus.COMPLETED.value]
activeModules = [m for m in modules if m.get("status") == TrainingModuleStatus.ACTIVE.value]
totalMinutes = sum(s.get("durationSeconds", 0) for s in completedSessions) // 60
totalMinutes = sum(s.get("durationSeconds", 0) for s in sessions) // 60
scores = []
for s in completedSessions:
for s in sessions:
raw = s.get("competenceScore")
if raw is not None:
try:
@ -364,29 +409,27 @@ class CommcoachObjects:
recentScores = self.getRecentScores(userId, limit=10)
contextSummaries = []
for ctx in activeContexts:
goalProgress = _calcGoalProgress(ctx.get("goals"))
contextSummaries.append({
"id": ctx.get("id"),
"title": ctx.get("title"),
"category": ctx.get("category"),
"sessionCount": ctx.get("sessionCount", 0),
"lastSessionAt": ctx.get("lastSessionAt"),
"goalProgress": goalProgress,
countByModule: Dict[str, int] = {}
for s in sessions:
mid = s.get("moduleId")
if mid:
countByModule[mid] = countByModule.get(mid, 0) + 1
moduleSummaries = []
for mod in activeModules:
modId = mod.get("id", "")
moduleSummaries.append({
"id": modId,
"title": mod.get("title"),
"moduleType": mod.get("moduleType"),
"sessionCount": countByModule.get(modId, 0),
"lastSessionAt": mod.get("lastSessionAt"),
})
allGoalProgress = []
for ctx in activeContexts:
gp = _calcGoalProgress(ctx.get("goals"))
if gp is not None:
allGoalProgress.append(gp)
overallGoalProgress = round(sum(allGoalProgress) / len(allGoalProgress)) if allGoalProgress else None
return {
"totalContexts": len(contexts),
"activeContexts": len(activeContexts),
"totalSessions": len(completedSessions),
"totalModules": len(modules),
"activeModules": len(activeModules),
"totalSessions": len(sessions),
"totalMinutes": totalMinutes,
"streakDays": profile.get("streakDays", 0) if profile else 0,
"longestStreak": profile.get("longestStreak", 0) if profile else 0,
@ -394,29 +437,12 @@ class CommcoachObjects:
"recentScores": recentScores,
"openTasks": self.getOpenTaskCount(userId, instanceId),
"completedTasks": self.getCompletedTaskCount(userId, instanceId),
"contexts": contextSummaries,
"goalProgress": overallGoalProgress,
"modules": moduleSummaries,
"badges": self.getBadges(userId, instanceId),
"level": _calcLevel(profile.get("totalSessions", 0) if profile else 0),
"level": _calcLevel(len(sessions)),
}
def _calcGoalProgress(goalsRaw) -> Optional[int]:
"""Calculate goal completion percentage from a context's goals JSON field."""
if not goalsRaw:
return None
goals = goalsRaw
if isinstance(goalsRaw, str):
try:
goals = json.loads(goalsRaw)
except (json.JSONDecodeError, TypeError):
return None
if not isinstance(goals, list) or len(goals) == 0:
return None
done = sum(1 for g in goals if isinstance(g, dict) and g.get("status") in ("done", "completed"))
return round(done / len(goals) * 100)
_LEVELS = [
(50, 5, "master", "Meister"),
(25, 4, "expert", "Experte"),

View file

@ -23,9 +23,19 @@ UI_OBJECTS = [
"meta": {"area": "dashboard"}
},
{
"objectKey": "ui.feature.commcoach.coaching",
"label": t("Arbeitsthemen", context="UI"),
"meta": {"area": "coaching"}
"objectKey": "ui.feature.commcoach.assistant",
"label": t("Assistent", context="UI"),
"meta": {"area": "assistant"}
},
{
"objectKey": "ui.feature.commcoach.modules",
"label": t("Module", context="UI"),
"meta": {"area": "modules"}
},
{
"objectKey": "ui.feature.commcoach.session",
"label": t("Session", context="UI"),
"meta": {"area": "session"}
},
{
"objectKey": "ui.feature.commcoach.settings",
@ -35,15 +45,15 @@ UI_OBJECTS = [
]
DATA_OBJECTS = [
# ── Record-Hierarchie: Context → Session → Message/Score, Context → Task ──
# ── Record-Hierarchie: TrainingModule → Session → Message/Score, TrainingModule → Task ──
{
"objectKey": "data.feature.commcoach.CoachingContext",
"label": t("Coaching-Kontext", context="UI"),
"objectKey": "data.feature.commcoach.TrainingModule",
"label": t("Trainings-Modul", context="UI"),
"meta": {
"table": "CoachingContext",
"fields": ["id", "title", "category", "status", "lastSessionAt"],
"table": "TrainingModule",
"fields": ["id", "title", "moduleType", "status", "lastSessionAt"],
"isParent": True,
"displayFields": ["title", "category", "status"],
"displayFields": ["title", "moduleType", "status"],
}
},
{
@ -51,10 +61,10 @@ DATA_OBJECTS = [
"label": t("Coaching-Session", context="UI"),
"meta": {
"table": "CoachingSession",
"fields": ["id", "contextId", "status", "summary", "startedAt", "endedAt", "competenceScore"],
"fields": ["id", "moduleId", "status", "summary", "startedAt", "endedAt", "competenceScore"],
"isParent": True,
"parentTable": "CoachingContext",
"parentKey": "contextId",
"parentTable": "TrainingModule",
"parentKey": "moduleId",
"displayFields": ["startedAt", "status"],
}
},
@ -63,7 +73,7 @@ DATA_OBJECTS = [
"label": t("Coaching-Nachricht", context="UI"),
"meta": {
"table": "CoachingMessage",
"fields": ["id", "sessionId", "contextId", "role", "content", "contentType"],
"fields": ["id", "sessionId", "moduleId", "role", "content", "contentType"],
"parentTable": "CoachingSession",
"parentKey": "sessionId",
}
@ -73,7 +83,7 @@ DATA_OBJECTS = [
"label": t("Coaching-Score", context="UI"),
"meta": {
"table": "CoachingScore",
"fields": ["id", "sessionId", "contextId", "dimension", "score", "trend"],
"fields": ["id", "sessionId", "moduleId", "dimension", "score", "trend"],
"parentTable": "CoachingSession",
"parentKey": "sessionId",
}
@ -83,9 +93,9 @@ DATA_OBJECTS = [
"label": t("Coaching-Aufgabe", context="UI"),
"meta": {
"table": "CoachingTask",
"fields": ["id", "contextId", "title", "status", "priority", "dueDate"],
"parentTable": "CoachingContext",
"parentKey": "contextId",
"fields": ["id", "moduleId", "title", "status", "priority", "dueDate"],
"parentTable": "TrainingModule",
"parentKey": "moduleId",
}
},
# ── Stammdaten (sessionübergreifend, scoped per userId) ──────────────────
@ -112,6 +122,15 @@ DATA_OBJECTS = [
"fields": ["id", "key", "label", "gender", "category"],
}
},
{
"objectKey": "data.feature.commcoach.ModulePersonaMapping",
"label": t("Modul-Persona-Zuordnung", context="UI"),
"meta": {
"table": "ModulePersonaMapping",
"group": "data.feature.commcoach.userData",
"fields": ["id", "moduleId", "personaId", "instanceId"],
}
},
{
"objectKey": "data.feature.commcoach.CoachingBadge",
"label": t("Coaching-Auszeichnung", context="UI"),
@ -130,19 +149,19 @@ DATA_OBJECTS = [
RESOURCE_OBJECTS = [
{
"objectKey": "resource.feature.commcoach.context.create",
"label": t("Kontext erstellen", context="UI"),
"meta": {"endpoint": "/api/commcoach/{instanceId}/contexts", "method": "POST"}
"objectKey": "resource.feature.commcoach.module.create",
"label": t("Modul erstellen", context="UI"),
"meta": {"endpoint": "/api/commcoach/{instanceId}/modules", "method": "POST"}
},
{
"objectKey": "resource.feature.commcoach.context.archive",
"label": t("Kontext archivieren", context="UI"),
"meta": {"endpoint": "/api/commcoach/{instanceId}/contexts/{contextId}/archive", "method": "POST"}
"objectKey": "resource.feature.commcoach.module.archive",
"label": t("Modul archivieren", context="UI"),
"meta": {"endpoint": "/api/commcoach/{instanceId}/modules/{moduleId}/archive", "method": "POST"}
},
{
"objectKey": "resource.feature.commcoach.session.start",
"label": t("Session starten", context="UI"),
"meta": {"endpoint": "/api/commcoach/{instanceId}/contexts/{contextId}/sessions/start", "method": "POST"}
"meta": {"endpoint": "/api/commcoach/{instanceId}/modules/{moduleId}/sessions/start", "method": "POST"}
},
{
"objectKey": "resource.feature.commcoach.session.complete",
@ -152,7 +171,17 @@ RESOURCE_OBJECTS = [
{
"objectKey": "resource.feature.commcoach.task.manage",
"label": t("Aufgaben verwalten", context="UI"),
"meta": {"endpoint": "/api/commcoach/{instanceId}/contexts/{contextId}/tasks", "method": "POST"}
"meta": {"endpoint": "/api/commcoach/{instanceId}/modules/{moduleId}/tasks", "method": "POST"}
},
{
"objectKey": "resource.feature.commcoach.persona.manage",
"label": t("Persona verwalten", context="UI"),
"meta": {"endpoint": "/api/commcoach/{instanceId}/personas", "method": "POST"}
},
{
"objectKey": "resource.feature.commcoach.modulePersonas.manage",
"label": t("Modul-Persona-Zuordnung verwalten", context="UI"),
"meta": {"endpoint": "/api/commcoach/{instanceId}/modules/{moduleId}/personas", "method": "PUT"}
},
]
@ -162,28 +191,31 @@ TEMPLATE_ROLES = [
"description": "Kommunikations-Coach Betrachter - Coaching-Daten ansehen (nur lesen)",
"accessRules": [
{"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.coaching", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.assistant", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.modules", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.session", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.settings", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
# Viewer: keine RESOURCE-Endpunkte (Mutationen); Regel explizit fuer konsistente Kontext-Matrix
{"context": "RESOURCE", "item": None, "view": False},
],
},
{
"roleLabel": "commcoach-user",
"description": "Kommunikations-Coach Benutzer - Kann eigene Coaching-Kontexte und Sessions verwalten",
"description": "Kommunikations-Coach Benutzer - Kann eigene Coaching-Module und Sessions verwalten",
"accessRules": [
{"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.coaching", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.assistant", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.modules", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.session", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.settings", "view": True},
{"context": "DATA", "item": "data.feature.commcoach.CoachingContext", "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
{"context": "DATA", "item": "data.feature.commcoach.TrainingModule", "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
{"context": "DATA", "item": "data.feature.commcoach.CoachingSession", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
{"context": "DATA", "item": "data.feature.commcoach.CoachingMessage", "view": True, "read": "m", "create": "m", "update": "n", "delete": "n"},
{"context": "DATA", "item": "data.feature.commcoach.CoachingTask", "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
{"context": "DATA", "item": "data.feature.commcoach.CoachingScore", "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
{"context": "DATA", "item": "data.feature.commcoach.CoachingUserProfile", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
{"context": "RESOURCE", "item": "resource.feature.commcoach.context.create", "view": True},
{"context": "RESOURCE", "item": "resource.feature.commcoach.context.archive", "view": True},
{"context": "RESOURCE", "item": "resource.feature.commcoach.module.create", "view": True},
{"context": "RESOURCE", "item": "resource.feature.commcoach.module.archive", "view": True},
{"context": "RESOURCE", "item": "resource.feature.commcoach.session.start", "view": True},
{"context": "RESOURCE", "item": "resource.feature.commcoach.session.complete", "view": True},
{"context": "RESOURCE", "item": "resource.feature.commcoach.task.manage", "view": True},
@ -252,6 +284,7 @@ def registerFeature(catalogService) -> bool:
meta=dataObj.get("meta")
)
_runMigrations()
_syncTemplateRolesToDb()
_seedBuiltinPersonas()
_registerScheduler()
@ -264,6 +297,135 @@ def registerFeature(catalogService) -> bool:
return False
def _runMigrations():
"""Idempotent DB migrations for CommCoach feature.
Runs on every bootstrap; each step checks preconditions before executing.
"""
try:
from .interfaceFeatureCommcoach import commcoachDatabase
from modules.shared.configuration import APP_CONFIG
import psycopg2
from psycopg2.extras import RealDictCursor
conn = psycopg2.connect(
host=APP_CONFIG.get("DB_HOST", "localhost"),
database=commcoachDatabase,
user=APP_CONFIG.get("DB_USER"),
password=APP_CONFIG.get("DB_PASSWORD_SECRET"),
port=int(APP_CONFIG.get("DB_PORT", 5432)),
cursor_factory=RealDictCursor,
)
conn.autocommit = False
cur = conn.cursor()
def _tableExists(name):
cur.execute(
"SELECT 1 FROM information_schema.tables WHERE LOWER(table_name) = LOWER(%s) AND table_schema = 'public'",
(name,),
)
return cur.fetchone() is not None
def _columnExists(table, column):
cur.execute(
"SELECT 1 FROM information_schema.columns WHERE LOWER(table_name) = LOWER(%s) AND LOWER(column_name) = LOWER(%s) AND table_schema = 'public'",
(table, column),
)
return cur.fetchone() is not None
migrated = False
# M1: Rename table CoachingContext -> TrainingModule
if _tableExists("CoachingContext") and not _tableExists("TrainingModule"):
cur.execute('ALTER TABLE "CoachingContext" RENAME TO "TrainingModule"')
logger.info("Migration M1: Renamed table CoachingContext -> TrainingModule")
migrated = True
# M2: Rename contextId -> moduleId on child tables
for childTable in ["CoachingSession", "CoachingMessage", "CoachingTask", "CoachingScore"]:
if _tableExists(childTable) and _columnExists(childTable, "contextId") and not _columnExists(childTable, "moduleId"):
cur.execute(f'ALTER TABLE "{childTable}" RENAME COLUMN "contextId" TO "moduleId"')
logger.info(f"Migration M2: Renamed contextId -> moduleId on {childTable}")
migrated = True
# M3: Add moduleType column with default 'coaching'
if _tableExists("TrainingModule") and not _columnExists("TrainingModule", "moduleType"):
cur.execute('ALTER TABLE "TrainingModule" ADD COLUMN "moduleType" TEXT DEFAULT \'coaching\'')
cur.execute('UPDATE "TrainingModule" SET "moduleType" = \'coaching\' WHERE "moduleType" IS NULL')
logger.info("Migration M3: Added moduleType column to TrainingModule")
migrated = True
# M4: Add personaId column
if _tableExists("TrainingModule") and not _columnExists("TrainingModule", "personaId"):
cur.execute('ALTER TABLE "TrainingModule" ADD COLUMN "personaId" TEXT')
logger.info("Migration M4: Added personaId column to TrainingModule")
migrated = True
# M5: Add kpiTargets column
if _tableExists("TrainingModule") and not _columnExists("TrainingModule", "kpiTargets"):
cur.execute('ALTER TABLE "TrainingModule" ADD COLUMN "kpiTargets" TEXT')
logger.info("Migration M5: Added kpiTargets column to TrainingModule")
migrated = True
# M6: Drop category column (replaced by moduleType)
if _tableExists("TrainingModule") and _columnExists("TrainingModule", "category"):
cur.execute('ALTER TABLE "TrainingModule" DROP COLUMN "category"')
logger.info("Migration M6: Dropped category column from TrainingModule")
migrated = True
# M7: Convert goals from JSON array to plain text
if _tableExists("TrainingModule") and _columnExists("TrainingModule", "goals"):
cur.execute("""
UPDATE "TrainingModule"
SET "goals" = subq.plainText
FROM (
SELECT id,
string_agg(elem->>'text', E'\\n') AS plainText
FROM "TrainingModule",
LATERAL jsonb_array_elements("goals"::jsonb) AS elem
WHERE "goals" IS NOT NULL
AND "goals" LIKE '[%'
GROUP BY id
) subq
WHERE "TrainingModule".id = subq.id
""")
rowCount = cur.rowcount
if rowCount > 0:
logger.info(f"Migration M7: Converted {rowCount} goals fields from JSON to plain text")
migrated = True
# M8: Create ModulePersonaMapping table
if not _tableExists("ModulePersonaMapping"):
cur.execute("""
CREATE TABLE "ModulePersonaMapping" (
id TEXT PRIMARY KEY,
"moduleId" TEXT NOT NULL,
"personaId" TEXT NOT NULL,
"instanceId" TEXT NOT NULL,
"createdAt" TEXT,
"updatedAt" TEXT,
UNIQUE("moduleId", "personaId")
)
""")
cur.execute('CREATE INDEX IF NOT EXISTS idx_mpm_module ON "ModulePersonaMapping" ("moduleId")')
cur.execute('CREATE INDEX IF NOT EXISTS idx_mpm_persona ON "ModulePersonaMapping" ("personaId")')
logger.info("Migration M8: Created ModulePersonaMapping table")
migrated = True
if migrated:
conn.commit()
logger.info("CommCoach DB migrations committed")
else:
conn.rollback()
cur.close()
conn.close()
except ImportError:
logger.debug("psycopg2 not available, skipping CommCoach DB migrations")
except Exception as e:
logger.warning(f"CommCoach DB migration failed (non-fatal): {e}")
def _seedBuiltinPersonas():
"""Seed builtin roleplay personas into the database."""
try:

View file

@ -2,7 +2,7 @@
# All rights reserved.
"""
CommCoach routes for the backend API.
Implements coaching context management, session streaming, tasks, and dashboard.
Implements training module management, session streaming, tasks, and dashboard.
"""
import logging
@ -23,14 +23,14 @@ from modules.interfaces.interfaceFeatures import getFeatureInterface
from . import interfaceFeatureCommcoach as interfaceDb
from .datamodelCommcoach import (
CoachingContext, CoachingContextStatus, CoachingSession, CoachingSessionStatus,
TrainingModule, TrainingModuleStatus, CoachingSession, CoachingSessionStatus,
CoachingMessage, CoachingMessageRole, CoachingMessageContentType,
CoachingTask, CoachingTaskStatus,
CoachingPersona, CoachingBadge,
CreateContextRequest, UpdateContextRequest,
CoachingPersona, CoachingBadge, ModulePersonaMapping,
CreateModuleRequest, UpdateModuleRequest,
SendMessageRequest, CreateTaskRequest, UpdateTaskRequest, UpdateTaskStatusRequest,
UpdateProfileRequest,
StartSessionRequest, CreatePersonaRequest, UpdatePersonaRequest,
StartSessionRequest, CreatePersonaRequest, UpdatePersonaRequest, SetModulePersonasRequest,
)
from .serviceCommcoach import CommcoachService, emitSessionEvent, getSessionEventQueue, cleanupSessionEvents
from modules.shared.i18nRegistry import apiRouteContext
@ -91,204 +91,200 @@ def _validateOwnership(record: dict, context: RequestContext, fieldName: str = "
# =========================================================================
# Context Endpoints
# Module Endpoints (formerly Context)
# =========================================================================
@router.get("/{instanceId}/contexts")
@router.get("/{instanceId}/modules")
@limiter.limit("60/minute")
async def listContexts(
async def listModules(
request: Request,
instanceId: str,
includeArchived: bool = False,
context: RequestContext = Depends(getRequestContext),
):
"""List all coaching contexts for the current user."""
"""List all training modules for the current user."""
mandateId = _validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
userId = str(context.user.id)
contexts = interface.getContexts(instanceId, userId, includeArchived=includeArchived)
return {"contexts": contexts}
modules = interface.getModules(instanceId, userId, includeArchived=includeArchived)
return {"modules": modules}
@router.post("/{instanceId}/contexts")
@router.post("/{instanceId}/modules")
@limiter.limit("20/minute")
async def createContext(
async def createModule(
request: Request,
instanceId: str,
body: CreateContextRequest,
body: CreateModuleRequest,
context: RequestContext = Depends(getRequestContext),
):
"""Create a new coaching context/dossier."""
"""Create a new training module."""
mandateId = _validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
userId = str(context.user.id)
goalsJson = None
if body.goals:
import uuid as _uuid
goalsList = [{"id": str(_uuid.uuid4()), "text": g, "status": "open", "createdAt": ""} for g in body.goals]
goalsJson = json.dumps(goalsList)
contextData = CoachingContext(
moduleData = TrainingModule(
userId=userId,
mandateId=mandateId,
instanceId=instanceId,
title=body.title,
description=body.description,
category=body.category,
goals=goalsJson,
moduleType=body.moduleType,
goals=body.goals,
personaId=body.personaId,
kpiTargets=body.kpiTargets,
).model_dump()
created = interface.createContext(contextData)
logger.info(f"CommCoach context created: {created.get('id')} for user {userId}")
_audit(context, "commcoach.context.created", "CoachingContext", created.get("id"), f"Title: {body.title}")
return {"context": created}
created = interface.createModule(moduleData)
logger.info(f"CommCoach module created: {created.get('id')} for user {userId}")
_audit(context, "commcoach.module.created", "TrainingModule", created.get("id"), f"Title: {body.title}")
return {"module": created}
@router.get("/{instanceId}/contexts/{contextId}")
@router.get("/{instanceId}/modules/{moduleId}")
@limiter.limit("60/minute")
async def getContext(
async def getModuleDetail(
request: Request,
instanceId: str,
contextId: str,
moduleId: str,
context: RequestContext = Depends(getRequestContext),
):
"""Get a coaching context with tasks and score summary."""
"""Get a training module with tasks and score summary."""
_validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
userId = str(context.user.id)
ctx = interface.getContext(contextId)
if not ctx:
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
_validateOwnership(ctx, context)
mod = interface.getModule(moduleId)
if not mod:
raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
_validateOwnership(mod, context)
tasks = interface.getTasks(contextId, userId)
scores = interface.getScores(contextId, userId)
sessions = interface.getSessions(contextId, userId)
tasks = interface.getTasks(moduleId, userId)
scores = interface.getScores(moduleId, userId)
sessions = interface.getSessions(moduleId, userId)
return {
"context": ctx,
"module": mod,
"tasks": tasks,
"scores": scores,
"sessions": sessions,
}
@router.put("/{instanceId}/contexts/{contextId}")
@router.put("/{instanceId}/modules/{moduleId}")
@limiter.limit("30/minute")
async def updateContext(
async def updateModuleFields(
request: Request,
instanceId: str,
contextId: str,
body: UpdateContextRequest,
moduleId: str,
body: UpdateModuleRequest,
context: RequestContext = Depends(getRequestContext),
):
_validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
ctx = interface.getContext(contextId)
if not ctx:
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
_validateOwnership(ctx, context)
mod = interface.getModule(moduleId)
if not mod:
raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
_validateOwnership(mod, context)
updates = body.model_dump(exclude_none=True)
updated = interface.updateContext(contextId, updates)
return {"context": updated}
updated = interface.updateModule(moduleId, updates)
return {"module": updated}
@router.delete("/{instanceId}/contexts/{contextId}")
@router.delete("/{instanceId}/modules/{moduleId}")
@limiter.limit("10/minute")
async def deleteContext(
async def deleteModuleAndData(
request: Request,
instanceId: str,
contextId: str,
moduleId: str,
context: RequestContext = Depends(getRequestContext),
):
_validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
ctx = interface.getContext(contextId)
if not ctx:
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
_validateOwnership(ctx, context)
mod = interface.getModule(moduleId)
if not mod:
raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
_validateOwnership(mod, context)
interface.deleteContext(contextId)
interface.deleteModule(moduleId)
return {"deleted": True}
@router.post("/{instanceId}/contexts/{contextId}/archive")
@router.post("/{instanceId}/modules/{moduleId}/archive")
@limiter.limit("10/minute")
async def archiveContext(
async def archiveModule(
request: Request,
instanceId: str,
contextId: str,
moduleId: str,
context: RequestContext = Depends(getRequestContext),
):
_validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
ctx = interface.getContext(contextId)
if not ctx:
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
_validateOwnership(ctx, context)
mod = interface.getModule(moduleId)
if not mod:
raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
_validateOwnership(mod, context)
updated = interface.updateContext(contextId, {"status": CoachingContextStatus.ARCHIVED.value})
_audit(context, "commcoach.context.archived", "CoachingContext", contextId)
return {"context": updated}
updated = interface.updateModule(moduleId, {"status": TrainingModuleStatus.ARCHIVED.value})
_audit(context, "commcoach.module.archived", "TrainingModule", moduleId)
return {"module": updated}
@router.post("/{instanceId}/contexts/{contextId}/activate")
@router.post("/{instanceId}/modules/{moduleId}/activate")
@limiter.limit("10/minute")
async def activateContext(
async def activateModule(
request: Request,
instanceId: str,
contextId: str,
moduleId: str,
context: RequestContext = Depends(getRequestContext),
):
_validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
ctx = interface.getContext(contextId)
if not ctx:
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
_validateOwnership(ctx, context)
mod = interface.getModule(moduleId)
if not mod:
raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
_validateOwnership(mod, context)
updated = interface.updateContext(contextId, {"status": CoachingContextStatus.ACTIVE.value})
return {"context": updated}
updated = interface.updateModule(moduleId, {"status": TrainingModuleStatus.ACTIVE.value})
return {"module": updated}
# =========================================================================
# Session Endpoints
# =========================================================================
@router.get("/{instanceId}/contexts/{contextId}/sessions")
@router.get("/{instanceId}/modules/{moduleId}/sessions")
@limiter.limit("60/minute")
async def listSessions(
request: Request,
instanceId: str,
contextId: str,
moduleId: str,
context: RequestContext = Depends(getRequestContext),
):
_validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
userId = str(context.user.id)
ctx = interface.getContext(contextId)
if not ctx:
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
_validateOwnership(ctx, context)
mod = interface.getModule(moduleId)
if not mod:
raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
_validateOwnership(mod, context)
sessions = interface.getSessions(contextId, userId)
sessions = interface.getSessions(moduleId, userId)
return {"sessions": sessions}
@router.post("/{instanceId}/contexts/{contextId}/sessions/start")
@router.post("/{instanceId}/modules/{moduleId}/sessions/start")
@limiter.limit("10/minute")
async def startSession(
request: Request,
instanceId: str,
contextId: str,
moduleId: str,
personaId: Optional[str] = None,
context: RequestContext = Depends(getRequestContext),
):
@ -297,22 +293,22 @@ async def startSession(
interface = _getInterface(context, instanceId)
userId = str(context.user.id)
ctx = interface.getContext(contextId)
if not ctx:
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
_validateOwnership(ctx, context)
mod = interface.getModule(moduleId)
if not mod:
raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
_validateOwnership(mod, context)
activeSession = interface.getActiveSession(contextId, userId)
activeSession = interface.getActiveSession(moduleId, userId)
if activeSession:
sessionId = activeSession.get("id")
messages = interface.getMessages(sessionId)
async def _resumedEventGenerator():
service = CommcoachService(context.user, mandateId, instanceId)
greetingText = await service.generateResumeGreeting(sessionId, contextId, messages, interface)
greetingText = await service.generateResumeGreeting(sessionId, moduleId, messages, interface)
assistantMsg = CoachingMessage(
sessionId=sessionId,
contextId=contextId,
moduleId=moduleId,
userId=userId,
role=CoachingMessageRole.ASSISTANT,
content=greetingText,
@ -323,7 +319,7 @@ async def startSession(
greetingForFrontend = {
"id": createdGreeting.get("id"),
"sessionId": sessionId,
"contextId": contextId,
"moduleId": moduleId,
"role": "assistant",
"content": greetingText,
"contentType": "text",
@ -365,7 +361,7 @@ async def startSession(
)
sessionData = CoachingSession(
contextId=contextId,
moduleId=moduleId,
userId=userId,
mandateId=mandateId,
instanceId=instanceId,
@ -378,7 +374,7 @@ async def startSession(
await emitSessionEvent(sessionId, "sessionState", {"session": created, "resumed": False})
service = CommcoachService(context.user, mandateId, instanceId)
asyncio.create_task(service.processSessionOpening(sessionId, contextId, interface))
asyncio.create_task(service.processSessionOpening(sessionId, moduleId, interface))
async def _newSessionEventGenerator():
from modules.shared.timeUtils import getIsoTimestamp
@ -399,8 +395,8 @@ async def startSession(
except asyncio.CancelledError:
pass
logger.info(f"CommCoach session started (streaming): {sessionId} for context {contextId}")
_audit(context, "commcoach.session.started", "CoachingSession", sessionId, f"Context: {contextId}")
logger.info(f"CommCoach session started (streaming): {sessionId} for module {moduleId}")
_audit(context, "commcoach.session.started", "CoachingSession", sessionId, f"Module: {moduleId}")
return StreamingResponse(
_newSessionEventGenerator(),
media_type="text/event-stream",
@ -504,7 +500,7 @@ async def sendMessageStream(
if session.get("status") != CoachingSessionStatus.ACTIVE.value:
raise HTTPException(status_code=400, detail=routeApiMsg("Session is not active"))
contextId = session.get("contextId")
moduleId = session.get("moduleId")
service = CommcoachService(context.user, mandateId, instanceId)
existingTask = _activeProcessTasks.get(sessionId)
@ -517,7 +513,7 @@ async def sendMessageStream(
task = asyncio.create_task(
service.processMessage(
sessionId, contextId, body.content, interface,
sessionId, moduleId, body.content, interface,
fileIds=body.fileIds,
dataSourceIds=body.dataSourceIds,
featureDataSourceIds=body.featureDataSourceIds,
@ -587,11 +583,11 @@ async def sendAudioStream(
from .serviceCommcoach import getUserVoicePrefs
language, _ = getUserVoicePrefs(str(context.user.id), mandateId)
contextId = session.get("contextId")
moduleId = session.get("moduleId")
service = CommcoachService(context.user, mandateId, instanceId)
asyncio.create_task(
service.processAudioMessage(sessionId, contextId, audioBody, language, interface)
service.processAudioMessage(sessionId, moduleId, audioBody, language, interface)
)
async def _eventGenerator():
@ -680,27 +676,27 @@ async def streamSession(
# Task Endpoints
# =========================================================================
@router.get("/{instanceId}/contexts/{contextId}/tasks")
@router.get("/{instanceId}/modules/{moduleId}/tasks")
@limiter.limit("60/minute")
async def listTasks(
request: Request,
instanceId: str,
contextId: str,
moduleId: str,
context: RequestContext = Depends(getRequestContext),
):
_validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
userId = str(context.user.id)
tasks = interface.getTasks(contextId, userId)
tasks = interface.getTasks(moduleId, userId)
return {"tasks": tasks}
@router.post("/{instanceId}/contexts/{contextId}/tasks")
@router.post("/{instanceId}/modules/{moduleId}/tasks")
@limiter.limit("30/minute")
async def createTask(
request: Request,
instanceId: str,
contextId: str,
moduleId: str,
body: CreateTaskRequest,
context: RequestContext = Depends(getRequestContext),
):
@ -708,13 +704,13 @@ async def createTask(
interface = _getInterface(context, instanceId)
userId = str(context.user.id)
ctx = interface.getContext(contextId)
if not ctx:
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
_validateOwnership(ctx, context)
mod = interface.getModule(moduleId)
if not mod:
raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
_validateOwnership(mod, context)
taskData = CoachingTask(
contextId=contextId,
moduleId=moduleId,
userId=userId,
mandateId=mandateId,
title=body.title,
@ -853,12 +849,12 @@ async def updateProfile(
# Export Endpoints (Iteration 2)
# =========================================================================
@router.get("/{instanceId}/contexts/{contextId}/export")
@router.get("/{instanceId}/modules/{moduleId}/export")
@limiter.limit("10/minute")
async def exportDossier(
request: Request,
instanceId: str,
contextId: str,
moduleId: str,
format: str = "md",
context: RequestContext = Depends(getRequestContext),
):
@ -867,26 +863,26 @@ async def exportDossier(
interface = _getInterface(context, instanceId)
userId = str(context.user.id)
ctx = interface.getContext(contextId)
if not ctx:
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
_validateOwnership(ctx, context)
mod = interface.getModule(moduleId)
if not mod:
raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
_validateOwnership(mod, context)
tasks = interface.getTasks(contextId, userId)
scores = interface.getScores(contextId, userId)
sessions = interface.getSessions(contextId, userId)
tasks = interface.getTasks(moduleId, userId)
scores = interface.getScores(moduleId, userId)
sessions = interface.getSessions(moduleId, userId)
from .serviceCommcoachExport import buildDossierMarkdown, renderDossierPdf
_audit(context, "commcoach.export.requested", "CoachingContext", contextId, f"format={format}")
_audit(context, "commcoach.export.requested", "TrainingModule", moduleId, f"format={format}")
if format == "pdf":
pdfBytes = await renderDossierPdf(ctx, sessions, tasks, scores)
pdfBytes = await renderDossierPdf(mod, sessions, tasks, scores)
return Response(content=pdfBytes, media_type="application/pdf",
headers={"Content-Disposition": f'attachment; filename="dossier_{contextId[:8]}.pdf"'})
headers={"Content-Disposition": f'attachment; filename="dossier_{moduleId[:8]}.pdf"'})
md = buildDossierMarkdown(ctx, sessions, tasks, scores)
md = buildDossierMarkdown(mod, sessions, tasks, scores)
return Response(content=md, media_type="text/markdown",
headers={"Content-Disposition": f'attachment; filename="dossier_{contextId[:8]}.md"'})
headers={"Content-Disposition": f'attachment; filename="dossier_{moduleId[:8]}.md"'})
@router.get("/{instanceId}/sessions/{sessionId}/export")
@ -907,11 +903,11 @@ async def exportSession(
raise HTTPException(status_code=404, detail=routeApiMsg("Session not found"))
_validateOwnership(session, context)
contextId = session.get("contextId")
moduleId = session.get("moduleId")
userId = str(context.user.id)
messages = interface.getMessages(sessionId)
tasks = interface.getTasks(contextId, userId) if contextId else []
scores = interface.getScores(contextId, userId) if contextId else []
tasks = interface.getTasks(moduleId, userId) if moduleId else []
scores = interface.getScores(moduleId, userId) if moduleId else []
from .serviceCommcoachExport import buildSessionMarkdown, renderSessionPdf
_audit(context, "commcoach.export.requested", "CoachingSession", sessionId, f"format={format}")
@ -935,13 +931,47 @@ async def exportSession(
async def listPersonas(
request: Request,
instanceId: str,
pagination: Optional[str] = Query(None),
mode: Optional[str] = Query(None, description="'filterValues' or 'ids'"),
column: Optional[str] = Query(None, description="Column key for mode=filterValues"),
context: RequestContext = Depends(getRequestContext),
):
_validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
userId = str(context.user.id)
personas = interface.getPersonas(userId, instanceId)
return {"personas": personas}
allPersonas = interface.getAllPersonas(instanceId)
if mode == "filterValues":
from modules.routes.routeHelpers import handleFilterValuesInMemory
if not column:
raise HTTPException(status_code=400, detail=routeApiMsg("column parameter required"))
return handleFilterValuesInMemory(allPersonas, column, pagination)
if mode == "ids":
from modules.routes.routeHelpers import handleIdsInMemory
return handleIdsInMemory(allPersonas, pagination)
if pagination:
import json as _json
from modules.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict
from modules.routes.routeHelpers import applyFiltersAndSort, paginateInMemory
paginationDict = _json.loads(pagination)
paginationDict = normalize_pagination_dict(paginationDict)
paginationParams = PaginationParams(**paginationDict)
filtered = applyFiltersAndSort(allPersonas, paginationParams)
pageItems, totalItems = paginateInMemory(filtered, paginationParams)
import math
return {
"items": pageItems,
"pagination": PaginationMetadata(
currentPage=paginationParams.page,
pageSize=paginationParams.pageSize,
totalItems=totalItems,
totalPages=math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0,
sort=[s.model_dump() for s in paginationParams.sort] if paginationParams.sort else [],
filters=paginationParams.filters,
).model_dump(),
}
return {"items": allPersonas, "pagination": None}
@router.post("/{instanceId}/personas")
@ -1017,6 +1047,43 @@ async def deletePersonaRoute(
return {"deleted": True}
# =========================================================================
# Module-Persona Mapping Endpoints
# =========================================================================
@router.get("/{instanceId}/modules/{moduleId}/personas")
@limiter.limit("60/minute")
async def getModulePersonas(
request: Request,
instanceId: str,
moduleId: str,
context: RequestContext = Depends(getRequestContext),
):
_validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
mappings = interface.getModulePersonas(moduleId)
personaIds = [m["personaId"] for m in mappings]
return {"personaIds": personaIds}
@router.put("/{instanceId}/modules/{moduleId}/personas")
@limiter.limit("20/minute")
async def setModulePersonas(
request: Request,
instanceId: str,
moduleId: str,
body: SetModulePersonasRequest,
context: RequestContext = Depends(getRequestContext),
):
_validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
module = interface.getModule(moduleId)
if not module:
raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
interface.setModulePersonas(moduleId, body.personaIds, instanceId)
return {"personaIds": body.personaIds}
# =========================================================================
# Badge + Score History Endpoints (Iteration 2)
# =========================================================================
@ -1035,16 +1102,46 @@ async def listBadges(
return {"badges": badges}
@router.get("/{instanceId}/contexts/{contextId}/scores/history")
@router.get("/{instanceId}/modules/{moduleId}/scores/history")
@limiter.limit("60/minute")
async def getScoreHistory(
request: Request,
instanceId: str,
contextId: str,
moduleId: str,
context: RequestContext = Depends(getRequestContext),
):
_validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
userId = str(context.user.id)
history = interface.getScoreHistory(contextId, userId)
history = interface.getScoreHistory(moduleId, userId)
return {"history": history}
# =========================================================================
# Backward-Compatibility Redirects (old /contexts/ paths → /modules/)
# =========================================================================
@router.get("/{instanceId}/contexts")
async def _redirectListContexts(instanceId: str, request: Request):
qs = f"?{request.query_params}" if request.query_params else ""
return Response(status_code=301, headers={"Location": f"/api/commcoach/{instanceId}/modules{qs}"})
@router.post("/{instanceId}/contexts")
async def _redirectCreateContext(instanceId: str, request: Request):
return Response(status_code=301, headers={"Location": f"/api/commcoach/{instanceId}/modules"})
@router.get("/{instanceId}/contexts/{contextId}")
async def _redirectGetContext(instanceId: str, contextId: str, request: Request):
return Response(status_code=301, headers={"Location": f"/api/commcoach/{instanceId}/modules/{contextId}"})
@router.put("/{instanceId}/contexts/{contextId}")
async def _redirectUpdateContext(instanceId: str, contextId: str, request: Request):
return Response(status_code=301, headers={"Location": f"/api/commcoach/{instanceId}/modules/{contextId}"})
@router.delete("/{instanceId}/contexts/{contextId}")
async def _redirectDeleteContext(instanceId: str, contextId: str, request: Request):
return Response(status_code=301, headers={"Location": f"/api/commcoach/{instanceId}/modules/{contextId}"})

View file

@ -420,7 +420,7 @@ async def _saveOrUpdateDocument(doc: Dict[str, Any], contextId: str, userId: str
logger.info(f"Document saved as platform FileItem: {fileItem.id} ({title})")
except Exception as e:
logger.warning(f"Failed to save document as FileItem: {e}")
logger.error(f"Failed to save document as FileItem: {e}", exc_info=True)
@ -483,12 +483,12 @@ def _loadDocumentContents(docIds: List[str], interface, mandateId: str = None, i
content = ""
try:
from modules.datamodels.datamodelKnowledge import FileContentIndex
idxRecords = mgmtIf.db.getRecordset(FileContentIndex, recordFilter={"fileId": fId})
idxRecords = mgmtIf.db.getRecordset(FileContentIndex, recordFilter={"id": fId})
if idxRecords:
idx = idxRecords[0] if isinstance(idxRecords[0], dict) else idxRecords[0].model_dump()
content = (idx.get("extractedText") or "")[:DOC_CONTENT_MAX_CHARS]
except Exception:
pass
except Exception as e:
logger.warning(f"Failed to load FileContentIndex for {fId}: {e}")
results.append({
"id": fId,
"title": f.get("fileName") or f.get("name") or "Dokument",
@ -557,13 +557,13 @@ def _getDocumentSummaries(contextId: str, userId: str, interface,
try:
from modules.datamodels.datamodelKnowledge import FileContentIndex
idxRecords = mgmtIf.db.getRecordset(
FileContentIndex, recordFilter={"fileId": fId}
FileContentIndex, recordFilter={"id": fId}
)
if idxRecords:
idx = idxRecords[0] if isinstance(idxRecords[0], dict) else idxRecords[0].model_dump()
snippet = (idx.get("extractedText") or "")[:200]
except Exception:
pass
except Exception as e:
logger.warning(f"Failed to load FileContentIndex for {fId}: {e}")
if snippet:
summaries.append(f"[{name}] {snippet}...")
else:
@ -690,7 +690,7 @@ def _buildConversationHistory(messages: List[Dict[str, Any]]) -> List[Dict[str,
return history
_TTS_WORD_LIMIT = 200
_TTS_WORD_LIMIT = 80
async def _prepareSpeechText(fullText: str, callAiFn) -> str:
@ -748,7 +748,7 @@ class CommcoachService:
# Store user message
userMsg = CoachingMessage(
sessionId=sessionId,
contextId=contextId,
moduleId=contextId,
userId=self.userId,
role=CoachingMessageRole.USER,
content=userContent,
@ -764,7 +764,7 @@ class CommcoachService:
})
# Build context
context = interface.getContext(contextId)
context = interface.getModule(contextId)
if not context:
logger.error(f"Context {contextId} not found")
return createdUserMsg
@ -857,7 +857,7 @@ class CommcoachService:
assistantMsg = CoachingMessage(
sessionId=sessionId,
contextId=contextId,
moduleId=contextId,
userId=self.userId,
role=CoachingMessageRole.ASSISTANT,
content=textContent,
@ -906,10 +906,14 @@ class CommcoachService:
)
agentService = getService("agent", serviceContext)
from modules.datamodels.datamodelAi import PriorityEnum, OperationTypeEnum
config = AgentConfig(
toolSet="commcoach" if useTools else "none",
maxRounds=3 if useTools else 1,
temperature=0.4,
excludeAllTools=not useTools,
priority=PriorityEnum.SPEED if not useTools else None,
operationType=OperationTypeEnum.DATA_QUERY if not useTools else None,
)
buildRagContextFn = _createCommcoachRagFn(
@ -946,6 +950,8 @@ class CommcoachService:
await emitSessionEvent(sessionId, "toolResult", event.data or {})
elif event.type == AgentEventTypeEnum.AGENT_PROGRESS:
await emitSessionEvent(sessionId, "agentProgress", event.data or {})
elif event.type == AgentEventTypeEnum.FILE_CREATED:
await emitSessionEvent(sessionId, "documentCreated", event.data or {})
elif event.type == AgentEventTypeEnum.ERROR:
await emitSessionEvent(sessionId, "error", {"message": event.content or "Agent error"})
@ -958,7 +964,7 @@ class CommcoachService:
"""
await emitSessionEvent(sessionId, "status", {"label": "Coach bereitet sich vor..."})
context = interface.getContext(contextId)
context = interface.getModule(contextId)
if not context:
logger.error(f"Context {contextId} not found")
await emitSessionEvent(sessionId, "error", {"message": "Context not found"})
@ -987,10 +993,14 @@ class CommcoachService:
)
isFirstSession = not previousSessionSummaries or len(previousSessionSummaries) == 0
logger.info(f"Session opening {sessionId}: isFirstSession={isFirstSession}, previousSessions={len(previousSessionSummaries) if previousSessionSummaries else 0}, persona={persona.get('key') if persona else None}")
if persona and persona.get("key") != "coach":
personaLabel = persona.get("label", "Gesprächspartner")
openingUserPrompt = f"Beginne das Gespräch in deiner Rolle als {personaLabel}. Stelle dich kurz vor und eröffne die Situation gemäss deiner Rollenbeschreibung."
if isFirstSession:
openingUserPrompt = f"Beginne das Gespräch in deiner Rolle als {personaLabel}. Stelle dich kurz vor und eröffne die Situation gemäss deiner Rollenbeschreibung."
else:
openingUserPrompt = f"Du bist weiterhin in deiner Rolle als {personaLabel}. Der Benutzer kehrt zu einem Folgegespräch zurück. Begrüsse ihn kurz zurück, beziehe dich auf das letzte Gespräch (siehe bisherige Sessions) und knüpfe dort an. Stelle dich NICHT erneut vor."
elif isFirstSession:
openingUserPrompt = "Dies ist die ERSTE Session zu diesem Thema. Begrüsse den Benutzer, stelle das Thema kurz vor und stelle eine offene Einstiegsfrage. Erfinde KEINE vorherigen Gespräche oder Zusammenfassungen."
else:
@ -1024,7 +1034,7 @@ class CommcoachService:
assistantMsg = CoachingMessage(
sessionId=sessionId,
contextId=contextId,
moduleId=contextId,
userId=self.userId,
role=CoachingMessageRole.ASSISTANT,
content=textContent,
@ -1046,7 +1056,7 @@ class CommcoachService:
async def generateResumeGreeting(self, sessionId: str, contextId: str, messages: list, interface) -> str:
"""Generate a follow-up greeting when user returns to an active session."""
context = interface.getContext(contextId)
context = interface.getModule(contextId)
if not context:
raise ValueError(f"Context {contextId} not found for resume greeting")
contextTitle = context.get("title", "Coaching")
@ -1100,8 +1110,10 @@ class CommcoachService:
if not session:
return {}
contextId = session.get("contextId")
context = interface.getContext(contextId) if contextId else None
contextId = session.get("moduleId")
if not contextId:
logger.error(f"completeSession: session {sessionId} has no moduleId")
context = interface.getModule(contextId) if contextId else None
messages = interface.getMessages(sessionId)
if len(messages) < 2:
@ -1156,7 +1168,7 @@ class CommcoachService:
for taskData in extractedTasks[:3]:
if isinstance(taskData, dict) and taskData.get("title"):
newTask = CoachingTask(
contextId=contextId,
moduleId=contextId,
sessionId=sessionId,
userId=self.userId,
mandateId=self.mandateId,
@ -1181,7 +1193,7 @@ class CommcoachService:
for scoreData in scores:
if isinstance(scoreData, dict) and "dimension" in scoreData and "score" in scoreData:
newScore = CoachingScore(
contextId=contextId,
moduleId=contextId,
sessionId=sessionId,
userId=self.userId,
mandateId=self.mandateId,
@ -1213,7 +1225,7 @@ class CommcoachService:
existingInsights.append({"text": insightText, "sessionId": sessionId, "createdAt": getIsoTimestamp()})
await emitSessionEvent(sessionId, "insightGenerated", {"text": insightText, "sessionId": sessionId})
if contextId and existingInsights:
interface.updateContext(contextId, {"insights": json.dumps(existingInsights[-10:])})
interface.updateModule(contextId, {"insights": json.dumps(existingInsights[-10:])})
except Exception as e:
logger.warning(f"Insight generation failed: {e}")
@ -1280,7 +1292,7 @@ class CommcoachService:
if contextId:
allSessions = interface.getSessions(contextId, self.userId)
completedCount = len([s for s in allSessions if s.get("status") == CoachingSessionStatus.COMPLETED.value])
interface.updateContext(contextId, {
interface.updateModule(contextId, {
"sessionCount": completedCount,
"lastSessionAt": getUtcTimestamp(),
})
@ -1429,7 +1441,7 @@ class CommcoachService:
"sessionSummaries": [],
}
ctx = interface.getContext(contextId)
ctx = interface.getModule(contextId)
rollingOverview = ctx.get("rollingOverview") if ctx else None
rollingUpTo = ctx.get("rollingOverviewUpToSessionCount") if ctx else None
@ -1506,7 +1518,7 @@ class CommcoachService:
)
if overviewResponse and overviewResponse.errorCount == 0 and overviewResponse.content:
newOverview = overviewResponse.content.strip()
interface.updateContext(contextId, {
interface.updateModule(contextId, {
"rollingOverview": newOverview,
"rollingOverviewUpToSessionCount": len(completedSessions),
})

View file

@ -143,7 +143,7 @@ async def checkAndAwardBadges(interface, userId: str, mandateId: str, instanceId
badgesToCheck.append(("roleplay_first", True))
try:
from .datamodelCommcoach import CoachingContextStatus
from .datamodelCommcoach import TrainingModuleStatus
allContexts = interface.db.getRecordset(
interface.db.getRecordset.__self__.__class__.__mro__[0] # avoid import issues
) if False else []

View file

@ -146,6 +146,57 @@ BUILTIN_PERSONAS: List[Dict[str, Any]] = [
"gender": "m",
"category": "builtin",
},
# --- Fachpersonen / Therapeutische & rechtliche Gesprächspartner ---
{
"key": "couples_therapist_f",
"label": "Paartherapeutin",
"description": "Dr. Eva Roth, erfahrene Paartherapeutin. Empathisch, strukturiert, stellt gezielte Fragen zu "
"Beziehungsdynamiken. Spiegelt Gefühle und Muster, ohne Partei zu ergreifen. Arbeitet mit der "
"Gewaltfreien Kommunikation und systemischen Methoden. Fragt nach Bedürfnissen hinter Vorwürfen "
"und lenkt das Gespräch auf konkrete Verhaltensänderungen statt Schuldzuweisungen.",
"gender": "f",
"category": "builtin",
},
{
"key": "psychologist_m",
"label": "Psychologe",
"description": "Dr. Markus Frei, klinischer Psychologe mit Schwerpunkt Stressbewältigung und Burnout-Prävention. "
"Ruhig, geduldig, stellt offene Fragen zur Selbstreflexion. Erkennt Denkmuster und benennt sie "
"behutsam. Arbeitet lösungsorientiert und hilft bei der Identifikation von Stressoren, Ressourcen "
"und Bewältigungsstrategien. Drängt nicht, lässt Raum für Stille und Nachdenken.",
"gender": "m",
"category": "builtin",
},
{
"key": "lawyer_m",
"label": "Rechtsanwalt",
"description": "lic. iur. Daniel Brandt, Wirtschaftsanwalt mit Fokus auf Vertragsrecht und Arbeitsrecht. Sachlich, "
"analytisch, prüft jede Aussage auf juristische Stichhaltigkeit. Fragt nach Fakten, Fristen und "
"Beweislage. Weist auf Risiken und Haftungsfragen hin. Formuliert präzise und erwartet dasselbe "
"vom Gegenüber. Kann unangenehme rechtliche Realitäten nüchtern kommunizieren.",
"gender": "m",
"category": "builtin",
},
{
"key": "mediator_f",
"label": "Mediatorin",
"description": "Sabine Lang, zertifizierte Wirtschaftsmediatorin. Strikt neutral, strukturiert den Dialog zwischen "
"Konfliktparteien. Stellt sicher, dass beide Seiten gehört werden. Arbeitet mit Ich-Botschaften und "
"Interessenklärung statt Positionsverhandlung. Unterbricht respektvoll bei Eskalation und lenkt "
"zurück auf Sachebene. Ziel ist immer eine tragfähige Vereinbarung, nicht Recht oder Unrecht.",
"gender": "f",
"category": "builtin",
},
{
"key": "hr_manager_f",
"label": "HR-Managerin",
"description": "Kathrin Vogt, Head of HR in einem Konzern. Kennt Arbeitsrecht, Feedbackkultur und Change-Prozesse. "
"Spricht diplomatisch aber klar. Achtet auf Compliance und Gleichbehandlung. Erwartet strukturierte "
"Argumentation bei Personalentscheiden. Reagiert sensibel auf Diskriminierungs- oder Mobbingthemen. "
"Kann sowohl Arbeitgeber- als auch Arbeitnehmerperspektive einnehmen.",
"gender": "f",
"category": "builtin",
},
]

View file

@ -62,7 +62,7 @@ async def _runDailyReminders():
try:
from modules.shared.configuration import APP_CONFIG
from modules.connectors.connectorDbPostgre import DatabaseConnector
from .datamodelCommcoach import CoachingUserProfile, CoachingContextStatus
from .datamodelCommcoach import CoachingUserProfile, TrainingModuleStatus
from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface
from modules.shared.notifyMandateAdmins import renderHtmlEmail, resolveMandateName
@ -94,10 +94,10 @@ async def _runDailyReminders():
continue
# Check if user has active contexts
from .datamodelCommcoach import CoachingContext
contexts = db.getRecordset(CoachingContext, recordFilter={
from .datamodelCommcoach import TrainingModule
contexts = db.getRecordset(TrainingModule, recordFilter={
"userId": userId,
"status": CoachingContextStatus.ACTIVE.value,
"status": TrainingModuleStatus.ACTIVE.value,
})
if not contexts:
continue

View file

@ -12,17 +12,30 @@ import uuid
from typing import Dict, Any, List, Optional
def _make_json_serializable(obj: Any) -> Any:
_INTERNAL_SKIP_KEYS = frozenset({"_context", "_orderedNodes"})
def _make_json_serializable(obj: Any, _depth: int = 0) -> Any:
"""
Recursively convert bytes to base64 strings so structures can be JSON-serialized
for storage in JSONB columns.
Internal runtime keys (_context, _orderedNodes) are skipped they hold live
Python objects (including back-references to nodeOutputs) and must never be
stored. A depth guard prevents runaway recursion on unexpected circular refs.
"""
if _depth > 50:
return None
if isinstance(obj, bytes):
return base64.b64encode(obj).decode("ascii")
if isinstance(obj, dict):
return {k: _make_json_serializable(v) for k, v in obj.items()}
return {
k: _make_json_serializable(v, _depth + 1)
for k, v in obj.items()
if k not in _INTERNAL_SKIP_KEYS
}
if isinstance(obj, list):
return [_make_json_serializable(v) for v in obj]
return [_make_json_serializable(v, _depth + 1) for v in obj]
return obj
from modules.datamodels.datamodelUam import User

View file

@ -4,7 +4,7 @@
from modules.shared.i18nRegistry import t
_AI_COMMON_PARAMS = [
{"name": "requireNeutralization", "type": "boolean", "required": False,
{"name": "requireNeutralization", "type": "bool", "required": False,
"frontendType": "checkbox", "default": False,
"description": t("Eingaben fuer diesen Call neutralisieren")},
{"name": "allowedModels", "type": "array", "required": False,
@ -19,25 +19,25 @@ AI_NODES = [
"label": t("Prompt"),
"description": t("Prompt eingeben und KI führt aus"),
"parameters": [
{"name": "aiPrompt", "type": "string", "required": True, "frontendType": "templateTextarea",
{"name": "aiPrompt", "type": "str", "required": True, "frontendType": "templateTextarea",
"description": t("KI-Prompt")},
{"name": "resultType", "type": "string", "required": False, "frontendType": "select",
{"name": "resultType", "type": "str", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["txt", "json", "md", "csv", "xml", "html", "pdf", "docx", "xlsx", "pptx", "png", "jpg"]},
"description": t("Ausgabeformat"), "default": "txt"},
{"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "dataRef",
"description": t("Dokumentenliste (Upstream-Output binden)"), "default": ""},
{"name": "context", "type": "string", "required": False, "frontendType": "dataRef",
"description": t("Kontextdaten fuer den Prompt (Upstream-Output binden)"), "default": ""},
{"name": "documentTheme", "type": "string", "required": False, "frontendType": "select",
{"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "hidden",
"description": t("Dokumente aus vorherigen Schritten"), "default": ""},
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
"description": t("Daten aus vorherigen Schritten"), "default": ""},
{"name": "documentTheme", "type": "str", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["general", "finance", "legal", "technical", "hr"]},
"description": t("Dokument-Thema (Style-Hinweis fuer den Renderer)"), "default": "general"},
{"name": "simpleMode", "type": "boolean", "required": False, "frontendType": "checkbox",
{"name": "simpleMode", "type": "bool", "required": False, "frontendType": "checkbox",
"description": t("Einfacher Modus"), "default": True},
] + _AI_COMMON_PARAMS,
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": [
"DocumentList", "AiResult", "TextResult", "Transit", "LoopItem", "ActionResult",
"FormPayload", "DocumentList", "AiResult", "TextResult", "Transit", "LoopItem", "ActionResult",
]}},
"outputPorts": {0: {"schema": "AiResult"}},
"meta": {"icon": "mdi-robot", "color": "#9C27B0", "usesAi": True},
@ -50,12 +50,18 @@ AI_NODES = [
"label": t("Web-Recherche"),
"description": t("Recherche im Web"),
"parameters": [
{"name": "prompt", "type": "string", "required": True, "frontendType": "textarea",
{"name": "prompt", "type": "str", "required": True, "frontendType": "textarea",
"description": t("Recherche-Anfrage")},
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
"description": t("Daten aus vorherigen Schritten"), "default": ""},
{"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "hidden",
"description": t("Dokumente aus vorherigen Schritten"), "default": ""},
] + _AI_COMMON_PARAMS,
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"inputPorts": {0: {"accepts": [
"FormPayload", "Transit", "AiResult", "DocumentList", "ActionResult", "LoopItem", "TextResult",
]}},
"outputPorts": {0: {"schema": "AiResult"}},
"meta": {"icon": "mdi-magnify", "color": "#9C27B0", "usesAi": True},
"_method": "ai",
@ -68,14 +74,14 @@ AI_NODES = [
"description": t("Dokumentinhalt zusammenfassen"),
"parameters": [
{"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef",
"description": t("Dokumentenliste (Upstream-Output binden)"), "default": ""},
{"name": "summaryLength", "type": "string", "required": False, "frontendType": "select",
"description": t("Dokumente aus vorherigen Schritten")},
{"name": "summaryLength", "type": "str", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["brief", "medium", "detailed"]},
"description": t("Kurz, mittel oder ausführlich"), "default": "medium"},
] + _AI_COMMON_PARAMS,
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
"inputPorts": {0: {"accepts": ["DocumentList", "Transit", "LoopItem"]}},
"outputPorts": {0: {"schema": "AiResult"}},
"meta": {"icon": "mdi-file-document-outline", "color": "#9C27B0", "usesAi": True},
"_method": "ai",
@ -88,13 +94,13 @@ AI_NODES = [
"description": t("Dokument in Zielsprache übersetzen"),
"parameters": [
{"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef",
"description": t("Dokumentenliste (Upstream-Output binden)"), "default": ""},
{"name": "targetLanguage", "type": "string", "required": True, "frontendType": "text",
"description": t("Dokumente aus vorherigen Schritten")},
{"name": "targetLanguage", "type": "str", "required": True, "frontendType": "text",
"description": t("Zielsprache (z.B. de, en, French)")},
] + _AI_COMMON_PARAMS,
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
"inputPorts": {0: {"accepts": ["DocumentList", "Transit", "LoopItem"]}},
"outputPorts": {0: {"schema": "AiResult"}},
"meta": {"icon": "mdi-translate", "color": "#9C27B0", "usesAi": True},
"_method": "ai",
@ -107,14 +113,14 @@ AI_NODES = [
"description": t("Dokument in anderes Format konvertieren"),
"parameters": [
{"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef",
"description": t("Dokumentenliste (Upstream-Output binden)"), "default": ""},
{"name": "targetFormat", "type": "string", "required": True, "frontendType": "select",
"description": t("Dokumente aus vorherigen Schritten")},
{"name": "targetFormat", "type": "str", "required": True, "frontendType": "select",
"frontendOptions": {"options": ["docx", "pdf", "xlsx", "csv", "txt", "html", "json", "md"]},
"description": t("Zielformat")},
] + _AI_COMMON_PARAMS,
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
"inputPorts": {0: {"accepts": ["DocumentList", "Transit", "LoopItem"]}},
"outputPorts": {0: {"schema": "DocumentList"}},
"meta": {"icon": "mdi-file-convert", "color": "#9C27B0", "usesAi": True},
"_method": "ai",
@ -126,12 +132,26 @@ AI_NODES = [
"label": t("Dokument generieren"),
"description": t("Dokument aus Prompt generieren"),
"parameters": [
{"name": "prompt", "type": "string", "required": True, "frontendType": "textarea",
{"name": "prompt", "type": "str", "required": True, "frontendType": "textarea",
"description": t("Generierungs-Prompt")},
{"name": "outputFormat", "type": "str", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["docx", "pdf", "txt", "html", "md"]},
"description": t("Ausgabeformat"), "default": "docx"},
{"name": "title", "type": "str", "required": False, "frontendType": "text",
"description": t("Dokumenttitel (Metadaten / Dateiname)"), "default": ""},
{"name": "documentType", "type": "str", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["letter", "memo", "proposal", "contract", "report", "email"]},
"description": t("Dokumentart (Inhaltshinweis fuer die KI)"), "default": "proposal"},
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
"description": t("Daten aus vorherigen Schritten"), "default": ""},
{"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "hidden",
"description": t("Dokumente aus vorherigen Schritten"), "default": ""},
] + _AI_COMMON_PARAMS,
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"inputPorts": {0: {"accepts": [
"FormPayload", "Transit", "AiResult", "DocumentList", "ActionResult", "LoopItem", "TextResult",
]}},
"outputPorts": {0: {"schema": "DocumentList"}},
"meta": {"icon": "mdi-file-plus", "color": "#9C27B0", "usesAi": True},
"_method": "ai",
@ -143,15 +163,21 @@ AI_NODES = [
"label": t("Code generieren"),
"description": t("Code aus Beschreibung generieren"),
"parameters": [
{"name": "prompt", "type": "string", "required": True, "frontendType": "textarea",
{"name": "prompt", "type": "str", "required": True, "frontendType": "textarea",
"description": t("Code-Generierungs-Prompt")},
{"name": "resultType", "type": "string", "required": False, "frontendType": "select",
{"name": "resultType", "type": "str", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["py", "js", "ts", "html", "java", "cpp", "txt", "json", "csv", "xml"]},
"description": t("Datei-Endung der erzeugten Code-Datei"), "default": "py"},
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
"description": t("Daten aus vorherigen Schritten"), "default": ""},
{"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "hidden",
"description": t("Dokumente aus vorherigen Schritten"), "default": ""},
] + _AI_COMMON_PARAMS,
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"inputPorts": {0: {"accepts": [
"FormPayload", "Transit", "AiResult", "DocumentList", "ActionResult", "LoopItem", "TextResult",
]}},
"outputPorts": {0: {"schema": "AiResult"}},
"meta": {"icon": "mdi-code-tags", "color": "#9C27B0", "usesAi": True},
"_method": "ai",
@ -163,10 +189,10 @@ AI_NODES = [
"label": t("KI-Konsolidierung"),
"description": t("Gesammelte Ergebnisse mit KI zusammenfassen, klassifizieren oder semantisch zusammenführen"),
"parameters": [
{"name": "mode", "type": "string", "required": False, "frontendType": "select",
{"name": "mode", "type": "str", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["summarize", "classify", "semanticMerge"]},
"description": t("Konsolidierungsmodus"), "default": "summarize"},
{"name": "prompt", "type": "string", "required": False, "frontendType": "textarea",
{"name": "prompt", "type": "str", "required": False, "frontendType": "textarea",
"description": t("Optionaler Prompt für die Konsolidierung"), "default": ""},
] + _AI_COMMON_PARAMS,
"inputs": 1,

View file

@ -11,23 +11,23 @@ CLICKUP_NODES = [
"label": t("Aufgaben suchen"),
"description": t("Aufgaben in einem Workspace suchen"),
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
{"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection",
"frontendOptions": {"authority": "clickup"},
"description": t("ClickUp-Verbindung")},
{"name": "teamId", "type": "string", "required": True, "frontendType": "text",
{"name": "teamId", "type": "str", "required": True, "frontendType": "text",
"description": t("Team-/Workspace-ID")},
{"name": "query", "type": "string", "required": True, "frontendType": "text",
{"name": "query", "type": "str", "required": True, "frontendType": "text",
"description": t("Suchbegriff")},
{"name": "page", "type": "number", "required": False, "frontendType": "number",
{"name": "page", "type": "int", "required": False, "frontendType": "number",
"description": t("Seite"), "default": 0},
{"name": "listId", "type": "string", "required": False, "frontendType": "clickupList",
{"name": "listId", "type": "str", "required": False, "frontendType": "clickupList",
"frontendOptions": {"dependsOn": "connectionReference"},
"description": t("In dieser Liste suchen")},
{"name": "includeClosed", "type": "boolean", "required": False, "frontendType": "checkbox",
{"name": "includeClosed", "type": "bool", "required": False, "frontendType": "checkbox",
"description": t("Erledigte einbeziehen"), "default": False},
{"name": "fullTaskData", "type": "boolean", "required": False, "frontendType": "checkbox",
{"name": "fullTaskData", "type": "bool", "required": False, "frontendType": "checkbox",
"description": t("Vollständige Daten"), "default": False},
{"name": "matchNameOnly", "type": "boolean", "required": False, "frontendType": "checkbox",
{"name": "matchNameOnly", "type": "bool", "required": False, "frontendType": "checkbox",
"description": t("Nur Titel"), "default": True},
],
"inputs": 1,
@ -44,15 +44,15 @@ CLICKUP_NODES = [
"label": t("Aufgaben auflisten"),
"description": t("Aufgaben einer Liste auflisten"),
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
{"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection",
"frontendOptions": {"authority": "clickup"},
"description": t("ClickUp-Verbindung")},
{"name": "pathQuery", "type": "string", "required": True, "frontendType": "clickupList",
{"name": "pathQuery", "type": "str", "required": True, "frontendType": "clickupList",
"frontendOptions": {"dependsOn": "connectionReference"},
"description": t("Pfad zur Liste")},
{"name": "page", "type": "number", "required": False, "frontendType": "number",
{"name": "page", "type": "int", "required": False, "frontendType": "number",
"description": t("Seite"), "default": 0},
{"name": "includeClosed", "type": "boolean", "required": False, "frontendType": "checkbox",
{"name": "includeClosed", "type": "bool", "required": False, "frontendType": "checkbox",
"description": t("Erledigte einbeziehen"), "default": False},
],
"inputs": 1,
@ -69,12 +69,12 @@ CLICKUP_NODES = [
"label": t("Aufgabe abrufen"),
"description": t("Eine Aufgabe abrufen"),
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
{"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection",
"frontendOptions": {"authority": "clickup"},
"description": t("ClickUp-Verbindung")},
{"name": "taskId", "type": "string", "required": False, "frontendType": "text",
{"name": "taskId", "type": "str", "required": False, "frontendType": "text",
"description": t("Task-ID")},
{"name": "pathQuery", "type": "string", "required": False, "frontendType": "text",
{"name": "pathQuery", "type": "str", "required": False, "frontendType": "text",
"description": t("Oder Pfad")},
],
"inputs": 1,
@ -91,34 +91,34 @@ CLICKUP_NODES = [
"label": t("Aufgabe erstellen"),
"description": t("Aufgabe erstellen"),
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
{"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection",
"frontendOptions": {"authority": "clickup"},
"description": t("ClickUp-Verbindung")},
{"name": "pathQuery", "type": "string", "required": False, "frontendType": "clickupList",
{"name": "pathQuery", "type": "str", "required": False, "frontendType": "clickupList",
"frontendOptions": {"dependsOn": "connectionReference"},
"description": t("Pfad zur Liste")},
{"name": "listId", "type": "string", "required": False, "frontendType": "text",
{"name": "listId", "type": "str", "required": False, "frontendType": "text",
"description": t("Listen-ID")},
{"name": "name", "type": "string", "required": True, "frontendType": "text",
{"name": "name", "type": "str", "required": True, "frontendType": "text",
"description": t("Name")},
{"name": "description", "type": "string", "required": False, "frontendType": "textarea",
{"name": "description", "type": "str", "required": False, "frontendType": "textarea",
"description": t("Beschreibung")},
{"name": "taskStatus", "type": "string", "required": False, "frontendType": "text",
{"name": "taskStatus", "type": "str", "required": False, "frontendType": "text",
"description": t("Status")},
{"name": "taskPriority", "type": "string", "required": False, "frontendType": "select",
{"name": "taskPriority", "type": "str", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["1", "2", "3", "4"]},
"description": t("Priorität 1-4")},
{"name": "taskDueDateMs", "type": "string", "required": False, "frontendType": "text",
{"name": "taskDueDateMs", "type": "str", "required": False, "frontendType": "text",
"description": t("Fälligkeit (ms)")},
{"name": "taskAssigneeIds", "type": "object", "required": False, "frontendType": "json",
"description": t("Zugewiesene")},
{"name": "taskTimeEstimateMs", "type": "string", "required": False, "frontendType": "text",
{"name": "taskTimeEstimateMs", "type": "str", "required": False, "frontendType": "text",
"description": t("Zeitschätzung (ms)")},
{"name": "taskTimeEstimateHours", "type": "string", "required": False, "frontendType": "text",
{"name": "taskTimeEstimateHours", "type": "str", "required": False, "frontendType": "text",
"description": t("Zeitschätzung (h)")},
{"name": "customFieldValues", "type": "object", "required": False, "frontendType": "json",
"description": t("Benutzerdefinierte Felder")},
{"name": "taskFields", "type": "string", "required": False, "frontendType": "json",
{"name": "taskFields", "type": "str", "required": False, "frontendType": "json",
"description": t("Zusätzliches JSON")},
],
"inputs": 1,
@ -135,14 +135,14 @@ CLICKUP_NODES = [
"label": t("Aufgabe aktualisieren"),
"description": t("Felder der Aufgabe ändern"),
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
{"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection",
"frontendOptions": {"authority": "clickup"},
"description": t("ClickUp-Verbindung")},
{"name": "taskId", "type": "string", "required": False, "frontendType": "text",
{"name": "taskId", "type": "str", "required": False, "frontendType": "text",
"description": t("Task-ID")},
{"name": "path", "type": "string", "required": False, "frontendType": "text",
{"name": "path", "type": "str", "required": False, "frontendType": "text",
"description": t("Oder Pfad")},
{"name": "taskUpdate", "type": "string", "required": False, "frontendType": "json",
{"name": "taskUpdate", "type": "str", "required": False, "frontendType": "json",
"description": t("JSON-Body für PUT /task/{id}, z.B. {\"name\":\"...\",\"status\":\"...\"}")},
],
"inputs": 1,
@ -159,16 +159,16 @@ CLICKUP_NODES = [
"label": t("Anhang hochladen"),
"description": t("Datei an Task anhängen"),
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
{"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection",
"frontendOptions": {"authority": "clickup"},
"description": t("ClickUp-Verbindung")},
{"name": "taskId", "type": "string", "required": False, "frontendType": "text",
{"name": "taskId", "type": "str", "required": False, "frontendType": "text",
"description": t("Task-ID")},
{"name": "path", "type": "string", "required": False, "frontendType": "text",
{"name": "path", "type": "str", "required": False, "frontendType": "text",
"description": t("Oder Pfad")},
{"name": "fileName", "type": "string", "required": False, "frontendType": "text",
{"name": "fileName", "type": "str", "required": False, "frontendType": "text",
"description": t("Dateiname")},
{"name": "content", "type": "string", "required": True, "frontendType": "hidden",
{"name": "content", "type": "str", "required": True, "frontendType": "hidden",
"description": t("Datei-Inhalt aus Upstream-Node (via Wire oder DataRef)"), "default": ""},
],
"inputs": 1,

View file

@ -10,7 +10,7 @@ CONTEXT_NODES = [
"label": t("Inhalt extrahieren"),
"description": t("Dokumentstruktur extrahieren ohne KI (Seiten, Abschnitte, Bilder, Tabellen)"),
"parameters": [
{"name": "documentList", "type": "string", "required": True, "frontendType": "hidden",
{"name": "documentList", "type": "str", "required": True, "frontendType": "hidden",
"description": t("Dokumentenliste (via Wire oder DataRef)"), "default": ""},
{"name": "extractionOptions", "type": "object", "required": False, "frontendType": "json",
"description": t(

View file

@ -10,7 +10,7 @@ DATA_NODES = [
"label": t("Sammeln"),
"description": t("Ergebnisse aus Schleifen-Iterationen sammeln"),
"parameters": [
{"name": "mode", "type": "string", "required": False, "frontendType": "select",
{"name": "mode", "type": "str", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["collect", "concat", "sum", "count"]},
"description": t("Aggregationsmodus"), "default": "collect"},
],
@ -27,9 +27,9 @@ DATA_NODES = [
"label": t("Filtern"),
"description": t("Elemente nach Bedingung filtern"),
"parameters": [
{"name": "condition", "type": "string", "required": True, "frontendType": "filterExpression",
{"name": "condition", "type": "str", "required": True, "frontendType": "filterExpression",
"description": t("Filterbedingung")},
{"name": "udmContentType", "type": "string", "required": False, "frontendType": "select",
{"name": "udmContentType", "type": "str", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["", "text", "image", "table", "code", "media", "link", "formula"]},
"description": t("UDM-ContentType-Filter (optional, leer = kein UDM-Filter)"), "default": ""},
],
@ -46,10 +46,10 @@ DATA_NODES = [
"label": t("Konsolidieren"),
"description": t("Gesammelte Ergebnisse deterministisch zusammenführen (Tabelle, CSV, Merge)"),
"parameters": [
{"name": "mode", "type": "string", "required": False, "frontendType": "select",
{"name": "mode", "type": "str", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["table", "concat", "merge", "csvJoin"]},
"description": t("Konsolidierungsmodus"), "default": "table"},
{"name": "separator", "type": "string", "required": False, "frontendType": "text",
{"name": "separator", "type": "str", "required": False, "frontendType": "text",
"description": t("Trennzeichen (für concat/csvJoin)"), "default": "\n"},
],
"inputs": 1,

View file

@ -10,14 +10,14 @@ EMAIL_NODES = [
"label": t("E-Mail prüfen"),
"description": t("Neue E-Mails prüfen"),
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
{"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection",
"frontendOptions": {"authority": "msft"},
"description": t("E-Mail-Konto Verbindung")},
{"name": "folder", "type": "string", "required": False, "frontendType": "text",
{"name": "folder", "type": "str", "required": False, "frontendType": "text",
"description": t("Ordner"), "default": "Inbox"},
{"name": "limit", "type": "number", "required": False, "frontendType": "number",
{"name": "limit", "type": "int", "required": False, "frontendType": "number",
"description": t("Max E-Mails"), "default": 100},
{"name": "filter", "type": "string", "required": False, "frontendType": "text",
{"name": "filter", "type": "str", "required": False, "frontendType": "text",
"description": t("Filter-Ausdruck (z.B. 'from:max@example.com hasAttachment:true betreff')"), "default": ""},
],
"inputs": 1,
@ -34,14 +34,14 @@ EMAIL_NODES = [
"label": t("E-Mail suchen"),
"description": t("E-Mails suchen"),
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
{"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection",
"frontendOptions": {"authority": "msft"},
"description": t("E-Mail-Konto Verbindung")},
{"name": "query", "type": "string", "required": True, "frontendType": "text",
{"name": "query", "type": "str", "required": True, "frontendType": "text",
"description": t("Suchausdruck (z.B. 'from:max@example.com hasAttachments:true Rechnung')")},
{"name": "folder", "type": "string", "required": False, "frontendType": "text",
{"name": "folder", "type": "str", "required": False, "frontendType": "text",
"description": t("Ordner"), "default": "All"},
{"name": "limit", "type": "number", "required": False, "frontendType": "number",
{"name": "limit", "type": "int", "required": False, "frontendType": "number",
"description": t("Max E-Mails"), "default": 100},
],
"inputs": 1,
@ -59,19 +59,19 @@ EMAIL_NODES = [
"description": t(
"AI-gestützt einen E-Mail-Entwurf aus Kontext und optionalen Dokumenten erstellen"),
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
{"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection",
"frontendOptions": {"authority": "msft"},
"description": t("E-Mail-Konto")},
{"name": "context", "type": "string", "required": False, "frontendType": "templateTextarea",
"description": t("Kontext / Brief-Beschreibung für die KI-Komposition"), "default": ""},
{"name": "to", "type": "string", "required": False, "frontendType": "text",
{"name": "context", "type": "Any", "required": False, "frontendType": "templateTextarea",
"description": t("Daten aus vorherigen Schritten (oder direkte Beschreibung)"), "default": ""},
{"name": "to", "type": "str", "required": False, "frontendType": "text",
"description": t("Empfänger (komma-separiert, optional für Entwurf)"), "default": ""},
{"name": "documentList", "type": "string", "required": False, "frontendType": "hidden",
{"name": "documentList", "type": "str", "required": False, "frontendType": "hidden",
"description": t("Anhang-Dokumente (via Wire oder DataRef)"), "default": ""},
{"name": "emailContent", "type": "string", "required": False, "frontendType": "hidden",
{"name": "emailContent", "type": "str", "required": False, "frontendType": "hidden",
"description": t("Direkt vorbereiteter Inhalt {subject, body, to} (via Wire — überspringt KI)"),
"default": ""},
{"name": "emailStyle", "type": "string", "required": False, "frontendType": "select",
{"name": "emailStyle", "type": "str", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["formal", "casual", "business"]},
"description": t("Stil"), "default": "business"},
],

View file

@ -10,25 +10,17 @@ FILE_NODES = [
"label": t("Datei erstellen"),
"description": t("Erstellt eine Datei aus Kontext (Text/Markdown von KI)."),
"parameters": [
{"name": "contentSources", "type": "json", "required": False, "frontendType": "json",
"description": t("Kontext-Quellen"), "default": []},
{"name": "outputFormat", "type": "string", "required": True, "frontendType": "select",
{"name": "outputFormat", "type": "str", "required": True, "frontendType": "select",
"frontendOptions": {"options": ["docx", "pdf", "txt", "html", "md"]},
"description": t("Ausgabeformat"), "default": "docx"},
{"name": "title", "type": "string", "required": False, "frontendType": "text",
{"name": "title", "type": "str", "required": False, "frontendType": "text",
"description": t("Dokumenttitel")},
{"name": "templateName", "type": "string", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["default", "corporate", "minimal"]},
"description": t("Stil-Vorlage")},
{"name": "language", "type": "string", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["de", "en", "fr"]},
"description": t("Sprache"), "default": "de"},
{"name": "context", "type": "string", "required": False, "frontendType": "hidden",
"description": t("Inhalt (via Wire oder DataRef)"), "default": ""},
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
"description": t("Daten aus vorherigen Schritten"), "default": ""},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["AiResult", "TextResult", "Transit"]}},
"inputPorts": {0: {"accepts": ["AiResult", "TextResult", "Transit", "FormPayload", "LoopItem", "ActionResult"]}},
"outputPorts": {0: {"schema": "DocumentList"}},
"meta": {"icon": "mdi-file-plus-outline", "color": "#2196F3", "usesAi": False},
"_method": "file",

View file

@ -3,25 +3,46 @@
from modules.shared.i18nRegistry import t
# Ports, die typische Schritt-Ausgaben durchreichen (nicht nur leerer Transit).
_FLOW_INPUT_SCHEMAS = [
"Transit",
"FormPayload",
"AiResult",
"TextResult",
"ActionResult",
"DocumentList",
"FileList",
"EmailList",
"TaskList",
"QueryResult",
"MergeResult",
"LoopItem",
"BoolResult",
"UdmDocument",
]
FLOW_NODES = [
{
"id": "flow.ifElse",
"category": "flow",
"label": t("Wenn / Sonst"),
"description": t("Verzweigung nach Bedingung"),
"description": t(
"Verzweigt anhand einer Bedingung auf ein vorheriges Feld oder einen Ausdruck. "
"Die Daten vom Eingangskanal werden an den gewählten Ausgang durchgereicht."
),
"parameters": [
{
"name": "condition",
"type": "string",
"type": "json",
"required": True,
"frontendType": "condition",
"description": t("Bedingung"),
"description": t("Bedingung: Feld aus einem vorherigen Schritt und Vergleich"),
},
],
"inputs": 1,
"outputs": 2,
"outputLabels": [t("Ja"), t("Nein")],
"inputPorts": {0: {"accepts": ["Transit"]}},
"inputPorts": {0: {"accepts": list(_FLOW_INPUT_SCHEMAS)}},
"outputPorts": {0: {"schema": "Transit"}, 1: {"schema": "Transit"}},
"executor": "flow",
"meta": {"icon": "mdi-source-branch", "color": "#FF9800", "usesAi": False},
@ -30,26 +51,29 @@ FLOW_NODES = [
"id": "flow.switch",
"category": "flow",
"label": t("Switch"),
"description": t("Mehrere Zweige nach Wert"),
"description": t(
"Mehrere Zweige nach einem Wert aus einem vorherigen Schritt (Data Picker). "
"Definiere Fälle mit Vergleichsoperator; der Eingang wird an den ersten passenden Zweig durchgereicht."
),
"parameters": [
{
"name": "value",
"type": "string",
"type": "Any",
"required": True,
"frontendType": "text",
"description": t("Zu vergleichender Wert"),
"frontendType": "dataRef",
"description": t("Wert zum Vergleichen (Feld aus einem vorherigen Schritt)"),
},
{
"name": "cases",
"type": "array",
"required": False,
"frontendType": "caseList",
"description": t("Fälle"),
"description": t("Fälle: Operator und Vergleichswert"),
},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}},
"inputPorts": {0: {"accepts": list(_FLOW_INPUT_SCHEMAS)}},
"outputPorts": {0: {"schema": "Transit"}},
"executor": "flow",
"meta": {"icon": "mdi-swap-horizontal", "color": "#FF9800", "usesAi": False},
@ -57,39 +81,43 @@ FLOW_NODES = [
{
"id": "flow.loop",
"category": "flow",
"label": t("Schleife / Für Jedes"),
"description": t("Über Array-Elemente oder UDM-Strukturebenen iterieren"),
"label": t("Schleife / Für jedes"),
"description": t(
"Iteriert über ein Array aus einem vorherigen Schritt (z. B. documente, Zeilen, Listeneinträge). "
"Optional: UDM-Ebene für strukturierte Dokumente."
),
"parameters": [
{
"name": "items",
"type": "string",
"type": "Any",
"required": True,
"frontendType": "text",
"description": t("Pfad zum Array"),
"frontendType": "dataRef",
"description": t("Liste oder Sammlung zum Durchlaufen (im Data Picker wählen)"),
},
{
"name": "level",
"type": "string",
"type": "str",
"required": False,
"frontendType": "select",
"frontendOptions": {"options": ["auto", "documents", "structuralNodes", "contentBlocks"]},
"description": t("UDM-Iterationsebene"),
"description": t("Nur bei UDM-Daten: welche Strukturebene als Elemente verwendet wird"),
"default": "auto",
},
{
"name": "concurrency",
"type": "number",
"type": "int",
"required": False,
"frontendType": "number",
"frontendOptions": {"min": 1, "max": 20},
"description": t("Parallele Iterationen (1 = sequentiell)"),
"description": t("Parallele Durchläufe (1 = nacheinander)"),
"default": 1,
},
],
"inputs": 1,
"outputs": 1,
"inputPorts": {0: {"accepts": [
"Transit", "UdmDocument", "EmailList", "DocumentList", "FileList", "TaskList", "ActionResult",
"Transit", "UdmDocument", "EmailList", "DocumentList", "FileList", "TaskList",
"ActionResult", "AiResult", "QueryResult", "FormPayload",
]}},
"outputPorts": {0: {"schema": "LoopItem"}},
"executor": "flow",
@ -99,30 +127,36 @@ FLOW_NODES = [
"id": "flow.merge",
"category": "flow",
"label": t("Zusammenführen"),
"description": t("Mehrere Zweige zusammenführen (2-5 Eingänge)"),
"description": t(
"Führt 25 Zweige zusammen, wenn alle verbunden sind. "
"Modus legt fest, wie die Eingabeobjekte im Ergebnis kombiniert werden."
),
"parameters": [
{
"name": "mode",
"type": "string",
"type": "str",
"required": False,
"frontendType": "select",
"frontendOptions": {"options": ["first", "all", "append"]},
"description": t("Zusammenführungsmodus"),
"description": t("first: erster Zweig; all: Dict-Felder zusammenführen; append: Listen anhängen"),
"default": "first",
},
{
"name": "inputCount",
"type": "number",
"type": "int",
"required": False,
"frontendType": "number",
"frontendOptions": {"min": 2, "max": 5},
"description": t("Anzahl Eingänge"),
"description": t("Anzahl Eingänge dieses Nodes (25)"),
"default": 2,
},
],
"inputs": 2,
"outputs": 1,
"inputPorts": {0: {"accepts": ["Transit"]}, 1: {"accepts": ["Transit"]}},
"inputPorts": {
0: {"accepts": list(_FLOW_INPUT_SCHEMAS)},
1: {"accepts": list(_FLOW_INPUT_SCHEMAS)},
},
"outputPorts": {0: {"schema": "MergeResult"}},
"executor": "flow",
"meta": {"icon": "mdi-call-merge", "color": "#FF9800", "usesAi": False},

View file

@ -3,6 +3,18 @@
from modules.shared.i18nRegistry import t
# Canonical form field types — single source of truth.
# portType maps to the PORT_TYPE_CATALOG primitive used by DataPicker / validateGraph.
FORM_FIELD_TYPES = [
{"id": "text", "label": "Text (einzeilig)", "portType": "str"},
{"id": "textarea", "label": "Text (mehrzeilig)", "portType": "str"},
{"id": "number", "label": "Zahl", "portType": "int"},
{"id": "boolean", "label": "Ja/Nein", "portType": "bool"},
{"id": "date", "label": "Datum", "portType": "str"},
{"id": "email", "label": "E-Mail", "portType": "str"},
{"id": "select", "label": "Auswahl", "portType": "str"},
]
INPUT_NODES = [
{
"id": "input.form",
@ -32,11 +44,11 @@ INPUT_NODES = [
"label": t("Genehmigung"),
"description": t("Benutzer genehmigt oder lehnt ab"),
"parameters": [
{"name": "title", "type": "string", "required": True, "frontendType": "text",
{"name": "title", "type": "str", "required": True, "frontendType": "text",
"description": t("Genehmigungstitel")},
{"name": "description", "type": "string", "required": False, "frontendType": "textarea",
{"name": "description", "type": "str", "required": False, "frontendType": "textarea",
"description": t("Was genehmigt werden soll")},
{"name": "approvalType", "type": "string", "required": False, "frontendType": "select",
{"name": "approvalType", "type": "str", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["generic", "document"]},
"description": t("Typ: document oder generic"), "default": "generic"},
],
@ -53,14 +65,14 @@ INPUT_NODES = [
"label": t("Upload"),
"description": t("Benutzer lädt Datei(en) hoch"),
"parameters": [
{"name": "accept", "type": "string", "required": False, "frontendType": "text",
{"name": "accept", "type": "str", "required": False, "frontendType": "text",
"description": t("Accept-String"), "default": ""},
{"name": "allowedTypes", "type": "json", "required": False, "frontendType": "multiselect",
"frontendOptions": {"options": ["pdf", "docx", "xlsx", "pptx", "txt", "csv", "jpg", "png", "gif"]},
"description": t("Ausgewählte Dateitypen"), "default": []},
{"name": "maxSize", "type": "number", "required": False, "frontendType": "number",
{"name": "maxSize", "type": "int", "required": False, "frontendType": "number",
"description": t("Max. Dateigröße in MB"), "default": 10},
{"name": "multiple", "type": "boolean", "required": False, "frontendType": "checkbox",
{"name": "multiple", "type": "bool", "required": False, "frontendType": "checkbox",
"description": t("Mehrere Dateien erlauben"), "default": False},
],
"inputs": 1,
@ -76,9 +88,9 @@ INPUT_NODES = [
"label": t("Kommentar"),
"description": t("Benutzer fügt einen Kommentar hinzu"),
"parameters": [
{"name": "placeholder", "type": "string", "required": False, "frontendType": "text",
{"name": "placeholder", "type": "str", "required": False, "frontendType": "text",
"description": t("Platzhalter"), "default": ""},
{"name": "required", "type": "boolean", "required": False, "frontendType": "checkbox",
{"name": "required", "type": "bool", "required": False, "frontendType": "checkbox",
"description": t("Kommentar erforderlich"), "default": True},
],
"inputs": 1,
@ -94,9 +106,9 @@ INPUT_NODES = [
"label": t("Prüfung"),
"description": t("Benutzer prüft Inhalt"),
"parameters": [
{"name": "contentRef", "type": "string", "required": True, "frontendType": "text",
{"name": "contentRef", "type": "str", "required": True, "frontendType": "text",
"description": t("Referenz auf Inhalt")},
{"name": "reviewType", "type": "string", "required": False, "frontendType": "select",
{"name": "reviewType", "type": "str", "required": False, "frontendType": "select",
"frontendOptions": {"options": ["generic", "document"]},
"description": t("Art der Prüfung"), "default": "generic"},
],
@ -115,7 +127,7 @@ INPUT_NODES = [
"parameters": [
{"name": "options", "type": "json", "required": True, "frontendType": "keyValueRows",
"description": t("Optionen"), "default": []},
{"name": "multiple", "type": "boolean", "required": False, "frontendType": "checkbox",
{"name": "multiple", "type": "bool", "required": False, "frontendType": "checkbox",
"description": t("Mehrfachauswahl erlauben"), "default": False},
],
"inputs": 1,
@ -131,11 +143,11 @@ INPUT_NODES = [
"label": t("Bestätigung"),
"description": t("Benutzer bestätigt Ja/Nein"),
"parameters": [
{"name": "question", "type": "string", "required": True, "frontendType": "text",
{"name": "question", "type": "str", "required": True, "frontendType": "text",
"description": t("Zu bestätigende Frage")},
{"name": "confirmLabel", "type": "string", "required": False, "frontendType": "text",
{"name": "confirmLabel", "type": "str", "required": False, "frontendType": "text",
"description": t("Label für Bestätigen-Button"), "default": "Confirm"},
{"name": "rejectLabel", "type": "string", "required": False, "frontendType": "text",
{"name": "rejectLabel", "type": "str", "required": False, "frontendType": "text",
"description": t("Label für Ablehnen-Button"), "default": "Reject"},
],
"inputs": 1,

View file

@ -25,7 +25,7 @@ REDMINE_NODES = [
"description": t("Einzelnes Redmine-Ticket aus dem Mirror laden."),
"parameters": [
dict(_REDMINE_INSTANCE_PARAM),
{"name": "ticketId", "type": "number", "required": True, "frontendType": "number",
{"name": "ticketId", "type": "int", "required": True, "frontendType": "number",
"description": t("Redmine-Ticket-ID")},
],
"inputs": 1,
@ -43,17 +43,17 @@ REDMINE_NODES = [
"description": t("Tickets aus dem lokalen Mirror mit Filtern (Tracker, Status, Zeitraum, Zuweisung)."),
"parameters": [
dict(_REDMINE_INSTANCE_PARAM),
{"name": "trackerIds", "type": "string", "required": False, "frontendType": "text",
{"name": "trackerIds", "type": "str", "required": False, "frontendType": "text",
"description": t("Tracker-IDs (Komma-separiert)"), "default": ""},
{"name": "status", "type": "string", "required": False, "frontendType": "text",
{"name": "status", "type": "str", "required": False, "frontendType": "text",
"description": t("Status-Filter: open | closed | *"), "default": "*"},
{"name": "dateFrom", "type": "string", "required": False, "frontendType": "date",
{"name": "dateFrom", "type": "str", "required": False, "frontendType": "date",
"description": t("Zeitraum ab (ISO-Datum)"), "default": ""},
{"name": "dateTo", "type": "string", "required": False, "frontendType": "date",
{"name": "dateTo", "type": "str", "required": False, "frontendType": "date",
"description": t("Zeitraum bis (ISO-Datum)"), "default": ""},
{"name": "assignedToId", "type": "number", "required": False, "frontendType": "number",
{"name": "assignedToId", "type": "int", "required": False, "frontendType": "number",
"description": t("Nur Tickets dieses Benutzers (ID)")},
{"name": "limit", "type": "number", "required": False, "frontendType": "number",
{"name": "limit", "type": "int", "required": False, "frontendType": "number",
"description": t("Max. Anzahl Tickets (1-500)"), "default": 100},
],
"inputs": 1,
@ -71,21 +71,21 @@ REDMINE_NODES = [
"description": t("Neues Ticket in Redmine anlegen. Mirror wird sofort aktualisiert."),
"parameters": [
dict(_REDMINE_INSTANCE_PARAM),
{"name": "subject", "type": "string", "required": True, "frontendType": "text",
{"name": "subject", "type": "str", "required": True, "frontendType": "text",
"description": t("Ticket-Titel")},
{"name": "trackerId", "type": "number", "required": True, "frontendType": "number",
{"name": "trackerId", "type": "int", "required": True, "frontendType": "number",
"description": t("Tracker-ID (Userstory, Feature, Task, ...)")},
{"name": "description", "type": "string", "required": False, "frontendType": "textarea",
{"name": "description", "type": "str", "required": False, "frontendType": "textarea",
"description": t("Ticket-Beschreibung"), "default": ""},
{"name": "statusId", "type": "number", "required": False, "frontendType": "number",
{"name": "statusId", "type": "int", "required": False, "frontendType": "number",
"description": t("Status-ID (optional)")},
{"name": "priorityId", "type": "number", "required": False, "frontendType": "number",
{"name": "priorityId", "type": "int", "required": False, "frontendType": "number",
"description": t("Prioritaet-ID (optional)")},
{"name": "assignedToId", "type": "number", "required": False, "frontendType": "number",
{"name": "assignedToId", "type": "int", "required": False, "frontendType": "number",
"description": t("Zugewiesene Benutzer-ID (optional)")},
{"name": "parentIssueId", "type": "number", "required": False, "frontendType": "number",
{"name": "parentIssueId", "type": "int", "required": False, "frontendType": "number",
"description": t("Uebergeordnetes Ticket (optional)")},
{"name": "customFields", "type": "string", "required": False, "frontendType": "textarea",
{"name": "customFields", "type": "str", "required": False, "frontendType": "textarea",
"description": t("Custom Fields als JSON {id: value}"), "default": ""},
],
"inputs": 1,
@ -103,25 +103,25 @@ REDMINE_NODES = [
"description": t("Felder eines Redmine-Tickets aktualisieren. Nur gesetzte Felder werden uebertragen."),
"parameters": [
dict(_REDMINE_INSTANCE_PARAM),
{"name": "ticketId", "type": "number", "required": True, "frontendType": "number",
{"name": "ticketId", "type": "int", "required": True, "frontendType": "number",
"description": t("Ticket-ID")},
{"name": "subject", "type": "string", "required": False, "frontendType": "text",
{"name": "subject", "type": "str", "required": False, "frontendType": "text",
"description": t("Neuer Titel")},
{"name": "description", "type": "string", "required": False, "frontendType": "textarea",
{"name": "description", "type": "str", "required": False, "frontendType": "textarea",
"description": t("Neue Beschreibung")},
{"name": "trackerId", "type": "number", "required": False, "frontendType": "number",
{"name": "trackerId", "type": "int", "required": False, "frontendType": "number",
"description": t("Neuer Tracker")},
{"name": "statusId", "type": "number", "required": False, "frontendType": "number",
{"name": "statusId", "type": "int", "required": False, "frontendType": "number",
"description": t("Neuer Status")},
{"name": "priorityId", "type": "number", "required": False, "frontendType": "number",
{"name": "priorityId", "type": "int", "required": False, "frontendType": "number",
"description": t("Neue Prioritaet")},
{"name": "assignedToId", "type": "number", "required": False, "frontendType": "number",
{"name": "assignedToId", "type": "int", "required": False, "frontendType": "number",
"description": t("Neue Zuweisung")},
{"name": "parentIssueId", "type": "number", "required": False, "frontendType": "number",
{"name": "parentIssueId", "type": "int", "required": False, "frontendType": "number",
"description": t("Neues Parent-Ticket")},
{"name": "notes", "type": "string", "required": False, "frontendType": "textarea",
{"name": "notes", "type": "str", "required": False, "frontendType": "textarea",
"description": t("Kommentar (Journal-Eintrag)"), "default": ""},
{"name": "customFields", "type": "string", "required": False, "frontendType": "textarea",
{"name": "customFields", "type": "str", "required": False, "frontendType": "textarea",
"description": t("Custom Fields als JSON {id: value}"), "default": ""},
],
"inputs": 1,
@ -139,13 +139,13 @@ REDMINE_NODES = [
"description": t("Aggregierte Kennzahlen (KPIs, Durchsatz, Status-Verteilung, Backlog) aus dem Mirror."),
"parameters": [
dict(_REDMINE_INSTANCE_PARAM),
{"name": "dateFrom", "type": "string", "required": False, "frontendType": "date",
{"name": "dateFrom", "type": "str", "required": False, "frontendType": "date",
"description": t("Zeitraum ab")},
{"name": "dateTo", "type": "string", "required": False, "frontendType": "date",
{"name": "dateTo", "type": "str", "required": False, "frontendType": "date",
"description": t("Zeitraum bis")},
{"name": "bucket", "type": "string", "required": False, "frontendType": "text",
{"name": "bucket", "type": "str", "required": False, "frontendType": "text",
"description": t("Bucket: day | week | month"), "default": "week"},
{"name": "trackerIds", "type": "string", "required": False, "frontendType": "text",
{"name": "trackerIds", "type": "str", "required": False, "frontendType": "text",
"description": t("Tracker-IDs (Komma-separiert)"), "default": ""},
],
"inputs": 1,
@ -163,7 +163,7 @@ REDMINE_NODES = [
"description": t("Tickets und Beziehungen aus Redmine in den lokalen Mirror uebernehmen."),
"parameters": [
dict(_REDMINE_INSTANCE_PARAM),
{"name": "force", "type": "boolean", "required": False, "frontendType": "checkbox",
{"name": "force", "type": "bool", "required": False, "frontendType": "checkbox",
"description": t("Vollsync erzwingen (ignoriert lastSyncAt)"), "default": False},
],
"inputs": 1,

View file

@ -10,14 +10,14 @@ SHAREPOINT_NODES = [
"label": t("Datei finden"),
"description": t("Datei nach Pfad oder Suche finden"),
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
{"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection",
"frontendOptions": {"authority": "msft"},
"description": t("SharePoint-Verbindung")},
{"name": "searchQuery", "type": "string", "required": True, "frontendType": "text",
{"name": "searchQuery", "type": "str", "required": True, "frontendType": "text",
"description": t("Suchanfrage oder Pfad")},
{"name": "site", "type": "string", "required": False, "frontendType": "text",
{"name": "site", "type": "str", "required": False, "frontendType": "text",
"description": t("Optionaler Site-Hinweis"), "default": ""},
{"name": "maxResults", "type": "number", "required": False, "frontendType": "number",
{"name": "maxResults", "type": "int", "required": False, "frontendType": "number",
"description": t("Max Ergebnisse"), "default": 1000},
],
"inputs": 1,
@ -34,10 +34,10 @@ SHAREPOINT_NODES = [
"label": t("Datei lesen"),
"description": t("Inhalt aus Datei extrahieren"),
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
{"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection",
"frontendOptions": {"authority": "msft"},
"description": t("SharePoint-Verbindung")},
{"name": "pathQuery", "type": "string", "required": True, "frontendType": "sharepointFile",
{"name": "pathQuery", "type": "str", "required": True, "frontendType": "sharepointFile",
"frontendOptions": {"dependsOn": "connectionReference"},
"description": t("Dateipfad")},
],
@ -55,13 +55,13 @@ SHAREPOINT_NODES = [
"label": t("Datei hochladen"),
"description": t("Datei zu SharePoint hochladen"),
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
{"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection",
"frontendOptions": {"authority": "msft"},
"description": t("SharePoint-Verbindung")},
{"name": "pathQuery", "type": "string", "required": True, "frontendType": "sharepointFolder",
{"name": "pathQuery", "type": "str", "required": True, "frontendType": "sharepointFolder",
"frontendOptions": {"dependsOn": "connectionReference"},
"description": t("Zielordner-Pfad")},
{"name": "content", "type": "string", "required": True, "frontendType": "hidden",
{"name": "content", "type": "str", "required": True, "frontendType": "hidden",
"description": t("Datei-Inhalt aus Upstream-Node (via Wire oder DataRef)"), "default": ""},
],
"inputs": 1,
@ -78,10 +78,10 @@ SHAREPOINT_NODES = [
"label": t("Dateien auflisten"),
"description": t("Dateien in Ordner auflisten"),
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
{"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection",
"frontendOptions": {"authority": "msft"},
"description": t("SharePoint-Verbindung")},
{"name": "pathQuery", "type": "string", "required": False, "frontendType": "sharepointFolder",
{"name": "pathQuery", "type": "str", "required": False, "frontendType": "sharepointFolder",
"frontendOptions": {"dependsOn": "connectionReference"},
"description": t("Ordnerpfad"), "default": "/"},
],
@ -99,10 +99,10 @@ SHAREPOINT_NODES = [
"label": t("Datei herunterladen"),
"description": t("Datei vom Pfad herunterladen"),
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
{"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection",
"frontendOptions": {"authority": "msft"},
"description": t("SharePoint-Verbindung")},
{"name": "pathQuery", "type": "string", "required": True, "frontendType": "sharepointFile",
{"name": "pathQuery", "type": "str", "required": True, "frontendType": "sharepointFile",
"frontendOptions": {"dependsOn": "connectionReference"},
"description": t("Vollständiger Dateipfad")},
],
@ -120,13 +120,13 @@ SHAREPOINT_NODES = [
"label": t("Datei kopieren"),
"description": t("Datei an Ziel kopieren"),
"parameters": [
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
{"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection",
"frontendOptions": {"authority": "msft"},
"description": t("SharePoint-Verbindung")},
{"name": "sourcePath", "type": "string", "required": True, "frontendType": "sharepointFile",
{"name": "sourcePath", "type": "str", "required": True, "frontendType": "sharepointFile",
"frontendOptions": {"dependsOn": "connectionReference"},
"description": t("Quelldatei-Pfad")},
{"name": "destPath", "type": "string", "required": True, "frontendType": "sharepointFolder",
{"name": "destPath", "type": "str", "required": True, "frontendType": "sharepointFolder",
"frontendOptions": {"dependsOn": "connectionReference"},
"description": t("Zielordner")},
],

View file

@ -46,7 +46,7 @@ TRIGGER_NODES = [
"parameters": [
{
"name": "cron",
"type": "string",
"type": "str",
"required": False,
"frontendType": "cron",
"description": t("Cron-Ausdruck"),

View file

@ -25,11 +25,11 @@ TRUSTEE_NODES = [
"description": t("Buchhaltungsdaten aus externem System importieren/aktualisieren."),
"parameters": [
dict(_TRUSTEE_INSTANCE_PARAM),
{"name": "forceRefresh", "type": "boolean", "required": False, "frontendType": "checkbox",
{"name": "forceRefresh", "type": "bool", "required": False, "frontendType": "checkbox",
"description": t("Import erzwingen"), "default": False},
{"name": "dateFrom", "type": "string", "required": False, "frontendType": "date",
{"name": "dateFrom", "type": "str", "required": False, "frontendType": "date",
"description": t("Startdatum"), "default": ""},
{"name": "dateTo", "type": "string", "required": False, "frontendType": "date",
{"name": "dateTo", "type": "str", "required": False, "frontendType": "date",
"description": t("Enddatum"), "default": ""},
],
"inputs": 1,
@ -46,14 +46,14 @@ TRUSTEE_NODES = [
"label": t("Dokumente extrahieren"),
"description": t("Dokumenttyp und Daten aus PDF/JPG per AI extrahieren."),
"parameters": [
{"name": "connectionReference", "type": "string", "required": False, "frontendType": "userConnection",
{"name": "connectionReference", "type": "str", "required": False, "frontendType": "userConnection",
"frontendOptions": {"authority": "msft"},
"description": t("SharePoint-Verbindung"), "default": ""},
{"name": "sharepointFolder", "type": "string", "required": False, "frontendType": "sharepointFolder",
{"name": "sharepointFolder", "type": "str", "required": False, "frontendType": "sharepointFolder",
"frontendOptions": {"dependsOn": "connectionReference"},
"description": t("SharePoint-Ordnerpfad"), "default": ""},
dict(_TRUSTEE_INSTANCE_PARAM),
{"name": "prompt", "type": "string", "required": False, "frontendType": "textarea",
{"name": "prompt", "type": "str", "required": False, "frontendType": "textarea",
"description": t("AI-Prompt für Extraktion"), "default": ""},
],
"inputs": 1,
@ -77,7 +77,7 @@ TRUSTEE_NODES = [
# is List[ActionDocument] (see datamodelChat.ActionResult). The
# DataPicker uses this string to filter compatible upstream paths.
{"name": "documentList", "type": "List[ActionDocument]", "required": True, "frontendType": "dataRef",
"description": t("Dokumentenliste — gebunden via DataRef.")},
"description": t("Dokumente aus vorherigen Schritten")},
dict(_TRUSTEE_INSTANCE_PARAM),
],
"inputs": 1,
@ -95,7 +95,7 @@ TRUSTEE_NODES = [
"description": t("Trustee-Positionen in Buchhaltungssystem übertragen."),
"parameters": [
{"name": "documentList", "type": "List[ActionDocument]", "required": True, "frontendType": "dataRef",
"description": t("Verarbeitete Dokumentenliste — gebunden via DataRef.")},
"description": t("Dokumente aus vorherigen Schritten")},
dict(_TRUSTEE_INSTANCE_PARAM),
],
"inputs": 1,
@ -113,25 +113,25 @@ TRUSTEE_NODES = [
"description": t("Daten aus der Trustee-DB lesen (Lookup, Aggregation, Roh-Export). Pendant zu refreshAccountingData ohne externen Sync."),
"parameters": [
dict(_TRUSTEE_INSTANCE_PARAM),
{"name": "mode", "type": "string", "required": True, "frontendType": "select",
{"name": "mode", "type": "str", "required": True, "frontendType": "select",
"frontendOptions": {"options": ["lookup", "raw", "aggregate"]},
"description": t("Abfragemodus"), "default": "lookup"},
{"name": "entity", "type": "string", "required": True, "frontendType": "select",
{"name": "entity", "type": "str", "required": True, "frontendType": "select",
"frontendOptions": {"options": ["tenantWithRent", "contact", "journalLines", "accounts", "balances"]},
"description": t("Entität, die gelesen werden soll"), "default": "tenantWithRent"},
{"name": "tenantNameRef", "type": "string", "required": False, "frontendType": "text",
{"name": "tenantNameRef", "type": "str", "required": False, "frontendType": "text",
"frontendOptions": {"dependsOn": "entity", "showWhen": ["tenantWithRent", "contact"]},
"description": t("Mietername (oder {{wire.feld}} aus Upstream)"), "default": ""},
{"name": "tenantAddressRef", "type": "string", "required": False, "frontendType": "text",
{"name": "tenantAddressRef", "type": "str", "required": False, "frontendType": "text",
"frontendOptions": {"dependsOn": "entity", "showWhen": ["tenantWithRent", "contact"]},
"description": t("Mieteradresse (Toleranz für Tippfehler)"), "default": ""},
{"name": "period", "type": "string", "required": False, "frontendType": "text",
{"name": "period", "type": "str", "required": False, "frontendType": "text",
"frontendOptions": {"dependsOn": "entity", "showWhen": ["tenantWithRent", "journalLines", "balances"]},
"description": t("Zeitraum (YYYY oder YYYY-MM-DD/YYYY-MM-DD)"), "default": ""},
{"name": "rentAccountPattern", "type": "string", "required": False, "frontendType": "text",
{"name": "rentAccountPattern", "type": "str", "required": False, "frontendType": "text",
"frontendOptions": {"dependsOn": "entity", "showWhen": ["tenantWithRent"]},
"description": t("Konto-Filter für Mietzins (z.B. '6000-6099' oder '6*')"), "default": ""},
{"name": "filterJson", "type": "string", "required": False, "frontendType": "textarea",
{"name": "filterJson", "type": "str", "required": False, "frontendType": "textarea",
"frontendOptions": {"dependsOn": "mode", "showWhen": ["raw", "aggregate"]},
"description": t("Optionaler JSON-Filter für mode=raw/aggregate"), "default": ""},
],

View file

@ -9,6 +9,7 @@ import logging
from typing import Dict, List, Any, Optional
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
from modules.features.graphicalEditor.nodeDefinitions.input import FORM_FIELD_TYPES
from modules.features.graphicalEditor.nodeAdapter import bindsActionFromLegacy
from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG, SYSTEM_VARIABLES
from modules.shared.i18nRegistry import normalizePrimaryLanguageTag, resolveText
@ -119,6 +120,7 @@ def getNodeTypesForApi(
"categories": categories,
"portTypeCatalog": catalogSerialized,
"systemVariables": SYSTEM_VARIABLES,
"formFieldTypes": FORM_FIELD_TYPES,
}

View file

@ -34,6 +34,8 @@ class PortField(BaseModel):
# FeatureInstanceRef.featureCode). Pickers/validators use it to filter compatible
# producers by sub-type. Type must be "str" when discriminator is True.
discriminator: bool = False
# Surfaces this field at the top of the DataPicker list as the most common pick.
recommended: bool = False
class PortSchema(BaseModel):
@ -153,7 +155,7 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
]),
"DocumentList": PortSchema(name="DocumentList", fields=[
PortField(name="documents", type="List[Document]",
description="Dokumentenliste"),
description="Dokumente aus vorherigen Schritten", recommended=True),
PortField(name="connection", type="ConnectionRef", required=False,
description="Verbindung, mit der die Liste erzeugt wurde"),
PortField(name="source", type="SharePointFolderRef", required=False,
@ -219,9 +221,9 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
PortField(name="prompt", type="str",
description="Prompt"),
PortField(name="response", type="str",
description="Antworttext"),
description="Antworttext", recommended=True),
PortField(name="responseData", type="Dict", required=False,
description="Strukturierte Antwort"),
description="Strukturierte Antwort (nur bei JSON-Ausgabe)"),
PortField(name="context", type="str",
description="Kontext"),
PortField(name="documents", type="List[Document]",
@ -642,6 +644,69 @@ def resolveSystemVariable(variable: str, context: Dict[str, Any]) -> Any:
# Output normalizers
# ---------------------------------------------------------------------------
def _file_record_to_document(f: Any) -> Optional[Dict[str, Any]]:
"""Map API / task-upload file dicts onto PortSchema ``Document`` fields."""
if f is None:
return None
if isinstance(f, str) and f.strip():
return {"id": f.strip()}
if not isinstance(f, dict):
return None
inner = f.get("file") if isinstance(f.get("file"), dict) else None
src = inner or f
out: Dict[str, Any] = {}
fid = src.get("id") or f.get("id")
if fid is not None and str(fid).strip():
out["id"] = str(fid).strip()
name = (
src.get("name")
or src.get("fileName")
or f.get("fileName")
or f.get("name")
)
if name is not None and str(name).strip():
out["name"] = str(name).strip()
mime = src.get("mimeType") or src.get("mime") or f.get("mimeType")
if mime is not None and str(mime).strip():
out["mimeType"] = str(mime).strip()
for k in ("sizeBytes", "downloadUrl", "filePath"):
v = src.get(k) if k in src else f.get(k)
if v is not None and v != "":
out[k] = v
return out if out else None
def _coerce_document_list_upload_fields(result: Dict[str, Any]) -> None:
"""
Human task ``input.upload`` completes with ``file`` / ``files`` / ``fileIds``.
DocumentList expects ``documents``. Without this, resume adds ``documents: []`` and drops the real files.
"""
docs = result.get("documents")
if isinstance(docs, list) and len(docs) > 0:
return
collected: List[Dict[str, Any]] = []
files = result.get("files")
if isinstance(files, list):
for item in files:
d = _file_record_to_document(item)
if d:
collected.append(d)
if not collected:
single = result.get("file")
d = _file_record_to_document(single)
if d:
collected.append(d)
if not collected and isinstance(result.get("fileIds"), list):
for fid in result["fileIds"]:
if fid is not None and str(fid).strip():
collected.append({"id": str(fid).strip()})
if not collected:
return
result["documents"] = collected
if not result.get("count"):
result["count"] = len(collected)
def normalizeToSchema(raw: Any, schemaName: str) -> Dict[str, Any]:
"""
Normalize raw executor output to match the declared port schema.
@ -658,8 +723,14 @@ def normalizeToSchema(raw: Any, schemaName: str) -> Dict[str, Any]:
if not schema or schemaName == "Transit":
return result
if schemaName == "DocumentList":
_coerce_document_list_upload_fields(result)
# Only default **required** fields. Optional fields stay absent so DataRefs / context
# resolution never pick a synthetic `{}` or `[]` (e.g. AiResult.responseData when the
# model returned plain text only).
for field in schema.fields:
if field.name not in result:
if field.name not in result and field.required:
result[field.name] = _defaultForType(field.type)
return result
@ -740,6 +811,9 @@ def _resolveTransitChain(
def deriveFormPayloadSchemaFromParam(node: Dict[str, Any], param_key: str) -> Optional[PortSchema]:
"""Derive output schema from a field-builder JSON list (``fields``, ``formFields``, …)."""
from modules.features.graphicalEditor.nodeDefinitions.input import FORM_FIELD_TYPES
_FORM_TYPE_TO_PORT: Dict[str, str] = {f["id"]: f["portType"] for f in FORM_FIELD_TYPES}
fields_param = (node.get("parameters") or {}).get(param_key)
if not fields_param or not isinstance(fields_param, list):
return None
@ -749,9 +823,11 @@ def deriveFormPayloadSchemaFromParam(node: Dict[str, Any], param_key: str) -> Op
_desc = resolveText(lab) if lab is not None else fname
if not str(_desc).strip():
_desc = fname
raw_type = str(ftype) if ftype is not None else "str"
port_type = _FORM_TYPE_TO_PORT.get(raw_type, raw_type)
portFields.append(PortField(
name=fname,
type=str(ftype) if ftype is not None else "str",
type=port_type,
description=_desc,
required=required,
))

View file

@ -223,6 +223,7 @@ class RedmineTicketMirror(PowerOnModel):
fixedVersionName: Optional[str] = Field(default=None, json_schema_extra={"label": "Zielversion", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
categoryId: Optional[int] = Field(default=None, json_schema_extra={"label": "Kategorie-ID", "frontend_type": "number", "frontend_readonly": True, "frontend_required": False})
categoryName: Optional[str] = Field(default=None, json_schema_extra={"label": "Kategorie", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
doneRatio: Optional[int] = Field(default=None, description="Redmine % done (0-100)", json_schema_extra={"label": "% erledigt", "frontend_type": "number", "frontend_readonly": True, "frontend_required": False})
closedOnTs: Optional[float] = Field(
default=None,
description="Best-effort UTC epoch when the ticket transitioned to a closed status. Approximated as updatedOnTs for closed tickets at sync time; used by Stats to render the open-vs-total snapshot chart.",
@ -338,6 +339,7 @@ class RedmineTicketDto(BaseModel):
fixedVersionName: Optional[str] = None
categoryId: Optional[int] = None
categoryName: Optional[str] = None
doneRatio: Optional[int] = None
createdOn: Optional[str] = None
updatedOn: Optional[str] = None
customFields: List[RedmineCustomFieldValueDto] = Field(default_factory=list)

View file

@ -222,6 +222,7 @@ def _mirroredRowToDto(
fixedVersionName=row.get("fixedVersionName"),
categoryId=row.get("categoryId"),
categoryName=row.get("categoryName"),
doneRatio=row.get("doneRatio"),
createdOn=row.get("createdOn"),
updatedOn=row.get("updatedOn"),
customFields=[

View file

@ -402,6 +402,7 @@ def _ticketRecordFromIssue(
"fixedVersionName": fixed_version.get("name"),
"categoryId": category.get("id"),
"categoryName": category.get("name"),
"doneRatio": issue.get("done_ratio"),
"createdOn": created_on,
"updatedOn": updated_on,
"createdOnTs": _parseRedmineDateToEpoch(created_on),

View file

@ -79,15 +79,47 @@ class TeamsbotTransferMode(str, Enum):
AUTO = "auto" # Automatic: anonymous → audio, authenticated → caption
class TeamsbotSeriesType(str, Enum):
"""Type of meeting series."""
WEEKLY = "weekly"
BIWEEKLY = "biweekly"
MONTHLY = "monthly"
ADHOC = "adhoc"
PROJECT = "project"
class TeamsbotModuleStatus(str, Enum):
"""Status of a meeting module."""
ACTIVE = "active"
ARCHIVED = "archived"
COMPLETED = "completed"
# ============================================================================
# Database Models (stored in PostgreSQL)
# ============================================================================
class TeamsbotMeetingModule(PowerOnModel):
"""A meeting module groups related sessions (e.g. 'Weekly Standup')."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Module ID")
instanceId: str = Field(description="Feature instance ID (FK)")
mandateId: str = Field(description="Mandate ID (FK)")
ownerUserId: str = Field(description="Owner user ID")
title: str = Field(description="Module title, e.g. 'Weekly Standup'")
seriesType: TeamsbotSeriesType = Field(default=TeamsbotSeriesType.ADHOC)
defaultBotId: Optional[str] = Field(default=None, description="FK to TeamsbotSystemBot")
defaultDirectorPrompts: Optional[str] = Field(default=None, description="JSON list of default director prompts")
goals: Optional[str] = Field(default=None, description="Free-text goals")
kpiTargets: Optional[str] = Field(default=None, description="JSON object with structured KPI targets")
status: TeamsbotModuleStatus = Field(default=TeamsbotModuleStatus.ACTIVE)
class TeamsbotSession(PowerOnModel):
"""A Teams Bot meeting session."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Session ID")
instanceId: str = Field(description="Feature instance ID (FK)")
mandateId: str = Field(description="Mandate ID (FK)")
moduleId: Optional[str] = Field(default=None, description="FK to TeamsbotMeetingModule (nullable during transition)")
meetingLink: str = Field(description="Teams meeting join link")
botName: str = Field(default="AI Assistant", description="Display name of the bot in the meeting")
status: TeamsbotSessionStatus = Field(default=TeamsbotSessionStatus.PENDING, description="Current session status")
@ -237,6 +269,27 @@ class TeamsbotSessionResponse(BaseModel):
botResponses: Optional[List[TeamsbotBotResponse]] = Field(default=None, description="Bot responses (if requested)")
class CreateMeetingModuleRequest(BaseModel):
"""Request to create a new meeting module."""
title: str
seriesType: Optional[TeamsbotSeriesType] = TeamsbotSeriesType.ADHOC
defaultBotId: Optional[str] = None
defaultDirectorPrompts: Optional[str] = None
goals: Optional[str] = None
kpiTargets: Optional[str] = None
class UpdateMeetingModuleRequest(BaseModel):
"""Request to update an existing meeting module."""
title: Optional[str] = None
seriesType: Optional[TeamsbotSeriesType] = None
defaultBotId: Optional[str] = None
defaultDirectorPrompts: Optional[str] = None
goals: Optional[str] = None
kpiTargets: Optional[str] = None
status: Optional[TeamsbotModuleStatus] = None
class TeamsbotConfigUpdateRequest(BaseModel):
"""Request to update teamsbot configuration."""
botName: Optional[str] = None
@ -339,7 +392,7 @@ class BridgeJoinRequest(BaseModel):
gatewayCallbackUrl: str = Field(description="Gateway URL for bridge callbacks")
gatewayWsUrl: str = Field(description="Gateway WebSocket URL for audio streaming")
sessionId: str = Field(description="Session ID for correlation")
gatewayBaseUrl: str = Field(description="Base URL of this gateway instance (e.g. https://gateway-prod.poweron-center.net)")
gatewayBaseUrl: str = Field(description="Base URL of this gateway instance (e.g. https://gateway-prod.poweron.swiss)")
class BridgeStatusResponse(BaseModel):

View file

@ -24,6 +24,7 @@ from .datamodelTeamsbot import (
TeamsbotDirectorPrompt,
TeamsbotDirectorPromptStatus,
TeamsbotDirectorPromptMode,
TeamsbotMeetingModule,
)
logger = logging.getLogger(__name__)
@ -330,6 +331,36 @@ class TeamsbotObjects:
count += 1
return count
# =========================================================================
# Meeting Modules
# =========================================================================
def getModules(self, instanceId: str) -> List[Dict[str, Any]]:
"""Get all meeting modules for a feature instance."""
records = self.db.getRecordset(TeamsbotMeetingModule, recordFilter={"instanceId": instanceId})
records.sort(key=lambda r: r.get("sysCreatedAt") or "", reverse=True)
return records
def getModule(self, moduleId: str) -> Optional[Dict[str, Any]]:
"""Get a single meeting module by ID."""
records = self.db.getRecordset(TeamsbotMeetingModule, recordFilter={"id": moduleId})
return records[0] if records else None
def createModule(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Create a new meeting module."""
return self.db.recordCreate(TeamsbotMeetingModule, data)
def updateModule(self, moduleId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Update an existing meeting module."""
return self.db.recordModify(TeamsbotMeetingModule, moduleId, updates)
def deleteModule(self, moduleId: str) -> bool:
"""Delete a meeting module. Unlinks all sessions first."""
sessions = self.db.getRecordset(TeamsbotSession, recordFilter={"moduleId": moduleId})
for session in sessions:
self.db.recordModify(TeamsbotSession, session["id"], {"moduleId": None})
return self.db.recordDelete(TeamsbotMeetingModule, moduleId)
# =========================================================================
# Stats / Aggregation
# =========================================================================
@ -338,14 +369,23 @@ class TeamsbotObjects:
"""Get aggregated statistics for a session."""
transcripts = self.db.getRecordset(TeamsbotTranscript, recordFilter={"sessionId": sessionId})
responses = self.db.getRecordset(TeamsbotBotResponse, recordFilter={"sessionId": sessionId})
prompts = self.db.getRecordset(TeamsbotDirectorPrompt, recordFilter={"sessionId": sessionId})
totalCost = sum(r.get("priceCHF", 0) for r in responses)
totalProcessingTime = sum(r.get("processingTime", 0) for r in responses)
speakers = list(set(t.get("speaker") for t in transcripts if t.get("speaker")))
firstTimestamp = min((t.get("timestamp") or 0 for t in transcripts), default=0)
lastTimestamp = max((t.get("timestamp") or 0 for t in transcripts), default=0)
durationSeconds = round(lastTimestamp - firstTimestamp, 1) if firstTimestamp and lastTimestamp else 0
return {
"transcriptSegments": len(transcripts),
"botResponses": len(responses),
"directorPrompts": len(prompts),
"totalCostCHF": round(totalCost, 4),
"totalProcessingTime": round(totalProcessingTime, 2),
"speakers": list(set(t.get("speaker") for t in transcripts if t.get("speaker"))),
"speakers": speakers,
"speakerCount": len(speakers),
"durationSeconds": durationSeconds,
}

View file

@ -24,6 +24,16 @@ UI_OBJECTS = [
"label": t("Dashboard", context="UI"),
"meta": {"area": "dashboard"}
},
{
"objectKey": "ui.feature.teamsbot.assistant",
"label": t("Assistent", context="UI"),
"meta": {"area": "assistant"}
},
{
"objectKey": "ui.feature.teamsbot.modules",
"label": t("Module", context="UI"),
"meta": {"area": "modules"}
},
{
"objectKey": "ui.feature.teamsbot.sessions",
"label": t("Sitzungen", context="UI"),
@ -38,13 +48,24 @@ UI_OBJECTS = [
# DATA Objects for RBAC catalog (tables/entities)
DATA_OBJECTS = [
{
"objectKey": "data.feature.teamsbot.TeamsbotMeetingModule",
"label": t("Meeting-Modul", context="UI"),
"meta": {
"table": "TeamsbotMeetingModule",
"fields": ["id", "title", "seriesType", "status", "ownerUserId"],
"isParent": True,
"displayFields": ["title", "seriesType", "status"],
}
},
{
"objectKey": "data.feature.teamsbot.TeamsbotSession",
"label": t("Sitzung", context="UI"),
"meta": {
"table": "TeamsbotSession",
"fields": ["id", "meetingLink", "botName", "status", "startedAt", "endedAt"],
"isParent": True,
"parentTable": "TeamsbotMeetingModule",
"parentKey": "moduleId",
"displayFields": ["botName", "status", "startedAt"],
}
},
@ -97,6 +118,16 @@ RESOURCE_OBJECTS = [
"label": t("Konfiguration bearbeiten", context="UI"),
"meta": {"endpoint": "/api/teamsbot/{instanceId}/config", "method": "PUT", "admin_only": True}
},
{
"objectKey": "resource.feature.teamsbot.module.create",
"label": t("Meeting-Modul erstellen", context="UI"),
"meta": {"endpoint": "/api/teamsbot/{instanceId}/modules", "method": "POST"}
},
{
"objectKey": "resource.feature.teamsbot.module.delete",
"label": t("Meeting-Modul loeschen", context="UI"),
"meta": {"endpoint": "/api/teamsbot/{instanceId}/modules/{moduleId}", "method": "DELETE"}
},
]
# Template roles for this feature with AccessRules
@ -114,6 +145,8 @@ TEMPLATE_ROLES = [
{"context": "RESOURCE", "item": "resource.feature.teamsbot.session.stop", "view": True},
{"context": "RESOURCE", "item": "resource.feature.teamsbot.session.delete", "view": True},
{"context": "RESOURCE", "item": "resource.feature.teamsbot.config.edit", "view": True},
{"context": "RESOURCE", "item": "resource.feature.teamsbot.module.create", "view": True},
{"context": "RESOURCE", "item": "resource.feature.teamsbot.module.delete", "view": True},
]
},
{
@ -121,6 +154,8 @@ TEMPLATE_ROLES = [
"description": "Teams Bot Betrachter - Sitzungen und Transkripte ansehen (nur lesen)",
"accessRules": [
{"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.teamsbot.assistant", "view": True},
{"context": "UI", "item": "ui.feature.teamsbot.modules", "view": True},
{"context": "UI", "item": "ui.feature.teamsbot.sessions", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
],
@ -130,12 +165,16 @@ TEMPLATE_ROLES = [
"description": "Teams Bot Benutzer - Kann Sitzungen starten/stoppen und Transkripte einsehen",
"accessRules": [
{"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.teamsbot.assistant", "view": True},
{"context": "UI", "item": "ui.feature.teamsbot.modules", "view": True},
{"context": "UI", "item": "ui.feature.teamsbot.sessions", "view": True},
{"context": "DATA", "item": "data.feature.teamsbot.TeamsbotMeetingModule", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
{"context": "DATA", "item": "data.feature.teamsbot.TeamsbotSession", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
{"context": "DATA", "item": "data.feature.teamsbot.TeamsbotTranscript", "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
{"context": "DATA", "item": "data.feature.teamsbot.TeamsbotBotResponse", "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
{"context": "RESOURCE", "item": "resource.feature.teamsbot.session.start", "view": True},
{"context": "RESOURCE", "item": "resource.feature.teamsbot.session.stop", "view": True},
{"context": "RESOURCE", "item": "resource.feature.teamsbot.module.create", "view": True},
],
},
]
@ -198,6 +237,7 @@ def registerFeature(catalogService) -> bool:
meta=dataObj.get("meta")
)
_runMigrations()
_syncTemplateRolesToDb()
logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI, {len(RESOURCE_OBJECTS)} resource, {len(DATA_OBJECTS)} data objects")
@ -208,6 +248,95 @@ def registerFeature(catalogService) -> bool:
return False
def _runMigrations():
"""Idempotent DB migrations for TeamsBot feature.
Runs on every bootstrap; each step checks preconditions before executing.
The TeamsbotMeetingModule table and TeamsbotSession.moduleId column are
auto-created by the DB connector from the Pydantic model. This migration
handles data backfill: creating default Adhoc modules for existing sessions.
"""
try:
from .interfaceFeatureTeamsbot import teamsbotDatabase
from .datamodelTeamsbot import TeamsbotMeetingModule, TeamsbotSession
from modules.shared.configuration import APP_CONFIG
import psycopg2
from psycopg2.extras import RealDictCursor
import uuid
conn = psycopg2.connect(
host=APP_CONFIG.get("DB_HOST", "localhost"),
database=teamsbotDatabase,
user=APP_CONFIG.get("DB_USER"),
password=APP_CONFIG.get("DB_PASSWORD_SECRET"),
port=int(APP_CONFIG.get("DB_PORT", 5432)),
cursor_factory=RealDictCursor,
)
conn.autocommit = False
cur = conn.cursor()
def _tableExists(name):
cur.execute(
"SELECT 1 FROM information_schema.tables WHERE LOWER(table_name) = LOWER(%s) AND table_schema = 'public'",
(name,),
)
return cur.fetchone() is not None
def _columnExists(table, column):
cur.execute(
"SELECT 1 FROM information_schema.columns WHERE LOWER(table_name) = LOWER(%s) AND LOWER(column_name) = LOWER(%s) AND table_schema = 'public'",
(table, column),
)
return cur.fetchone() is not None
migrated = False
# M1: Create default Adhoc modules for orphaned sessions
# (only runs if TeamsbotSession table exists with moduleId column
# and there are sessions without a moduleId)
if _tableExists("TeamsbotSession") and _columnExists("TeamsbotSession", "moduleId"):
cur.execute("""
SELECT DISTINCT "instanceId", "mandateId"
FROM "TeamsbotSession"
WHERE "moduleId" IS NULL AND "instanceId" IS NOT NULL
""")
orphanGroups = cur.fetchall()
for group in orphanGroups:
instId = group["instanceId"]
mandId = group["mandateId"]
if not instId:
continue
adhocId = str(uuid.uuid4())
import time as _time
now = _time.time()
cur.execute("""
INSERT INTO "TeamsbotMeetingModule" (id, "instanceId", "mandateId", "ownerUserId", title, "seriesType", status, "sysCreatedAt")
VALUES (%s, %s, %s, 'system', 'Adhoc', 'adhoc', 'active', %s)
""", (adhocId, instId, mandId, now))
cur.execute("""
UPDATE "TeamsbotSession"
SET "moduleId" = %s
WHERE "instanceId" = %s AND "moduleId" IS NULL
""", (adhocId, instId))
sessionCount = cur.rowcount
logger.info(f"Migration M1: Created Adhoc module for instanceId={instId}, assigned {sessionCount} sessions")
migrated = True
if migrated:
conn.commit()
logger.info("TeamsBot DB migrations committed")
else:
conn.rollback()
cur.close()
conn.close()
except ImportError:
logger.debug("psycopg2 not available, skipping TeamsBot DB migrations")
except Exception as e:
logger.warning(f"TeamsBot DB migration failed (non-fatal): {e}")
def _syncTemplateRolesToDb() -> int:
"""Sync template roles and their AccessRules to the database."""
try:

View file

@ -39,6 +39,9 @@ from .datamodelTeamsbot import (
TeamsbotDirectorPromptCreateRequest,
TeamsbotDirectorPromptMode,
TeamsbotDirectorPromptStatus,
TeamsbotMeetingModule,
CreateMeetingModuleRequest,
UpdateMeetingModuleRequest,
DIRECTOR_PROMPT_FILE_LIMIT,
DIRECTOR_PROMPT_TEXT_LIMIT,
)
@ -167,6 +170,100 @@ def _getInstanceConfig(instanceId: str) -> TeamsbotConfig:
return TeamsbotConfig()
# =========================================================================
# Meeting Module Endpoints
# =========================================================================
@router.get("/{instanceId}/modules")
@limiter.limit("60/minute")
async def listModules(
request: Request,
instanceId: str,
context: RequestContext = Depends(getRequestContext),
):
"""List all meeting modules for a feature instance."""
_validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
modules = interface.getModules(instanceId)
return {"modules": modules}
@router.post("/{instanceId}/modules")
@limiter.limit("30/minute")
async def createModule(
request: Request,
instanceId: str,
body: CreateMeetingModuleRequest,
context: RequestContext = Depends(getRequestContext),
):
"""Create a new meeting module."""
interface = _getInterface(context, instanceId)
mandateId = _validateInstanceAccess(instanceId, context)
data = body.model_dump(exclude_none=True)
data["instanceId"] = instanceId
data["mandateId"] = mandateId
data["ownerUserId"] = str(context.user.id)
module = interface.createModule(data)
return {"module": module}
@router.get("/{instanceId}/modules/{moduleId}")
@limiter.limit("60/minute")
async def getModuleDetail(
request: Request,
instanceId: str,
moduleId: str,
context: RequestContext = Depends(getRequestContext),
):
"""Get a single module with its sessions."""
_validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
module = interface.getModule(moduleId)
if not module:
raise HTTPException(status_code=404, detail="Module not found")
sessions = interface.getSessions(instanceId)
moduleSessions = [s for s in sessions if s.get("moduleId") == moduleId]
return {"module": module, "sessions": moduleSessions}
@router.put("/{instanceId}/modules/{moduleId}")
@limiter.limit("30/minute")
async def updateModule(
request: Request,
instanceId: str,
moduleId: str,
body: UpdateMeetingModuleRequest,
context: RequestContext = Depends(getRequestContext),
):
"""Update an existing meeting module."""
_validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
module = interface.getModule(moduleId)
if not module:
raise HTTPException(status_code=404, detail="Module not found")
updates = body.model_dump(exclude_none=True)
updated = interface.updateModule(moduleId, updates)
return {"module": updated}
@router.delete("/{instanceId}/modules/{moduleId}")
@limiter.limit("10/minute")
async def deleteModule(
request: Request,
instanceId: str,
moduleId: str,
context: RequestContext = Depends(getRequestContext),
):
"""Delete a meeting module and unlink its sessions."""
_validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
module = interface.getModule(moduleId)
if not module:
raise HTTPException(status_code=404, detail="Module not found")
interface.deleteModule(moduleId)
return {"success": True}
# =========================================================================
# Session Endpoints
# =========================================================================
@ -385,8 +482,9 @@ async def streamSession(
"""Generate SSE events from the session event queue."""
from .service import sessionEvents
# Send initial session state
yield f"data: {json.dumps({'type': 'sessionState', 'data': session})}\n\n"
# Send initial session state with stats
stats = interface.getSessionStats(sessionId)
yield f"data: {json.dumps({'type': 'sessionState', 'data': session, 'stats': stats})}\n\n"
# Send current bot WebSocket connection state so the operator UI can
# render the live indicator without waiting for the next connect/disconnect.

View file

@ -3409,6 +3409,8 @@ class TeamsbotService:
"status": "toolCall",
"toolName": toolName,
})
elif event.type == AgentEventTypeEnum.FILE_CREATED:
await _emitSessionEvent(sessionId, "documentCreated", event.data or {})
elif event.type == AgentEventTypeEnum.FINAL:
finalText = (event.content or "").strip()
elif event.type == AgentEventTypeEnum.ERROR:

View file

@ -4028,58 +4028,92 @@ class AppObjects:
raise
# -------------------------------------------------------------------------
# Table Grouping (user-defined groups for FormGeneratorTable instances)
# Table List Views (saved display presets: filters, sort, groupByLevels)
# -------------------------------------------------------------------------
def getTableGrouping(self, contextKey: str):
"""
Load the group tree for the current user and the given contextKey.
Returns a TableGrouping instance or None if no grouping has been saved yet.
contextKey identifies the table instance, e.g. "connections", "prompts",
"admin/users", "trustee/{instanceId}/documents".
"""
from modules.datamodels.datamodelPagination import TableGrouping
def getTableListViews(self, contextKey: str) -> list:
"""Return all saved views for the current user and contextKey."""
from modules.datamodels.datamodelPagination import TableListView
try:
records = self.db.getRecordset(
TableGrouping,
rows = self.db.getRecordset(
TableListView,
recordFilter={"userId": str(self.userId), "contextKey": contextKey},
)
if not records:
return None
row = records[0]
return TableGrouping.model_validate(row) if isinstance(row, dict) else row
result = []
for row in (rows or []):
try:
result.append(TableListView.model_validate(row) if isinstance(row, dict) else row)
except Exception:
pass
return result
except Exception as e:
logger.error(f"getTableGrouping failed for user={self.userId} key={contextKey}: {e}")
logger.error(f"getTableListViews failed for user={self.userId} context={contextKey}: {e}")
return []
def getTableListView(self, contextKey: str, viewKey: str):
"""Return one view by viewKey or None if not found."""
from modules.datamodels.datamodelPagination import TableListView
try:
rows = self.db.getRecordset(
TableListView,
recordFilter={"userId": str(self.userId), "contextKey": contextKey, "viewKey": viewKey},
)
if not rows:
return None
row = rows[0]
return TableListView.model_validate(row) if isinstance(row, dict) else row
except Exception as e:
logger.error(f"getTableListView failed for user={self.userId} key={viewKey}: {e}")
return None
def upsertTableGrouping(self, contextKey: str, rootGroups: list):
"""
Create or replace the group tree for the current user and contextKey.
def createTableListView(self, contextKey: str, viewKey: str, displayName: str, config: dict):
"""Create a new view. Raises ValueError if viewKey already exists for this context."""
from modules.datamodels.datamodelPagination import TableListView
from modules.shared.timeUtils import getUtcTimestamp
if self.getTableListView(contextKey=contextKey, viewKey=viewKey) is not None:
raise ValueError(f"View '{viewKey}' already exists for context '{contextKey}'")
data = {
"id": str(uuid.uuid4()),
"userId": str(self.userId),
"contextKey": contextKey,
"viewKey": viewKey,
"displayName": displayName,
"config": config,
"updatedAt": getUtcTimestamp(),
}
try:
self.db.recordCreate(TableListView, data)
return TableListView.model_validate(data)
except Exception as e:
logger.error(f"createTableListView failed: {e}")
raise
rootGroups is a list of TableGroupNode-compatible dicts (the full tree).
Returns the saved TableGrouping instance.
"""
from modules.datamodels.datamodelPagination import TableGrouping
def updateTableListView(self, viewId: str, updates: dict):
"""Update an existing view by its primary key id."""
from modules.datamodels.datamodelPagination import TableListView
from modules.shared.timeUtils import getUtcTimestamp
try:
existing = self.getTableGrouping(contextKey)
data = {
"id": existing.id if existing else str(uuid.uuid4()),
"userId": str(self.userId),
"contextKey": contextKey,
"rootGroups": rootGroups,
"updatedAt": getUtcTimestamp(),
}
if existing:
self.db.recordModify(TableGrouping, existing.id, data)
else:
self.db.recordCreate(TableGrouping, data)
return TableGrouping.model_validate(data)
updates = {**updates, "updatedAt": getUtcTimestamp()}
self.db.recordModify(TableListView, viewId, updates)
rows = self.db.getRecordset(TableListView, recordFilter={"id": viewId})
if rows:
row = rows[0]
return TableListView.model_validate(row) if isinstance(row, dict) else row
return None
except Exception as e:
logger.error(f"upsertTableGrouping failed for user={self.userId} key={contextKey}: {e}")
logger.error(f"updateTableListView failed for id={viewId}: {e}")
raise
def deleteTableListView(self, viewId: str) -> bool:
"""Delete a view by primary key id. Returns True on success."""
from modules.datamodels.datamodelPagination import TableListView
try:
self.db.recordDelete(TableListView, viewId)
return True
except Exception as e:
logger.error(f"deleteTableListView failed for id={viewId}: {e}")
return False
# Public Methods

View file

@ -908,18 +908,22 @@ class BillingObjects:
)
def reconcileMandateStorageBilling(self, mandateId: str) -> Optional[Dict[str, Any]]:
"""Debit prepay pool for new storage overage using period high-watermark (no credit on delete)."""
"""Debit prepay pool for new storage overage using period high-watermark (no credit on delete).
Skipped for enterprise subscriptions (hard-block via assertCapacity instead)."""
settings = self.getSettings(mandateId)
if not settings:
return None
from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot
from modules.datamodels.datamodelSubscription import getPlan
from modules.datamodels.datamodelSubscription import getPlan, getEffectiveLimits
subIface = _getSubRoot()
usedMB = float(subIface.getMandateDataVolumeMB(mandateId))
sub = subIface.getOperativeForMandate(mandateId)
if sub and sub.get("isEnterprise"):
return None
plan = getPlan(sub.get("planKey", "")) if sub else None
includedMB = plan.maxDataVolumeMB if plan and plan.maxDataVolumeMB is not None else None
limits = getEffectiveLimits(sub, plan) if sub else {}
includedMB = limits.get("maxDataVolumeMB")
if includedMB is None:
return None
@ -966,27 +970,37 @@ class BillingObjects:
# Subscription AI-Budget Credit
# =========================================================================
def creditSubscriptionBudget(self, mandateId: str, planKey: str, periodLabel: str = "") -> Optional[Dict[str, Any]]:
def creditSubscriptionBudget(
self, mandateId: str, planKey: str, periodLabel: str = "",
enterpriseBudgetOverride: Optional[float] = None,
) -> Optional[Dict[str, Any]]:
"""Credit AI budget to the mandate pool account.
Amount = budgetAiPerUserCHF * activeUsers (dynamic, not the static plan.budgetAiCHF).
For standard plans: amount = budgetAiPerUserCHF * activeUsers.
For enterprise: uses the fixed ``enterpriseBudgetOverride`` amount.
Should be called once per billing period (initial activation + each invoice.paid).
Returns the created CREDIT transaction or None if budget is 0."""
from modules.datamodels.datamodelSubscription import getPlan
if enterpriseBudgetOverride is not None and enterpriseBudgetOverride > 0:
amount = enterpriseBudgetOverride
description = f"AI-Budget Enterprise ({planKey})"
if periodLabel:
description += f" {periodLabel}"
else:
from modules.datamodels.datamodelSubscription import getPlan
plan = getPlan(planKey)
if not plan or not plan.budgetAiPerUserCHF or plan.budgetAiPerUserCHF <= 0:
return None
plan = getPlan(planKey)
if not plan or not plan.budgetAiPerUserCHF or plan.budgetAiPerUserCHF <= 0:
return None
from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot
subRoot = _getSubRoot()
activeUsers = max(subRoot.countActiveUsers(mandateId), 1)
amount = plan.budgetAiPerUserCHF * activeUsers
from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot
subRoot = _getSubRoot()
activeUsers = max(subRoot.countActiveUsers(mandateId), 1)
amount = plan.budgetAiPerUserCHF * activeUsers
description = f"AI-Budget ({planKey}, {activeUsers} User)"
if periodLabel:
description += f" {periodLabel}"
poolAccount = self.getOrCreateMandateAccount(mandateId)
description = f"AI-Budget ({planKey}, {activeUsers} User)"
if periodLabel:
description += f" {periodLabel}"
transaction = BillingTransaction(
accountId=poolAccount["id"],
@ -998,8 +1012,8 @@ class BillingObjects:
)
created = self.createTransaction(transaction)
logger.info(
"AI-Budget credited mandate=%s plan=%s users=%d amount=%.2f CHF",
mandateId, planKey, activeUsers, amount,
"AI-Budget credited mandate=%s plan=%s amount=%.2f CHF",
mandateId, planKey, amount,
)
return created
@ -1027,7 +1041,8 @@ class BillingObjects:
delta > 0: user added -> CREDIT pro-rata portion
delta < 0: user removed -> DEBIT pro-rata portion
"""
Skipped for enterprise subscriptions (fixed budget, no pro-rata)."""
from modules.datamodels.datamodelSubscription import getPlan
plan = getPlan(planKey)
@ -1039,6 +1054,8 @@ class BillingObjects:
operative = subRoot.getOperativeForMandate(mandateId)
if not operative:
return None
if operative.get("isEnterprise"):
return None
periodStart = operative.get("currentPeriodStart")
periodEnd = operative.get("currentPeriodEnd")

View file

@ -37,8 +37,6 @@ logger = logging.getLogger(__name__)
managementDatabase = "poweron_management"
registerDatabase(managementDatabase)
# Singleton factory for Management instances with AI service per context
_instancesManagement = {}
# Custom exceptions for file handling
class FileError(Exception):
@ -114,16 +112,22 @@ class ComponentObjects:
# Update database context
self.db.updateContext(self.userId)
def _effective_user_id(self) -> Optional[str]:
"""User id for audit + FileData writes; singleton hub may unset userId but keep currentUser."""
if self.userId:
return self.userId
if self.currentUser is not None:
return getattr(self.currentUser, "id", None)
return None
def __del__(self):
"""Cleanup method to close database connection."""
"""Release the database connector reference (shared connectors stay open)."""
if hasattr(self, 'db') and self.db is not None:
try:
self.db.close()
except Exception as e:
logger.error(f"Error closing database connection: {e}")
logger.debug(f"User context set: userId={self.userId}")
except Exception:
pass
def _initializeDatabase(self):
"""Initializes the database connection directly."""
@ -1379,10 +1383,31 @@ class ComponentObjects:
fileSize=fileSize,
fileHash=fileHash,
)
# Ensure audit user is always stored: workflow/singleton contexts sometimes leave
# the connector without _current_user_id, so _saveRecord skips sysCreatedBy →
# getFile/createFileData RBAC then breaks (None != self.userId).
uid = self._effective_user_id()
if uid:
fileItem = fileItem.model_copy(update={"sysCreatedBy": str(uid)})
# Store in database
self.db.recordCreate(FileItem, fileItem)
verify = self.db.getRecordset(FileItem, recordFilter={"id": fileItem.id})
verify_creator = (verify[0].get("sysCreatedBy") if verify else None)
logger.info(
"createFile: id=%s name=%s scope=%s model_sysCreatedBy=%r db_sysCreatedBy=%r mandateId=%r featureInstanceId=%r "
"verify_rows=%s db=%s",
fileItem.id,
uniqueName,
fileItem.scope,
getattr(fileItem, "sysCreatedBy", None),
verify_creator,
mandateId or None,
featureInstanceId if featureInstanceId else None,
len(verify) if verify else 0,
getattr(self.db, "dbDatabase", "?"),
)
return fileItem
def _isFileOwner(self, file) -> bool:
@ -1503,44 +1528,8 @@ class ComponentObjects:
raise FileDeletionError(f"Error deleting files in batch: {str(e)}")
def _ensureFeatureInstanceGroup(self, featureInstanceId: str, contextKey: str = "files/list") -> Optional[str]:
"""Return the groupId of the default group for a feature instance.
Creates the group if it doesn't exist yet."""
try:
import modules.interfaces.interfaceDbApp as _appIface
appInterface = _appIface.getInterface(self._currentUser)
existing = appInterface.getTableGrouping(contextKey)
nodes = [n.model_dump() if hasattr(n, 'model_dump') else (n if isinstance(n, dict) else vars(n)) for n in (existing.rootGroups if existing else [])]
# Look for group with name matching featureInstanceId
def _find(nds):
for nd in nds:
nid = nd.get("id") if isinstance(nd, dict) else getattr(nd, "id", None)
nmeta = nd.get("meta", {}) if isinstance(nd, dict) else getattr(nd, "meta", {})
if (nmeta or {}).get("featureInstanceId") == featureInstanceId:
return nid
subs = nd.get("subGroups", []) if isinstance(nd, dict) else getattr(nd, "subGroups", [])
result = _find(subs)
if result:
return result
return None
found = _find(nodes)
if found:
return found
# Create new group
import uuid
newId = str(uuid.uuid4())
newGroup = {
"id": newId,
"name": featureInstanceId,
"itemIds": [],
"subGroups": [],
"meta": {"featureInstanceId": featureInstanceId},
}
nodes.append(newGroup)
appInterface.upsertTableGrouping(contextKey, nodes)
return newId
except Exception as e:
logger.error(f"_ensureFeatureInstanceGroup failed: {e}")
return None
"""Stub — file group tree removed. Returns None."""
return None
def copyFile(self, sourceFileId: str, newFileName: Optional[str] = None) -> FileItem:
"""Create a full duplicate of a file (FileItem + FileData)."""
@ -1579,14 +1568,134 @@ class ComponentObjects:
return success
# FileData methods - data operations
def _getFileItemForDataWrite(self, fileId: str) -> Optional[FileItem]:
"""Resolve FileItem for storing FileData: RBAC-aware getFile, then same-user row fallback.
createFile() can insert a row that getFile() still hides (e.g. scope NULL vs GROUP rules,
or connector / context edge cases). The creator must still be allowed to attach blob data.
"""
logger.info(
"[FileData] resolve start fileId=%s iface_userId=%r effective_uid=%r mandateId=%r featureInstanceId=%r db=%s",
fileId,
self.userId,
self._effective_user_id(),
self.mandateId,
self.featureInstanceId,
getattr(self.db, "dbDatabase", "?"),
)
file = self.getFile(fileId)
if file:
logger.info("[FileData] getFile OK fileId=%s", fileId)
return file
uid = self._effective_user_id()
if not uid:
logger.error(
"[FileData] FAIL no user id fileId=%s userId=%r hasCurrentUser=%s",
fileId,
self.userId,
self.currentUser is not None,
)
return None
uid_s = str(uid)
rows = self.db.getRecordset(FileItem, recordFilter={"id": fileId})
if not rows:
logger.error(
"[FileData] FAIL no FileItem row fileId=%s (createFile committed to same db? db=%s)",
fileId,
getattr(self.db, "dbDatabase", "?"),
)
return None
row = dict(rows[0])
creator = row.get("sysCreatedBy")
creator_s = str(creator) if creator is not None else None
if creator_s != uid_s:
if not creator_s:
try:
self.db.recordModify(FileItem, fileId, {"sysCreatedBy": uid_s})
row["sysCreatedBy"] = uid_s
logger.warning(
"[FileData] patched NULL sysCreatedBy fileId=%s -> %s",
fileId,
uid_s,
)
except Exception as e:
logger.error(
"[FileData] FAIL patch sysCreatedBy fileId=%s: %s",
fileId,
e,
exc_info=True,
)
return None
else:
# _saveRecord used to overwrite explicit creators with contextvar "system"
if creator_s == "system":
try:
self.db.recordModify(FileItem, fileId, {"sysCreatedBy": uid_s})
row["sysCreatedBy"] = uid_s
logger.warning(
"[FileData] patched sysCreatedBy system→user fileId=%s -> %s",
fileId,
uid_s,
)
except Exception as e:
logger.error(
"[FileData] FAIL patch system sysCreatedBy fileId=%s: %s",
fileId,
e,
exc_info=True,
)
return None
else:
logger.error(
"[FileData] FAIL creator mismatch fileId=%s row.sysCreatedBy=%r (%s) effective_uid=%r (%s) scope=%r",
fileId,
creator,
type(creator).__name__,
uid,
type(uid).__name__,
row.get("scope"),
)
return None
logger.info(
"[FileData] RBAC miss, owner fallback OK fileId=%s scope=%r sysCreatedBy=%r",
fileId,
row.get("scope"),
row.get("sysCreatedBy"),
)
try:
if row.get("sysCreatedAt") is None or row.get("sysCreatedAt") in (0, 0.0):
row["sysCreatedAt"] = getUtcTimestamp()
if row.get("scope") is None:
row["scope"] = "personal"
if row.get("neutralize") is None:
row["neutralize"] = False
return FileItem(**row)
except Exception as e:
logger.error(
"[FileData] FAIL FileItem(**row) fileId=%s keys=%s err=%s",
fileId,
list(row.keys()),
e,
exc_info=True,
)
return None
def createFileData(self, fileId: str, data: bytes) -> bool:
"""Stores the binary data of a file in the database."""
try:
logger.info(
"[FileData] createFileData enter fileId=%s bytes=%s",
fileId,
len(data) if data is not None else 0,
)
# Check file access
file = self.getFile(fileId)
file = self._getFileItemForDataWrite(fileId)
if not file:
logger.error(f"File with ID {fileId} not found when storing data")
logger.error(
"[FileData] FAIL _getFileItemForDataWrite returned None fileId=%s",
fileId,
)
return False
# Determine if this is a text-based format
@ -1630,13 +1739,11 @@ class ComponentObjects:
}
self.db.recordCreate(FileData, fileDataObj)
# Clear cache to ensure fresh data
logger.debug(f"Successfully stored data for file {fileId} (base64Encoded: {base64Encoded})")
logger.info("[FileData] recordCreate OK fileId=%s base64Encoded=%s", fileId, base64Encoded)
return True
except Exception as e:
logger.error(f"Error storing data for file {fileId}: {str(e)}")
logger.error("Error storing data for file %s: %s", fileId, e, exc_info=True)
return False
def getFileData(self, fileId: str) -> Optional[bytes]:
@ -2162,10 +2269,11 @@ class ComponentObjects:
def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> 'ComponentObjects':
"""
Returns a ComponentObjects instance.
If currentUser is provided, initializes with user context.
Otherwise, returns an instance with only database access.
Returns a ComponentObjects instance scoped to the given user/mandate/featureInstance.
Each call creates a lightweight instance whose DB connector is already
cached inside ``getCachedConnector``, so the overhead is minimal.
Args:
currentUser: The authenticated user
mandateId: The mandate ID from RequestContext (X-Mandate-Id header). Required.
@ -2173,16 +2281,12 @@ def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] =
"""
effectiveMandateId = str(mandateId) if mandateId else None
effectiveFeatureInstanceId = str(featureInstanceId) if featureInstanceId else None
# Create new instance if not exists
if "default" not in _instancesManagement:
_instancesManagement["default"] = ComponentObjects()
interface = _instancesManagement["default"]
interface = ComponentObjects()
if currentUser:
interface.setUserContext(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
else:
logger.info("Returning interface without user context")
return interface

View file

@ -27,6 +27,7 @@ from modules.datamodels.datamodelSubscription import (
BUILTIN_PLANS,
getPlan as getPlanFromCatalog,
_getSelectablePlans,
getEffectiveLimits,
)
logger = logging.getLogger(__name__)
@ -276,33 +277,42 @@ class SubscriptionObjects:
)
plan = self.getPlan(sub.get("planKey", ""))
if not plan:
return True
limits = getEffectiveLimits(sub, plan)
isEnterprise = sub.get("isEnterprise", False)
if resourceType == "users":
cap = plan.maxUsers
cap = limits["maxUsers"]
if cap is None:
return True
current = self.countActiveUsers(mandateId)
if current + delta > cap:
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
raise SubscriptionCapacityException(resourceType=resourceType, currentCount=current, maxAllowed=cap)
raise SubscriptionCapacityException(
resourceType=resourceType, currentCount=current, maxAllowed=cap,
isEnterprise=isEnterprise,
)
elif resourceType == "featureInstances":
cap = plan.maxFeatureInstances
cap = limits["maxFeatureInstances"]
if cap is None:
return True
current = self.countActiveFeatureInstances(mandateId)
if current + delta > cap:
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
raise SubscriptionCapacityException(resourceType=resourceType, currentCount=current, maxAllowed=cap)
raise SubscriptionCapacityException(
resourceType=resourceType, currentCount=current, maxAllowed=cap,
isEnterprise=isEnterprise,
)
elif resourceType == "dataVolumeMB":
cap = plan.maxDataVolumeMB
cap = limits["maxDataVolumeMB"]
if cap is None:
return True
currentMB = self.getMandateDataVolumeMB(mandateId)
if currentMB + delta > cap:
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
raise SubscriptionCapacityException(resourceType=resourceType, currentCount=int(currentMB), maxAllowed=cap)
raise SubscriptionCapacityException(
resourceType=resourceType, currentCount=int(currentMB), maxAllowed=cap,
isEnterprise=isEnterprise,
)
return True
@ -325,10 +335,11 @@ class SubscriptionObjects:
if not sub:
return None
plan = self.getPlan(sub.get("planKey", ""))
if not plan or not plan.maxDataVolumeMB:
limits = getEffectiveLimits(sub, plan)
limitMB = limits["maxDataVolumeMB"]
if not limitMB:
return None
usedMB = self.getMandateDataVolumeMB(mandateId)
limitMB = plan.maxDataVolumeMB
percent = (usedMB / limitMB * 100) if limitMB > 0 else 0
if percent >= 80:
return {"usedMB": round(usedMB, 2), "limitMB": limitMB, "percent": round(percent, 1), "warning": True}

View file

@ -747,6 +747,7 @@ def buildFilesScopeWhereClause(
Only own files: sysCreatedBy = currentUser
WITH instance context (Instanz-Seiten):
- scope = 'personal' AND sysCreatedBy = me (creator's personal files; e.g. workflow outputs)
- sysCreatedBy = me AND featureInstanceId = X (own personal files of this instance)
- scope = 'featureInstance' AND featureInstanceId = X
- scope = 'mandate' AND mandateId = M (M = mandate of the instance)
@ -780,6 +781,15 @@ def buildFilesScopeWhereClause(
scopeParts: List[str] = []
scopeValues: List = []
# Personal files created by this user must remain visible even when the request
# carries mandate/instance context (GROUP reads use this clause). Otherwise
# createFile → createFileData → getFile fails and workflow outputs vanish from /files.
# Also treat scope IS NULL as legacy/personal for the owner (column default not applied).
scopeParts.append(
'(("scope" = \'personal\' OR "scope" IS NULL) AND "sysCreatedBy" = %s)'
)
scopeValues.append(currentUser.id)
if featureInstanceId:
# 1) Own personal files of this specific instance
scopeParts.append('("sysCreatedBy" = %s AND "featureInstanceId" = %s)')

View file

@ -658,6 +658,11 @@
"key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden",
"value": ""
},
{
"context": "ui",
"key": "Dossiers",
"value": "UDB tab label for chat workflows / cases"
},
{
"context": "ui",
"key": "Dokument",
@ -4046,6 +4051,11 @@
"key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden",
"value": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden"
},
{
"context": "ui",
"key": "Dossiers",
"value": "Dossiers"
},
{
"context": "ui",
"key": "Dokument",
@ -7404,6 +7414,11 @@
"key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden",
"value": "This field is managed by {provider} and cannot be changed"
},
{
"context": "ui",
"key": "Dossiers",
"value": "Dossiers"
},
{
"context": "ui",
"key": "Dokument",
@ -10617,6 +10632,11 @@
"key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden",
"value": "Ce champ est géré par {provider} et ne peut pas être modifié"
},
{
"context": "ui",
"key": "Dossiers",
"value": "Dossiers"
},
{
"context": "ui",
"key": "Dokument",

View file

@ -9,9 +9,9 @@ Features:
- Admin endpoints: Manage settings, add credits, view all accounts
"""
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response, Query, Header
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response, Query, Header, status
from fastapi.responses import JSONResponse
from typing import List, Dict, Any, Optional
from fastapi import status
import logging
from datetime import date, datetime, timezone
from pydantic import BaseModel, Field
@ -24,7 +24,13 @@ from modules.interfaces.interfaceDbBilling import getInterface as getBillingInte
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import getService as getBillingService
import json
import math
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
from modules.datamodels.datamodelPagination import (
PaginationParams,
PaginatedResponse,
PaginationMetadata,
normalize_pagination_dict,
AppliedViewMeta,
)
from modules.datamodels.datamodelBilling import (
BillingAccount,
BillingTransaction,
@ -478,50 +484,193 @@ def getBalanceForMandate(
raise HTTPException(status_code=500, detail=str(e))
@router.get("/transactions", response_model=List[TransactionResponse])
def _normalize_billing_tx_dict(t: Dict[str, Any]) -> Dict[str, Any]:
"""Make billing transaction rows JSON/grouping-safe (datetimes → str, enums → str)."""
from datetime import date as date_cls, datetime as dt_cls
r = dict(t)
for k, v in list(r.items()):
if isinstance(v, dt_cls):
r[k] = v.isoformat()
elif isinstance(v, date_cls):
r[k] = v.isoformat()
for ek in ("transactionType", "referenceType"):
if ek in r and r[ek] is not None and not isinstance(r[ek], str):
ev = r[ek]
r[ek] = getattr(ev, "value", None) or str(ev)
return r
def _load_billing_user_transactions_normalized(billingService) -> List[Dict[str, Any]]:
raw = billingService.getTransactionHistory(limit=5000)
return [_normalize_billing_tx_dict(t) for t in raw]
def _view_user_transactions_filtered_list(
billing_interface,
load_mandate_ids: Optional[List[str]],
effective_scope: str,
personal_user_id: Optional[str],
pagination_params: PaginationParams,
ctx_user,
) -> List[Dict[str, Any]]:
"""Up to 5000 rows: SQL window + in-memory filters/sort (incl. enriched columns)."""
from modules.interfaces.interfaceDbManagement import ComponentObjects
bulk_params = pagination_params.model_copy(deep=True)
bulk_params.page = 1
bulk_params.pageSize = 5000
bulk_result = billing_interface.getTransactionsForMandatesPaginated(
mandateIds=load_mandate_ids,
pagination=bulk_params,
scope=effective_scope,
userId=personal_user_id,
)
all_items = [_normalize_billing_tx_dict(dict(x)) for x in bulk_result.items]
comp = ComponentObjects()
comp.setUserContext(ctx_user)
if pagination_params.filters:
all_items = comp._applyFilters(all_items, pagination_params.filters)
if pagination_params.sort:
all_items = comp._applySorting(all_items, pagination_params.sort)
return all_items
@router.get("/transactions")
@limiter.limit("30/minute")
def getTransactions(
request: Request,
limit: int = Query(default=50, ge=1, le=500),
offset: int = Query(default=0, ge=0),
ctx: RequestContext = Depends(getRequestContext)
pagination: Optional[str] = Query(
None,
description="JSON PaginationParams for table UI (filters, sort, viewKey, groupByLevels).",
),
mode: Optional[str] = Query(None, description="'filterValues' | 'ids' with pagination"),
column: Optional[str] = Query(None, description="Column for mode=filterValues"),
ctx: RequestContext = Depends(getRequestContext),
):
"""
Get transaction history across all mandates the user belongs to.
Without ``pagination`` query: legacy behaviour returns a JSON array of
transactions (`limit`/`offset` window).
With ``pagination`` JSON: returns ``{ items, pagination, groupLayout?, appliedView? }``.
Table list views use contextKey ``billing/transactions``.
"""
try:
billingService = getBillingService(
ctx.user,
ctx.mandateId,
featureCode="billing"
featureCode="billing",
)
# Fetch enough transactions for pagination
if pagination:
from modules.routes.routeHelpers import (
applyViewToParams,
buildGroupLayout,
effective_group_by_levels,
handleFilterValuesInMemory,
handleIdsInMemory,
resolveView,
)
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
from modules.interfaces.interfaceDbManagement import ComponentObjects
CONTEXT_KEY = "billing/transactions"
try:
paginationDict = json.loads(pagination)
if not paginationDict:
raise ValueError("empty pagination")
paginationDict = normalize_pagination_dict(paginationDict)
paginationParams = PaginationParams(**paginationDict)
except (json.JSONDecodeError, ValueError, TypeError) as e:
raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}")
appInterface = getAppInterface(ctx.user)
viewKey = paginationParams.viewKey
viewConfig, viewDisplayName = resolveView(appInterface, CONTEXT_KEY, viewKey)
viewMeta = AppliedViewMeta(viewKey=viewKey, displayName=viewDisplayName) if viewKey else None
paginationParams = applyViewToParams(paginationParams, viewConfig)
groupByLevels = effective_group_by_levels(paginationParams, viewConfig)
all_items = _load_billing_user_transactions_normalized(billingService)
if mode == "filterValues":
if not column:
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
return handleFilterValuesInMemory(all_items, column, pagination)
if mode == "ids":
return handleIdsInMemory(all_items, pagination)
comp = ComponentObjects()
comp.setUserContext(ctx.user)
if paginationParams.filters:
all_items = comp._applyFilters(all_items, paginationParams.filters)
if paginationParams.sort:
all_items = comp._applySorting(all_items, paginationParams.sort)
totalItems = len(all_items)
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
if not groupByLevels:
pstart = (paginationParams.page - 1) * paginationParams.pageSize
page_items = all_items[pstart : pstart + paginationParams.pageSize]
group_layout = None
else:
page_items, group_layout = buildGroupLayout(
all_items,
groupByLevels,
paginationParams.page,
paginationParams.pageSize,
)
resp: Dict[str, Any] = {
"items": page_items,
"pagination": PaginationMetadata(
currentPage=paginationParams.page,
pageSize=paginationParams.pageSize,
totalItems=totalItems,
totalPages=totalPages,
sort=paginationParams.sort,
filters=paginationParams.filters,
).model_dump(),
}
if group_layout:
resp["groupLayout"] = group_layout.model_dump()
if viewMeta:
resp["appliedView"] = viewMeta.model_dump()
return JSONResponse(content=resp)
transactions = billingService.getTransactionHistory(limit=offset + limit)
# Convert to response model
result = []
for t in transactions[offset:offset + limit]:
result.append(TransactionResponse(
id=t.get("id"),
accountId=t.get("accountId"),
transactionType=TransactionTypeEnum(t.get("transactionType", "DEBIT")),
amount=t.get("amount", 0.0),
description=t.get("description", ""),
referenceType=ReferenceTypeEnum(t["referenceType"]) if t.get("referenceType") else None,
workflowId=t.get("workflowId"),
featureCode=t.get("featureCode"),
featureInstanceId=t.get("featureInstanceId"),
aicoreProvider=t.get("aicoreProvider"),
aicoreModel=t.get("aicoreModel"),
createdByUserId=t.get("createdByUserId"),
sysCreatedAt=t.get("sysCreatedAt"),
mandateId=t.get("mandateId"),
mandateName=t.get("mandateName")
))
result: List[TransactionResponse] = []
for t in transactions[offset : offset + limit]:
result.append(
TransactionResponse(
id=t.get("id"),
accountId=t.get("accountId"),
transactionType=TransactionTypeEnum(t.get("transactionType", "DEBIT")),
amount=t.get("amount", 0.0),
description=t.get("description", ""),
referenceType=ReferenceTypeEnum(t["referenceType"]) if t.get("referenceType") else None,
workflowId=t.get("workflowId"),
featureCode=t.get("featureCode"),
featureInstanceId=t.get("featureInstanceId"),
aicoreProvider=t.get("aicoreProvider"),
aicoreModel=t.get("aicoreModel"),
createdByUserId=t.get("createdByUserId"),
sysCreatedAt=t.get("sysCreatedAt"),
mandateId=t.get("mandateId"),
mandateName=t.get("mandateName"),
)
)
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting billing transactions: {e}")
raise HTTPException(status_code=500, detail=str(e))
@ -1757,7 +1906,7 @@ def getUserViewStatistics(
@router.get("/view/users/transactions", response_model=PaginatedResponse[UserTransactionResponse])
@limiter.limit("30/minute")
@limiter.limit("120/minute")
def getUserViewTransactions(
request: Request,
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
@ -1808,7 +1957,6 @@ def getUserViewTransactions(
if mode == "filterValues":
if not column:
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
from fastapi.responses import JSONResponse
crossFilterParams = parseCrossFilterPagination(column, pagination)
values = billingInterface.getTransactionDistinctValues(
mandateIds=loadMandateIds,
@ -1820,7 +1968,6 @@ def getUserViewTransactions(
return JSONResponse(content=values)
if mode == "ids":
from fastapi.responses import JSONResponse
paginationParams = None
if pagination:
import json as _json
@ -1835,6 +1982,66 @@ def getUserViewTransactions(
) if hasattr(billingInterface, 'getTransactionIds') else []
return JSONResponse(content=ids)
if mode == "groupSummary":
if not pagination:
raise HTTPException(status_code=400, detail="pagination required for groupSummary")
import json as _json
from collections import defaultdict
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
from modules.routes.routeHelpers import (
applyViewToParams,
effective_group_by_levels,
resolveView,
)
pagination_dict = _json.loads(pagination)
pagination_dict = normalize_pagination_dict(pagination_dict)
summary_params = PaginationParams(**pagination_dict)
CONTEXT_KEY = "billing/view/users/transactions"
app_interface = getAppInterface(ctx.user)
summary_vk = summary_params.viewKey
summary_view_cfg, _ = resolveView(app_interface, CONTEXT_KEY, summary_vk)
summary_params = applyViewToParams(summary_params, summary_view_cfg)
levels = effective_group_by_levels(summary_params, summary_view_cfg)
if not levels or not levels[0].get("field"):
raise HTTPException(
status_code=400,
detail="groupByLevels[0].field required for groupSummary",
)
field = levels[0]["field"]
null_label = str(levels[0].get("nullLabel") or "")
all_rows = _view_user_transactions_filtered_list(
billingInterface,
loadMandateIds,
scope,
personalUserId,
summary_params,
ctx.user,
)
counts: Dict[str, int] = defaultdict(int)
labels: Dict[str, str] = {}
null_key = "\x00NULL"
for item in all_rows:
raw = item.get(field)
if raw is None or raw == "":
nk = null_key
labels[nk] = null_label
else:
nk = str(raw)
if nk not in labels:
labels[nk] = nk
counts[nk] += 1
groups_out: List[Dict[str, Any]] = []
for nk in sorted(counts.keys(), key=lambda x: (x == null_key, labels.get(x, x).lower())):
groups_out.append(
{
"value": None if nk == null_key else nk,
"label": labels.get(nk, nk),
"totalCount": counts[nk],
}
)
return JSONResponse(content={"groups": groups_out})
paginationParams = None
if pagination:
import json as _json
@ -1847,15 +2054,21 @@ def getUserViewTransactions(
if not paginationParams:
paginationParams = PaginationParams(page=1, pageSize=50)
result = billingInterface.getTransactionsForMandatesPaginated(
mandateIds=loadMandateIds,
pagination=paginationParams,
scope=effectiveScope,
userId=personalUserId,
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
from modules.routes.routeHelpers import (
applyViewToParams,
buildGroupLayout,
effective_group_by_levels,
resolveView,
)
logger.debug(f"SQL-paginated {result.totalItems} transactions for user {ctx.user.id} "
f"(scope={scope}, mandateId={mandateId}, page={paginationParams.page})")
CONTEXT_KEY = "billing/view/users/transactions"
appInterface = getAppInterface(ctx.user)
viewKey = paginationParams.viewKey
viewConfig, viewDisplayName = resolveView(appInterface, CONTEXT_KEY, viewKey)
viewMeta = AppliedViewMeta(viewKey=viewKey, displayName=viewDisplayName) if viewKey else None
paginationParams = applyViewToParams(paginationParams, viewConfig)
groupByLevels = effective_group_by_levels(paginationParams, viewConfig)
def _toResponse(d):
return UserTransactionResponse(
@ -1875,9 +2088,56 @@ def getUserViewTransactions(
mandateId=d.get("mandateId"),
mandateName=d.get("mandateName"),
userId=d.get("userId"),
userName=d.get("userName")
userName=d.get("userName"),
)
if groupByLevels:
all_items = _view_user_transactions_filtered_list(
billingInterface,
loadMandateIds,
effectiveScope,
personalUserId,
paginationParams,
ctx.user,
)
totalItems = len(all_items)
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
page_items, group_layout = buildGroupLayout(
all_items,
groupByLevels,
paginationParams.page,
paginationParams.pageSize,
)
resp: Dict[str, Any] = {
"items": [_toResponse(d).model_dump(mode="json") for d in page_items],
"pagination": PaginationMetadata(
currentPage=paginationParams.page,
pageSize=paginationParams.pageSize,
totalItems=totalItems,
totalPages=totalPages,
sort=paginationParams.sort,
filters=paginationParams.filters,
).model_dump(mode="json"),
}
if group_layout:
resp["groupLayout"] = group_layout.model_dump(mode="json")
if viewMeta:
resp["appliedView"] = viewMeta.model_dump(mode="json")
return JSONResponse(content=resp)
result = billingInterface.getTransactionsForMandatesPaginated(
mandateIds=loadMandateIds,
pagination=paginationParams,
scope=effectiveScope,
userId=personalUserId,
)
logger.debug(
f"SQL-paginated {result.totalItems} transactions for user {ctx.user.id} "
f"(scope={scope}, mandateId={mandateId}, page={paginationParams.page})"
)
return PaginatedResponse(
items=[_toResponse(d) for d in result.items],
pagination=PaginationMetadata(
@ -1887,7 +2147,7 @@ def getUserViewTransactions(
totalPages=result.totalPages,
sort=paginationParams.sort,
filters=paginationParams.filters,
)
),
)
except Exception as e:

View file

@ -17,6 +17,7 @@ import logging
import json
import math
from urllib.parse import quote
from fastapi.responses import JSONResponse
from modules.datamodels.datamodelUam import User, UserConnection, AuthAuthority, ConnectionStatus
from modules.datamodels.datamodelSecurity import Token
@ -154,12 +155,12 @@ async def get_connections(
"""
from modules.routes.routeHelpers import (
handleFilterValuesInMemory, handleIdsInMemory, enrichRowsWithFkLabels,
handleGroupingInRequest, applyGroupScopeFilter,
resolveView, applyViewToParams, buildGroupLayout, effective_group_by_levels,
)
from modules.datamodels.datamodelPagination import AppliedViewMeta
CONTEXT_KEY = "connections"
# Parse pagination params early — needed for grouping in all modes
paginationParams = None
if pagination:
try:
@ -171,7 +172,13 @@ async def get_connections(
raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}")
interface = getInterface(currentUser)
groupCtx = handleGroupingInRequest(paginationParams, interface, CONTEXT_KEY)
# Resolve view and merge config into params
viewKey = paginationParams.viewKey if paginationParams else None
viewConfig, viewDisplayName = resolveView(interface, CONTEXT_KEY, viewKey)
viewMeta = AppliedViewMeta(viewKey=viewKey, displayName=viewDisplayName) if viewKey else None
paginationParams = applyViewToParams(paginationParams, viewConfig)
groupByLevels = effective_group_by_levels(paginationParams, viewConfig)
def _buildEnhancedItems():
connections = interface.getUserConnections(currentUser.id)
@ -200,7 +207,6 @@ async def get_connections(
try:
items = _buildEnhancedItems()
enrichRowsWithFkLabels(items, UserConnection)
items = applyGroupScopeFilter(items, groupCtx.itemIds)
return handleFilterValuesInMemory(items, column, pagination)
except Exception as e:
logger.error(f"Error getting filter values for connections: {str(e)}")
@ -208,19 +214,60 @@ async def get_connections(
if mode == "ids":
try:
items = applyGroupScopeFilter(_buildEnhancedItems(), groupCtx.itemIds)
return handleIdsInMemory(items, pagination)
return handleIdsInMemory(_buildEnhancedItems(), pagination)
except Exception as e:
logger.error(f"Error getting IDs for connections: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
try:
# NOTE: Cannot use db.getRecordsetPaginated() here because each connection
# is enriched with computed tokenStatus/tokenExpiresAt (requires per-row DB lookup).
# Token refresh also may trigger re-fetch. Connections per user are typically < 10,
# so in-memory pagination is acceptable.
if mode == "groupSummary":
if not pagination:
raise HTTPException(status_code=400, detail="pagination required for groupSummary")
from modules.routes.routeHelpers import (
apply_strategy_b_filters_and_sort,
build_group_summary_groups,
)
if not groupByLevels or not groupByLevels[0].get("field"):
raise HTTPException(
status_code=400,
detail="groupByLevels[0].field required for groupSummary",
)
field = groupByLevels[0]["field"]
null_label = str(groupByLevels[0].get("nullLabel") or "")
connections = interface.getUserConnections(currentUser.id)
try:
refresh_result = await token_refresh_service.refresh_expired_tokens(currentUser.id)
if refresh_result.get("refreshed", 0) > 0:
logger.info(
"Silently refreshed %s tokens for user %s (groupSummary)",
refresh_result["refreshed"],
currentUser.id,
)
connections = interface.getUserConnections(currentUser.id)
except Exception as e:
logger.warning(f"Silent token refresh failed for user {currentUser.id}: {str(e)}")
enhanced_connections_dict = []
for connection in connections:
tokenStatus, tokenExpiresAt = getTokenStatusForConnection(interface, connection.id)
enhanced_connections_dict.append({
"id": connection.id,
"userId": connection.userId,
"authority": connection.authority.value if hasattr(connection.authority, 'value') else str(connection.authority),
"externalId": connection.externalId,
"externalUsername": connection.externalUsername or "",
"externalEmail": connection.externalEmail,
"status": connection.status.value if hasattr(connection.status, 'value') else str(connection.status),
"connectedAt": connection.connectedAt,
"lastChecked": connection.lastChecked,
"expiresAt": connection.expiresAt,
"tokenStatus": tokenStatus,
"tokenExpiresAt": tokenExpiresAt
})
enrichRowsWithFkLabels(enhanced_connections_dict, UserConnection)
filtered = apply_strategy_b_filters_and_sort(enhanced_connections_dict, paginationParams, currentUser)
groups_out = build_group_summary_groups(filtered, field, null_label)
return JSONResponse(content={"groups": groups_out})
# SECURITY FIX: All users (including admins) can only see their own connections
try:
connections = interface.getUserConnections(currentUser.id)
# Perform silent token refresh for expired OAuth connections
@ -235,7 +282,7 @@ async def get_connections(
enhanced_connections_dict = []
for connection in connections:
tokenStatus, tokenExpiresAt = getTokenStatusForConnection(interface, connection.id)
connection_dict = {
enhanced_connections_dict.append({
"id": connection.id,
"userId": connection.userId,
"authority": connection.authority.value if hasattr(connection.authority, 'value') else str(connection.authority),
@ -248,46 +295,31 @@ async def get_connections(
"expiresAt": connection.expiresAt,
"tokenStatus": tokenStatus,
"tokenExpiresAt": tokenExpiresAt
}
enhanced_connections_dict.append(connection_dict)
})
enrichRowsWithFkLabels(enhanced_connections_dict, UserConnection)
enhanced_connections_dict = applyGroupScopeFilter(enhanced_connections_dict, groupCtx.itemIds)
if paginationParams is None:
return {
"items": enhanced_connections_dict,
"pagination": None,
"groupTree": groupCtx.groupTree,
}
return {"items": enhanced_connections_dict, "pagination": None}
# Apply filtering if provided
# Apply filtering and sorting over full list (Strategy B)
component_interface = ComponentObjects()
component_interface.setUserContext(currentUser)
if paginationParams.filters:
component_interface = ComponentObjects()
component_interface.setUserContext(currentUser)
enhanced_connections_dict = component_interface._applyFilters(
enhanced_connections_dict,
paginationParams.filters
)
# Apply sorting if provided
enhanced_connections_dict = component_interface._applyFilters(enhanced_connections_dict, paginationParams.filters)
if paginationParams.sort:
component_interface = ComponentObjects()
component_interface.setUserContext(currentUser)
enhanced_connections_dict = component_interface._applySorting(
enhanced_connections_dict,
paginationParams.sort
)
enhanced_connections_dict = component_interface._applySorting(enhanced_connections_dict, paginationParams.sort)
totalItems = len(enhanced_connections_dict)
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
endIdx = startIdx + paginationParams.pageSize
paged_connections = enhanced_connections_dict[startIdx:endIdx]
# Strategy B grouping: operates on full filtered+sorted list, then slices
page_items, groupLayout = buildGroupLayout(
enhanced_connections_dict, groupByLevels, paginationParams.page, paginationParams.pageSize
)
return {
"items": paged_connections,
response: dict = {
"items": page_items,
"pagination": PaginationMetadata(
currentPage=paginationParams.page,
pageSize=paginationParams.pageSize,
@ -296,9 +328,13 @@ async def get_connections(
sort=paginationParams.sort,
filters=paginationParams.filters
).model_dump(),
"groupTree": groupCtx.groupTree,
}
if groupLayout:
response["groupLayout"] = groupLayout.model_dump()
if viewMeta:
response["appliedView"] = viewMeta.model_dump()
return response
except HTTPException:
raise
except Exception as e:

View file

@ -5,6 +5,7 @@ from fastapi.responses import JSONResponse
from typing import List, Dict, Any, Optional
import logging
import json
import math
# Import auth module
from modules.auth import limiter, getCurrentUser, getRequestContext, RequestContext
@ -500,9 +501,10 @@ def get_files(
from modules.routes.routeHelpers import (
handleIdsMode,
handleFilterValuesInMemory,
handleGroupingInRequest, applyGroupScopeFilter,
resolveView, applyViewToParams, buildGroupLayout, effective_group_by_levels,
)
import modules.interfaces.interfaceDbApp as _appIface
from modules.datamodels.datamodelPagination import AppliedViewMeta
managementInterface = interfaceDbManagement.getInterface(
currentUser,
@ -510,11 +512,40 @@ def get_files(
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
)
appInterface = _appIface.getInterface(currentUser)
groupCtx = handleGroupingInRequest(paginationParams, appInterface, "files/list")
# Resolve view and merge config into params
viewKey = paginationParams.viewKey if paginationParams else None
viewConfig, viewDisplayName = resolveView(appInterface, "files/list", viewKey)
viewMeta = AppliedViewMeta(viewKey=viewKey, displayName=viewDisplayName) if viewKey else None
paginationParams = applyViewToParams(paginationParams, viewConfig)
groupByLevels = effective_group_by_levels(paginationParams, viewConfig)
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]
if mode == "groupSummary":
if not pagination:
raise HTTPException(status_code=400, detail="pagination required for groupSummary")
from modules.routes.routeHelpers import (
apply_strategy_b_filters_and_sort,
build_group_summary_groups,
)
if not groupByLevels or not groupByLevels[0].get("field"):
raise HTTPException(
status_code=400,
detail="groupByLevels[0].field required for groupSummary",
)
field = groupByLevels[0]["field"]
null_label = str(groupByLevels[0].get("nullLabel") or "")
allFiles = managementInterface.getAllFiles()
allItems = enrichRowsWithFkLabels(
_filesToDicts(allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])),
FileItem,
)
filtered = apply_strategy_b_filters_and_sort(allItems, paginationParams, currentUser)
groups_out = build_group_summary_groups(filtered, field, null_label)
return JSONResponse(content={"groups": groups_out})
if mode == "filterValues":
if not column:
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
@ -522,33 +553,72 @@ def get_files(
items = allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])
itemDicts = _filesToDicts(items)
enrichRowsWithFkLabels(itemDicts, FileItem)
itemDicts = applyGroupScopeFilter(itemDicts, groupCtx.itemIds)
return handleFilterValuesInMemory(itemDicts, column, pagination)
if mode == "ids":
recordFilter = {"sysCreatedBy": managementInterface.userId}
return handleIdsMode(managementInterface.db, FileItem, pagination, recordFilter)
result = managementInterface.getAllFiles(pagination=paginationParams)
if not groupByLevels:
# 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=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
if paginationParams:
enriched = applyGroupScopeFilter(enrichRowsWithFkLabels(_filesToDicts(result.items), FileItem), groupCtx.itemIds)
return {
"items": enriched,
"pagination": PaginationMetadata(
currentPage=paginationParams.page,
pageSize=paginationParams.pageSize,
totalItems=result.totalItems,
totalPages=result.totalPages,
sort=paginationParams.sort,
filters=paginationParams.filters
).model_dump(),
"groupTree": groupCtx.groupTree,
}
else:
items = result if isinstance(result, list) else (result.items if hasattr(result, "items") else [result])
enriched = applyGroupScopeFilter(enrichRowsWithFkLabels(_filesToDicts(items), FileItem), groupCtx.itemIds)
return {"items": enriched, "pagination": None, "groupTree": groupCtx.groupTree}
# 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
totalItems = len(allItems)
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
page_items, groupLayout = buildGroupLayout(allItems, groupByLevels, paginationParams.page, paginationParams.pageSize)
resp = {
"items": page_items,
"pagination": PaginationMetadata(
currentPage=paginationParams.page,
pageSize=paginationParams.pageSize,
totalItems=totalItems,
totalPages=totalPages,
sort=paginationParams.sort,
filters=paginationParams.filters
).model_dump(),
}
if groupLayout:
resp["groupLayout"] = groupLayout.model_dump()
if viewMeta:
resp["appliedView"] = viewMeta.model_dump()
return resp
except HTTPException:
raise
except Exception as e:
@ -559,34 +629,11 @@ def get_files(
)
def _addFileToGroup(appInterface, fileId: str, groupId: str, contextKey: str = "files/list"):
"""Add a file to a group in the persisted groupTree (upsert)."""
from modules.routes.routeHelpers import _collectItemIds
try:
existing = appInterface.getTableGrouping(contextKey)
if not existing:
return
nodes = [n.model_dump() if hasattr(n, 'model_dump') else n for n in existing.rootGroups]
def _add(nds):
for nd in nds:
nid = nd.get("id") if isinstance(nd, dict) else getattr(nd, "id", None)
if nid == groupId:
itemIds = list(nd.get("itemIds", []) if isinstance(nd, dict) else getattr(nd, "itemIds", []))
if fileId not in itemIds:
itemIds.append(fileId)
if isinstance(nd, dict):
nd["itemIds"] = itemIds
else:
nd.itemIds = itemIds
return True
subs = nd.get("subGroups", []) if isinstance(nd, dict) else getattr(nd, "subGroups", [])
if _add(subs):
return True
return False
_add(nodes)
appInterface.upsertTableGrouping(contextKey, nodes)
except Exception as e:
logger.warning(f"_addFileToGroup failed: {e}")
def _LEGACY_addFileToGroup_REMOVED():
"""Removed — file-group tree no longer exists. Use multi-select bulk operations."""
pass
@router.post("/upload", status_code=status.HTTP_201_CREATED)
@ -596,7 +643,6 @@ async def upload_file(
file: UploadFile = File(...),
workflowId: Optional[str] = Form(None),
featureInstanceId: Optional[str] = Form(None),
groupId: Optional[str] = Form(None),
currentUser: User = Depends(getCurrentUser),
context: RequestContext = Depends(getRequestContext),
) -> JSONResponse:
@ -630,12 +676,6 @@ async def upload_file(
managementInterface.updateFile(fileItem.id, {"featureInstanceId": featureInstanceId})
fileItem.featureInstanceId = featureInstanceId
# Add to group if groupId was provided
if groupId:
import modules.interfaces.interfaceDbApp as _appIface
appInterface = _appIface.getInterface(currentUser)
_addFileToGroup(appInterface, fileItem.id, groupId)
# Determine response message based on duplicate type
if duplicateType == "exact_duplicate":
message = f"File '{file.filename}' already exists with identical content. Reusing existing file."
@ -843,82 +883,68 @@ def batchDownload(
raise HTTPException(status_code=500, detail=str(e))
# ── Group bulk endpoints ──────────────────────────────────────────────────────
# ── Bulk file operations (replace former group-based bulk routes) ─────────────
def _get_group_item_ids(contextKey: str, groupId: str, appInterface) -> set:
"""Collect all file IDs in a group and its sub-groups from the stored groupTree."""
from modules.routes.routeHelpers import _collectItemIds
try:
existing = appInterface.getTableGrouping(contextKey)
if not existing:
return set()
nodes = [n.model_dump() if hasattr(n, 'model_dump') else n for n in existing.rootGroups]
result = _collectItemIds(nodes, groupId)
return result or set()
except Exception as e:
logger.error(f"_get_group_item_ids failed for groupId={groupId}: {e}")
return set()
@router.patch("/groups/{groupId}/scope")
@limiter.limit("60/minute")
def patch_group_scope(
@router.post("/bulk/scope")
@limiter.limit("30/minute")
def bulk_set_scope(
request: Request,
groupId: str = Path(..., description="Group ID"),
body: dict = Body(...),
currentUser: User = Depends(getCurrentUser),
context: RequestContext = Depends(getRequestContext),
):
"""Set scope for all files in a group (recursive)."""
scope = body.get("scope")
if not scope:
raise HTTPException(status_code=400, detail="scope is required")
"""Set scope for a list of files by their IDs."""
fileIds: list = body.get("fileIds") or []
scope: str = body.get("scope") or ""
if not fileIds:
raise HTTPException(status_code=400, detail="fileIds is required")
validScopes = {"personal", "featureInstance", "mandate", "global"}
if scope not in validScopes:
raise HTTPException(status_code=400, detail=f"Invalid scope. Must be one of {validScopes}")
if scope == "global" and not context.isSysAdmin:
raise HTTPException(status_code=403, detail="Only sysadmins can set global scope")
try:
import modules.interfaces.interfaceDbApp as _appIface
managementInterface = interfaceDbManagement.getInterface(
currentUser,
mandateId=str(context.mandateId) if context.mandateId else None,
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None,
)
appInterface = _appIface.getInterface(currentUser)
fileIds = _get_group_item_ids("files/list", groupId, appInterface)
updated = 0
for fid in fileIds:
try:
managementInterface.updateFile(fid, {"scope": scope})
updated += 1
except Exception as e:
logger.error(f"patch_group_scope: failed to update file {fid}: {e}")
return {"groupId": groupId, "scope": scope, "filesUpdated": updated}
logger.error(f"bulk_set_scope: failed for file {fid}: {e}")
return {"scope": scope, "filesUpdated": updated}
except HTTPException:
raise
except Exception as e:
logger.error(f"patch_group_scope error: {e}")
logger.error(f"bulk_set_scope error: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.patch("/groups/{groupId}/neutralize")
@limiter.limit("60/minute")
def patch_group_neutralize(
@router.post("/bulk/neutralize")
@limiter.limit("30/minute")
def bulk_set_neutralize(
request: Request,
groupId: str = Path(..., description="Group ID"),
body: dict = Body(...),
currentUser: User = Depends(getCurrentUser),
context: RequestContext = Depends(getRequestContext),
):
"""Toggle neutralize for all files in a group (recursive, incl. knowledge purge/reindex)."""
"""Set neutralize flag for a list of files by their IDs (incl. knowledge purge/reindex)."""
fileIds: list = body.get("fileIds") or []
neutralize = body.get("neutralize")
if not fileIds:
raise HTTPException(status_code=400, detail="fileIds is required")
if neutralize is None:
raise HTTPException(status_code=400, detail="neutralize is required")
try:
import modules.interfaces.interfaceDbApp as _appIface
managementInterface = interfaceDbManagement.getInterface(
currentUser,
mandateId=str(context.mandateId) if context.mandateId else None,
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None,
)
appInterface = _appIface.getInterface(currentUser)
fileIds = _get_group_item_ids("files/list", groupId, appInterface)
updated = 0
for fid in fileIds:
try:
@ -929,39 +955,37 @@ def patch_group_neutralize(
kIface = interfaceDbKnowledge.getInterface(currentUser)
kIface.purgeFileKnowledge(fid)
except Exception as ke:
logger.warning(f"patch_group_neutralize: knowledge purge failed for {fid}: {ke}")
logger.warning(f"bulk_set_neutralize: knowledge purge failed for {fid}: {ke}")
updated += 1
except Exception as e:
logger.error(f"patch_group_neutralize: failed for file {fid}: {e}")
return {"groupId": groupId, "neutralize": neutralize, "filesUpdated": updated}
logger.error(f"bulk_set_neutralize: failed for file {fid}: {e}")
return {"neutralize": neutralize, "filesUpdated": updated}
except HTTPException:
raise
except Exception as e:
logger.error(f"patch_group_neutralize error: {e}")
logger.error(f"bulk_set_neutralize error: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/groups/{groupId}/download")
@limiter.limit("20/minute")
async def download_group_zip(
@router.post("/bulk/download-zip")
@limiter.limit("10/minute")
async def bulk_download_zip(
request: Request,
groupId: str = Path(..., description="Group ID"),
body: dict = Body(...),
currentUser: User = Depends(getCurrentUser),
context: RequestContext = Depends(getRequestContext),
):
"""Download all files in a group as a ZIP archive."""
"""Download a list of files as a ZIP archive."""
import io, zipfile
fileIds: list = body.get("fileIds") or []
if not fileIds:
raise HTTPException(status_code=400, detail="fileIds is required")
try:
import modules.interfaces.interfaceDbApp as _appIface
managementInterface = interfaceDbManagement.getInterface(
currentUser,
mandateId=str(context.mandateId) if context.mandateId else None,
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None,
)
appInterface = _appIface.getInterface(currentUser)
fileIds = _get_group_item_ids("files/list", groupId, appInterface)
if not fileIds:
raise HTTPException(status_code=404, detail="Group not found or empty")
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for fid in fileIds:
@ -969,63 +993,21 @@ async def download_group_zip(
fileMeta = managementInterface.getFile(fid)
fileData = managementInterface.getFileData(fid)
if fileMeta and fileData:
name = (fileMeta.get("fileName") if isinstance(fileMeta, dict) else getattr(fileMeta, "fileName", fid)) or fid
name = (getattr(fileMeta, "fileName", None) or fid)
zf.writestr(name, fileData)
except Exception as fe:
logger.warning(f"download_group_zip: skipping file {fid}: {fe}")
logger.warning(f"bulk_download_zip: skipping file {fid}: {fe}")
buf.seek(0)
from fastapi.responses import StreamingResponse
return StreamingResponse(
buf,
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="group-{groupId}.zip"'},
headers={"Content-Disposition": 'attachment; filename="files.zip"'},
)
except HTTPException:
raise
except Exception as e:
logger.error(f"download_group_zip error: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/groups/{groupId}")
@limiter.limit("30/minute")
def delete_group(
request: Request,
groupId: str = Path(..., description="Group ID"),
deleteItems: bool = Query(False, description="If true, also delete all files in the group"),
currentUser: User = Depends(getCurrentUser),
context: RequestContext = Depends(getRequestContext),
):
"""Remove a group from the groupTree. Optionally delete all its files."""
try:
import modules.interfaces.interfaceDbApp as _appIface
appInterface = _appIface.getInterface(currentUser)
fileIds = _get_group_item_ids("files/list", groupId, appInterface)
# Remove group from tree
existing = appInterface.getTableGrouping("files/list")
if existing:
from modules.routes.routeHelpers import _removeGroupFromTree
newRoots = _removeGroupFromTree([n.model_dump() if hasattr(n, 'model_dump') else n for n in existing.rootGroups], groupId)
appInterface.upsertTableGrouping("files/list", newRoots)
# Optionally delete files
deletedFiles = 0
if deleteItems:
managementInterface = interfaceDbManagement.getInterface(
currentUser,
mandateId=str(context.mandateId) if context.mandateId else None,
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None,
)
for fid in fileIds:
try:
managementInterface.deleteFile(fid)
deletedFiles += 1
except Exception as e:
logger.error(f"delete_group: failed to delete file {fid}: {e}")
return {"groupId": groupId, "deletedFiles": deletedFiles}
except HTTPException:
raise
except Exception as e:
logger.error(f"delete_group error: {e}")
logger.error(f"bulk_download_zip error: {e}")
raise HTTPException(status_code=500, detail=str(e))

View file

@ -131,11 +131,9 @@ def get_mandates(
handleFilterValuesInMemory, handleIdsInMemory,
handleFilterValuesMode, handleIdsMode,
parseCrossFilterPagination,
handleGroupingInRequest, applyGroupScopeFilter,
)
appInterface = interfaceDbApp.getRootInterface()
groupCtx = handleGroupingInRequest(paginationParams, appInterface, "mandates")
def _mandateItemsForAdmin():
items = []
@ -154,23 +152,18 @@ def get_mandates(
values = appInterface.db.getDistinctColumnValues(Mandate, column, crossPagination)
return JSONResponse(content=sorted(values, key=lambda v: str(v).lower()))
else:
mandateItems = applyGroupScopeFilter(_mandateItemsForAdmin(), groupCtx.itemIds)
return handleFilterValuesInMemory(mandateItems, column, pagination)
return handleFilterValuesInMemory(_mandateItemsForAdmin(), column, pagination)
if mode == "ids":
if isPlatformAdmin:
return handleIdsMode(appInterface.db, Mandate, pagination)
else:
mandateItems = applyGroupScopeFilter(_mandateItemsForAdmin(), groupCtx.itemIds)
return handleIdsInMemory(mandateItems, pagination)
return handleIdsInMemory(_mandateItemsForAdmin(), pagination)
if isPlatformAdmin:
result = appInterface.getAllMandates(pagination=paginationParams)
items = result.items if hasattr(result, 'items') else (result if isinstance(result, list) else [])
items = applyGroupScopeFilter(
[i.model_dump() if hasattr(i, 'model_dump') else (i if isinstance(i, dict) else vars(i)) for i in items],
groupCtx.itemIds,
)
items = [i.model_dump() if hasattr(i, 'model_dump') else (i if isinstance(i, dict) else vars(i)) for i in items]
if paginationParams and hasattr(result, 'items'):
return PaginatedResponse(
items=items,
@ -182,13 +175,11 @@ def get_mandates(
sort=paginationParams.sort,
filters=paginationParams.filters
),
groupTree=groupCtx.groupTree,
)
else:
return PaginatedResponse(items=items, pagination=None, groupTree=groupCtx.groupTree)
return PaginatedResponse(items=items, pagination=None)
else:
mandateItems = applyGroupScopeFilter(_mandateItemsForAdmin(), groupCtx.itemIds)
return PaginatedResponse(items=mandateItems, pagination=None, groupTree=groupCtx.groupTree)
return PaginatedResponse(items=_mandateItemsForAdmin(), pagination=None)
except HTTPException:
raise

View file

@ -3,8 +3,10 @@
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Query
from typing import List, Dict, Any, Optional
from fastapi import status
from fastapi.responses import JSONResponse
import logging
import json
import math
# Import auth module
from modules.auth import limiter, getCurrentUser
@ -46,13 +48,13 @@ def get_prompts(
"""
from modules.routes.routeHelpers import (
handleFilterValuesInMemory, handleIdsInMemory, enrichRowsWithFkLabels,
handleGroupingInRequest, applyGroupScopeFilter,
resolveView, applyViewToParams, buildGroupLayout, effective_group_by_levels,
)
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
from modules.datamodels.datamodelPagination import AppliedViewMeta
CONTEXT_KEY = "prompts"
# Parse pagination params early — needed for grouping in all modes
paginationParams = None
if pagination:
try:
@ -64,7 +66,13 @@ def get_prompts(
raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}")
appInterface = getAppInterface(currentUser)
groupCtx = handleGroupingInRequest(paginationParams, appInterface, CONTEXT_KEY)
# Resolve view and merge config into params
viewKey = paginationParams.viewKey if paginationParams else None
viewConfig, viewDisplayName = resolveView(appInterface, CONTEXT_KEY, viewKey)
viewMeta = AppliedViewMeta(viewKey=viewKey, displayName=viewDisplayName) if viewKey else None
paginationParams = applyViewToParams(paginationParams, viewConfig)
groupByLevels = effective_group_by_levels(paginationParams, viewConfig)
def _promptsToEnrichedDicts(promptItems):
dicts = [r.model_dump() if hasattr(r, 'model_dump') else (dict(r) if not isinstance(r, dict) else r) for r in promptItems]
@ -73,43 +81,98 @@ def get_prompts(
managementInterface = interfaceDbManagement.getInterface(currentUser)
if mode == "groupSummary":
if not pagination:
raise HTTPException(status_code=400, detail="pagination required for groupSummary")
from modules.routes.routeHelpers import (
apply_strategy_b_filters_and_sort,
build_group_summary_groups,
)
if not groupByLevels or not groupByLevels[0].get("field"):
raise HTTPException(
status_code=400,
detail="groupByLevels[0].field required for groupSummary",
)
field = groupByLevels[0]["field"]
null_label = str(groupByLevels[0].get("nullLabel") or "")
result = managementInterface.getAllPrompts(pagination=None)
allItems = _promptsToEnrichedDicts(
result if isinstance(result, list) else (result.items if hasattr(result, "items") else [])
)
filtered = apply_strategy_b_filters_and_sort(allItems, paginationParams, currentUser)
groups_out = build_group_summary_groups(filtered, field, null_label)
return JSONResponse(content={"groups": groups_out})
if mode == "filterValues":
if not column:
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
result = managementInterface.getAllPrompts(pagination=None)
items = _promptsToEnrichedDicts(result)
items = applyGroupScopeFilter(items, groupCtx.itemIds)
return handleFilterValuesInMemory(items, column, pagination)
return handleFilterValuesInMemory(_promptsToEnrichedDicts(result), column, pagination)
if mode == "ids":
result = managementInterface.getAllPrompts(pagination=None)
items = _promptsToEnrichedDicts(result)
items = applyGroupScopeFilter(items, groupCtx.itemIds)
return handleIdsInMemory(items, pagination)
return handleIdsInMemory(_promptsToEnrichedDicts(result), pagination)
result = managementInterface.getAllPrompts(pagination=paginationParams)
if not groupByLevels:
# No grouping: let DB handle pagination directly
result = managementInterface.getAllPrompts(pagination=paginationParams)
if paginationParams and hasattr(result, 'items'):
response: dict = {
"items": _promptsToEnrichedDicts(result.items),
"pagination": PaginationMetadata(
currentPage=paginationParams.page,
pageSize=paginationParams.pageSize,
totalItems=result.totalItems,
totalPages=result.totalPages,
sort=paginationParams.sort,
filters=paginationParams.filters
).model_dump(),
}
else:
response = {"items": _promptsToEnrichedDicts(result if isinstance(result, list) else [result]), "pagination": None}
if viewMeta:
response["appliedView"] = viewMeta.model_dump()
return response
if paginationParams:
items = applyGroupScopeFilter(_promptsToEnrichedDicts(result.items), groupCtx.itemIds)
return {
"items": items,
"pagination": PaginationMetadata(
currentPage=paginationParams.page,
pageSize=paginationParams.pageSize,
totalItems=result.totalItems,
totalPages=result.totalPages,
sort=paginationParams.sort,
filters=paginationParams.filters
).model_dump(),
"groupTree": groupCtx.groupTree,
}
else:
items = applyGroupScopeFilter(_promptsToEnrichedDicts(result), groupCtx.itemIds)
return {
"items": items,
"pagination": None,
"groupTree": groupCtx.groupTree,
}
# Strategy B grouping: load all, filter+sort in-memory, group, then slice
result = managementInterface.getAllPrompts(pagination=None)
allItems = _promptsToEnrichedDicts(result if isinstance(result, list) else (result.items if hasattr(result, 'items') else []))
if not paginationParams:
response = {"items": allItems, "pagination": None}
if viewMeta:
response["appliedView"] = viewMeta.model_dump()
return response
if paginationParams.filters or paginationParams.sort:
from modules.interfaces.interfaceDbManagement import ComponentObjects
comp = ComponentObjects()
comp.setUserContext(currentUser)
if paginationParams.filters:
allItems = comp._applyFilters(allItems, paginationParams.filters)
if paginationParams.sort:
allItems = comp._applySorting(allItems, paginationParams.sort)
totalItems = len(allItems)
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
page_items, groupLayout = buildGroupLayout(allItems, groupByLevels, paginationParams.page, paginationParams.pageSize)
response = {
"items": page_items,
"pagination": PaginationMetadata(
currentPage=paginationParams.page,
pageSize=paginationParams.pageSize,
totalItems=totalItems,
totalPages=totalPages,
sort=paginationParams.sort,
filters=paginationParams.filters
).model_dump(),
}
if groupLayout:
response["groupLayout"] = groupLayout.model_dump()
if viewMeta:
response["appliedView"] = viewMeta.model_dump()
return response
@router.post("", response_model=Prompt)

View file

@ -208,7 +208,6 @@ def get_users(
- GET /api/users/ (no pagination - returns all users in mandate)
- GET /api/users/?pagination={"page":1,"pageSize":10,"sort":[]}
"""
# Parse pagination early — needed for grouping in all modes
_paginationParams = None
if pagination:
try:
@ -219,10 +218,6 @@ def get_users(
except (json.JSONDecodeError, ValueError) as e:
raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}")
from modules.routes.routeHelpers import handleGroupingInRequest as _handleGrouping, applyGroupScopeFilter as _applyGroupScope
_appInterfaceForGrouping = interfaceDbApp.getInterface(context.user, mandateId=context.mandateId)
_groupCtx = _handleGrouping(_paginationParams, _appInterfaceForGrouping, "users")
if mode == "filterValues":
if not column:
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
@ -233,14 +228,12 @@ def get_users(
try:
paginationParams = _paginationParams
appInterface = _appInterfaceForGrouping
if context.mandateId:
# Get users for specific mandate using getUsersByMandate
result = appInterface.getUsersByMandate(str(context.mandateId), paginationParams)
appInterface = interfaceDbApp.getInterface(context.user, mandateId=context.mandateId)
if context.mandateId:
result = appInterface.getUsersByMandate(str(context.mandateId), paginationParams)
if paginationParams and hasattr(result, 'items'):
enriched = _applyGroupScope(enrichRowsWithFkLabels(_usersToDicts(result.items), User), _groupCtx.itemIds)
enriched = enrichRowsWithFkLabels(_usersToDicts(result.items), User)
return {
"items": enriched,
"pagination": PaginationMetadata(
@ -251,18 +244,14 @@ def get_users(
sort=paginationParams.sort,
filters=paginationParams.filters
).model_dump(),
"groupTree": _groupCtx.groupTree,
}
else:
users = result if isinstance(result, list) else result.items if hasattr(result, 'items') else []
enriched = _applyGroupScope(enrichRowsWithFkLabels(_usersToDicts(users), User), _groupCtx.itemIds)
return {"items": enriched, "pagination": None, "groupTree": _groupCtx.groupTree}
return {"items": enrichRowsWithFkLabels(_usersToDicts(users), User), "pagination": None}
elif context.isPlatformAdmin:
# PlatformAdmin without mandateId — DB-level pagination via interface
result = appInterface.getAllUsers(paginationParams)
if paginationParams and hasattr(result, 'items'):
enriched = _applyGroupScope(enrichRowsWithFkLabels(_usersToDicts(result.items), User), _groupCtx.itemIds)
enriched = enrichRowsWithFkLabels(_usersToDicts(result.items), User)
return {
"items": enriched,
"pagination": PaginationMetadata(
@ -273,18 +262,13 @@ def get_users(
sort=paginationParams.sort,
filters=paginationParams.filters
).model_dump(),
"groupTree": _groupCtx.groupTree,
}
else:
users = result if isinstance(result, list) else (result.items if hasattr(result, 'items') else [])
enriched = _applyGroupScope(enrichRowsWithFkLabels(_usersToDicts(users), User), _groupCtx.itemIds)
return {"items": enriched, "pagination": None, "groupTree": _groupCtx.groupTree}
return {"items": enrichRowsWithFkLabels(_usersToDicts(users), User), "pagination": None}
else:
# Non-SysAdmin without mandateId: aggregate users across all admin mandates
rootInterface = getRootInterface()
userMandates = rootInterface.getUserMandates(str(context.user.id))
# Find mandates where user has admin role
adminMandateIds = []
for um in userMandates:
umId = getattr(um, 'id', None)
@ -297,13 +281,10 @@ def get_users(
if role and role.roleLabel == "admin" and not role.featureInstanceId:
adminMandateIds.append(str(mandateId))
break
if not adminMandateIds:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=routeApiMsg("No admin access to any mandate")
)
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("No admin access to any mandate"))
from modules.datamodels.datamodelMembership import UserMandate as UserMandateModel
allUM = rootInterface.db.getRecordset(UserMandateModel, recordFilter={"mandateId": adminMandateIds})
uniqueUserIds = list({
@ -312,13 +293,10 @@ def get_users(
if (um.get("userId") if isinstance(um, dict) else getattr(um, "userId", None))
})
batchUsers = rootInterface.getUsersByIds(uniqueUserIds) if uniqueUserIds else {}
allUsers = [
u.model_dump() if hasattr(u, 'model_dump') else vars(u)
for u in batchUsers.values()
]
allUsers = [u.model_dump() if hasattr(u, 'model_dump') else vars(u) for u in batchUsers.values()]
from modules.routes.routeHelpers import applyFiltersAndSort as _applyFiltersAndSortHelper
filteredUsers = _applyGroupScope(_applyFiltersAndSortHelper(allUsers, paginationParams), _groupCtx.itemIds)
filteredUsers = _applyFiltersAndSortHelper(allUsers, paginationParams)
enriched = enrichRowsWithFkLabels(filteredUsers, User)
if paginationParams:
@ -327,7 +305,6 @@ def get_users(
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
endIdx = startIdx + paginationParams.pageSize
return {
"items": enriched[startIdx:endIdx],
"pagination": PaginationMetadata(
@ -338,10 +315,9 @@ def get_users(
sort=paginationParams.sort,
filters=paginationParams.filters
).model_dump(),
"groupTree": _groupCtx.groupTree,
}
else:
return {"items": enriched, "pagination": None, "groupTree": _groupCtx.groupTree}
return {"items": enriched, "pagination": None}
except HTTPException:
raise
except Exception as e:

View file

@ -704,154 +704,260 @@ def paginateInMemory(
# ---------------------------------------------------------------------------
# Table Grouping helpers
# View resolution and Strategy B grouping engine
# ---------------------------------------------------------------------------
from dataclasses import dataclass, field as dc_field
@dataclass
class GroupingContext:
def resolveView(interface, contextKey: str, viewKey: Optional[str]):
"""
Result of handleGroupingInRequest.
Carries the group tree for the response and the resolved item-ID set for
group-scope filtering (None = no active group scope).
Load a TableListView for the current user and contextKey.
Returns (config_dict, display_name):
- (None, None) when viewKey is None / empty
- (config, str | None) otherwise config may be {}; display_name from the row
Raises HTTPException(404) when viewKey is explicitly set but the view
does not exist (prevents silent fallback to ungrouped behaviour).
"""
groupTree: Optional[list] # List[TableGroupNode] serialised as dicts — for response
itemIds: Optional[set] # Set[str] when groupId was set, else None
from fastapi import HTTPException
if not viewKey:
return None, None
try:
view = interface.getTableListView(contextKey=contextKey, viewKey=viewKey)
except Exception as e:
logger.warning(f"resolveView: store lookup failed for key={viewKey!r} context={contextKey!r}: {e}")
view = None
if view is None:
raise HTTPException(status_code=404, detail=f"View '{viewKey}' not found for context '{contextKey}'")
cfg = view.config or {}
dname = getattr(view, "displayName", None) or None
return cfg, dname
def _collectItemIds(nodes: list, groupId: str) -> Optional[set]:
def effective_group_by_levels(
pagination_params: Optional["PaginationParams"],
view_config: Optional[dict],
) -> List[Dict[str, Any]]:
"""
Recursively search *nodes* for a node whose id == groupId and collect
all itemIds from it and all its descendant subGroups.
Returns None if the group is not found.
Choose grouping levels for this request.
If the client sends ``groupByLevels`` (including ``[]``), it wins over the
saved view. If the key is omitted (``None``), use the view's levels.
"""
for node in nodes:
nodeId = node.get("id") if isinstance(node, dict) else getattr(node, "id", None)
if nodeId == groupId:
ids: set = set()
_collectAllIds(node, ids)
return ids
subGroups = node.get("subGroups", []) if isinstance(node, dict) else getattr(node, "subGroups", [])
result = _collectItemIds(subGroups, groupId)
if result is not None:
return result
return None
if pagination_params is not None:
req = getattr(pagination_params, "groupByLevels", None)
if req is not None:
out: List[Dict[str, Any]] = []
for lvl in req:
if hasattr(lvl, "model_dump"):
out.append(lvl.model_dump())
elif isinstance(lvl, dict):
out.append(dict(lvl))
else:
out.append(dict(lvl)) # type: ignore[arg-type]
return out
vc = (view_config or {}).get("groupByLevels") if view_config else None
return list(vc or [])
def _collectAllIds(node, ids: set) -> None:
"""Collect itemIds from a node and all its descendants into ids."""
nodeItemIds = node.get("itemIds", []) if isinstance(node, dict) else getattr(node, "itemIds", [])
for iid in nodeItemIds:
ids.add(str(iid))
subGroups = node.get("subGroups", []) if isinstance(node, dict) else getattr(node, "subGroups", [])
for child in subGroups:
_collectAllIds(child, ids)
def _removeGroupFromTree(nodes: list, groupId: str) -> list:
"""Remove a group node (and all descendants) from the tree by id."""
result = []
for node in nodes:
nodeId = node.get("id") if isinstance(node, dict) else getattr(node, "id", None)
if nodeId == groupId:
continue # skip this node (remove it)
subGroups = node.get("subGroups", []) if isinstance(node, dict) else getattr(node, "subGroups", [])
filtered_sub = _removeGroupFromTree(subGroups, groupId)
if isinstance(node, dict):
node = {**node, "subGroups": filtered_sub}
result.append(node)
return result
def handleGroupingInRequest(
paginationParams: Optional[PaginationParams],
interface,
contextKey: str,
) -> GroupingContext:
def applyViewToParams(params: Optional["PaginationParams"], viewConfig: Optional[dict]) -> Optional["PaginationParams"]:
"""
Central grouping handler call at the start of every list route that
supports table grouping.
Merge a view's saved configuration into PaginationParams.
Steps (in order):
1. If paginationParams.saveGroupTree is set:
persist the new tree via interface.upsertTableGrouping, then clear
saveGroupTree from paginationParams so it is not treated as a filter.
2. Load the current group tree from the DB (used in step 3 and response).
3. If paginationParams.groupId is set:
resolve it to a Set[str] of itemIds (including all sub-groups),
then clear groupId from paginationParams so it is not treated as a
normal filter field.
4. Return a GroupingContext with groupTree (for the response) and itemIds
(for applyGroupScopeFilter).
Priority: explicit request fields win over view defaults.
- sort: use request sort if non-empty, otherwise view sort
- filters: deep-merge (request filters win per-key)
- pageSize: use request value (already set by normalize_pagination_dict)
The caller does NOT need to handle any grouping logic itself just call
applyGroupScopeFilter(items, groupCtx.itemIds) and embed groupCtx.groupTree
in the response dict.
Returns the (mutated) params, or a new minimal PaginationParams when
params is None (so callers always get a valid object).
"""
from modules.datamodels.datamodelPagination import TableGroupNode
from modules.datamodels.datamodelPagination import PaginationParams, SortField
if not viewConfig:
return params
groupTree = None
itemIds = None
if params is None:
params = PaginationParams(page=1, pageSize=25)
if paginationParams is None:
# Sort: request wins if non-empty
if not params.sort and viewConfig.get("sort"):
try:
existing = interface.getTableGrouping(contextKey)
if existing:
groupTree = [n.model_dump() if hasattr(n, "model_dump") else n for n in existing.rootGroups]
params.sort = [
SortField(**s) if isinstance(s, dict) else s
for s in viewConfig["sort"]
]
except Exception as e:
logger.warning(f"handleGroupingInRequest: getTableGrouping failed: {e}")
return GroupingContext(groupTree=groupTree, itemIds=None)
logger.warning(f"applyViewToParams: could not parse view sort: {e}")
# Step 1: persist saveGroupTree if present
if paginationParams.saveGroupTree is not None:
try:
saved = interface.upsertTableGrouping(contextKey, paginationParams.saveGroupTree)
groupTree = [n.model_dump() if hasattr(n, "model_dump") else n for n in saved.rootGroups]
except Exception as e:
logger.error(f"handleGroupingInRequest: upsertTableGrouping failed: {e}")
paginationParams.saveGroupTree = None
# Filters: deep-merge (request filters take priority per-key)
viewFilters = viewConfig.get("filters") or {}
if viewFilters:
merged = dict(viewFilters)
if params.filters:
merged.update(params.filters)
params.filters = merged
# Step 2: load current tree (only if not already set from save above)
if groupTree is None:
try:
existing = interface.getTableGrouping(contextKey)
if existing:
groupTree = [n.model_dump() if hasattr(n, "model_dump") else n for n in existing.rootGroups]
except Exception as e:
logger.warning(f"handleGroupingInRequest: getTableGrouping failed: {e}")
return params
# Step 3: resolve groupId to itemIds set
if paginationParams.groupId is not None:
targetGroupId = paginationParams.groupId
paginationParams.groupId = None # remove so it is not treated as a normal filter
if groupTree:
itemIds = _collectItemIds(groupTree, targetGroupId)
if itemIds is None:
logger.warning(
f"handleGroupingInRequest: groupId={targetGroupId!r} not found in tree "
f"for contextKey={contextKey!r} — returning empty set"
)
itemIds = set() # unknown group → show nothing rather than everything
def apply_strategy_b_filters_and_sort(
items: List[Dict[str, Any]],
pagination_params: Optional[PaginationParams],
current_user: Any,
) -> List[Dict[str, Any]]:
"""
Shared in-memory filter + sort pass for Strategy B (files/prompts/connections lists).
"""
if not pagination_params:
return list(items)
from modules.interfaces.interfaceDbManagement import ComponentObjects
comp = ComponentObjects()
comp.setUserContext(current_user)
out = list(items)
if pagination_params.filters:
out = comp._applyFilters(out, pagination_params.filters)
if pagination_params.sort:
out = comp._applySorting(out, pagination_params.sort)
return out
def build_group_summary_groups(
items: List[Dict[str, Any]],
field: str,
null_label: str = "",
) -> List[Dict[str, Any]]:
"""
Build {"value", "label", "totalCount"} for mode=groupSummary (single grouping level).
"""
from collections import defaultdict
counts: Dict[str, int] = defaultdict(int)
display_by_key: Dict[str, str] = {}
null_key = "\x00NULL"
label_attr = f"{field}Label"
for item in items:
raw = item.get(field)
if raw is None or raw == "":
nk = null_key
display = null_label
else:
# groupId sent but no tree saved yet → return empty (nothing belongs to any group)
logger.warning(
f"handleGroupingInRequest: groupId={targetGroupId!r} set but no tree exists "
f"for contextKey={contextKey!r} — returning empty set"
)
itemIds = set()
nk = str(raw)
display = None
lbl = item.get(label_attr)
if lbl is not None and lbl != "":
display = str(lbl)
if display is None:
display = nk
counts[nk] += 1
if nk not in display_by_key:
display_by_key[nk] = display
return GroupingContext(groupTree=groupTree, itemIds=itemIds)
ordered_keys = sorted(
counts.keys(),
key=lambda x: (x == null_key, str(display_by_key.get(x, x)).lower()),
)
return [
{
"value": None if nk == null_key else nk,
"label": display_by_key.get(nk, nk),
"totalCount": counts[nk],
}
for nk in ordered_keys
]
def applyGroupScopeFilter(items: List[Dict[str, Any]], itemIds: Optional[set]) -> List[Dict[str, Any]]:
def buildGroupLayout(
all_items: List[Dict[str, Any]],
groupByLevels: List[Dict[str, Any]],
page: int,
pageSize: int,
) -> tuple:
"""
Filter items to those whose "id" field is in itemIds.
Returns items unchanged when itemIds is None (no active group scope).
Works for both normal list items and for mode=ids / mode=filterValues flows
call it before handleIdsInMemory / handleFilterValuesInMemory.
Apply multi-level grouping to all_items, slice to the requested page,
and return (page_items, GroupLayout | None).
Strategy B: grouping operates on the full filtered+sorted candidate list.
Items are stably re-sorted by the group path so that members of the same
group are always contiguous (preserving the existing per-group sort order
from the caller).
Parameters
----------
all_items: fully filtered and user-sorted list of row dicts.
groupByLevels: list of {"field": str, "nullLabel": str, "direction": "asc"|"desc"} dicts.
page, pageSize: 1-based page index and page size.
Returns
-------
(page_items, GroupLayout | None)
"""
if itemIds is None:
return items
return [item for item in items if str(item.get("id", "")) in itemIds]
from functools import cmp_to_key
from modules.datamodels.datamodelPagination import GroupBand, GroupLayout
if not groupByLevels:
offset = (page - 1) * pageSize
return all_items[offset:offset + pageSize], None
levels = [lvl.get("field", "") for lvl in groupByLevels if lvl.get("field")]
if not levels:
offset = (page - 1) * pageSize
return all_items[offset:offset + pageSize], None
nullLabels = {lvl.get("field", ""): lvl.get("nullLabel", "") for lvl in groupByLevels}
def _path_key(item: dict) -> tuple:
return tuple(
str(item.get(f) or "") if item.get(f) is not None else nullLabels.get(f, "")
for f in levels
)
def _item_cmp(a: dict, b: dict) -> int:
pa, pb = _path_key(a), _path_key(b)
for i in range(len(levels)):
if pa[i] != pb[i]:
asc = (groupByLevels[i].get("direction") or "asc").lower() != "desc"
if pa[i] < pb[i]:
return -1 if asc else 1
return 1 if asc else -1
return 0
# Sort by group path (per-level asc/desc); order within same path stays stable in Py3.12+
all_items.sort(key=cmp_to_key(_item_cmp))
# Build global band list from the full sorted list
bands_global: List[dict] = []
current_path: Optional[tuple] = None
current_start = 0
for i, item in enumerate(all_items):
path = _path_key(item)
if path != current_path:
if current_path is not None:
bands_global.append({"path": list(current_path), "startIdx": current_start, "endIdx": i})
current_path = path
current_start = i
if current_path is not None:
bands_global.append({"path": list(current_path), "startIdx": current_start, "endIdx": len(all_items)})
# Slice to page
page_start = (page - 1) * pageSize
page_end = page_start + pageSize
page_items = all_items[page_start:page_end]
# Find bands that have at least one row on this page
bands_on_page: List[GroupBand] = []
for band in bands_global:
inter_start = max(band["startIdx"], page_start)
inter_end = min(band["endIdx"], page_end)
if inter_start >= inter_end:
continue
path_list = band["path"]
bands_on_page.append(GroupBand(
path=path_list,
label=path_list[-1] if path_list else "",
startRowIndex=inter_start - page_start,
rowCount=inter_end - inter_start,
))
group_layout = GroupLayout(levels=levels, bands=bands_on_page) if bands_on_page else GroupLayout(levels=levels, bands=[])
return page_items, group_layout

View file

@ -106,13 +106,14 @@ class SubscriptionStatusResponse(BaseModel):
usage: Optional[SubscriptionUsage] = None
def _computeUsage(mandateId: str, plan) -> SubscriptionUsage:
def _computeUsage(mandateId: str, plan, operative: Optional[Dict[str, Any]] = None) -> SubscriptionUsage:
"""Compute current usage metrics for a mandate's subscription."""
try:
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelMembership import UserMandate
from modules.datamodels.datamodelFeatures import FeatureInstance
from modules.interfaces.interfaceDbKnowledge import aggregateMandateRagTotalBytes
from modules.datamodels.datamodelSubscription import getEffectiveLimits
rootIf = getRootInterface()
@ -128,7 +129,8 @@ def _computeUsage(mandateId: str, plan) -> SubscriptionUsage:
ragBytes = aggregateMandateRagTotalBytes(mandateId)
usedMB = round(ragBytes / (1024 * 1024), 2)
maxMB = plan.maxDataVolumeMB if plan else None
limits = getEffectiveLimits(operative, plan) if operative else {}
maxMB = limits.get("maxDataVolumeMB") if limits else (plan.maxDataVolumeMB if plan else None)
storagePercent = round((usedMB / maxMB) * 100, 1) if maxMB else None
return SubscriptionUsage(
@ -207,7 +209,7 @@ def getStatus(request: Request, context: RequestContext = Depends(getRequestCont
plan = subService.getPlan(operative.get("planKey", ""))
usage = _computeUsage(mandateId, plan)
usage = _computeUsage(mandateId, plan, operative)
return SubscriptionStatusResponse(
active=True,
@ -451,13 +453,16 @@ def _buildEnrichedSubscriptions() -> List[Dict[str, Any]]:
sub["planTitle"] = resolveText(plan.title) if plan else planKey
if sub.get("status") in operativeValues:
userPrice = sub.get("snapshotPricePerUserCHF", 0) or 0
instPrice = sub.get("snapshotPricePerInstanceCHF", 0) or 0
userCount = userCountMap.get(mid, 0)
instanceCount = instanceCountMap.get(mid, 0)
includedModules = plan.includedModules if plan else 0
billableModules = max(0, instanceCount - includedModules)
sub["monthlyRevenueCHF"] = round(userPrice * userCount + instPrice * billableModules, 2)
if sub.get("isEnterprise"):
sub["monthlyRevenueCHF"] = round(sub.get("enterpriseFlatPriceCHF") or 0, 2)
else:
userPrice = sub.get("snapshotPricePerUserCHF", 0) or 0
instPrice = sub.get("snapshotPricePerInstanceCHF", 0) or 0
includedModules = plan.includedModules if plan else 0
billableModules = max(0, instanceCount - includedModules)
sub["monthlyRevenueCHF"] = round(userPrice * userCount + instPrice * billableModules, 2)
sub["activeUsers"] = userCount
sub["activeInstances"] = instanceCount
else:
@ -570,13 +575,16 @@ def _getDataVolumeUsage(
ragBytes = aggregateMandateRagTotalBytes(mandateId)
ragMB = round(ragBytes / (1024 * 1024), 2)
from modules.datamodels.datamodelSubscription import getEffectiveLimits
maxMB = None
subIf = _getSubRootIf()
operative = subIf.getOperativeForMandate(mandateId)
if operative:
plan = subIf.getPlan(operative.get("planKey") or "")
if plan and plan.maxDataVolumeMB is not None:
maxMB = int(plan.maxDataVolumeMB)
limits = getEffectiveLimits(operative, plan)
if limits["maxDataVolumeMB"] is not None:
maxMB = int(limits["maxDataVolumeMB"])
usedMB = ragMB
percentUsed = round((usedMB / maxMB) * 100, 1) if maxMB else None
@ -593,3 +601,147 @@ def _getDataVolumeUsage(
"percentUsed": percentUsed,
"warning": (percentUsed or 0) >= 80,
}
# =============================================================================
# Enterprise Subscription (SysAdmin-only)
# =============================================================================
class EnterpriseCreateRequest(BaseModel):
mandateId: str = Field(..., description="Target mandate ID")
startDate: float = Field(..., description="Period start (UTC unix timestamp)")
endDate: float = Field(..., description="Period end (UTC unix timestamp)")
autoRenew: bool = Field(default=False, description="Auto-renew at period end")
flatPriceCHF: float = Field(..., description="Flat price per period (CHF)")
maxUsers: Optional[int] = Field(None, description="Max users (None = unlimited)")
maxFeatureInstances: Optional[int] = Field(None, description="Max feature instances (None = unlimited)")
maxDataVolumeMB: Optional[int] = Field(None, description="Max storage in MB (None = unlimited)")
budgetAiCHF: Optional[float] = Field(None, description="Fixed AI budget per period (CHF)")
note: Optional[str] = Field(None, description="Free-text note (e.g. contract reference)")
class EnterpriseRenewRequest(BaseModel):
subscriptionId: str = Field(..., description="ID of the enterprise subscription to renew")
newEndDate: float = Field(..., description="New period end (UTC unix timestamp)")
autoRenew: Optional[bool] = Field(None, description="Override auto-renew flag")
flatPriceCHF: Optional[float] = Field(None, description="Override flat price (CHF)")
maxUsers: Optional[int] = Field(None, description="Override max users")
maxFeatureInstances: Optional[int] = Field(None, description="Override max feature instances")
maxDataVolumeMB: Optional[int] = Field(None, description="Override max storage (MB)")
budgetAiCHF: Optional[float] = Field(None, description="Override AI budget (CHF)")
note: Optional[str] = Field(None, description="Override note")
class EnterpriseUpdateRequest(BaseModel):
subscriptionId: str = Field(..., description="ID of the enterprise subscription to update")
enterpriseFlatPriceCHF: Optional[float] = Field(None, description="New flat price (CHF)")
enterpriseMaxUsers: Optional[int] = Field(None, description="New max users")
enterpriseMaxFeatureInstances: Optional[int] = Field(None, description="New max feature instances")
enterpriseMaxDataVolumeMB: Optional[int] = Field(None, description="New max storage (MB)")
enterpriseBudgetAiCHF: Optional[float] = Field(None, description="New AI budget (CHF)")
enterpriseNote: Optional[str] = Field(None, description="New note")
recurring: Optional[bool] = Field(None, description="Update auto-renew flag")
@router.post("/enterprise/create", response_model=Dict[str, Any])
@limiter.limit("10/minute")
def createEnterprise(
request: Request,
data: EnterpriseCreateRequest,
context: RequestContext = Depends(getRequestContext),
):
"""SysAdmin: create an enterprise subscription with custom flat pricing and limits."""
if not context.isPlatformAdmin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Sysadmin role required"))
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import (
getService as getSubscriptionService,
)
try:
subService = getSubscriptionService(context.user, data.mandateId)
return subService.createEnterprise(
mandateId=data.mandateId,
startDate=data.startDate,
endDate=data.endDate,
autoRenew=data.autoRenew,
flatPriceCHF=data.flatPriceCHF,
maxUsers=data.maxUsers,
maxFeatureInstances=data.maxFeatureInstances,
maxDataVolumeMB=data.maxDataVolumeMB,
budgetAiCHF=data.budgetAiCHF,
note=data.note,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Error creating enterprise subscription: %s", e)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/enterprise/renew", response_model=Dict[str, Any])
@limiter.limit("10/minute")
def renewEnterprise(
request: Request,
data: EnterpriseRenewRequest,
context: RequestContext = Depends(getRequestContext),
):
"""SysAdmin: renew an enterprise subscription (expire old, create new with same or overridden params)."""
if not context.isPlatformAdmin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Sysadmin role required"))
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import (
getService as getSubscriptionService,
)
from modules.interfaces.interfaceDbSubscription import getRootInterface as getSubRootInterface
sub = getSubRootInterface().getById(data.subscriptionId)
if not sub:
raise HTTPException(status_code=404, detail=routeApiMsg("Subscription not found"))
mandateId = sub["mandateId"]
overrides = {}
for field in ("autoRenew", "flatPriceCHF", "maxUsers", "maxFeatureInstances",
"maxDataVolumeMB", "budgetAiCHF", "note"):
val = getattr(data, field, None)
if val is not None:
overrides[field] = val
try:
subService = getSubscriptionService(context.user, mandateId)
return subService.renewEnterprise(data.subscriptionId, data.newEndDate, overrides or None)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Error renewing enterprise subscription %s: %s", data.subscriptionId, e)
raise HTTPException(status_code=500, detail=str(e))
@router.put("/enterprise/update", response_model=Dict[str, Any])
@limiter.limit("30/minute")
def updateEnterprise(
request: Request,
data: EnterpriseUpdateRequest,
context: RequestContext = Depends(getRequestContext),
):
"""SysAdmin: update enterprise subscription parameters (limits, price, note)."""
if not context.isPlatformAdmin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Sysadmin role required"))
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import (
getService as getSubscriptionService,
)
from modules.interfaces.interfaceDbSubscription import getRootInterface as getSubRootInterface
sub = getSubRootInterface().getById(data.subscriptionId)
if not sub:
raise HTTPException(status_code=404, detail=routeApiMsg("Subscription not found"))
mandateId = sub["mandateId"]
changes = {k: v for k, v in data.model_dump(exclude={"subscriptionId"}).items() if v is not None}
try:
subService = getSubscriptionService(context.user, mandateId)
return subService.updateEnterprise(data.subscriptionId, changes)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error("Error updating enterprise subscription %s: %s", data.subscriptionId, e)
raise HTTPException(status_code=500, detail=str(e))

View file

@ -0,0 +1,177 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
CRUD endpoints for saved table views (TableListView).
A view stores a named preset of filters, sort order, and groupByLevels for a
specific table (identified by contextKey). Views are per-user and optionally
per-mandate.
Route prefix: /api/table-views
"""
import logging
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Query, Request
from fastapi import status
from modules.auth import limiter, getCurrentUser
from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelPagination import TableListView
import modules.interfaces.interfaceDbApp as interfaceDbApp
logger = logging.getLogger(__name__)
router = APIRouter(
prefix="/api/table-views",
tags=["Table Views"],
responses={404: {"description": "Not found"}},
)
def _ownedOrRaise(view: Optional[TableListView], viewId: str, userId: str):
"""Raise 404 when view is missing; ownership is implicitly guaranteed by the
interface layer (views are always queried with the current userId)."""
if view is None:
raise HTTPException(status_code=404, detail=f"View '{viewId}' not found")
return view
# ---------------------------------------------------------------------------
# List views for a context
# ---------------------------------------------------------------------------
@router.get("")
@limiter.limit("60/minute")
def list_views(
request: Request,
contextKey: str = Query(..., description="Table context key, e.g. 'connections', 'files/list'"),
currentUser: User = Depends(getCurrentUser),
):
"""List all saved views for the current user and contextKey."""
iface = interfaceDbApp.getInterface(currentUser)
views = iface.getTableListViews(contextKey=contextKey)
return [v.model_dump() if hasattr(v, "model_dump") else v for v in views]
# ---------------------------------------------------------------------------
# Get one view
# ---------------------------------------------------------------------------
@router.get("/{viewKey}")
@limiter.limit("60/minute")
def get_view(
request: Request,
viewKey: str = Path(..., description="View slug"),
contextKey: str = Query(..., description="Table context key"),
currentUser: User = Depends(getCurrentUser),
):
"""Return a single saved view by its viewKey."""
iface = interfaceDbApp.getInterface(currentUser)
view = iface.getTableListView(contextKey=contextKey, viewKey=viewKey)
if view is None:
raise HTTPException(status_code=404, detail=f"View '{viewKey}' not found for context '{contextKey}'")
return view.model_dump() if hasattr(view, "model_dump") else view
# ---------------------------------------------------------------------------
# Create a view
# ---------------------------------------------------------------------------
@router.post("", status_code=status.HTTP_201_CREATED)
@limiter.limit("30/minute")
def create_view(
request: Request,
body: dict = Body(...),
currentUser: User = Depends(getCurrentUser),
):
"""
Create a new saved view.
Body fields:
- contextKey (required): table context key
- viewKey (required): short slug, unique per (user, contextKey)
- displayName (required): human-readable label
- config (optional): view config dict with keys:
schemaVersion, filters, sort, groupByLevels
"""
contextKey = body.get("contextKey")
viewKey = body.get("viewKey")
displayName = body.get("displayName")
config = body.get("config") or {}
if not contextKey:
raise HTTPException(status_code=400, detail="contextKey is required")
if not viewKey:
raise HTTPException(status_code=400, detail="viewKey is required")
if not displayName:
raise HTTPException(status_code=400, detail="displayName is required")
iface = interfaceDbApp.getInterface(currentUser)
try:
view = iface.createTableListView(
contextKey=contextKey,
viewKey=viewKey,
displayName=displayName,
config=config,
)
return view.model_dump() if hasattr(view, "model_dump") else view
except ValueError as e:
raise HTTPException(status_code=409, detail=str(e))
except Exception as e:
logger.error(f"create_view failed: {e}")
raise HTTPException(status_code=500, detail="Failed to create view")
# ---------------------------------------------------------------------------
# Update a view (by id)
# ---------------------------------------------------------------------------
@router.put("/{viewId}")
@limiter.limit("30/minute")
def update_view(
request: Request,
viewId: str = Path(..., description="View primary-key id (not viewKey)"),
body: dict = Body(...),
currentUser: User = Depends(getCurrentUser),
):
"""
Update an existing view.
Updatable fields: displayName, viewKey, config.
The contextKey cannot be changed after creation.
"""
allowed = {"displayName", "viewKey", "config"}
updates = {k: v for k, v in body.items() if k in allowed}
if not updates:
raise HTTPException(status_code=400, detail=f"No updatable fields provided. Allowed: {allowed}")
iface = interfaceDbApp.getInterface(currentUser)
try:
updated = iface.updateTableListView(viewId=viewId, updates=updates)
except Exception as e:
logger.error(f"update_view failed: {e}")
raise HTTPException(status_code=500, detail="Failed to update view")
if updated is None:
raise HTTPException(status_code=404, detail=f"View id='{viewId}' not found")
return updated.model_dump() if hasattr(updated, "model_dump") else updated
# ---------------------------------------------------------------------------
# Delete a view (by id)
# ---------------------------------------------------------------------------
@router.delete("/{viewId}", status_code=status.HTTP_204_NO_CONTENT)
@limiter.limit("30/minute")
def delete_view(
request: Request,
viewId: str = Path(..., description="View primary-key id"),
currentUser: User = Depends(getCurrentUser),
):
"""Delete a saved view by its primary-key id."""
iface = interfaceDbApp.getInterface(currentUser)
deleted = iface.deleteTableListView(viewId=viewId)
if not deleted:
raise HTTPException(status_code=404, detail=f"View id='{viewId}' not found or could not be deleted")

View file

@ -203,16 +203,40 @@ def _createDispatchHandler(actionExecutor, methodName: str, actionName: str, ser
args["featureInstanceId"] = context["featureInstanceId"]
if "mandateId" not in args and context.get("mandateId"):
args["mandateId"] = context["mandateId"]
if "parentOperationId" not in args:
import time as _time
toolOpId = f"agentTool_{methodName}_{actionName}_{int(_time.time())}"
chatSvc = getattr(services, "chat", None) if services else None
if chatSvc:
try:
chatSvc.progressLogStart(toolOpId, methodName.capitalize(), actionName, "Agent tool")
except Exception:
pass
args["parentOperationId"] = toolOpId
else:
toolOpId = None
chatSvc = None
result = await actionExecutor.executeAction(methodName, actionName, args)
data = _formatActionResult(result, services, context)
if toolOpId and chatSvc:
try:
chatSvc.progressLogFinish(toolOpId, result.success)
except Exception:
pass
data, sideEvents = _formatActionResult(result, services, context)
return ToolResult(
toolCallId="",
toolName=f"{methodName}_{actionName}",
success=result.success,
data=data,
error=result.error
error=result.error,
sideEvents=sideEvents or None,
)
except Exception as e:
if toolOpId and chatSvc:
try:
chatSvc.progressLogFinish(toolOpId, False)
except Exception:
pass
logger.error(f"ActionToolAdapter dispatch failed for {methodName}_{actionName}: {e}")
return ToolResult(
toolCallId="",
@ -226,11 +250,12 @@ def _createDispatchHandler(actionExecutor, methodName: str, actionName: str, ser
_INLINE_CONTENT_LIMIT = 2000
def _persistLargeDocument(doc, services, context: Dict[str, Any]) -> Optional[str]:
"""Save an ActionDocument with large content as a workspace file.
def _persistLargeDocument(doc, services, context: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Save an ActionDocument as a workspace file.
Returns a formatted result line (with file id + docItem ref) or None
if persistence is not possible.
Handles both str and bytes documentData.
Returns a dict with 'line' (formatted result) and 'fileInfo' (for sideEvents),
or None if persistence is not possible.
"""
if not services:
return None
@ -238,49 +263,77 @@ def _persistLargeDocument(doc, services, context: Dict[str, Any]) -> Optional[st
if not chatService:
return None
docData = getattr(doc, "documentData", None)
if not docData or not isinstance(docData, str):
if not docData:
return None
if isinstance(docData, bytes):
docBytes = docData
elif isinstance(docData, str):
docBytes = docData.encode("utf-8")
else:
return None
docName = getattr(doc, "documentName", "unnamed")
docBytes = docData.encode("utf-8")
docMime = getattr(doc, "mimeType", "application/octet-stream")
try:
fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(docBytes, docName)
fiId = context.get("featureInstanceId") or getattr(services, "featureInstanceId", "")
if fiId:
chatService.interfaceDbComponent.updateFile(fileItem.id, {"featureInstanceId": fiId})
from modules.serviceCenter.services.serviceAgent.coreTools._helpers import (
_attachFileAsChatDocument,
_formatToolFileResult,
_getOrCreateTempFolder,
)
updateFields = {}
tempFolderId = _getOrCreateTempFolder(chatService)
if tempFolderId:
chatService.interfaceDbComponent.updateFile(fileItem.id, {"folderId": tempFolderId})
updateFields["folderId"] = tempFolderId
fiId = context.get("featureInstanceId") or getattr(services, "featureInstanceId", "")
if fiId:
updateFields["featureInstanceId"] = fiId
updateFields["scope"] = "featureInstance"
mandateId = context.get("mandateId") or getattr(services, "mandateId", "")
if mandateId:
updateFields["mandateId"] = mandateId
if updateFields:
logger.debug("_persistLargeDocument: updating file %s with %s", fileItem.id, updateFields)
chatService.interfaceDbComponent.updateFile(fileItem.id, updateFields)
else:
logger.warning("_persistLargeDocument: no updateFields for file %s (tempFolderId=%s, fiId=%s)", fileItem.id, tempFolderId, fiId)
chatDocId = _attachFileAsChatDocument(
services, fileItem,
label=f"action_doc:{docName}",
userMessage=f"Action document: {docName}",
)
return _formatToolFileResult(
line = _formatToolFileResult(
fileItem=fileItem,
chatDocId=chatDocId,
actionLabel="Produced",
extraInfo="Use readFile to read the content.",
)
return {
"line": line,
"fileInfo": {
"fileId": fileItem.id,
"fileName": docName,
"mimeType": docMime,
"fileSize": len(docBytes),
},
}
except Exception as e:
logger.warning(f"_persistLargeDocument failed for {docName}: {e}")
logger.error(f"_persistLargeDocument failed for {docName}: {e}", exc_info=True)
return None
def _formatActionResult(result, services=None, context: Optional[Dict[str, Any]] = None) -> str:
def _formatActionResult(result, services=None, context: Optional[Dict[str, Any]] = None):
"""Format an ActionResult into a text representation for the agent.
Documents whose content exceeds the inline limit are persisted as
workspace files so the agent can access them via readFile /
ai_process / searchInFileContent.
Documents whose content exceeds the inline limit (or is binary bytes)
are persisted as workspace files.
Returns (str, list) the formatted text and a list of sideEvent dicts.
"""
parts = []
sideEvents = []
ctx = context or {}
if result.resultLabel:
@ -296,13 +349,23 @@ def _formatActionResult(result, services=None, context: Optional[Dict[str, Any]]
docType = getattr(doc, "mimeType", "unknown")
docData = getattr(doc, "documentData", None)
isLarge = docData and isinstance(docData, str) and len(docData) >= _INLINE_CONTENT_LIMIT
if isLarge:
persistedLine = _persistLargeDocument(doc, services, ctx)
if persistedLine:
needsPersist = (
(isinstance(docData, bytes) and len(docData) > 0) or
(isinstance(docData, str) and len(docData) >= _INLINE_CONTENT_LIMIT)
)
if needsPersist:
persisted = _persistLargeDocument(doc, services, ctx)
if persisted:
parts.append(f" - {docName} ({docType})")
parts.append(f" {persistedLine}")
parts.append(f" {persisted['line']}")
sideEvents.append({
"type": "fileCreated",
"data": persisted["fileInfo"],
})
continue
logger.error(f"Document '{docName}' ({docType}, {len(docData)} bytes) could not be persisted")
parts.append(f" - {docName} ({docType}) [ERROR: persistence failed]")
continue
parts.append(f" - {docName} ({docType})")
if docData and isinstance(docData, str) and len(docData) < _INLINE_CONTENT_LIMIT:
@ -311,4 +374,4 @@ def _formatActionResult(result, services=None, context: Optional[Dict[str, Any]]
if not parts:
parts.append("Action completed successfully." if result.success else "Action failed.")
return "\n".join(parts)
return "\n".join(parts), sideEvents

View file

@ -32,6 +32,44 @@ from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# RAG session cache -- avoids repeated embedding + vector search per turn
# ---------------------------------------------------------------------------
_RAG_CACHE_TTL_S = 120.0
_RAG_CACHE_MAX_MSGS = 5
_RAG_CACHE_MAX_ENTRIES = 200
_ragCache: Dict[str, Dict[str, Any]] = {}
async def _getOrRefreshRag(
workflowId: str,
buildRagContextFn,
*,
forceRefresh: bool = False,
**ragKwargs,
) -> str:
"""Return cached RAG context or compute fresh. Thread-safe via GIL for dict ops."""
now = time.time()
cached = _ragCache.get(workflowId)
if cached and not forceRefresh:
age = now - cached["ts"]
if age < _RAG_CACHE_TTL_S and cached["msgs"] < _RAG_CACHE_MAX_MSGS:
cached["msgs"] += 1
return cached["ctx"]
ragKwargs["workflowId"] = workflowId
ctx = await buildRagContextFn(**ragKwargs)
if len(_ragCache) >= _RAG_CACHE_MAX_ENTRIES:
oldest = min(_ragCache, key=lambda k: _ragCache[k]["ts"])
_ragCache.pop(oldest, None)
_ragCache[workflowId] = {"ctx": ctx or "", "ts": now, "msgs": 0}
return ctx or ""
async def runAgentLoop(
prompt: str,
toolRegistry: ToolRegistry,
@ -75,15 +113,20 @@ async def runAgentLoop(
featureInstanceId=featureInstanceId
)
activeToolSet = config.toolSet if config else None
tools = toolRegistry.getTools(toolSet=activeToolSet)
toolDefinitions = toolRegistry.formatToolsForFunctionCalling(toolSet=activeToolSet)
if config and config.excludeAllTools:
tools = []
toolDefinitions = None
toolsText = ""
else:
activeToolSet = config.toolSet if config else None
tools = toolRegistry.getTools(toolSet=activeToolSet)
toolDefinitions = toolRegistry.formatToolsForFunctionCalling(toolSet=activeToolSet)
# Text-based tool descriptions are ONLY used as fallback when native function
# calling is unavailable. Including both creates conflicting instructions
# (text ```tool_call format vs native tool_use blocks) and can cause the model
# to respond with plain text instead of actual tool calls.
toolsText = "" if toolDefinitions else toolRegistry.formatToolsForPrompt(toolSet=activeToolSet)
# Text-based tool descriptions are ONLY used as fallback when native function
# calling is unavailable. Including both creates conflicting instructions
# (text ```tool_call format vs native tool_use blocks) and can cause the model
# to respond with plain text instead of actual tool calls.
toolsText = "" if toolDefinitions else toolRegistry.formatToolsForPrompt(toolSet=activeToolSet)
if systemPromptOverride:
systemPrompt = systemPromptOverride
@ -100,7 +143,7 @@ async def runAgentLoop(
roundStartTime = time.time()
roundLog = AgentRoundLog(roundNumber=state.currentRound)
# RAG context injection (before each round for fresh relevance)
# RAG context injection (cached for conversational turns, fresh for tool turns)
if buildRagContextFn:
try:
latestUserMsg = ""
@ -108,9 +151,12 @@ async def runAgentLoop(
if msg.get("role") == "user":
latestUserMsg = msg.get("content", "")
break
ragContext = await buildRagContextFn(
isConversational = config and config.excludeAllTools
ragContext = await _getOrRefreshRag(
workflowId,
buildRagContextFn,
forceRefresh=not isConversational,
currentPrompt=latestUserMsg or prompt,
workflowId=workflowId,
userId=userId,
featureInstanceId=featureInstanceId,
mandateId=mandateId,
@ -166,12 +212,15 @@ async def runAgentLoop(
)
# AI call
aiOptions = AiCallOptions(
operationType=config.operationType or OperationTypeEnum.AGENT,
temperature=config.temperature,
)
if config.priority:
aiOptions.priority = config.priority
aiRequest = AiCallRequest(
prompt="",
options=AiCallOptions(
operationType=config.operationType or OperationTypeEnum.AGENT,
temperature=config.temperature
),
options=aiOptions,
messages=conversation.messages,
tools=toolDefinitions if toolDefinitions else None,
)

View file

@ -228,14 +228,17 @@ def _registerDataSourceTools(registry: ToolRegistry, services):
fileName = f"{fileName}.zip"
chatService = services.chat
fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(fileBytes, fileName)
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
if fiId:
chatService.interfaceDbComponent.updateFile(fileItem.id, {"featureInstanceId": fiId})
if _sourceNeutralize:
chatService.interfaceDbComponent.updateFile(fileItem.id, {"neutralize": True})
updateFields = {}
tempFolderId = _getOrCreateTempFolder(chatService)
if tempFolderId:
chatService.interfaceDbComponent.updateFile(fileItem.id, {"folderId": tempFolderId})
updateFields["folderId"] = tempFolderId
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
if fiId:
updateFields["featureInstanceId"] = fiId
if _sourceNeutralize:
updateFields["neutralize"] = True
if updateFields:
chatService.interfaceDbComponent.updateFile(fileItem.id, updateFields)
chatDocId = _attachFileAsChatDocument(
services, fileItem,

View file

@ -47,13 +47,29 @@ def _looksLikeBinary(data: bytes, sampleSize: int = 1024) -> bool:
def _getOrCreateTempFolder(chatService) -> Optional[str]:
"""Deprecated stub: folder-based organisation has been replaced by grouping.
Returns None unconditionally so callers skip the (now removed) folderId
assignment. Remove callers incrementally and delete this stub afterwards.
"""
logger.debug("_getOrCreateTempFolder called folder support removed, returning None")
return None
"""Return the ID of the user's 'Temp' folder, creating it if it doesn't exist."""
ifc = getattr(chatService, "interfaceDbComponent", None)
if not ifc:
logger.warning("_getOrCreateTempFolder: no interfaceDbComponent on chatService")
return None
userId = getattr(ifc, "userId", None)
if not userId:
logger.warning("_getOrCreateTempFolder: userId is None on interfaceDbComponent")
return None
try:
ownFolders = ifc.getOwnFolderTree()
for f in ownFolders:
if f.get("name") == "Temp":
folderId = f.get("id")
logger.debug("_getOrCreateTempFolder: found existing Temp folder %s", folderId)
return str(folderId) if folderId else None
newFolder = ifc.createFolder("Temp")
folderId = newFolder.get("id") if isinstance(newFolder, dict) else getattr(newFolder, "id", None)
logger.info("_getOrCreateTempFolder: created Temp folder %s for user %s", folderId, userId)
return str(folderId) if folderId else None
except Exception as e:
logger.warning("_getOrCreateTempFolder failed: %s", e)
return None
async def _getOrCreateInstanceGroup(
@ -61,34 +77,8 @@ async def _getOrCreateInstanceGroup(
featureInstanceId: str,
contextKey: str = "files/list",
) -> Optional[str]:
"""Return groupId of the default group for a feature instance; create if needed."""
try:
existing = appInterface.getTableGrouping(contextKey)
nodes = [
n.model_dump() if hasattr(n, "model_dump") else (n if isinstance(n, dict) else vars(n))
for n in (existing.rootGroups if existing else [])
]
def _find(nds):
for nd in nds:
meta = nd.get("meta", {}) if isinstance(nd, dict) else getattr(nd, "meta", {})
if (meta or {}).get("featureInstanceId") == featureInstanceId:
return nd.get("id") if isinstance(nd, dict) else getattr(nd, "id", None)
found = _find(nd.get("subGroups", []) if isinstance(nd, dict) else getattr(nd, "subGroups", []))
if found:
return found
return None
found = _find(nodes)
if found:
return found
newId = str(uuid.uuid4())
nodes.append({"id": newId, "name": featureInstanceId, "itemIds": [], "subGroups": [], "meta": {"featureInstanceId": featureInstanceId}})
appInterface.upsertTableGrouping(contextKey, nodes)
return newId
except Exception as e:
logger.error(f"_getOrCreateInstanceGroup: {e}")
return None
"""Stub — file group tree removed. Returns None; callers that checked the result will skip group assignment."""
return None
async def _getOrCreateTempGroup(
@ -96,8 +86,8 @@ async def _getOrCreateTempGroup(
sessionId: str,
contextKey: str = "files/list",
) -> Optional[str]:
"""Return groupId of a temporary group for a session; create if needed."""
return await _getOrCreateInstanceGroup(appInterface, f"_temp_{sessionId}", contextKey)
"""Stub — file group tree removed. Returns None."""
return None
def _attachFileAsChatDocument(

View file

@ -222,12 +222,15 @@ def _registerMediaTools(registry: ToolRegistry, services):
if fileItem:
fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?")
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
if fiId:
chatService.interfaceDbComponent.updateFile(fid, {"featureInstanceId": fiId})
updateFields = {}
tempFolderId = _getOrCreateTempFolder(chatService)
if tempFolderId:
chatService.interfaceDbComponent.updateFile(fid, {"folderId": tempFolderId})
updateFields["folderId"] = tempFolderId
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
if fiId:
updateFields["featureInstanceId"] = fiId
if updateFields:
chatService.interfaceDbComponent.updateFile(fid, updateFields)
chatDocId = _attachFileAsChatDocument(
services, fileItem,
label=f"renderDocument:{docName}",
@ -517,12 +520,15 @@ def _registerMediaTools(registry: ToolRegistry, services):
if fileItem:
fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?")
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
if fiId:
chatService.interfaceDbComponent.updateFile(fid, {"featureInstanceId": fiId})
updateFields = {}
tempFolderId = _getOrCreateTempFolder(chatService)
if tempFolderId:
chatService.interfaceDbComponent.updateFile(fid, {"folderId": tempFolderId})
updateFields["folderId"] = tempFolderId
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
if fiId:
updateFields["featureInstanceId"] = fiId
if updateFields:
chatService.interfaceDbComponent.updateFile(fid, updateFields)
chatDocId = _attachFileAsChatDocument(
services, fileItem,
label=f"generateImage:{docName}",
@ -679,12 +685,16 @@ def _registerMediaTools(registry: ToolRegistry, services):
fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(pngData, fileName)
fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?") if isinstance(fileItem, dict) else "?"
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
if fiId and fid != "?":
chatService.interfaceDbComponent.updateFile(fid, {"featureInstanceId": fiId})
tempFolderId = _getOrCreateTempFolder(chatService)
if tempFolderId and fid != "?":
chatService.interfaceDbComponent.updateFile(fid, {"folderId": tempFolderId})
if fid != "?":
updateFields = {}
tempFolderId = _getOrCreateTempFolder(chatService)
if tempFolderId:
updateFields["folderId"] = tempFolderId
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
if fiId:
updateFields["featureInstanceId"] = fiId
if updateFields:
chatService.interfaceDbComponent.updateFile(fid, updateFields)
chatDocId = _attachFileAsChatDocument(
services, fileItem,

View file

@ -312,52 +312,7 @@ def _registerWorkspaceTools(registry: ToolRegistry, services):
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
if fiId:
dbMgmt.updateFile(fileItem.id, {"featureInstanceId": fiId})
if args.get("groupId"):
try:
appIface = chatService.interfaceDbApp
existing = appIface.getTableGrouping("files/list")
nodes = [n.model_dump() if hasattr(n, "model_dump") else (n if isinstance(n, dict) else vars(n)) for n in (existing.rootGroups if existing else [])]
def _addToGroup(nds, gid, fid):
for nd in nds:
nid = nd.get("id") if isinstance(nd, dict) else getattr(nd, "id", None)
if nid == gid:
ids = list(nd.get("itemIds", []) if isinstance(nd, dict) else getattr(nd, "itemIds", []))
if fid not in ids:
ids.append(fid)
if isinstance(nd, dict):
nd["itemIds"] = ids
return True
if _addToGroup(nd.get("subGroups", []) if isinstance(nd, dict) else getattr(nd, "subGroups", []), gid, fid):
return True
return False
_addToGroup(nodes, args["groupId"], fileItem.id)
appIface.upsertTableGrouping("files/list", nodes)
except Exception as _ge:
logger.warning(f"writeFile: failed to add file to group {args['groupId']}: {_ge}")
elif fiId:
try:
appIface = chatService.interfaceDbApp
instanceGroupId = await _getOrCreateInstanceGroup(appIface, fiId)
if instanceGroupId:
existing = appIface.getTableGrouping("files/list")
nodes = [n.model_dump() if hasattr(n, "model_dump") else (n if isinstance(n, dict) else vars(n)) for n in (existing.rootGroups if existing else [])]
def _addToGroup2(nds, gid, fid):
for nd in nds:
nid = nd.get("id") if isinstance(nd, dict) else getattr(nd, "id", None)
if nid == gid:
ids = list(nd.get("itemIds", []) if isinstance(nd, dict) else getattr(nd, "itemIds", []))
if fid not in ids:
ids.append(fid)
if isinstance(nd, dict):
nd["itemIds"] = ids
return True
if _addToGroup2(nd.get("subGroups", []) if isinstance(nd, dict) else getattr(nd, "subGroups", []), gid, fid):
return True
return False
_addToGroup2(nodes, instanceGroupId, fileItem.id)
appIface.upsertTableGrouping("files/list", nodes)
except Exception as _ge:
logger.warning(f"writeFile: failed to add file to instance group for {fiId}: {_ge}")
# File group tree removed — groupId arg and instance-group assignment no longer apply
if args.get("tags"):
dbMgmt.updateFile(fileItem.id, {"tags": args["tags"]})
@ -746,136 +701,7 @@ def _registerWorkspaceTools(registry: ToolRegistry, services):
readOnly=False
)
# ---- Group tools (replaces folder-based tools) ----
async def _listGroups(args: Dict[str, Any], context: Dict[str, Any]):
contextKey = args.get("contextKey", "files/list")
try:
chatService = services.chat
appInterface = chatService.interfaceDbApp
existing = appInterface.getTableGrouping(contextKey)
if not existing:
return ToolResult(toolCallId="", toolName="listGroups", success=True, data="No groups found.")
def _flatten(nodes, depth=0):
result = []
for n in nodes:
nd = n.model_dump() if hasattr(n, "model_dump") else (n if isinstance(n, dict) else vars(n))
result.append({"id": nd.get("id"), "name": nd.get("name"), "depth": depth, "itemCount": len(nd.get("itemIds", []))})
result.extend(_flatten(nd.get("subGroups", []), depth + 1))
return result
groups = _flatten(existing.rootGroups)
lines = "\n".join(
f"{' ' * g['depth']}- {g['name']} (id: {g['id']}, items: {g['itemCount']})"
for g in groups
) if groups else "No groups found."
return ToolResult(toolCallId="", toolName="listGroups", success=True, data=lines)
except Exception as e:
return ToolResult(toolCallId="", toolName="listGroups", success=False, error=str(e))
async def _listItemsInGroup(args: Dict[str, Any], context: Dict[str, Any]):
groupId = args.get("groupId", "")
contextKey = args.get("contextKey", "files/list")
if not groupId:
return ToolResult(toolCallId="", toolName="listItemsInGroup", success=False, error="groupId is required")
try:
from modules.routes.routeHelpers import _collectItemIds
chatService = services.chat
appInterface = chatService.interfaceDbApp
existing = appInterface.getTableGrouping(contextKey)
if not existing:
return ToolResult(toolCallId="", toolName="listItemsInGroup", success=True, data="No groups found.")
nodes = [n.model_dump() if hasattr(n, "model_dump") else (n if isinstance(n, dict) else vars(n)) for n in existing.rootGroups]
ids = _collectItemIds(nodes, groupId)
itemList = list(ids) if ids else []
return ToolResult(
toolCallId="", toolName="listItemsInGroup", success=True,
data="\n".join(f"- {fid}" for fid in itemList) if itemList else "No items in group.",
)
except Exception as e:
return ToolResult(toolCallId="", toolName="listItemsInGroup", success=False, error=str(e))
async def _addItemsToGroup(args: Dict[str, Any], context: Dict[str, Any]):
groupId = args.get("groupId", "")
itemIds = args.get("itemIds", [])
contextKey = args.get("contextKey", "files/list")
if not groupId:
return ToolResult(toolCallId="", toolName="addItemsToGroup", success=False, error="groupId is required")
if not itemIds:
return ToolResult(toolCallId="", toolName="addItemsToGroup", success=False, error="itemIds is required")
try:
chatService = services.chat
appInterface = chatService.interfaceDbApp
existing = appInterface.getTableGrouping(contextKey)
nodes = [n.model_dump() if hasattr(n, "model_dump") else (n if isinstance(n, dict) else vars(n)) for n in (existing.rootGroups if existing else [])]
def _add(nds):
for nd in nds:
nid = nd.get("id") if isinstance(nd, dict) else getattr(nd, "id", None)
if nid == groupId:
existing_ids = list(nd.get("itemIds", []) if isinstance(nd, dict) else getattr(nd, "itemIds", []))
for fid in itemIds:
if fid not in existing_ids:
existing_ids.append(fid)
if isinstance(nd, dict):
nd["itemIds"] = existing_ids
return True
if _add(nd.get("subGroups", []) if isinstance(nd, dict) else getattr(nd, "subGroups", [])):
return True
return False
found = _add(nodes)
if not found:
return ToolResult(toolCallId="", toolName="addItemsToGroup", success=False, error=f"Group {groupId} not found")
appInterface.upsertTableGrouping(contextKey, nodes)
return ToolResult(
toolCallId="", toolName="addItemsToGroup", success=True,
data=f"Added {len(itemIds)} item(s) to group {groupId}",
)
except Exception as e:
return ToolResult(toolCallId="", toolName="addItemsToGroup", success=False, error=str(e))
registry.register(
"listGroups", _listGroups,
description="List all groups in the file grouping tree. Groups replace folders for organising files.",
parameters={
"type": "object",
"properties": {
"contextKey": {"type": "string", "description": "Grouping context key (default: 'files/list')"},
}
},
readOnly=True
)
registry.register(
"listItemsInGroup", _listItemsInGroup,
description="List all file IDs assigned to a specific group (includes sub-groups recursively).",
parameters={
"type": "object",
"properties": {
"groupId": {"type": "string", "description": "The group ID to inspect"},
"contextKey": {"type": "string", "description": "Grouping context key (default: 'files/list')"},
},
"required": ["groupId"]
},
readOnly=True
)
registry.register(
"addItemsToGroup", _addItemsToGroup,
description="Add one or more file IDs to an existing group.",
parameters={
"type": "object",
"properties": {
"groupId": {"type": "string", "description": "The group ID to add files to"},
"itemIds": {"type": "array", "items": {"type": "string"}, "description": "List of file IDs to add"},
"contextKey": {"type": "string", "description": "Grouping context key (default: 'files/list')"},
},
"required": ["groupId", "itemIds"]
},
readOnly=False
)
# Group tree tools removed — file grouping now uses view-based display grouping (TableListView)
registry.register(
"replaceInFile", _replaceInFile,

View file

@ -6,7 +6,7 @@ from typing import List, Dict, Any, Optional
from enum import Enum
from pydantic import BaseModel, Field
from modules.shared.timeUtils import getUtcTimestamp
from modules.datamodels.datamodelAi import OperationTypeEnum
from modules.datamodels.datamodelAi import OperationTypeEnum, PriorityEnum
import uuid
@ -101,6 +101,18 @@ class AgentConfig(BaseModel):
"manipulate the workflow graph, not execute its actions."
),
)
excludeAllTools: bool = Field(
default=False,
description=(
"If True, send no tool definitions to the LLM at all. "
"Used for pure conversational turns (e.g. CommCoach coaching chat) "
"where tools are not needed and would only add latency."
),
)
priority: Optional[PriorityEnum] = Field(
default=None,
description="Model selection priority: speed | quality | cost | balanced. None = use default (balanced).",
)
class AgentState(BaseModel):

View file

@ -203,7 +203,8 @@ class AgentService:
# ContentParts" symptom we see when the workspace route calls
# runAgent for an attached single-file data source.
# Mirrors workflowManager._propagateWorkflowToContext.
if workflowId and workflowId != "unknown":
isChatWorkflowId = workflowId and workflowId != "unknown" and ":" not in workflowId
if isChatWorkflowId:
try:
workflow = getattr(self.services, "workflow", None)
if workflow is None or getattr(workflow, "id", None) != workflowId:

View file

@ -17,7 +17,7 @@ SANDBOX_ALLOWED_MODULES = {
}
_PYTHON_BLOCKED_BUILTINS = {
"open", "exec", "eval", "compile", "__import__", "globals", "locals",
"exec", "eval", "compile", "__import__", "globals", "locals",
"getattr", "setattr", "delattr", "breakpoint", "exit", "quit",
"input", "memoryview",
}
@ -73,6 +73,29 @@ def _buildRestrictedGlobals() -> Dict[str, Any]:
return {"__builtins__": safeBuiltins}
class _VirtualFS:
"""In-memory virtual filesystem for sandbox open() calls."""
def __init__(self):
self.files: Dict[str, str] = {}
def open(self, name, mode="r", **_kwargs):
if "r" in mode and "+" not in mode:
if name not in self.files:
raise FileNotFoundError(f"Virtual file '{name}' not found")
buf = io.StringIO(self.files[name])
buf.name = name
return buf
buf = io.StringIO()
buf.name = name
realClose = buf.close
def _flushingClose():
self.files[name] = buf.getvalue()
realClose()
buf.close = _flushingClose
return buf
def _makeReadFile(services):
"""Create a readFile(fileId) closure bound to the current services context."""
def readFile(fileId: str) -> str:
@ -92,6 +115,8 @@ async def executePython(code: str, *, services=None) -> Dict[str, Any]:
def _run():
restrictedGlobals = _buildRestrictedGlobals()
vfs = _VirtualFS()
restrictedGlobals["__builtins__"]["open"] = vfs.open
if services:
restrictedGlobals["__builtins__"]["readFile"] = _makeReadFile(services)
capturedOutput = io.StringIO()

View file

@ -57,8 +57,7 @@ from .subJsonResponseHandling import JsonResponseHandler
from .subLoopingUseCases import LoopingUseCaseRegistry
from modules.workflows.processing.shared.stateTools import checkWorkflowStopped
from modules.shared.jsonContinuation import getContexts
from modules.shared.jsonUtils import buildContinuationContext, extractJsonString, tryParseJson
from modules.shared.jsonUtils import tryParseJson
from modules.shared.jsonUtils import buildContinuationContext, tryParseJson
from modules.shared.jsonUtils import closeJsonStructures
from modules.shared.jsonUtils import stripCodeFences, normalizeJsonText
@ -142,6 +141,8 @@ class AiCallLooper:
MAX_MERGE_FAILS = 3
mergeFailCount = 0 # Global counter for merge failures across entire loop
lastValidCompletePart = None # Store last successfully parsed completePart for fallback
MAX_CONSECUTIVE_EMPTY_RESPONSES = 3
consecutive_empty_responses = 0
# Get parent operation ID for iteration operations (parentId should be operationId, not log entry ID)
parentOperationId = operationId # Use the parent's operationId directly
@ -284,8 +285,26 @@ class AiCallLooper:
break
if not result or not result.strip():
logger.warning(f"Iteration {iteration}: Empty response, stopping")
break
consecutive_empty_responses += 1
logger.warning(
"Iteration %s: Empty AI response (consecutive %s/%s) modelName=%s errorCount=%s",
iteration,
consecutive_empty_responses,
MAX_CONSECUTIVE_EMPTY_RESPONSES,
getattr(response, "modelName", None),
getattr(response, "errorCount", None),
)
if iterationOperationId:
self.services.chat.progressLogFinish(iterationOperationId, False)
if consecutive_empty_responses >= MAX_CONSECUTIVE_EMPTY_RESPONSES:
logger.error(
"Stopping loop: %s consecutive empty responses from model",
consecutive_empty_responses,
)
break
continue
consecutive_empty_responses = 0
# Check if this is a text response (not document generation)
# Text responses don't need JSON parsing - return immediately after first successful response
@ -354,9 +373,8 @@ class AiCallLooper:
if lastValidCompletePart:
try:
extracted = extractJsonString(lastValidCompletePart)
parsed, parseErr, _ = tryParseJson(extracted)
if parseErr is None and parsed:
parsed, parseErr, _ = tryParseJson(lastValidCompletePart)
if parseErr is None:
normalized = self._normalizeJsonStructure(parsed, useCase)
return json.dumps(normalized, indent=2, ensure_ascii=False)
except Exception:
@ -384,11 +402,10 @@ class AiCallLooper:
# This ensures retry iterations use the correct base context
lastRawResponse = candidateJson
# Try direct parse of candidate
# Try direct parse of candidate (same pipeline as structure filling / getContexts)
try:
extracted = extractJsonString(candidateJson)
parsed, parseErr, _ = tryParseJson(extracted)
if parseErr is None and parsed:
parsed, parseErr, extracted = tryParseJson(candidateJson)
if parseErr is None:
# Direct parse succeeded - FINISHED
# Commit candidate to jsonBase
jsonBase = candidateJson
@ -421,18 +438,21 @@ class AiCallLooper:
# STEP 6: DECIDE based on jsonParsingSuccess and overlapContext
if contexts.jsonParsingSuccess and contexts.overlapContext == "":
# JSON is complete (no cut point) - FINISHED
# Use completePart for final result (closed, repaired JSON)
# No more merging needed, so we don't need the cut version
jsonBase = contexts.completePart
# getContexts and downstream must agree with tryParseJson (same as structure filling).
logger.info(f"Iteration {iteration}: jsonParsingSuccess=true, overlapContext='', JSON complete")
# Store and parse completePart
lastValidCompletePart = contexts.completePart
try:
extracted = extractJsonString(contexts.completePart)
parsed, parseErr, _ = tryParseJson(extracted)
if parseErr is not None:
from modules.shared.jsonUtils import repairBrokenJson
repaired = repairBrokenJson(extracted)
if repaired:
parsed = repaired
parseErr = None
logger.info(f"Iteration {iteration}: repairBrokenJson succeeded for completePart")
if parseErr is None and parsed:
normalized = self._normalizeJsonStructure(parsed, useCase)
result = json.dumps(normalized, indent=2, ensure_ascii=False)
@ -447,13 +467,30 @@ class AiCallLooper:
return useCase.finalResultHandler(
result, normalized, extracted, debugPrefix, self.services
)
return useCase.finalResultHandler(
result, normalized, extracted, debugPrefix, self.services
)
except Exception as e:
logger.warning(f"Iteration {iteration}: Failed to parse completePart: {e}")
# Fallback: return completePart as-is
if iterationOperationId:
self.services.chat.progressLogFinish(iterationOperationId, True)
return contexts.completePart
logger.warning(
f"Iteration {iteration}: completePart not serializable after getContexts success: {e}"
)
mergeFailCount += 1
if mergeFailCount >= MAX_MERGE_FAILS:
logger.error(
f"Iteration {iteration}: Max failures ({MAX_MERGE_FAILS}) "
"after output pipeline mismatch"
)
if iterationOperationId:
self.services.chat.progressLogFinish(iterationOperationId, False)
return jsonBase if jsonBase else ""
if iterationOperationId:
self.services.chat.progressLogUpdate(
iterationOperationId,
0.7,
f"Output pipeline failed ({mergeFailCount}/{MAX_MERGE_FAILS}), retrying",
)
self.services.chat.progressLogFinish(iterationOperationId, True)
continue
elif contexts.jsonParsingSuccess and contexts.overlapContext != "":
# JSON parseable but has cut point - CONTINUE to next iteration
@ -502,9 +539,8 @@ class AiCallLooper:
if lastValidCompletePart:
try:
extracted = extractJsonString(lastValidCompletePart)
parsed, parseErr, _ = tryParseJson(extracted)
if parseErr is None and parsed:
parsed, parseErr, _ = tryParseJson(lastValidCompletePart)
if parseErr is None:
normalized = self._normalizeJsonStructure(parsed, useCase)
return json.dumps(normalized, indent=2, ensure_ascii=False)
except Exception:
@ -532,10 +568,30 @@ class AiCallLooper:
if iteration >= maxIterations:
logger.warning(f"AI call stopped after maximum iterations ({maxIterations})")
# This code path should never be reached because all registered use cases
# return early when JSON is complete. This would only execute for use cases that
# require section extraction, but no such use cases are currently registered.
logger.error(f"Unexpected code path: reached end of loop without return for use case '{useCaseId}'")
# Prefer last repaired complete JSON from getContexts (raw `result` is only the last fragment).
if lastValidCompletePart and useCase and not useCase.requiresExtraction:
try:
parsed, parseErr, extracted = tryParseJson(lastValidCompletePart)
if parseErr is None:
normalized = self._normalizeJsonStructure(parsed, useCase)
out = json.dumps(normalized, indent=2, ensure_ascii=False)
if useCase.finalResultHandler:
logger.warning(
"callAiWithLooping: max iterations — returning last valid completePart for %r",
useCaseId,
)
return useCase.finalResultHandler(
out, normalized, extracted, debugPrefix, self.services
)
except Exception as e:
logger.debug("Max-iterations fallback on completePart failed: %s", e)
logger.error(
"End of callAiWithLooping without success for use case %r (iterations=%s, lastResultLen=%s)",
useCaseId,
iteration,
len(result) if isinstance(result, str) else 0,
)
return result if result else ""
def _isJsonStringIncomplete(self, jsonString: str) -> bool:

View file

@ -54,6 +54,15 @@ def _handleCodeContentFinalResult(result: str, parsedJsonForUseCase: Any, extrac
return final_json
def _lift_section_plain_text(d: Dict[str, Any]) -> Optional[str]:
"""Models often return {\"text\": \"...\"} without an elements array; extract usable prose."""
for key in ("text", "body", "summary", "response", "output", "answer", "message", "content"):
v = d.get(key)
if isinstance(v, str) and v.strip():
return v.strip()
return None
def _normalizeSectionContentJson(parsed: Any, useCaseId: str) -> Any:
"""Normalize JSON structure for section_content use case."""
# For section_content, expect {"elements": [...]} structure
@ -77,15 +86,29 @@ def _normalizeSectionContentJson(parsed: Any, useCaseId: str) -> Any:
# Convert plain list of elements to elements structure
return {"elements": parsed}
elif isinstance(parsed, dict):
# If it already has "elements", return as-is
if "elements" in parsed:
els = parsed.get("elements")
if isinstance(els, list) and len(els) > 0:
return parsed
lifted = _lift_section_plain_text(parsed)
if lifted:
out = dict(parsed)
out["elements"] = [{"type": "paragraph", "content": {"text": lifted}}]
logger.info(
"section_content: promoted plain-text field to elements (%d chars)",
len(lifted),
)
return out
return parsed
# If it has "type" and looks like an element, wrap in elements array
elif parsed.get("type"):
if parsed.get("type"):
return {"elements": [parsed]}
# Otherwise, assume it's already in correct format
else:
return parsed
lifted = _lift_section_plain_text(parsed)
if lifted:
return {
**parsed,
"elements": [{"type": "paragraph", "content": {"text": lifted}}],
}
return parsed
# For other use cases, return as-is (they have their own structures)
return parsed

View file

@ -27,6 +27,36 @@ class _AiResponseFallback:
logger = logging.getLogger(__name__)
def _elements_from_section_content_ai_json(parsed: Any) -> List[Any]:
"""Normalize section_content AI JSON (incl. models that return {\"text\": ...}) into elements."""
from modules.serviceCenter.services.serviceAi.subLoopingUseCases import _normalizeSectionContentJson
if parsed is None:
return []
if isinstance(parsed, dict):
has_nonempty_elements = (
isinstance(parsed.get("elements"), list) and len(parsed["elements"]) > 0
)
if not has_nonempty_elements:
# Valid full-document envelope (same normalized shape the renderer uses elsewhere)
docs = parsed.get("documents")
if isinstance(docs, list) and docs and isinstance(docs[0], dict):
secs = docs[0].get("sections")
if isinstance(secs, list) and secs and isinstance(secs[0], dict):
parsed = secs[0]
elif (
isinstance(parsed.get("sections"), list)
and parsed["sections"]
and isinstance(parsed["sections"][0], dict)
):
parsed = parsed["sections"][0]
norm = _normalizeSectionContentJson(parsed, "section_content")
if isinstance(norm, dict):
els = norm.get("elements")
return list(els) if isinstance(els, list) else []
return []
class StructureFiller:
"""Handles filling document structure with content."""
@ -524,38 +554,12 @@ class StructureFiller:
if generatedElements:
elements.extend(generatedElements)
else:
# Fallback: Try to parse JSON response directly with repair logic
try:
from modules.shared.jsonUtils import tryParseJson, repairBrokenJson
# Use tryParseJson which handles extraction and basic parsing
fallbackElements, parseError, cleanedStr = tryParseJson(aiResponse.content)
# If parsing failed, try repair
if parseError and isinstance(aiResponse.content, str):
logger.warning(f"Initial JSON parse failed for section {sectionId}, attempting repair: {str(parseError)}")
repairedJson = repairBrokenJson(aiResponse.content)
if repairedJson:
fallbackElements = repairedJson
parseError = None
logger.info(f"Successfully repaired JSON for section {sectionId}")
if parseError:
raise parseError
if isinstance(fallbackElements, list):
elements.extend(fallbackElements)
elif isinstance(fallbackElements, dict) and "elements" in fallbackElements:
elements.extend(fallbackElements["elements"])
elif isinstance(fallbackElements, dict) and fallbackElements.get("type"):
elements.append(fallbackElements)
except (json.JSONDecodeError, ValueError) as json_error:
logger.error(f"Error parsing JSON response for section {sectionId}: {str(json_error)}")
elements.append({
"type": "error",
"message": f"Failed to parse JSON response: {str(json_error)}",
"sectionId": sectionId
})
logger.error(f"No elements produced for section {sectionId} (callAiWithLooping must return parseable JSON)")
elements.append({
"type": "error",
"message": f"No parsed content for section {sectionId}",
"sectionId": sectionId
})
return elements
@ -671,7 +675,7 @@ class StructureFiller:
try:
self.services.chat.progressLogUpdate(sectionOperationId, 0.4, "Calling AI for content generation")
operationType = OperationTypeEnum.DATA_ANALYSE
operationType = OperationTypeEnum.DATA_GENERATE
options = AiCallOptions(
operationType=operationType,
priority=PriorityEnum.BALANCED,
@ -703,22 +707,17 @@ class StructureFiller:
)
try:
from modules.shared.jsonUtils import tryParseJson, repairBrokenJson
from modules.shared.jsonUtils import tryParseJson
if isinstance(aiResponseJson, str) and ("---" in aiResponseJson or aiResponseJson.count("```json") > 1):
generatedElements = self._extractAndMergeMultipleJsonBlocks(aiResponseJson, contentType, sectionId)
else:
parsedResponse, parseError, cleanedStr = tryParseJson(aiResponseJson)
if parsedResponse is None:
logger.warning(f"Section {sectionId}: tryParseJson failed, attempting repair")
repairedStr = repairBrokenJson(aiResponseJson)
parsedResponse, parseError2, _ = tryParseJson(repairedStr)
if parsedResponse and isinstance(parsedResponse, dict):
generatedElements = parsedResponse.get("elements", [])
elif parsedResponse and isinstance(parsedResponse, list):
generatedElements = parsedResponse
else:
parsedResponse, parseError, _ = tryParseJson(aiResponseJson)
if parseError is not None:
logger.error(f"Section {sectionId}: tryParseJson failed: {parseError}")
generatedElements = []
else:
generatedElements = _elements_from_section_content_ai_json(parsedResponse)
except Exception as parseErr:
logger.error(f"Section {sectionId}: JSON parse error: {parseErr}")
generatedElements = []
@ -930,22 +929,13 @@ class StructureFiller:
self.services.chat.progressLogUpdate(sectionOperationId, 0.4, "Calling AI for content generation")
operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_ANALYSE
operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_GENERATE
if operationType == OperationTypeEnum.IMAGE_GENERATE:
maxPromptLength = 4000
if len(generationPrompt) > maxPromptLength:
logger.warning(f"Truncating DALL-E prompt from {len(generationPrompt)} to {maxPromptLength} characters")
generationPrompt = generationPrompt[:maxPromptLength].rsplit('\n', 1)[0]
# Write debug file for IMAGE_GENERATE (direct callAi, no _callAiWithLooping)
self.services.utils.writeDebugFile(
generationPrompt,
f"{chapterId}_section_{sectionId}_prompt"
)
imagePrompt = self._buildImagePrompt(section, generationHint, language)
self.services.utils.writeDebugFile(imagePrompt, f"{chapterId}_section_{sectionId}_prompt")
request = AiCallRequest(
prompt=generationPrompt,
prompt=imagePrompt,
contentParts=[],
options=AiCallOptions(
operationType=operationType,
@ -956,8 +946,6 @@ class StructureFiller:
checkWorkflowStopped(self.services)
aiResponse = await self.aiService.callAi(request)
generatedElements = []
# Write debug file for IMAGE_GENERATE response (direct callAi, no _callAiWithLooping)
self.services.utils.writeDebugFile(
aiResponse.content if hasattr(aiResponse, 'content') else str(aiResponse),
f"{chapterId}_section_{sectionId}_response"
@ -996,47 +984,20 @@ class StructureFiller:
)
try:
# Use tryParseJson which handles extraction and basic parsing
from modules.shared.jsonUtils import tryParseJson, repairBrokenJson
# Check if response contains multiple JSON blocks (separated by --- or multiple ```json blocks)
# This can happen when AI returns multiple complete responses
from modules.shared.jsonUtils import tryParseJson
if isinstance(aiResponseJson, str) and ("---" in aiResponseJson or aiResponseJson.count("```json") > 1):
logger.info(f"Section {sectionId}: Detected multiple JSON blocks in response, attempting to merge")
generatedElements = self._extractAndMergeMultipleJsonBlocks(aiResponseJson, contentType, sectionId)
else:
parsedResponse, parseError, cleanedStr = tryParseJson(aiResponseJson)
# If parsing failed, try repair
if parseError and isinstance(aiResponseJson, str):
logger.warning(f"Initial JSON parse failed for section {sectionId}, attempting repair: {str(parseError)}")
repairedJson = repairBrokenJson(aiResponseJson)
if repairedJson:
parsedResponse = repairedJson
parseError = None
logger.info(f"Successfully repaired JSON for section {sectionId}")
if parseError:
parsedResponse, parseError, _ = tryParseJson(aiResponseJson)
if parseError is not None:
raise parseError
if isinstance(parsedResponse, list):
generatedElements = parsedResponse
elif isinstance(parsedResponse, dict):
if "elements" in parsedResponse:
generatedElements = parsedResponse["elements"]
elif "sections" in parsedResponse and len(parsedResponse["sections"]) > 0:
firstSection = parsedResponse["sections"][0]
generatedElements = firstSection.get("elements", [])
elif parsedResponse.get("type"):
generatedElements = [parsedResponse]
else:
generatedElements = []
else:
generatedElements = []
generatedElements = _elements_from_section_content_ai_json(parsedResponse)
aiResponse = _AiResponseFallback(aiResponseJson)
except Exception as parseError:
logger.error(f"Error parsing response from _callAiWithLooping for section {sectionId}: {str(parseError)}")
logger.warning(f"JSON parse failed for section {sectionId}, using fallback: {str(parseError)}")
aiResponse = _AiResponseFallback(aiResponseJson)
generatedElements = []
@ -1112,22 +1073,13 @@ class StructureFiller:
self.services.chat.progressLogUpdate(sectionOperationId, 0.4, "Calling AI for content generation")
operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_ANALYSE
operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_GENERATE
if operationType == OperationTypeEnum.IMAGE_GENERATE:
maxPromptLength = 4000
if len(generationPrompt) > maxPromptLength:
logger.warning(f"Truncating DALL-E prompt from {len(generationPrompt)} to {maxPromptLength} characters")
generationPrompt = generationPrompt[:maxPromptLength].rsplit('\n', 1)[0]
# Write debug file for IMAGE_GENERATE (direct callAi, no _callAiWithLooping)
self.services.utils.writeDebugFile(
generationPrompt,
f"{chapterId}_section_{sectionId}_prompt"
)
imagePrompt = self._buildImagePrompt(section, generationHint, language)
self.services.utils.writeDebugFile(imagePrompt, f"{chapterId}_section_{sectionId}_prompt")
request = AiCallRequest(
prompt=generationPrompt,
prompt=imagePrompt,
contentParts=[],
options=AiCallOptions(
operationType=operationType,
@ -1135,10 +1087,9 @@ class StructureFiller:
processingMode=ProcessingModeEnum.DETAILED
)
)
checkWorkflowStopped(self.services)
aiResponse = await self.aiService.callAi(request)
generatedElements = []
# Write debug file for IMAGE_GENERATE response (direct callAi, no _callAiWithLooping)
self.services.utils.writeDebugFile(
aiResponse.content if hasattr(aiResponse, 'content') else str(aiResponse),
f"{chapterId}_section_{sectionId}_response"
@ -1179,25 +1130,19 @@ class StructureFiller:
)
try:
parsedResponse = json.loads(self.services.utils.jsonExtractString(aiResponseJson))
if isinstance(parsedResponse, list):
generatedElements = parsedResponse
elif isinstance(parsedResponse, dict):
if "elements" in parsedResponse:
generatedElements = parsedResponse["elements"]
elif "sections" in parsedResponse and len(parsedResponse["sections"]) > 0:
firstSection = parsedResponse["sections"][0]
generatedElements = firstSection.get("elements", [])
elif parsedResponse.get("type"):
generatedElements = [parsedResponse]
else:
generatedElements = []
else:
from modules.shared.jsonUtils import tryParseJson
parsedResponse, parseError, _ = tryParseJson(aiResponseJson)
if parseError is not None:
logger.error(
f"Error parsing response from _callAiWithLooping for section {sectionId}: {parseError}"
)
generatedElements = []
else:
generatedElements = _elements_from_section_content_ai_json(parsedResponse)
aiResponse = _AiResponseFallback(aiResponseJson)
except Exception as parseError:
logger.error(f"Error parsing response from _callAiWithLooping for section {sectionId}: {str(parseError)}")
logger.warning(f"JSON parse failed for section {sectionId}, using fallback: {str(parseError)}")
aiResponse = _AiResponseFallback(aiResponseJson)
generatedElements = []
@ -1371,22 +1316,13 @@ class StructureFiller:
self.services.chat.progressLogUpdate(sectionOperationId, 0.4, "Calling AI for content generation")
operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_ANALYSE
operationType = OperationTypeEnum.IMAGE_GENERATE if contentType == "image" else OperationTypeEnum.DATA_GENERATE
if operationType == OperationTypeEnum.IMAGE_GENERATE:
maxPromptLength = 4000
if len(generationPrompt) > maxPromptLength:
logger.warning(f"Truncating DALL-E prompt from {len(generationPrompt)} to {maxPromptLength} characters")
generationPrompt = generationPrompt[:maxPromptLength].rsplit('\n', 1)[0]
# Write debug file for IMAGE_GENERATE (direct callAi, no _callAiWithLooping)
self.services.utils.writeDebugFile(
generationPrompt,
f"{chapterId}_section_{sectionId}_prompt"
)
imagePrompt = self._buildImagePrompt(section, generationHint, language)
self.services.utils.writeDebugFile(imagePrompt, f"{chapterId}_section_{sectionId}_prompt")
request = AiCallRequest(
prompt=generationPrompt,
prompt=imagePrompt,
contentParts=[],
options=AiCallOptions(
operationType=operationType,
@ -1396,8 +1332,6 @@ class StructureFiller:
)
aiResponse = await self.aiService.callAi(request)
generatedElements = []
# Write debug file for IMAGE_GENERATE response (direct callAi, no _callAiWithLooping)
self.services.utils.writeDebugFile(
aiResponse.content if hasattr(aiResponse, 'content') else str(aiResponse),
f"{chapterId}_section_{sectionId}_response"
@ -1439,25 +1373,19 @@ class StructureFiller:
)
try:
parsedResponse = json.loads(self.services.utils.jsonExtractString(aiResponseJson))
if isinstance(parsedResponse, list):
generatedElements = parsedResponse
elif isinstance(parsedResponse, dict):
if "elements" in parsedResponse:
generatedElements = parsedResponse["elements"]
elif "sections" in parsedResponse and len(parsedResponse["sections"]) > 0:
firstSection = parsedResponse["sections"][0]
generatedElements = firstSection.get("elements", [])
elif parsedResponse.get("type"):
generatedElements = [parsedResponse]
else:
generatedElements = []
else:
from modules.shared.jsonUtils import tryParseJson
parsedResponse, parseError, _ = tryParseJson(aiResponseJson)
if parseError is not None:
logger.error(
f"Error parsing response from _callAiWithLooping for section {sectionId}: {parseError}"
)
generatedElements = []
else:
generatedElements = _elements_from_section_content_ai_json(parsedResponse)
aiResponse = _AiResponseFallback(aiResponseJson)
except Exception as parseError:
logger.error(f"Error parsing response from _callAiWithLooping for section {sectionId}: {str(parseError)}")
logger.warning(f"JSON parse failed for section {sectionId}, using fallback: {str(parseError)}")
aiResponse = _AiResponseFallback(aiResponseJson)
generatedElements = []
@ -2166,6 +2094,14 @@ Return only valid JSON. Do not include any explanatory text outside the JSON.
"""
return prompt
def _buildImagePrompt(self, section: Dict[str, Any], generationHint: str, language: str = "de") -> str:
"""Build a concise image-generation prompt from generationHint only.
Image models need short, visual descriptions - not the full document
context or user prompt that can be hundreds of KB."""
sectionTitle = section.get("title", "")
description = generationHint or sectionTitle or "Generate an image"
return f"{description}\nLanguage for any text in the image: {language.upper()}"
def _getContentStructureExample(self, contentType: str) -> str:
"""Get the JSON structure example for a specific content type."""
structures = {

View file

@ -90,8 +90,7 @@ class StructureGenerator:
)
try:
# Baue Chapter-Struktur-Prompt mit Content-Index
structurePrompt = self._buildChapterStructurePrompt(
structurePrompt, templateStructure = self._buildChapterStructurePrompt(
userPrompt=userPrompt,
contentParts=contentParts,
outputFormat=outputFormat
@ -108,12 +107,6 @@ class StructureGenerator:
resultFormat="json"
)
structurePrompt, templateStructure = self._buildChapterStructurePrompt(
userPrompt=userPrompt,
contentParts=contentParts,
outputFormat=outputFormat
)
# Create prompt builder for continuation support
async def buildChapterStructurePromptWithContinuation(
continuationContext: Any,
@ -196,6 +189,13 @@ CRITICAL:
contentParts=None # Do not pass ContentParts - only metadata needed, not content extraction
)
if not isinstance(aiResponseJson, str) or not aiResponseJson.strip():
raise ValueError(
"Structure generation returned no JSON text from the model (empty response after retries). "
"Check the AI provider, allowed models, billing, and debug artifact "
"'chapter_structure_generation_response'."
)
# Parse the complete JSON response (looping system already handles completion)
extractedJson = self.services.utils.jsonExtractString(aiResponseJson)
parsedJson, parseError, cleanedJson = self.services.utils.jsonTryParse(extractedJson)
@ -215,7 +215,12 @@ CRITICAL:
raise ValueError(f"Failed to parse JSON structure after repair: {str(parseError)}")
else:
logger.error(f"Failed to repair JSON. Parse error: {str(parseError)}")
logger.error(f"Cleaned JSON preview (first 500 chars): {cleanedJson[:500]}")
raw_preview = (extractedJson or "")[:500]
logger.error(
"Raw extract preview (first 500 chars): %r",
raw_preview,
)
logger.error(f"Cleaned JSON preview (first 500 chars): {cleanedJson[:500]!r}")
raise ValueError(f"Failed to parse JSON structure: {str(parseError)}")
else:
structure = parsedJson

View file

@ -183,8 +183,8 @@ def _normalizeReturnUrl(returnUrl: str) -> str:
Validate and normalize an absolute frontend return URL.
Allowed examples:
- https://nyla.poweron-center.net/billing/transactions
- https://nyla-int.poweron-center.net/billing/transactions?tab=overview
- https://nyla.poweron.swiss/billing/transactions
- https://nyla-int.poweron.swiss/billing/transactions?tab=overview
"""
if not returnUrl:
raise ValueError("returnUrl is required")
@ -309,7 +309,7 @@ def create_checkout_session(
"footer": (
"Diese Rechnung wurde bereits via Kreditkarte bezahlt. "
"MWST-Nr. PowerOn: siehe Stripe-Rechnungs-Template. "
"Bei Fragen: billing@poweron-center.net"
"Bei Fragen: billing@poweron.swiss"
),
}
customFields: List[Dict[str, str]] = []

View file

@ -23,7 +23,7 @@ class ChatService:
from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface
from modules.interfaces.interfaceDbChat import getInterface as getChatInterface
self.interfaceDbApp = getAppInterface(context.user, mandateId=context.mandate_id)
self.interfaceDbComponent = getComponentInterface(context.user, mandateId=context.mandate_id)
self.interfaceDbComponent = getComponentInterface(context.user, mandateId=context.mandate_id, featureInstanceId=context.feature_instance_id)
self.interfaceDbChat = getChatInterface(
context.user,
mandateId=context.mandate_id,
@ -36,6 +36,26 @@ class ChatService:
"""Workflow from context (stable during workflow execution)."""
return self._context.workflow
def _chat_document_from_management_file(self, file_id: str) -> Optional[ChatDocument]:
"""Build a ChatDocument when docItem references a management FileItem (e.g. automation uploads) without a chat message."""
try:
fi = self.interfaceDbComponent.getFile(file_id)
except Exception as e:
logger.debug("getFile(%s) failed: %s", file_id, e)
return None
if fi is None:
return None
wf = self._workflow
wf_id = wf.id if wf else "no-workflow"
return ChatDocument(
id=file_id,
messageId=f"_filestore:{wf_id}",
fileId=fi.id,
fileName=fi.fileName or "document",
fileSize=int(fi.fileSize or 0),
mimeType=fi.mimeType or "application/octet-stream",
)
def getChatDocumentsFromDocumentList(self, documentList) -> List[ChatDocument]:
"""Get ChatDocuments from a DocumentReferenceList.
@ -126,14 +146,28 @@ class ChatService:
if message.documents:
for doc in message.documents:
if doc.id == docId:
if doc.id == docId or getattr(doc, "fileId", None) == docId:
allDocuments.append(doc)
docFound = True
logger.debug(f"Matched document reference '{docRef}' to document {doc.id} (fileName: {getattr(doc, 'fileName', 'unknown')}) by documentId")
logger.debug(
f"Matched document reference '{docRef}' to document {doc.id} "
f"(fileName: {getattr(doc, 'fileName', 'unknown')}) by id/fileId"
)
break
if docFound:
break
if not docFound:
synth = self._chat_document_from_management_file(docId)
if synth is not None:
allDocuments.append(synth)
docFound = True
logger.info(
"Resolved document reference %r via FileItem %s (automation / transient workflow)",
docRef,
docId,
)
# Fallback: If not found by documentId and it looks like a filename (has file extension), try filename matching
# This handles cases where AI incorrectly generates docItem:filename.docx
if not docFound and '.' in docId and len(parts) == 2:
@ -485,34 +519,12 @@ class ChatService:
return results
def listGroups(self, contextKey: str = "files/list") -> list:
"""List all groups in the groupTree for the current context."""
try:
existing = self.interfaceDbApp.getTableGrouping(contextKey)
if not existing:
return []
def _flatten(nodes, depth=0):
result = []
for n in nodes:
nd = n.model_dump() if hasattr(n, "model_dump") else (n if isinstance(n, dict) else vars(n))
result.append({"id": nd.get("id"), "name": nd.get("name"), "depth": depth, "itemCount": len(nd.get("itemIds", []))})
result.extend(_flatten(nd.get("subGroups", []), depth + 1))
return result
return _flatten(existing.rootGroups)
except Exception as e:
return []
"""Stub — file group tree removed. Returns empty list."""
return []
def listFilesInGroup(self, groupId: str, contextKey: str = "files/list") -> list:
"""List file IDs in a specific group (recursive)."""
try:
from modules.routes.routeHelpers import _collectItemIds
existing = self.interfaceDbApp.getTableGrouping(contextKey)
if not existing:
return []
nodes = [n.model_dump() if hasattr(n, "model_dump") else (n if isinstance(n, dict) else vars(n)) for n in existing.rootGroups]
ids = _collectItemIds(nodes, groupId)
return list(ids) if ids else []
except Exception:
return []
"""Stub — file group tree removed. Returns empty list."""
return []
# ---- DataSource CRUD ----

View file

@ -118,30 +118,57 @@ class BaseRenderer(ABC):
para = style["paragraph"]
lst = style["list"]
cb = style["codeBlock"]
colors = style.get("colors") if isinstance(style.get("colors"), dict) else {}
primaryColor = colors.get("primary", "#1F3864")
rawDocTitle = style.get("documentTitle")
docTitle = rawDocTitle if isinstance(rawDocTitle, dict) else {}
titleSizePt = docTitle.get("sizePt")
if titleSizePt is None:
titleSizePt = max(int(h1["sizePt"]) + 4, 26)
titleColor = docTitle.get("color", primaryColor)
titleBold = docTitle.get("weight", "bold") == "bold"
titleAlign = docTitle.get("align", "center")
if titleAlign not in ("left", "center", "right"):
titleAlign = "center"
titleSpaceBefore = docTitle.get("spaceBeforePt", 0)
titleSpaceAfter = docTitle.get("spaceAfterPt", 18)
return {
"title": {
"font_size": h1["sizePt"], "color": h1["color"],
"bold": h1.get("weight") == "bold", "align": "left",
"font_size": titleSizePt,
"color": titleColor,
"bold": titleBold,
"align": titleAlign,
"space_before": titleSpaceBefore,
"space_after": titleSpaceAfter,
},
"heading1": {
"font_size": h1["sizePt"], "color": h1["color"],
"bold": h1.get("weight") == "bold", "align": "left",
"space_before": h1.get("spaceBeforePt", 24),
"space_after": h1.get("spaceAfterPt", 8),
},
"heading2": {
"font_size": h2["sizePt"], "color": h2["color"],
"bold": h2.get("weight") == "bold", "align": "left",
"space_before": h2.get("spaceBeforePt", 20),
"space_after": h2.get("spaceAfterPt", 6),
},
"heading3": {
"font_size": h3["sizePt"], "color": h3["color"],
"bold": h3.get("weight") == "bold", "align": "left",
"space_before": h3.get("spaceBeforePt", 16),
"space_after": h3.get("spaceAfterPt", 4),
},
"heading4": {
"font_size": h4["sizePt"], "color": h4["color"],
"bold": h4.get("weight") == "bold", "align": "left",
"space_before": h4.get("spaceBeforePt", 12),
"space_after": h4.get("spaceAfterPt", 3),
},
"paragraph": {
"font_size": para["sizePt"], "color": para["color"],
"bold": False, "align": "left",
"line_height": para.get("lineSpacing", 1.15),
},
"table_header": {
"background": tbl["headerBg"], "text_color": tbl["headerFg"],
@ -157,6 +184,7 @@ class BaseRenderer(ABC):
"bullet_list": {
"font_size": lst["sizePt"], "color": para["color"],
"indent": lst["indentPt"],
"bullet_char": lst.get("bulletChar", "\u2022"),
},
"code_block": {
"font": style["fonts"]["monospace"],

View file

@ -79,7 +79,15 @@ class RendererCodeCsv(BaseCodeRenderer):
return renderedDocs
async def render(self, extractedContent: Dict[str, Any], title: str, userPrompt: str = None, aiService=None, *, style: Dict[str, Any] = None) -> List[RenderedDocument]:
async def render(
self,
extractedContent: Dict[str, Any],
title: str,
userPrompt: str = None,
aiService=None,
*,
style: Dict[str, Any] = None,
) -> List[RenderedDocument]:
"""
Render method for document generation compatibility.
Delegates to document renderer if needed, or handles code files directly.
@ -94,7 +102,7 @@ class RendererCodeCsv(BaseCodeRenderer):
# Document generation path - delegate to document renderer
from .rendererCsv import RendererCsv
documentRenderer = RendererCsv(self.services)
return await documentRenderer.render(extractedContent, title, userPrompt, aiService)
return await documentRenderer.render(extractedContent, title, userPrompt, aiService, style=style)
def _validateAndFixCsv(self, content: str) -> str:
"""Validate CSV structure and fix common issues."""

View file

@ -91,7 +91,15 @@ class RendererCodeJson(BaseCodeRenderer):
return renderedDocs
async def render(self, extractedContent: Dict[str, Any], title: str, userPrompt: str = None, aiService=None, *, style: Dict[str, Any] = None) -> List[RenderedDocument]:
async def render(
self,
extractedContent: Dict[str, Any],
title: str,
userPrompt: str = None,
aiService=None,
*,
style: Dict[str, Any] = None,
) -> List[RenderedDocument]:
"""
Render method for document generation compatibility.
Delegates to document renderer if needed, or handles code files directly.
@ -107,7 +115,7 @@ class RendererCodeJson(BaseCodeRenderer):
# Import here to avoid circular dependency
from .rendererJson import RendererJson
documentRenderer = RendererJson(self.services)
return await documentRenderer.render(extractedContent, title, userPrompt, aiService)
return await documentRenderer.render(extractedContent, title, userPrompt, aiService, style=style)
def _extractJsonStatistics(self, parsed: Any) -> Dict[str, Any]:
"""Extract JSON statistics for validation (object count, array count, key count)."""

View file

@ -78,11 +78,20 @@ class RendererCodeXml(BaseCodeRenderer):
return renderedDocs
async def render(self, extractedContent: Dict[str, Any], title: str, userPrompt: str = None, aiService=None, *, style: Dict[str, Any] = None) -> List[RenderedDocument]:
async def render(
self,
extractedContent: Dict[str, Any],
title: str,
userPrompt: str = None,
aiService=None,
*,
style: Dict[str, Any] = None,
) -> List[RenderedDocument]:
"""
Render method for document generation compatibility.
For XML, we only support code generation (no document renderer exists yet).
"""
_ = style
# Check if this is code generation (has files array)
if "files" in extractedContent:
# Code generation path - use renderCodeFiles

View file

@ -39,8 +39,17 @@ class RendererCsv(BaseRenderer):
"""
return ["table", "code_block"]
async def render(self, extractedContent: Dict[str, Any], title: str, userPrompt: str = None, aiService=None, *, style: Dict[str, Any] = None) -> List[RenderedDocument]:
async def render(
self,
extractedContent: Dict[str, Any],
title: str,
userPrompt: str = None,
aiService=None,
*,
style: Dict[str, Any] = None,
) -> List[RenderedDocument]:
"""Render extracted JSON content to CSV format. Produces one CSV file per table section."""
_ = style
try:
# Validate JSON structure
if not self._validateJsonStructure(extractedContent):

View file

@ -146,8 +146,8 @@ class RendererHtml(BaseRenderer):
htmlParts.append('</head>')
htmlParts.append('<body>')
# Document header
htmlParts.append(f'<header><h1 class="document-title">{documentTitle}</h1></header>')
# Document header (not an h1 — body headings keep a single outline level scale)
htmlParts.append(f'<header><p class="document-title">{documentTitle}</p></header>')
# Main content
htmlParts.append('<main>')
@ -412,16 +412,27 @@ class RendererHtml(BaseRenderer):
css_parts.append(" margin: 0; padding: 20px;")
css_parts.append("}")
# Document title (uses h1 style)
h1 = headings.get("h1", {})
docTitle = style.get("documentTitle") if isinstance(style.get("documentTitle"), dict) else {}
dtSize = docTitle.get("sizePt")
if dtSize is None:
dtSize = max(headings.get("h1", {}).get("sizePt", 22) + 4, 26)
dtColor = docTitle.get("color", primaryColor)
dtWeight = docTitle.get("weight", "bold")
dtAlign = docTitle.get("align", "center")
if dtAlign not in ("left", "center", "right"):
dtAlign = "center"
dtSpaceAfter = docTitle.get("spaceAfterPt", 18)
css_parts.append(".document-title {")
css_parts.append(f" font-size: {h1.get('sizePt', 24)}pt;")
css_parts.append(f" color: {h1.get('color', primaryColor)};")
css_parts.append(f" font-weight: {h1.get('weight', 'bold')};")
css_parts.append(" margin: 0 0 1em 0;")
css_parts.append(f" font-size: {dtSize}pt;")
css_parts.append(f" color: {dtColor};")
css_parts.append(f" font-weight: {dtWeight};")
css_parts.append(f" text-align: {dtAlign};")
css_parts.append(" margin: 0;")
css_parts.append(f" margin-bottom: {dtSpaceAfter}pt;")
css_parts.append("}")
# Headings h1-h4
h1 = headings.get("h1", {})
for level in range(1, 5):
key = f"h{level}"
h = headings.get(key, h1 if level == 1 else headings.get(f"h{level-1}", {}))

View file

@ -43,8 +43,17 @@ class RendererImage(BaseRenderer):
"""
return ["image"]
async def render(self, extractedContent: Dict[str, Any], title: str, userPrompt: str = None, aiService=None, *, style: Dict[str, Any] = None) -> List[RenderedDocument]:
async def render(
self,
extractedContent: Dict[str, Any],
title: str,
userPrompt: str = None,
aiService=None,
*,
style: Dict[str, Any] = None,
) -> List[RenderedDocument]:
"""Render extracted JSON content to image format using AI image generation."""
_ = style
try:
# Generate AI image from content
imageContent = await self._generateAiImage(extractedContent, title, userPrompt, aiService)

View file

@ -42,8 +42,17 @@ class RendererJson(BaseRenderer):
# Return all types except image
return [st for st in supportedSectionTypes if st != "image"]
async def render(self, extractedContent: Dict[str, Any], title: str, userPrompt: str = None, aiService=None, *, style: Dict[str, Any] = None) -> List[RenderedDocument]:
async def render(
self,
extractedContent: Dict[str, Any],
title: str,
userPrompt: str = None,
aiService=None,
*,
style: Dict[str, Any] = None,
) -> List[RenderedDocument]:
"""Render extracted JSON content to JSON format."""
_ = style
try:
# The extracted content should already be JSON from the AI
# Just validate and format it

View file

@ -40,8 +40,17 @@ class RendererMarkdown(BaseRenderer):
from modules.datamodels.datamodelJson import supportedSectionTypes
return [st for st in supportedSectionTypes if st != "image"]
async def render(self, extractedContent: Dict[str, Any], title: str, userPrompt: str = None, aiService=None, *, style: Dict[str, Any] = None) -> List[RenderedDocument]:
async def render(
self,
extractedContent: Dict[str, Any],
title: str,
userPrompt: str = None,
aiService=None,
*,
style: Dict[str, Any] = None,
) -> List[RenderedDocument]:
"""Render extracted JSON content to Markdown format."""
_ = style
try:
# Generate markdown from JSON structure
markdownContent = self._generateMarkdownFromJson(extractedContent, title)
@ -280,7 +289,8 @@ class RendererMarkdown(BaseRenderer):
if text:
level = max(1, min(6, level))
return f"{'#' * level} {text}"
md_level = min(6, level + 1)
return f"{'#' * md_level} {text}"
return ""

View file

@ -192,6 +192,7 @@ class RendererPdf(BaseRenderer):
# Extract sections and metadata from standardized schema
sections = self._extractSections(json_content)
metadata = self._extractMetadata(json_content)
# Create a buffer to hold the PDF
buffer = io.BytesIO()
@ -204,8 +205,13 @@ class RendererPdf(BaseRenderer):
else:
doc = SimpleDocTemplate(buffer, pagesize=A4, rightMargin=72, leftMargin=72, topMargin=72, bottomMargin=18)
# Build PDF content (no cover page — body starts on page 1; filename still uses `title`)
# Body starts on page 1 — optional document title uses styles["title"] (distinct from H1)
story = []
document_title = (title or "").strip()
if not document_title and isinstance(metadata, dict):
document_title = (metadata.get("title") or "").strip()
if document_title:
story.append(self._paragraphFromInlineMarkdown(document_title, self._createDocumentTitleStyle(styles)))
# Process each section (sections already extracted above)
self.services.utils.debugLogToFile(f"PDF SECTIONS TO PROCESS: {len(sections)} sections", "PDF_RENDERER")
@ -561,6 +567,22 @@ class RendererPdf(BaseRenderer):
"space_before": sb,
}
def _createDocumentTitleStyle(self, styles: Dict[str, Any]) -> ParagraphStyle:
"""Paragraph style for the document title (metadata/doc title — not heading level 1)."""
title_style_def = styles.get("title") or {}
fs = title_style_def.get("font_size", 26)
bold = title_style_def.get("bold", True)
return ParagraphStyle(
"DocumentTitle",
fontName="Helvetica-Bold" if bold else "Helvetica",
fontSize=fs,
spaceAfter=title_style_def.get("space_after", 18),
spaceBefore=title_style_def.get("space_before", 0),
alignment=self._getAlignment(title_style_def.get("align", "center")),
textColor=self._hexToColor(title_style_def.get("color", "#1F3864")),
leading=fs * 1.25,
)
def _createHeadingStyle(self, styles: Dict[str, Any], level: int) -> ParagraphStyle:
"""Create heading style from style definitions."""
heading_key = f"heading{level}"
@ -851,25 +873,35 @@ class RendererPdf(BaseRenderer):
return []
def _renderJsonBulletList(self, list_data: Dict[str, Any], styles: Dict[str, Any]) -> List[Any]:
"""Render a JSON bullet list to PDF elements using AI-generated styles."""
"""Render a JSON bullet list to PDF elements."""
try:
content = list_data.get("content", {})
if not isinstance(content, dict):
return []
items = content.get("items", [])
bulletStyleDef = styles.get("bullet_list", {})
normalStyle = self._createNormalStyle(styles)
indent = bulletStyleDef.get("indent", 18)
bulletStyle = ParagraphStyle(
"BulletItem",
fontSize=bulletStyleDef.get("font_size", 11),
textColor=self._hexToColor(bulletStyleDef.get("color", "#333333")),
leftIndent=indent,
firstLineIndent=-indent,
spaceAfter=2,
leading=bulletStyleDef.get("font_size", 11) * 1.25,
)
bulletChar = bulletStyleDef.get("bullet_char", "\u2022")
elements = []
for item in items:
runs = self._inlineRunsForListItem(item)
if isinstance(item, list):
xml = self._renderInlineRunsToPdfXml(runs)
elements.append(Paragraph(f"\u2022 {_wrapEmojiSpansInXml(xml)}", normalStyle))
elements.append(Paragraph(f"{bulletChar} {_wrapEmojiSpansInXml(xml)}", bulletStyle))
elif isinstance(item, str):
elements.append(Paragraph(f"\u2022 {self._markdownInlineToReportlabXml(item)}", normalStyle))
elements.append(Paragraph(f"{bulletChar} {self._markdownInlineToReportlabXml(item)}", bulletStyle))
elif isinstance(item, dict) and "text" in item:
elements.append(Paragraph(f"\u2022 {self._markdownInlineToReportlabXml(item['text'])}", normalStyle))
elements.append(Paragraph(f"{bulletChar} {self._markdownInlineToReportlabXml(item['text'])}", bulletStyle))
if elements:
elements.append(Spacer(1, bulletStyleDef.get("space_after", 3)))

View file

@ -8,7 +8,7 @@ import re
from .documentRendererBaseTemplate import BaseRenderer
from modules.datamodels.datamodelDocument import RenderedDocument
from typing import Dict, Any, List, Optional
from typing import Dict, Any, List, Optional, Union
class RendererText(BaseRenderer):
"""Renders content to plain text format with format-specific extraction."""
@ -76,8 +76,17 @@ class RendererText(BaseRenderer):
# Text renderer accepts all types except images
return [st for st in supportedSectionTypes if st != "image"]
async def render(self, extractedContent: Dict[str, Any], title: str, userPrompt: str = None, aiService=None, *, style: Dict[str, Any] = None) -> List[RenderedDocument]:
async def render(
self,
extractedContent: Dict[str, Any],
title: str,
userPrompt: str = None,
aiService=None,
*,
style: Dict[str, Any] = None,
) -> List[RenderedDocument]:
"""Render extracted JSON content to plain text format."""
_ = style # unified style from renderReport; plain text ignores formatting hints
try:
# Generate text from JSON structure
textContent = self._generateTextFromJson(extractedContent, title)
@ -187,8 +196,10 @@ class RendererText(BaseRenderer):
textParts.append(f"[Reference: {label}]")
continue
elif element_type == "extracted_text":
# Extracted text format
# Extracted text format (str or raw bytes from ContentPart)
content = element.get("content", "")
if isinstance(content, (bytes, bytearray, memoryview)):
content = bytes(content).decode("utf-8", errors="replace")
source = element.get("source", "")
if content:
source_text = f" (Source: {source})" if source else ""
@ -263,16 +274,16 @@ class RendererText(BaseRenderer):
textParts = []
# Create table header
headerLine = " | ".join(str(header) for header in headers)
headerLine = " | ".join(self._tableCellToPlainText(h) for h in headers)
textParts.append(headerLine)
# Add separator line
separatorLine = " | ".join("-" * len(str(header)) for header in headers)
separatorLine = " | ".join("-" * len(self._tableCellToPlainText(h)) for h in headers)
textParts.append(separatorLine)
# Add data rows
for row in rows:
rowLine = " | ".join(str(cellData) for cellData in row)
rowLine = " | ".join(self._tableCellToPlainText(cellData) for cellData in row)
textParts.append(rowLine)
return '\n'.join(textParts)
@ -299,6 +310,9 @@ class RendererText(BaseRenderer):
textParts.append(f"- {self._stripMarkdownForPlainText(item)}")
elif isinstance(item, dict) and "text" in item:
textParts.append(f"- {self._stripMarkdownForPlainText(item['text'])}")
elif isinstance(item, list):
# markdownToDocumentJson: each item is List[InlineRun]
textParts.append(f"- {self._inlineRunsToPlainText(item)}")
return '\n'.join(textParts)
@ -311,22 +325,24 @@ class RendererText(BaseRenderer):
try:
# Extract from nested content structure: element.content.{text, level}
content = headingData.get("content", {})
if not isinstance(content, dict):
if isinstance(content, dict) and content:
text = self._stripMarkdownForPlainText(content.get("text", ""))
level = content.get("level", 1)
else:
# AI shorthand: {"type":"heading","text":"...","level":2}
text = self._stripMarkdownForPlainText(str(headingData.get("text", "") or ""))
level = headingData.get("level", 1)
if not text:
return ""
text = self._stripMarkdownForPlainText(content.get("text", ""))
level = content.get("level", 1)
if text:
level = max(1, min(6, level))
if level == 1:
return f"{text}\n{'=' * len(text)}"
elif level == 2:
return f"{text}\n{'-' * len(text)}"
else:
return f"{'#' * level} {text}"
return ""
try:
level_i = int(level) if level is not None else 1
except (TypeError, ValueError):
level_i = 1
level_i = max(1, min(6, level_i))
md_level = min(6, level_i + 1)
return f"{'#' * md_level} {text}"
except Exception as e:
self.logger.warning(f"Error rendering heading: {str(e)}")
return ""
@ -345,12 +361,65 @@ class RendererText(BaseRenderer):
text = re.sub(r'`([^`]+)`', r'\1', text)
return text.strip()
def _inlineRunsToPlainText(self, runs: Union[List[Any], Any]) -> str:
"""Flatten InlineRun dicts (from markdownToDocumentJson) to a single string."""
if runs is None:
return ""
if isinstance(runs, dict):
runs = [runs]
if not isinstance(runs, list):
return self._stripMarkdownForPlainText(str(runs))
parts: List[str] = []
for run in runs:
if not isinstance(run, dict):
parts.append(str(run))
continue
t = run.get("type") or "text"
val = run.get("value", "")
if t == "text":
parts.append(str(val))
elif t in ("bold", "italic", "code"):
parts.append(str(val))
elif t == "link":
parts.append(str(val))
elif t == "image":
parts.append(f"[{val}]")
else:
parts.append(str(val))
return "".join(parts)
def _tableCellToPlainText(self, cell: Any) -> str:
"""Table header/cell: plain str, legacy dict, or List[InlineRun]."""
if cell is None:
return ""
if isinstance(cell, str):
return self._stripMarkdownForPlainText(cell)
if isinstance(cell, list):
return self._inlineRunsToPlainText(cell)
if isinstance(cell, dict) and "text" in cell:
return self._stripMarkdownForPlainText(str(cell["text"]))
return self._stripMarkdownForPlainText(str(cell))
def _renderJsonParagraph(self, paragraphData: Dict[str, Any]) -> str:
"""Render a JSON paragraph to text. Strips markdown for plain text output."""
try:
# Extract from nested content structure
content = paragraphData.get("content", {})
# Models often return {"type":"paragraph","text":"..."} without nested "content"
top = paragraphData.get("text")
raw_content = paragraphData.get("content", {})
if isinstance(top, str) and top.strip():
if raw_content is None or raw_content == {}:
return self._stripMarkdownForPlainText(top)
if isinstance(raw_content, dict):
if not (raw_content.get("text") or raw_content.get("inlineRuns")):
return self._stripMarkdownForPlainText(top)
content = raw_content
if content is None:
content = {}
if isinstance(content, dict):
runs = self._inlineRunsFromContent(content)
if runs:
return self._stripMarkdownForPlainText(self._inlineRunsToPlainText(runs))
text = content.get("text", "")
elif isinstance(content, str):
text = content

View file

@ -16,11 +16,19 @@ DEFAULT_STYLE: Dict[str, Any] = {
"accent": "#2980B9",
"background": "#FFFFFF",
},
"documentTitle": {
"sizePt": 28,
"weight": "bold",
"color": "#1F3864",
"spaceBeforePt": 0,
"spaceAfterPt": 18,
"align": "center",
},
"headings": {
"h1": {"sizePt": 24, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 12, "spaceAfterPt": 6},
"h2": {"sizePt": 18, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 10, "spaceAfterPt": 4},
"h3": {"sizePt": 14, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 8, "spaceAfterPt": 3},
"h4": {"sizePt": 12, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 6, "spaceAfterPt": 2},
"h1": {"sizePt": 22, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 22, "spaceAfterPt": 8},
"h2": {"sizePt": 18, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 20, "spaceAfterPt": 6},
"h3": {"sizePt": 14, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 16, "spaceAfterPt": 4},
"h4": {"sizePt": 12, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 12, "spaceAfterPt": 3},
},
"paragraph": {"sizePt": 11, "lineSpacing": 1.15, "color": "#333333"},
"table": {

View file

@ -0,0 +1,89 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""Enterprise subscription auto-renewal scheduler.
Runs daily via eventManager (APScheduler). Checks all enterprise subscriptions
with autoRenew=True whose period has ended and renews them automatically
(old -> EXPIRED, new -> ACTIVE with same duration and params, budget credit,
invoice email).
"""
import logging
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
async def _runEnterpriseAutoRenewal() -> None:
"""Scheduled task: auto-renew enterprise subscriptions whose period has ended."""
try:
from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot
from modules.datamodels.datamodelSubscription import SubscriptionStatusEnum
subIface = _getSubRoot()
allSubs = subIface.listAll([SubscriptionStatusEnum.ACTIVE])
nowTs = datetime.now(timezone.utc).timestamp()
renewed = 0
for sub in allSubs:
if not sub.get("isEnterprise"):
continue
if not sub.get("recurring"):
continue
periodEnd = sub.get("currentPeriodEnd")
if not periodEnd or periodEnd > nowTs:
continue
mandateId = sub["mandateId"]
subId = sub["id"]
periodStart = sub.get("currentPeriodStart") or sub.get("startedAt") or nowTs
periodDuration = periodEnd - periodStart
if periodDuration <= 0:
periodDuration = 30 * 86400
newEndDate = nowTs + periodDuration
try:
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import (
getService as getSubscriptionService,
)
from modules.security.rootAccess import getRootUser
rootUser = getRootUser()
subService = getSubscriptionService(rootUser, mandateId)
subService.renewEnterprise(subId, newEndDate)
renewed += 1
logger.info(
"Auto-renewed enterprise subscription %s for mandate %s (new end: %s)",
subId, mandateId,
datetime.fromtimestamp(newEndDate, tz=timezone.utc).isoformat(),
)
except Exception as e:
logger.error(
"Auto-renewal failed for enterprise subscription %s mandate %s: %s",
subId, mandateId, e,
)
if renewed:
logger.info("Enterprise auto-renewal completed: %d subscription(s) renewed", renewed)
except Exception as e:
logger.error("Enterprise auto-renewal task failed: %s", e)
def registerEnterpriseRenewalScheduler() -> None:
"""Register the enterprise auto-renewal cron job (daily at 06:00 UTC)."""
try:
from modules.shared.eventManagement import eventManager
eventManager.registerCron(
jobId="enterprise_auto_renewal",
func=_runEnterpriseAutoRenewal,
cronKwargs={
"hour": "6",
"minute": "0",
},
)
logger.info("Enterprise auto-renewal scheduler registered (daily at 06:00 UTC)")
except Exception as e:
logger.error("Failed to register enterprise auto-renewal scheduler: %s", e)

View file

@ -26,6 +26,7 @@ from modules.interfaces.interfaceDbSubscription import (
getInterface as getSubscriptionInterface,
InvalidTransitionError,
)
from modules.shared.i18nRegistry import t
logger = logging.getLogger(__name__)
@ -581,6 +582,256 @@ class SubscriptionService:
def syncStripeQuantity(self, subscriptionId: str):
self._interface.syncQuantityToStripe(subscriptionId)
# =========================================================================
# Enterprise subscription management (sysadmin-only)
# =========================================================================
def createEnterprise(
self, mandateId: str,
startDate: float, endDate: float, autoRenew: bool,
flatPriceCHF: float,
maxUsers: Optional[int], maxFeatureInstances: Optional[int],
maxDataVolumeMB: Optional[int], budgetAiCHF: Optional[float],
note: Optional[str] = None,
) -> Dict[str, Any]:
"""Create a new enterprise subscription with custom flat pricing and limits.
1. Cleanup PENDING/SCHEDULED predecessors
2. Expire current operative subscription (no Stripe cancel)
3. Create ACTIVE MandateSubscription with enterprise fields
4. Credit fixed AI budget to mandate pool
5. Send invoice email to mandate admins
"""
self._cleanupPreparatorySubscriptions(mandateId)
currentOperative = self._interface.getOperativeForMandate(mandateId)
if currentOperative:
self._expireOperative(currentOperative["id"], mandateId)
sub = MandateSubscription(
mandateId=mandateId,
planKey="ENTERPRISE",
status=SubscriptionStatusEnum.ACTIVE,
recurring=autoRenew,
startedAt=datetime.now(timezone.utc).timestamp(),
currentPeriodStart=startDate,
currentPeriodEnd=endDate,
isEnterprise=True,
enterpriseFlatPriceCHF=flatPriceCHF,
enterpriseMaxUsers=maxUsers,
enterpriseMaxFeatureInstances=maxFeatureInstances,
enterpriseMaxDataVolumeMB=maxDataVolumeMB,
enterpriseBudgetAiCHF=budgetAiCHF,
enterpriseNote=note,
)
created = self._interface.createSubscription(sub)
self.invalidateCache(mandateId)
self._creditEnterpriseBudget(mandateId, budgetAiCHF, "Erstaktivierung")
_notifyEnterpriseInvoice(mandateId, created)
logger.info("Enterprise subscription created for mandate %s: id=%s", mandateId, created["id"])
return created
def renewEnterprise(
self, subscriptionId: str, newEndDate: float,
overrides: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Renew an enterprise subscription: expire old, create new with same or overridden params.
1. Load + validate old subscription
2. Expire old subscription
3. Create new ACTIVE subscription (clone params, apply overrides)
4. Credit AI budget
5. Send invoice email
"""
oldSub = self._interface.getById(subscriptionId)
if not oldSub:
raise ValueError(f"Subscription {subscriptionId} not found")
if not oldSub.get("isEnterprise"):
raise ValueError(f"Subscription {subscriptionId} is not an enterprise subscription")
mandateId = oldSub["mandateId"]
self._interface.forceExpire(subscriptionId)
self.invalidateCache(mandateId)
overrides = overrides or {}
nowTs = datetime.now(timezone.utc).timestamp()
startDate = nowTs
autoRenew = overrides.get("autoRenew", oldSub.get("recurring", False))
flatPriceCHF = overrides.get("flatPriceCHF", oldSub.get("enterpriseFlatPriceCHF"))
maxUsers = overrides.get("maxUsers", oldSub.get("enterpriseMaxUsers"))
maxFeatureInstances = overrides.get("maxFeatureInstances", oldSub.get("enterpriseMaxFeatureInstances"))
maxDataVolumeMB = overrides.get("maxDataVolumeMB", oldSub.get("enterpriseMaxDataVolumeMB"))
budgetAiCHF = overrides.get("budgetAiCHF", oldSub.get("enterpriseBudgetAiCHF"))
note = overrides.get("note", oldSub.get("enterpriseNote"))
sub = MandateSubscription(
mandateId=mandateId,
planKey="ENTERPRISE",
status=SubscriptionStatusEnum.ACTIVE,
recurring=autoRenew,
startedAt=nowTs,
currentPeriodStart=startDate,
currentPeriodEnd=newEndDate,
isEnterprise=True,
enterpriseFlatPriceCHF=flatPriceCHF,
enterpriseMaxUsers=maxUsers,
enterpriseMaxFeatureInstances=maxFeatureInstances,
enterpriseMaxDataVolumeMB=maxDataVolumeMB,
enterpriseBudgetAiCHF=budgetAiCHF,
enterpriseNote=note,
)
created = self._interface.createSubscription(sub)
self.invalidateCache(mandateId)
self._creditEnterpriseBudget(mandateId, budgetAiCHF, "Erneuerung")
_notifyEnterpriseInvoice(mandateId, created)
logger.info(
"Enterprise subscription renewed for mandate %s: old=%s -> new=%s",
mandateId, subscriptionId, created["id"],
)
return created
def updateEnterprise(self, subscriptionId: str, changes: Dict[str, Any]) -> Dict[str, Any]:
"""Update enterprise subscription parameters (limits, note, flat price).
Only enterprise-specific fields are allowed. No status change."""
sub = self._interface.getById(subscriptionId)
if not sub:
raise ValueError(f"Subscription {subscriptionId} not found")
if not sub.get("isEnterprise"):
raise ValueError(f"Subscription {subscriptionId} is not an enterprise subscription")
allowedFields = {
"enterpriseFlatPriceCHF", "enterpriseMaxUsers", "enterpriseMaxFeatureInstances",
"enterpriseMaxDataVolumeMB", "enterpriseBudgetAiCHF", "enterpriseNote",
"recurring",
}
updateData = {k: v for k, v in changes.items() if k in allowedFields}
if not updateData:
raise ValueError("No valid enterprise fields to update")
result = self._interface.updateFields(subscriptionId, updateData)
self.invalidateCache(sub["mandateId"])
logger.info("Enterprise subscription %s updated: %s", subscriptionId, list(updateData.keys()))
return result
def _creditEnterpriseBudget(
self, mandateId: str, budgetAiCHF: Optional[float], periodLabel: str,
) -> None:
if not budgetAiCHF or budgetAiCHF <= 0:
return
try:
from modules.interfaces.interfaceDbBilling import getRootInterface as _getBillingRoot
_getBillingRoot().creditSubscriptionBudget(
mandateId, "ENTERPRISE", periodLabel=periodLabel,
enterpriseBudgetOverride=budgetAiCHF,
)
except Exception as e:
logger.error("Enterprise budget credit failed for mandate %s: %s", mandateId, e)
# ============================================================================
# Enterprise Invoice Email
# ============================================================================
def _notifyEnterpriseInvoice(mandateId: str, subRecord: Dict[str, Any]) -> None:
"""Send enterprise invoice email to mandate admins."""
try:
from modules.shared.notifyMandateAdmins import notifyMandateAdmins
rawHtml = _buildEnterpriseInvoiceHtml(subRecord)
flatPrice = subRecord.get("enterpriseFlatPriceCHF") or 0
notifyMandateAdmins(
mandateId,
t("[PowerOn] Enterprise-Abonnement — Rechnung") + f" (CHF {flatPrice:,.2f})",
t("Enterprise-Abonnement — Rechnung"),
[
t("Das Enterprise-Abonnement wurde aktiviert."),
t("Bitte begleichen Sie den Rechnungsbetrag innert 10 Tagen."),
t("Details zum Abonnement finden Sie unter Billing-Verwaltung."),
],
rawHtmlBlock=rawHtml,
)
except Exception as e:
logger.error("Enterprise invoice email failed for mandate %s: %s", mandateId, e)
def _buildEnterpriseInvoiceHtml(subRecord: Dict[str, Any]) -> str:
"""Build HTML invoice summary for enterprise subscription email."""
flatPrice = subRecord.get("enterpriseFlatPriceCHF") or 0
maxUsers = subRecord.get("enterpriseMaxUsers")
maxFeatures = subRecord.get("enterpriseMaxFeatureInstances")
maxStorageMB = subRecord.get("enterpriseMaxDataVolumeMB")
budgetAi = subRecord.get("enterpriseBudgetAiCHF")
note = subRecord.get("enterpriseNote") or ""
periodStart = subRecord.get("currentPeriodStart")
periodEnd = subRecord.get("currentPeriodEnd")
def _chf(amount: float) -> str:
return f"CHF {amount:,.2f}".replace(",", "'")
def _fmtDate(ts: Optional[float]) -> str:
if not ts:
return ""
from datetime import datetime, timezone
return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%d.%m.%Y")
detailRows = ""
if maxUsers is not None:
detailRows += (
f'<tr><td style="padding:4px 0;color:#555;">{t("Benutzer")}</td>'
f'<td style="padding:4px 0;text-align:right;color:#333;">max. {maxUsers}</td></tr>'
)
if maxFeatures is not None:
detailRows += (
f'<tr><td style="padding:4px 0;color:#555;">{t("Module")}</td>'
f'<td style="padding:4px 0;text-align:right;color:#333;">max. {maxFeatures}</td></tr>'
)
if maxStorageMB is not None:
storageLabel = f"{maxStorageMB} MB" if maxStorageMB < 1024 else f"{maxStorageMB / 1024:.1f} GB"
detailRows += (
f'<tr><td style="padding:4px 0;color:#555;">{t("Datenvolumen")}</td>'
f'<td style="padding:4px 0;text-align:right;color:#333;">max. {storageLabel}</td></tr>'
)
if budgetAi is not None and budgetAi > 0:
detailRows += (
f'<tr><td style="padding:4px 0;color:#555;">{t("KI-Budget")}</td>'
f'<td style="padding:4px 0;text-align:right;color:#333;">{_chf(budgetAi)}</td></tr>'
)
noteHtml = ""
if note:
import html as htmlmod
noteHtml = (
f'<p style="margin:8px 0 0 0;font-size:13px;color:#6b7280;">'
f'{t("Notiz")}: {htmlmod.escape(note)}</p>'
)
return (
f'<table style="width:100%;border-collapse:collapse;font-size:14px;margin:8px 0;">'
f'<tbody>'
f'<tr style="border-bottom:1px solid #e5e7eb;">'
f'<td style="padding:6px 0;color:#555;">{t("Zeitraum")}</td>'
f'<td style="padding:6px 0;text-align:right;color:#333;">{_fmtDate(periodStart)} {_fmtDate(periodEnd)}</td>'
f'</tr>'
f'{detailRows}'
f'<tr style="border-top:2px solid #1a1a2e;">'
f'<td style="padding:10px 0;font-weight:700;color:#1a1a2e;">{t("Pauschale")}</td>'
f'<td style="padding:10px 0;text-align:right;font-weight:700;color:#1a1a2e;font-size:16px;">'
f'{_chf(flatPrice)}</td>'
f'</tr>'
f'</tbody>'
f'</table>'
f'<p style="margin:8px 0 0 0;font-size:13px;color:#6b7280;">'
f'{t("Zahlungsfrist")}: {t("10 Tage")}</p>'
f'{noteHtml}'
)
# ============================================================================
# Notifications
@ -608,66 +859,66 @@ def _notifySubscriptionChange(
templates: Dict[str, Dict[str, Any]] = {
"activated": {
"subject": f"[PowerOn] Abonnement aktiviert — {planLabel}",
"headline": "Abonnement aktiviert",
"subject": f"[PowerOn] {t('Abonnement aktiviert')}{planLabel}",
"headline": t("Abonnement aktiviert"),
"paragraphs": [
p for p in [
f"Das Abonnement wurde auf den Plan «{planLabel}» aktiviert.",
t("Das Abonnement wurde auf den Plan «{planLabel}» aktiviert.").format(planLabel=planLabel),
platformHint,
"Sie können Ihr Abonnement jederzeit unter Billing-Verwaltung Abonnement einsehen und verwalten.",
t("Sie können Ihr Abonnement jederzeit unter Billing-Verwaltung Abonnement einsehen und verwalten."),
] if p
],
},
"cancelled": {
"subject": f"[PowerOn] Abonnement gekündigt — {planLabel}",
"headline": "Abonnement gekündigt",
"subject": f"[PowerOn] {t('Abonnement gekündigt')}{planLabel}",
"headline": t("Abonnement gekündigt"),
"paragraphs": [
p for p in [
f"Das Abonnement «{planLabel}» wurde gekündigt.",
t("Das Abonnement «{planLabel}» wurde gekündigt.").format(planLabel=planLabel),
platformHint,
"Die Kündigung wird zum Ende der aktuellen bezahlten Periode wirksam. Bis dahin bleibt der volle Zugang bestehen.",
t("Die Kündigung wird zum Ende der aktuellen bezahlten Periode wirksam. Bis dahin bleibt der volle Zugang bestehen."),
] if p
],
},
"force_cancelled": {
"subject": f"[PowerOn] Abonnement sofort beendet — {planLabel}",
"headline": "Abonnement sofort beendet",
"subject": f"[PowerOn] {t('Abonnement sofort beendet')}{planLabel}",
"headline": t("Abonnement sofort beendet"),
"paragraphs": [
p for p in [
f"Das Abonnement «{planLabel}» wurde durch den Plattform-Administrator sofort beendet.",
t("Das Abonnement «{planLabel}» wurde durch den Plattform-Administrator sofort beendet.").format(planLabel=planLabel),
platformHint,
"Der Zugang wurde per sofort deaktiviert. Bei Fragen wenden Sie sich an den Plattform-Support.",
t("Der Zugang wurde per sofort deaktiviert. Bei Fragen wenden Sie sich an den Plattform-Support."),
] if p
],
},
"trial_expired": {
"subject": "[PowerOn] Testphase abgelaufen",
"headline": "Testphase abgelaufen",
"subject": f"[PowerOn] {t('Testphase abgelaufen')}",
"headline": t("Testphase abgelaufen"),
"paragraphs": [
p for p in [
"Die kostenlose Testphase ist abgelaufen.",
t("Die kostenlose Testphase ist abgelaufen."),
platformHint,
"Bitte wählen Sie einen Plan unter Billing-Verwaltung Abonnement, damit der Zugang nicht unterbrochen wird.",
t("Bitte wählen Sie einen Plan unter Billing-Verwaltung Abonnement, damit der Zugang nicht unterbrochen wird."),
] if p
],
},
"payment_failed": {
"subject": f"[PowerOn] Zahlung fehlgeschlagen — {planLabel}",
"headline": "Zahlung fehlgeschlagen",
"subject": f"[PowerOn] {t('Zahlung fehlgeschlagen')}{planLabel}",
"headline": t("Zahlung fehlgeschlagen"),
"paragraphs": [
p for p in [
f"Die Zahlung für das Abonnement «{planLabel}» ist fehlgeschlagen.",
t("Die Zahlung für das Abonnement «{planLabel}» ist fehlgeschlagen.").format(planLabel=planLabel),
platformHint,
"Bitte aktualisieren Sie Ihr Zahlungsmittel unter Billing-Verwaltung.",
t("Bitte aktualisieren Sie Ihr Zahlungsmittel unter Billing-Verwaltung."),
] if p
],
},
}
tpl = templates.get(event, {
"subject": f"[PowerOn] Abonnement-Änderung — {planLabel}",
"headline": "Abonnement-Änderung",
"paragraphs": [f"Änderung am Abonnement «{planLabel}»."],
"subject": f"[PowerOn] {t('Abonnement-Änderung')}{planLabel}",
"headline": t("Abonnement-Änderung"),
"paragraphs": [t("Änderung am Abonnement «{planLabel}».").format(planLabel=planLabel)],
})
notifyMandateAdmins(
@ -699,7 +950,7 @@ def _buildInvoiceSummaryHtml(
instanceTotal = billableModules * instancePrice
netTotal = userTotal + instanceTotal
periodLabel = {"MONTHLY": "Monatlich", "YEARLY": "Jährlich"}.get(plan.billingPeriod, plan.billingPeriod)
periodLabel = {"MONTHLY": t("Monatlich"), "YEARLY": t("Jährlich")}.get(plan.billingPeriod, plan.billingPeriod)
def _chf(amount: float) -> str:
return f"CHF {amount:,.2f}".replace(",", "'")
@ -707,13 +958,13 @@ def _buildInvoiceSummaryHtml(
rows = ""
if userPrice > 0:
rows += (
f'<tr><td style="padding:6px 0;color:#333;">Benutzer-Lizenzen</td>'
f'<tr><td style="padding:6px 0;color:#333;">{t("Benutzer-Lizenzen")}</td>'
f'<td style="padding:6px 8px;color:#555;text-align:right;">{userCount} × {_chf(userPrice)}</td>'
f'<td style="padding:6px 0;color:#333;text-align:right;font-weight:600;">{_chf(userTotal)}</td></tr>\n'
)
if instancePrice > 0 and billableModules > 0:
rows += (
f'<tr><td style="padding:6px 0;color:#333;">Module ({instanceCount} total, {plan.includedModules} inkl.)</td>'
f'<tr><td style="padding:6px 0;color:#333;">{t("Module")} ({instanceCount} total, {plan.includedModules} {t("inkl.")})</td>'
f'<td style="padding:6px 8px;color:#555;text-align:right;">{billableModules} × {_chf(instancePrice)}</td>'
f'<td style="padding:6px 0;color:#333;text-align:right;font-weight:600;">{_chf(instanceTotal)}</td></tr>\n'
)
@ -733,7 +984,7 @@ def _buildInvoiceSummaryHtml(
invoiceLink = (
f'<p style="margin:12px 0 0 0;font-size:14px;">'
f'<a href="{htmlmod.escape(hostedUrl)}" style="color:#3b82f6;text-decoration:underline;">'
f'Vollständige Rechnung mit MwSt-Ausweis anzeigen</a></p>\n'
f'{t("Vollständige Rechnung mit MwSt-Ausweis anzeigen")}</a></p>\n'
)
except Exception as e:
logger.warning("Could not fetch Stripe invoice URL for sub %s: %s", stripeSubId, e)
@ -741,13 +992,13 @@ def _buildInvoiceSummaryHtml(
return (
f'<table style="width:100%;border-collapse:collapse;font-size:14px;margin:8px 0;">'
f'<thead><tr style="border-bottom:2px solid #e5e7eb;">'
f'<th style="text-align:left;padding:8px 0;color:#6b7280;font-weight:500;">Position</th>'
f'<th style="text-align:right;padding:8px;color:#6b7280;font-weight:500;">Menge × Preis</th>'
f'<th style="text-align:right;padding:8px 0;color:#6b7280;font-weight:500;">Total</th>'
f'<th style="text-align:left;padding:8px 0;color:#6b7280;font-weight:500;">{t("Position")}</th>'
f'<th style="text-align:right;padding:8px;color:#6b7280;font-weight:500;">{t("Menge")} × {t("Preis")}</th>'
f'<th style="text-align:right;padding:8px 0;color:#6b7280;font-weight:500;">{t("Total")}</th>'
f'</tr></thead>'
f'<tbody>{rows}</tbody>'
f'<tfoot><tr style="border-top:2px solid #1a1a2e;">'
f'<td style="padding:10px 0;font-weight:700;color:#1a1a2e;">Netto-Total ({periodLabel})</td>'
f'<td style="padding:10px 0;font-weight:700;color:#1a1a2e;">{t("Netto-Total")} ({periodLabel})</td>'
f'<td></td>'
f'<td style="padding:10px 0;text-align:right;font-weight:700;color:#1a1a2e;font-size:16px;">{_chf(netTotal)}</td>'
f'</tr></tfoot>'
@ -776,7 +1027,7 @@ def _buildCancelSummaryHtml(subRecord: Dict[str, Any], platformUrl: str = "") ->
parts.append(
f'<p style="margin:4px 0;font-size:14px;">'
f'<a href="{htmlmod.escape(hostedUrl)}" style="color:#3b82f6;text-decoration:underline;">'
f'Letzte Stripe-Rechnung anzeigen</a></p>'
f'{t("Letzte Stripe-Rechnung anzeigen")}</a></p>'
)
except Exception as e:
logger.warning("Could not fetch Stripe invoice URL for sub %s: %s", stripeSubId, e)
@ -822,7 +1073,7 @@ class SubscriptionInactiveException(Exception):
self.mandateId = mandateId
self.reason = _subscriptionReasonForStatus(status)
self.userAction = _subscriptionUserActionForStatus(status)
self.message = message or (
self.message = message or t(
"Kein aktives Abonnement für diesen Mandanten. Bitte wählen Sie einen Plan unter Billing."
)
super().__init__(self.message)
@ -837,47 +1088,62 @@ class SubscriptionInactiveException(Exception):
return out
_SUBSCRIPTION_LIMITS_UI_HINT_DE = (
" Details zu Ihrem Abonnement, den enthaltenen Limits und Upgrade-Optionen: "
"Menü «Administration» → «Billing» → Registerkarte «Abonnement»."
)
SUBSCRIPTION_USER_ACTION_CONTACT_ADMIN = "CONTACT_ADMIN"
def _subscriptionLimitsHint() -> str:
return " " + t(
"Details zu Ihrem Abonnement, den enthaltenen Limits und Upgrade-Optionen: "
"Menü «Administration» → «Billing» → Registerkarte «Abonnement»."
)
def _enterpriseLimitsHint() -> str:
return " " + t(
"Ihr Enterprise-Abonnement wird vom Plattform-Administrator verwaltet. "
"Bitte kontaktieren Sie den Administrator für eine Anpassung der Limiten."
)
class SubscriptionCapacityException(Exception):
def __init__(self, resourceType: str, currentCount: int, maxAllowed: int, message: Optional[str] = None):
def __init__(self, resourceType: str, currentCount: int, maxAllowed: int,
message: Optional[str] = None, isEnterprise: bool = False):
self.resourceType = resourceType
self.currentCount = currentCount
self.maxAllowed = maxAllowed
self.isEnterprise = isEnterprise
hint = _enterpriseLimitsHint() if isEnterprise else _subscriptionLimitsHint()
if message is not None:
self.message = message
elif resourceType == "users":
self.message = (
f"Mit dem aktuellen Abonnement sind für diesen Mandanten höchstens {maxAllowed} "
f"Benutzer zulässig (derzeit {currentCount}). "
f"Ohne Planwechsel können keine weiteren Benutzer hinzugefügt werden."
) + _SUBSCRIPTION_LIMITS_UI_HINT_DE
self.message = t(
"Mit dem aktuellen Abonnement sind für diesen Mandanten höchstens {maxAllowed} "
"Benutzer zulässig (derzeit {currentCount}). "
"Ohne Planwechsel können keine weiteren Benutzer hinzugefügt werden."
).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint
elif resourceType == "featureInstances":
self.message = (
f"Es sind höchstens {maxAllowed} aktive Module erlaubt (derzeit {currentCount}). "
f"Bitte Abonnement erweitern oder ein Modul entfernen."
) + _SUBSCRIPTION_LIMITS_UI_HINT_DE
self.message = t(
"Es sind höchstens {maxAllowed} aktive Module erlaubt (derzeit {currentCount}). "
"Bitte Abonnement erweitern oder ein Modul entfernen."
).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint
elif resourceType == "dataVolumeMB":
self.message = (
f"Das im Abonnement enthaltene Datenvolumen ({maxAllowed} MB) reicht nicht "
f"(aktuell ca. {currentCount} MB). Bitte Speicher-Limit oder Plan anpassen."
) + _SUBSCRIPTION_LIMITS_UI_HINT_DE
self.message = t(
"Das im Abonnement enthaltene Datenvolumen ({maxAllowed} MB) reicht nicht "
"(aktuell ca. {currentCount} MB). Bitte Speicher-Limit oder Plan anpassen."
).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint
else:
self.message = (
f"Abonnement-Limit überschritten (Ressource «{resourceType}»: "
f"aktuell {currentCount}, erlaubt {maxAllowed})."
) + _SUBSCRIPTION_LIMITS_UI_HINT_DE
self.message = t(
"Abonnement-Limit überschritten (Ressource «{resourceType}»: "
"aktuell {currentCount}, erlaubt {maxAllowed})."
).format(resourceType=resourceType, currentCount=currentCount, maxAllowed=maxAllowed) + hint
super().__init__(self.message)
def toClientDict(self) -> Dict[str, Any]:
action = SUBSCRIPTION_USER_ACTION_CONTACT_ADMIN if self.isEnterprise else SUBSCRIPTION_USER_ACTION_UPGRADE
return {
"error": f"SUBSCRIPTION_{self.resourceType.upper()}_LIMIT",
"currentCount": self.currentCount, "maxAllowed": self.maxAllowed,
"message": self.message, "userAction": SUBSCRIPTION_USER_ACTION_UPGRADE,
"message": self.message, "userAction": action,
"subscriptionUiPath": "/admin/billing?tab=subscription",
}

View file

@ -2172,11 +2172,13 @@ def getContexts(
>>> print(contexts.overlapContext) # "" (empty - JSON is complete)
>>> print(contexts.jsonParsingSuccess) # True
"""
# First, check if original JSON is already complete (parseable without modification)
# Completeness must use the same pipeline as callers (fences, balanced extract, normalization).
from modules.shared.jsonUtils import tryParseJson as _utils_try_parse_json
jsonIsComplete = False
if truncatedJson and truncatedJson.strip():
parsed, error = _tryParseJson(truncatedJson.strip())
if error is None:
_parsed_hdr, error_hdr, _ = _utils_try_parse_json(truncatedJson)
if error_hdr is None:
jsonIsComplete = True
logger.debug("Original JSON is already complete (no cut point)")
@ -2193,28 +2195,27 @@ def getContexts(
jsonParsingSuccess = False
if completePart and completePart.strip():
# First attempt: parse as-is
parsed, error = _tryParseJson(completePart)
parsed, error, _ = _utils_try_parse_json(completePart)
if error is None:
jsonParsingSuccess = True
else:
# Second attempt: repair internal errors and retry
logger.debug(f"Initial parse failed: {error}, attempting repair")
logger.debug(f"Initial parse failed: {error}, attempting internal repair")
repairedCompletePart = _repairInternalJsonErrors(completePart)
parsed, error = _tryParseJson(repairedCompletePart)
parsed, error, _ = _utils_try_parse_json(repairedCompletePart)
if error is None:
# Repair succeeded - use repaired version
completePart = repairedCompletePart
jsonParsingSuccess = True
logger.debug("JSON repair successful")
else:
# Repair also failed - keep original completePart, mark as failed
logger.debug(f"JSON repair also failed: {error}")
jsonParsingSuccess = False
# If completePart parses successfully, the merged/candidate JSON is structurally complete
# after repair/closing — overlap from extractContinuationContexts on the *raw* candidate
# would falsely signal truncation and trap callAiWithLooping in continuation iterations.
if jsonParsingSuccess:
overlap = ""
return JsonContinuationContexts(
overlapContext=overlap,
hierarchyContext=hierarchy,

Some files were not shown because too many files have changed in this diff Show more