commit
57a9257047
120 changed files with 4973 additions and 2377 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ ENV
|
|||
*~
|
||||
|
||||
# Environment files (will be handled separately)
|
||||
env_*.env
|
||||
env-*.env
|
||||
.env.local
|
||||
|
||||
# Logs
|
||||
|
|
|
|||
16
.github/workflows/deploy-gcp.yml
vendored
16
.github/workflows/deploy-gcp.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
.github/workflows/int_gateway-int.yml
vendored
4
.github/workflows/int_gateway-int.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
4
.github/workflows/main_gateway-prod.yml
vendored
4
.github/workflows/main_gateway-prod.yml
vendored
|
|
@ -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
2
.gitignore
vendored
|
|
@ -131,7 +131,7 @@ env.bak/
|
|||
venv.bak/
|
||||
|
||||
# Don't ignore environment templates
|
||||
!env*.env
|
||||
!env-*.env
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
|
|
|
|||
|
|
@ -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
7
app.py
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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="/")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}"})
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 2–5 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 (2–5)"),
|
||||
"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},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ TRIGGER_NODES = [
|
|||
"parameters": [
|
||||
{
|
||||
"name": "cron",
|
||||
"type": "string",
|
||||
"type": "str",
|
||||
"required": False,
|
||||
"frontendType": "cron",
|
||||
"description": t("Cron-Ausdruck"),
|
||||
|
|
|
|||
|
|
@ -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": ""},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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=[
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)')
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
177
modules/routes/routeTableViews.py
Normal file
177
modules/routes/routeTableViews.py
Normal 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")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]] = []
|
||||
|
|
|
|||
|
|
@ -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 ----
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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)."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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}", {}))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue