commit
57a9257047
120 changed files with 4973 additions and 2377 deletions
|
|
@ -29,10 +29,10 @@ ENV
|
||||||
*.swo
|
*.swo
|
||||||
*~
|
*~
|
||||||
|
|
||||||
# Environment files (env_gcp.env will be copied as .env by workflow)
|
# Environment files (env-gateway-*.env will be copied as .env by workflow)
|
||||||
env_*.env
|
env-*.env
|
||||||
.env.local
|
.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
|
# Logs
|
||||||
*.log
|
*.log
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,8 @@ jobs:
|
||||||
cd /srv/gateway/current &&
|
cd /srv/gateway/current &&
|
||||||
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/gateway.git &&
|
git remote set-url origin ssh://git@git.poweron.swiss:2222/PowerOn/gateway.git &&
|
||||||
git pull &&
|
git pull &&
|
||||||
cp env_prod_forgejo.env .env &&
|
cp env-gateway-prod-forgejo.env .env &&
|
||||||
rm -f env_*.env &&
|
rm -f env-*.env &&
|
||||||
source .venv/bin/activate &&
|
source .venv/bin/activate &&
|
||||||
pip install -r requirements.txt --no-cache-dir &&
|
pip install -r requirements.txt --no-cache-dir &&
|
||||||
sudo systemctl restart gateway
|
sudo systemctl restart gateway
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ ENV
|
||||||
*~
|
*~
|
||||||
|
|
||||||
# Environment files (will be handled separately)
|
# Environment files (will be handled separately)
|
||||||
env_*.env
|
env-*.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|
||||||
# Logs
|
# 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
|
# 2. Create secret "CONFIG_KEY" in Secret Manager with your master key
|
||||||
# 3. Grant the service account access to Secret Manager secrets
|
# 3. Grant the service account access to Secret Manager secrets
|
||||||
# 4. Create Cloud SQL instance (if not exists)
|
# 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:
|
# Environment Selection:
|
||||||
# - Push to 'main' branch → uses env_prod.env (production)
|
# - Push to 'main' branch → uses env-gateway-prod.env (production)
|
||||||
# - Push to 'int' branch → uses env_int.env (integration)
|
# - Push to 'int' branch → uses env-gateway-int.env (integration)
|
||||||
# - Manual dispatch → select environment (prod/int) to use corresponding env file
|
# - Manual dispatch → select environment (prod/int) to use corresponding env file
|
||||||
|
|
||||||
name: Deploy Gateway to Google Cloud Run
|
name: Deploy Gateway to Google Cloud Run
|
||||||
|
|
@ -70,10 +70,10 @@ jobs:
|
||||||
fi
|
fi
|
||||||
echo "env_type=$ENV_TYPE" >> $GITHUB_OUTPUT
|
echo "env_type=$ENV_TYPE" >> $GITHUB_OUTPUT
|
||||||
echo "service_name=gateway-$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 "Determined environment: $ENV_TYPE"
|
||||||
echo "Service name: gateway-$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
|
- name: Authenticate to Google Cloud
|
||||||
uses: google-github-actions/auth@v2
|
uses: google-github-actions/auth@v2
|
||||||
|
|
@ -98,11 +98,11 @@ jobs:
|
||||||
echo "Using $ENV_FILE"
|
echo "Using $ENV_FILE"
|
||||||
cp "$ENV_FILE" .env
|
cp "$ENV_FILE" .env
|
||||||
else
|
else
|
||||||
echo "Warning: $ENV_FILE not found, using env_prod.env as fallback"
|
echo "Warning: $ENV_FILE not found, using env-gateway-prod.env as fallback"
|
||||||
cp env_prod.env .env
|
cp env-gateway-prod.env .env
|
||||||
fi
|
fi
|
||||||
# Clean up other env files (optional, for security)
|
# Clean up other env files (optional, for security)
|
||||||
rm -f env_*.env
|
rm -f env-*.env
|
||||||
|
|
||||||
- name: Build and push container image
|
- name: Build and push container image
|
||||||
working-directory: ./gateway
|
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
|
run: unzip release.zip
|
||||||
|
|
||||||
- name: Set productive environment
|
- name: Set productive environment
|
||||||
run: cp env_int.env .env
|
run: cp env-gateway-int.env .env
|
||||||
|
|
||||||
- name: Clean up environment files
|
- name: Clean up environment files
|
||||||
run: rm -f env_*.env
|
run: rm -f env-*.env
|
||||||
|
|
||||||
- name: 'Deploy to Azure Web App'
|
- name: 'Deploy to Azure Web App'
|
||||||
uses: azure/webapps-deploy@v3
|
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
|
run: unzip release.zip
|
||||||
|
|
||||||
- name: Set productive environment
|
- name: Set productive environment
|
||||||
run: cp env_prod.env .env
|
run: cp env-gateway-prod.env .env
|
||||||
|
|
||||||
- name: Clean up environment files
|
- name: Clean up environment files
|
||||||
run: rm -f env_*.env
|
run: rm -f env-*.env
|
||||||
|
|
||||||
- name: 'Deploy to Azure Web App'
|
- name: 'Deploy to Azure Web App'
|
||||||
uses: azure/webapps-deploy@v3
|
uses: azure/webapps-deploy@v3
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -131,7 +131,7 @@ env.bak/
|
||||||
venv.bak/
|
venv.bak/
|
||||||
|
|
||||||
# Don't ignore environment templates
|
# Don't ignore environment templates
|
||||||
!env*.env
|
!env-*.env
|
||||||
|
|
||||||
# Spyder project settings
|
# Spyder project settings
|
||||||
.spyderproject
|
.spyderproject
|
||||||
|
|
|
||||||
|
|
@ -28,13 +28,13 @@ COPY requirements.lock .
|
||||||
RUN pip install --no-cache-dir --upgrade pip && \
|
RUN pip install --no-cache-dir --upgrade pip && \
|
||||||
pip install --no-cache-dir -r requirements.lock
|
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 . .
|
COPY . .
|
||||||
|
|
||||||
# Create directories for logs (Cloud Run uses /tmp for writable storage)
|
# Create directories for logs (Cloud Run uses /tmp for writable storage)
|
||||||
RUN mkdir -p /tmp/logs /tmp/debug
|
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
|
# These are decrypted at runtime using the master key from Secret Manager
|
||||||
# (mounted as CONFIG_KEY environment variable in Cloud Run)
|
# (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
|
from modules.shared.auditLogger import registerAuditLogCleanupScheduler
|
||||||
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
|
# Recover background jobs that were RUNNING when the previous worker died
|
||||||
try:
|
try:
|
||||||
from modules.serviceCenter.services.serviceBackgroundJobs.mainBackgroundJobService import (
|
from modules.serviceCenter.services.serviceBackgroundJobs.mainBackgroundJobService import (
|
||||||
|
|
@ -600,6 +604,9 @@ app.include_router(promptRouter)
|
||||||
from modules.routes.routeDataConnections import router as connectionsRouter
|
from modules.routes.routeDataConnections import router as connectionsRouter
|
||||||
app.include_router(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
|
from modules.routes.routeSecurityLocal import router as localRouter
|
||||||
app.include_router(localRouter)
|
app.include_router(localRouter)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,11 +45,6 @@ Connector_StacSwisstopo_MAX_RETRIES = 3
|
||||||
Connector_StacSwisstopo_RETRY_DELAY = 1.0
|
Connector_StacSwisstopo_RETRY_DELAY = 1.0
|
||||||
Connector_StacSwisstopo_ENABLE_CACHE = True
|
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 company information (shown on invoice emails)
|
||||||
Operator_CompanyName = PowerOn AG
|
Operator_CompanyName = PowerOn AG
|
||||||
Operator_Address = Birmensdorferstrasse 94, 8003 Zürich
|
Operator_Address = Birmensdorferstrasse 94, 8003 Zürich
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ APP_JWT_KEY_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpERjlrSktmZHVuQnJ1VVJDdndLaUcxZGJsT2Z
|
||||||
APP_TOKEN_EXPIRY=300
|
APP_TOKEN_EXPIRY=300
|
||||||
|
|
||||||
# CORS Configuration
|
# 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
|
# Logging configuration
|
||||||
APP_LOGGING_LOG_LEVEL = DEBUG
|
APP_LOGGING_LOG_LEVEL = DEBUG
|
||||||
|
|
@ -3,7 +3,9 @@
|
||||||
# System Configuration
|
# System Configuration
|
||||||
APP_ENV_TYPE = int
|
APP_ENV_TYPE = int
|
||||||
APP_ENV_LABEL = Integration Instance
|
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_KEY_SYSVAR = CONFIG_KEY
|
||||||
APP_INIT_PASS_ADMIN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjWm41MWZ4TUZGaVlrX3pWZWNwakJsY3Facm0wLVZDd1VKeTFoZEVZQnItcEdUUnVJS1NXeDBpM2xKbGRsYmxOSmRhc29PZjJSU2txQjdLbUVrTTE1NEJjUXBHbV9NOVJWZUR3QlJkQnJvTEU9
|
APP_INIT_PASS_ADMIN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjWm41MWZ4TUZGaVlrX3pWZWNwakJsY3Facm0wLVZDd1VKeTFoZEVZQnItcEdUUnVJS1NXeDBpM2xKbGRsYmxOSmRhc29PZjJSU2txQjdLbUVrTTE1NEJjUXBHbV9NOVJWZUR3QlJkQnJvTEU9
|
||||||
APP_INIT_PASS_EVENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjdmtrakgxa0djekZVNGtTZV8wM2I5UUpCZllveVBMWXROYk5yS3BiV3JEelJSM09VYTRONHpnY3VtMGxDRk5JTEZSRFhtcDZ0RVRmZ1RicTFhb3c5dVZRQ1o4SmlkLVpPTW5MMTU2eTQ0Vkk9
|
APP_INIT_PASS_EVENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjdmtrakgxa0djekZVNGtTZV8wM2I5UUpCZllveVBMWXROYk5yS3BiV3JEelJSM09VYTRONHpnY3VtMGxDRk5JTEZSRFhtcDZ0RVRmZ1RicTFhb3c5dVZRQ1o4SmlkLVpPTW5MMTU2eTQ0Vkk9
|
||||||
|
|
@ -19,7 +21,7 @@ APP_JWT_KEY_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNUctb2RwU25iR3ZnanBOdHZhWUtIajZ1RnZ
|
||||||
APP_TOKEN_EXPIRY=300
|
APP_TOKEN_EXPIRY=300
|
||||||
|
|
||||||
# CORS Configuration
|
# 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
|
# Logging configuration
|
||||||
APP_LOGGING_LOG_LEVEL = DEBUG
|
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)
|
# 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_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
|
||||||
Service_MSFT_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm83T29rMDZvcV9qTG5xb1FzUkdqS1llbzRxSEJXbmpONFFtcUtfZXdtZjQybmJSMjBjMEpnRVhiOGRuczZvVFBFdVVTQV80SG9PSnRQTEpLdVViNm5wc2E5aGRLWjZ4TGF1QjVkNmdRSzBpNWNkYXVublFYclVEdEM5TVBBZWVVMW5RVWk=
|
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_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
|
||||||
Service_MSFT_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm83T29rMDZvcV9qTG5xb1FzUkdqS1llbzRxSEJXbmpONFFtcUtfZXdtZjQybmJSMjBjMEpnRVhiOGRuczZvVFBFdVVTQV80SG9PSnRQTEpLdVViNm5wc2E5aGRLWjZ4TGF1QjVkNmdRSzBpNWNkYXVublFYclVEdEM5TVBBZWVVMW5RVWk=
|
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_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
|
||||||
Service_GOOGLE_AUTH_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNThGeVRNd3hacThtRnE0bzlDa0JPUWQyaEd6QjlFckdsMGZjRlRfUks2bXV3aDdVRTF3LVRlZVY5WjVzSXV4ZGNnX002RDl3dkNYdGFzZkxVUW01My1wTHRCanVCLUozZEx4TlduQlB5MnpvNTR2SGlvbFl1YkhzTEtsSi1SOEo=
|
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_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
|
||||||
Service_GOOGLE_DATA_CLIENT_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjNThGeVRNd3hacThtRnE0bzlDa0JPUWQyaEd6QjlFckdsMGZjRlRfUks2bXV3aDdVRTF3LVRlZVY5WjVzSXV4ZGNnX002RDl3dkNYdGFzZkxVUW01My1wTHRCanVCLUozZEx4TlduQlB5MnpvNTR2SGlvbFl1YkhzTEtsSi1SOEo=
|
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.
|
# 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_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
|
||||||
Service_CLICKUP_CLIENT_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5SE1uVURMNVE3NkM4cHBKa2R2TjBnLWdpSXI5dHpKWGExZVFiUF95TFNnZ1NwLWFLdmh6eWFZTHVHYTBzU2FGRUpLYkVyM1NvZjZkWDZHN21qUER5ZVNOaGpCc3NrUGd3VnFTclF3OW1nUlVuWXQ1UVhDLVpyb1BwRExOeFpDeVhtbEhDVnd4TVdpbzNBNk5QQWFPdjdza0xBWGxFY1E3WFpCSUlNa1l4RDlBPQ==
|
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.
|
# 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_KEY_SYSVAR = CONFIG_KEY
|
||||||
APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW1WbUt6bGtQYkdrNkZDOXNOLXFua1hqeFF2RHJnRXJ5VlVGV3hOZm41QjZOMlNTb0duYXNxZi05dXVTc2xDVkx0SVBFLUhncVo5T0VUZHE0UTZLWWw3ck09
|
APP_INIT_PASS_ADMIN_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3UnJRV0sySFlDblpXUlREclREaW1WbUt6bGtQYkdrNkZDOXNOLXFua1hqeFF2RHJnRXJ5VlVGV3hOZm41QjZOMlNTb0duYXNxZi05dXVTc2xDVkx0SVBFLUhncVo5T0VUZHE0UTZLWWw3ck09
|
||||||
APP_INIT_PASS_EVENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3QVpIY19DQVZSSzJmc2F0VEZvQlU1cHBhTEgxdHdnR3g4eW01aTEzYTUxc1gxTDR1RVVpSHRXYjV6N1BLZUdCUGlfOW1qdy0xSHFVRkNBcGZvaGlSSkZycXRuUllaWnpyVGRoeFg1dGEyNUk9
|
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
|
# PostgreSQL DB Host
|
||||||
DB_HOST=gateway-prod-server.postgres.database.azure.com
|
DB_HOST=gateway-prod-server.postgres.database.azure.com
|
||||||
|
|
@ -19,7 +20,7 @@ APP_JWT_KEY_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3elhfV0Rnd2pQRjlMdkVwX1FnSmRhSzNZUl
|
||||||
APP_TOKEN_EXPIRY=300
|
APP_TOKEN_EXPIRY=300
|
||||||
|
|
||||||
# CORS Configuration
|
# 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
|
# Logging configuration
|
||||||
APP_LOGGING_LOG_LEVEL = DEBUG
|
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)
|
# 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_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
|
||||||
Service_MSFT_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4=
|
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_ID = c7e7112d-61dc-4f3a-8cd3-08cc4cd7504c
|
||||||
Service_MSFT_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBESkk2T25scFU1T1pNd2FENTFRM3kzcEpSXy1HT0trQkR2Wnl3U3RYbExzRy1YUTkxd3lPZE84U2lhX3FZanp5TjhYRGluLXVjU3hjaWRBUnZLbVhtRDItZ3FxNXJ3MUxicUZTXzJWZVNrR0VKN3ZlNEtET1ppOFk0MzNmbkwyRmROUk4=
|
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_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
|
||||||
Service_GOOGLE_AUTH_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o=
|
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_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.apps.googleusercontent.com
|
||||||
Service_GOOGLE_DATA_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3eWFwSEZ4YnRJcjU1OW5kcXZKdkt1Z3gzWDFhVW5Eelh3VnpnNlppcWxweHY5UUQzeDIyVk83cW1XNVE4bllVWnR2MjlSQzFrV1UyUVV6OUt5b3Vqa3QzMUIwNFBqc2FVSXRxTlQ1OHVJZVFibnhBQ2puXzBwSXp5NUZhZjM1d1o=
|
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.
|
# 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_ID = O3FX3H602A30MQN4I4SBNGJLIDBD5SL4
|
||||||
Service_CLICKUP_CLIENT_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6VGw5WDdhdDRsVENSalhSSUV0OFFxbEx0V1l6aktNV0E5Y18xU3JHLUlqMWVJdmxyajAydVZRaDJkZzJOVXhxRV9ROFRZbWxlRjh4c3NtQnRFMmRtZWpzTWVsdngtWldlNXRKTURHQjJCOEt6alMwQlkwOFYyVVJWNURJUGJIZDIxYVlfNnBrMU54M0Q3TVdVbFZqRkJKTUtqa05wUkV4eGZvbXNsVi1nNVdBPQ==
|
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.
|
# 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
|
return 1.0
|
||||||
|
|
||||||
elif requestedPriority == PriorityEnum.SPEED:
|
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:
|
elif requestedPriority == PriorityEnum.QUALITY:
|
||||||
return model.qualityRating / 10.0
|
return model.qualityRating / 10.0
|
||||||
|
|
|
||||||
|
|
@ -351,6 +351,7 @@ class AiAnthropic(BaseConnectorAi):
|
||||||
|
|
||||||
# Parse response
|
# Parse response
|
||||||
anthropicResponse = response.json()
|
anthropicResponse = response.json()
|
||||||
|
stop_reason = anthropicResponse.get("stop_reason")
|
||||||
|
|
||||||
# Extract content and tool_use blocks from response
|
# Extract content and tool_use blocks from response
|
||||||
content = ""
|
content = ""
|
||||||
|
|
@ -374,9 +375,25 @@ class AiAnthropic(BaseConnectorAi):
|
||||||
|
|
||||||
if not content and not toolCalls:
|
if not content and not toolCalls:
|
||||||
logger.warning(f"Anthropic API returned empty content. Full response: {anthropicResponse}")
|
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", "")}
|
metadata = {"response_id": anthropicResponse.get("id", "")}
|
||||||
|
if stop_reason:
|
||||||
|
metadata["stop_reason"] = stop_reason
|
||||||
if toolCalls:
|
if toolCalls:
|
||||||
metadata["toolCalls"] = toolCalls
|
metadata["toolCalls"] = toolCalls
|
||||||
|
|
||||||
|
|
@ -492,6 +509,19 @@ class AiAnthropic(BaseConnectorAi):
|
||||||
f"Anthropic stream returned empty response: model={model.name}, "
|
f"Anthropic stream returned empty response: model={model.name}, "
|
||||||
f"stopReason={stopReason}"
|
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] = {}
|
metadata: Dict[str, Any] = {}
|
||||||
if stopReason:
|
if stopReason:
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,28 @@ ALGORITHM = APP_CONFIG.get("Auth_ALGORITHM")
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES = int(APP_CONFIG.get("APP_TOKEN_EXPIRY"))
|
ACCESS_TOKEN_EXPIRE_MINUTES = int(APP_CONFIG.get("APP_TOKEN_EXPIRY"))
|
||||||
REFRESH_TOKEN_EXPIRE_DAYS = int(APP_CONFIG.get("APP_REFRESH_TOKEN_EXPIRY", "7"))
|
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
|
def _cookiePolicy() -> Tuple[bool, str, str]:
|
||||||
# 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")
|
Return (useSecure, samesiteStarlette, samesiteSetCookieHeader).
|
||||||
USE_SECURE_COOKIES = APP_API_URL.startswith("https://") if APP_API_URL else False
|
|
||||||
|
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"]:
|
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:
|
def setAccessTokenCookie(response: Response, token: str, expiresDelta: Optional[timedelta] = None) -> None:
|
||||||
"""Set access token as httpOnly cookie."""
|
"""Set access token as httpOnly cookie."""
|
||||||
|
useSecure, samesite, _ = _cookiePolicy()
|
||||||
maxAge = int(expiresDelta.total_seconds()) if expiresDelta else ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
maxAge = int(expiresDelta.total_seconds()) if expiresDelta else ACCESS_TOKEN_EXPIRE_MINUTES * 60
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key="auth_token",
|
key="auth_token",
|
||||||
value=token,
|
value=token,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
secure=USE_SECURE_COOKIES, # Only secure in production (HTTPS)
|
secure=useSecure,
|
||||||
samesite="strict",
|
samesite=samesite,
|
||||||
path="/",
|
path="/",
|
||||||
max_age=maxAge
|
max_age=maxAge
|
||||||
)
|
)
|
||||||
|
|
@ -68,12 +87,13 @@ def setAccessTokenCookie(response: Response, token: str, expiresDelta: Optional[
|
||||||
|
|
||||||
def setRefreshTokenCookie(response: Response, token: str) -> None:
|
def setRefreshTokenCookie(response: Response, token: str) -> None:
|
||||||
"""Set refresh token as httpOnly cookie."""
|
"""Set refresh token as httpOnly cookie."""
|
||||||
|
useSecure, samesite, _ = _cookiePolicy()
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
key="refresh_token",
|
key="refresh_token",
|
||||||
value=token,
|
value=token,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
secure=USE_SECURE_COOKIES, # Only secure in production (HTTPS)
|
secure=useSecure,
|
||||||
samesite="strict",
|
samesite=samesite,
|
||||||
path="/",
|
path="/",
|
||||||
max_age=REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60
|
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.
|
Clear access token cookie by setting it to expire immediately.
|
||||||
Uses both raw header manipulation and FastAPI's delete_cookie for maximum browser compatibility.
|
Uses both raw header manipulation and FastAPI's delete_cookie for maximum browser compatibility.
|
||||||
"""
|
"""
|
||||||
# Build secure flag based on environment
|
useSecure, samesite, samesiteHeader = _cookiePolicy()
|
||||||
secure_flag = "; Secure" if USE_SECURE_COOKIES else ""
|
secure_flag = "; Secure" if useSecure else ""
|
||||||
|
|
||||||
# Primary method: Raw Set-Cookie header for guaranteed deletion
|
# Primary method: Raw Set-Cookie header for guaranteed deletion
|
||||||
response.headers.append(
|
response.headers.append(
|
||||||
"Set-Cookie",
|
"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
|
# Fallback: Also use FastAPI's built-in method (match SameSite/Secure for invalidation)
|
||||||
response.delete_cookie(key="auth_token", path="/")
|
response.delete_cookie(
|
||||||
|
key="auth_token",
|
||||||
|
path="/",
|
||||||
|
secure=useSecure,
|
||||||
|
httponly=True,
|
||||||
|
samesite=samesite,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def clearRefreshTokenCookie(response: Response) -> None:
|
def clearRefreshTokenCookie(response: Response) -> None:
|
||||||
|
|
@ -102,16 +128,22 @@ def clearRefreshTokenCookie(response: Response) -> None:
|
||||||
Clear refresh token cookie by setting it to expire immediately.
|
Clear refresh token cookie by setting it to expire immediately.
|
||||||
Uses both raw header manipulation and FastAPI's delete_cookie for maximum browser compatibility.
|
Uses both raw header manipulation and FastAPI's delete_cookie for maximum browser compatibility.
|
||||||
"""
|
"""
|
||||||
# Build secure flag based on environment
|
useSecure, samesite, samesiteHeader = _cookiePolicy()
|
||||||
secure_flag = "; Secure" if USE_SECURE_COOKIES else ""
|
secure_flag = "; Secure" if useSecure else ""
|
||||||
|
|
||||||
# Primary method: Raw Set-Cookie header for guaranteed deletion
|
# Primary method: Raw Set-Cookie header for guaranteed deletion
|
||||||
response.headers.append(
|
response.headers.append(
|
||||||
"Set-Cookie",
|
"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
|
# Fallback: Also use FastAPI's built-in method (match SameSite/Secure for invalidation)
|
||||||
response.delete_cookie(key="refresh_token", path="/")
|
response.delete_cookie(
|
||||||
|
key="refresh_token",
|
||||||
|
path="/",
|
||||||
|
secure=useSecure,
|
||||||
|
httponly=True,
|
||||||
|
samesite=samesite,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -834,7 +834,10 @@ class DatabaseConnector:
|
||||||
createdTs = record.get("sysCreatedAt")
|
createdTs = record.get("sysCreatedAt")
|
||||||
if createdTs is None or createdTs == 0 or createdTs == 0.0:
|
if createdTs is None or createdTs == 0 or createdTs == 0.0:
|
||||||
record["sysCreatedAt"] = currentTime
|
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
|
record["sysCreatedBy"] = effective_user_id
|
||||||
elif not record.get("sysCreatedBy"):
|
elif not record.get("sysCreatedBy"):
|
||||||
if effective_user_id:
|
if effective_user_id:
|
||||||
|
|
@ -1531,7 +1534,7 @@ class DatabaseConnector:
|
||||||
createdTs = rec.get("sysCreatedAt")
|
createdTs = rec.get("sysCreatedAt")
|
||||||
if createdTs is None or createdTs == 0 or createdTs == 0.0:
|
if createdTs is None or createdTs == 0 or createdTs == 0.0:
|
||||||
rec["sysCreatedAt"] = currentTime
|
rec["sysCreatedAt"] = currentTime
|
||||||
if effectiveUserId:
|
if effectiveUserId and not rec.get("sysCreatedBy"):
|
||||||
rec["sysCreatedBy"] = effectiveUserId
|
rec["sysCreatedBy"] = effectiveUserId
|
||||||
elif not rec.get("sysCreatedBy") and effectiveUserId:
|
elif not rec.get("sysCreatedBy") and effectiveUserId:
|
||||||
rec["sysCreatedBy"] = 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 typing import List, Dict, Any, Optional, Generic, TypeVar
|
||||||
from pydantic import BaseModel, Field, ConfigDict
|
from pydantic import BaseModel, Field, ConfigDict
|
||||||
import math
|
import math
|
||||||
|
import uuid
|
||||||
|
|
||||||
T = TypeVar('T')
|
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).
|
startRowIndex and rowCount are 0-based indices relative to the current page's items[].
|
||||||
Groups can be nested to arbitrary depth via subGroups.
|
|
||||||
"""
|
"""
|
||||||
id: str
|
path: List[str] = Field(..., description="Hierarchical group key (one entry per level)")
|
||||||
name: str
|
label: str = Field(..., description="Display label for this band (last path element)")
|
||||||
itemIds: List[str] = Field(default_factory=list)
|
startRowIndex: int = Field(..., description="0-based start index within items[] on this page")
|
||||||
subGroups: List['TableGroupNode'] = Field(default_factory=list)
|
rowCount: int = Field(..., description="Number of items in this band on this page")
|
||||||
order: int = 0
|
|
||||||
isExpanded: bool = True
|
|
||||||
|
|
||||||
TableGroupNode.model_rebuild()
|
|
||||||
|
|
||||||
|
|
||||||
class TableGrouping(BaseModel):
|
class GroupLayout(BaseModel):
|
||||||
"""
|
"""
|
||||||
Persisted grouping configuration for one (user, contextKey) pair.
|
Grouping structure for the current response page.
|
||||||
Stored in table_groupings in poweron_app (auto-created).
|
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.
|
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
|
userId: str
|
||||||
|
mandateId: Optional[str] = None
|
||||||
contextKey: str
|
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
|
updatedAt: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sort and pagination models
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class SortField(BaseModel):
|
class SortField(BaseModel):
|
||||||
"""
|
"""Single sort field configuration."""
|
||||||
Single sort field configuration.
|
|
||||||
"""
|
|
||||||
field: str = Field(..., description="Field name to sort by")
|
field: str = Field(..., description="Field name to sort by")
|
||||||
direction: str = Field(..., description="Sort direction: 'asc' or 'desc'")
|
direction: str = Field(..., description="Sort direction: 'asc' or 'desc'")
|
||||||
|
|
||||||
|
|
@ -61,16 +106,13 @@ class PaginationParams(BaseModel):
|
||||||
"""
|
"""
|
||||||
Complete pagination state including page, sorting, and filters.
|
Complete pagination state including page, sorting, and filters.
|
||||||
|
|
||||||
Grouping extensions (both optional — omit when not using grouping):
|
View extension (optional):
|
||||||
groupId — Scope the request to items belonging to this group.
|
viewKey — Slug of a saved TableListView for this (user, contextKey) pair.
|
||||||
The backend resolves it to an itemIds IN-filter before
|
The server loads the view, merges its filters/sort/groupByLevels
|
||||||
applying normal pagination/search/filter logic.
|
into the effective query (request fields take priority over view
|
||||||
Also applied for mode=ids and mode=filterValues so that
|
defaults for explicitly provided fields), and returns groupLayout
|
||||||
bulk-select and filter-dropdowns respect the group scope.
|
in the response when groupByLevels is non-empty.
|
||||||
saveGroupTree — If present the backend persists this tree for the current
|
Omit or set to None for the default (ungrouped) view.
|
||||||
(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.
|
|
||||||
"""
|
"""
|
||||||
page: int = Field(ge=1, description="Current page number (1-based)")
|
page: int = Field(ge=1, description="Current page number (1-based)")
|
||||||
pageSize: int = Field(ge=1, le=1000, description="Number of items per page")
|
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
|
- Supported operators: equals/eq, contains, startsWith, endsWith, gt, gte, lt, lte, in, notIn
|
||||||
- Multiple filters are combined with AND logic"""
|
- Multiple filters are combined with AND logic"""
|
||||||
)
|
)
|
||||||
groupId: Optional[str] = Field(
|
viewKey: Optional[str] = Field(
|
||||||
default=None,
|
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,
|
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.
|
Response containing paginated data and metadata.
|
||||||
|
|
||||||
groupTree is included when the endpoint supports table grouping and the
|
groupLayout is included when the effective view has groupByLevels configured.
|
||||||
current user has a saved group tree for the requested contextKey.
|
It describes how to render group header rows in the current page's items[].
|
||||||
It is None when grouping is not configured for the endpoint or the user
|
Omitted (None) when no grouping is active.
|
||||||
has not created any groups yet. Frontend must treat None as an empty tree.
|
|
||||||
|
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")
|
items: List[T] = Field(..., description="Array of items for current page")
|
||||||
pagination: Optional[PaginationMetadata] = Field(..., description="Pagination metadata (None if pagination not applied)")
|
pagination: Optional[PaginationMetadata] = Field(..., description="Pagination metadata (None if pagination not applied)")
|
||||||
groupTree: Optional[List[TableGroupNode]] = Field(
|
groupLayout: Optional[GroupLayout] = Field(
|
||||||
default=None,
|
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)
|
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]:
|
def normalize_pagination_dict(pagination_dict: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Normalize pagination dictionary to handle frontend variations.
|
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:
|
- Moves top-level "search" field into filters if present.
|
||||||
pagination_dict: Raw pagination dictionary from frontend
|
- Silently drops legacy fields (groupId, saveGroupTree) that were part of the
|
||||||
|
old tree-grouping implementation so old clients do not cause validation errors.
|
||||||
Returns:
|
- Passes viewKey through unchanged.
|
||||||
Normalized pagination dictionary ready for PaginationParams parsing
|
|
||||||
"""
|
"""
|
||||||
if not pagination_dict:
|
if not pagination_dict:
|
||||||
return pagination_dict
|
return pagination_dict
|
||||||
|
|
||||||
# Create a copy to avoid modifying the original
|
|
||||||
normalized = dict(pagination_dict)
|
normalized = dict(pagination_dict)
|
||||||
|
|
||||||
# Ensure required fields have sensible defaults
|
|
||||||
if "page" not in normalized:
|
if "page" not in normalized:
|
||||||
normalized["page"] = 1
|
normalized["page"] = 1
|
||||||
if "pageSize" not in normalized:
|
if "pageSize" not in normalized:
|
||||||
normalized["pageSize"] = 25
|
normalized["pageSize"] = 25
|
||||||
|
|
||||||
# Move top-level "search" into filters if present
|
# Move top-level "search" into filters
|
||||||
if "search" in normalized:
|
if "search" in normalized:
|
||||||
if "filters" not in normalized or normalized["filters"] is None:
|
if "filters" not in normalized or normalized["filters"] is None:
|
||||||
normalized["filters"] = {}
|
normalized["filters"] = {}
|
||||||
normalized["filters"]["search"] = normalized.pop("search")
|
normalized["filters"]["search"] = normalized.pop("search")
|
||||||
|
|
||||||
# groupId / saveGroupTree are valid PaginationParams fields — pass through unchanged.
|
# Drop legacy tree-grouping fields — harmless if already absent
|
||||||
# No transformation needed; Pydantic will validate them.
|
normalized.pop("groupId", None)
|
||||||
|
normalized.pop("saveGroupTree", None)
|
||||||
|
|
||||||
return normalized
|
return normalized
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ StripePlanPrice (persisted Stripe IDs per plan).
|
||||||
State Machine: see wiki/concepts/Subscription-State-Machine.md
|
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 enum import Enum
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
@ -284,12 +284,63 @@ class MandateSubscription(PowerOnModel):
|
||||||
json_schema_extra={"label": "Stripe-Item (Instanzen)"},
|
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)
|
# Built-in plan catalog (static, no env dependency)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
|
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(
|
"ROOT": SubscriptionPlan(
|
||||||
planKey="ROOT",
|
planKey="ROOT",
|
||||||
selectableByUser=False,
|
selectableByUser=False,
|
||||||
|
|
@ -415,3 +466,35 @@ def getPlan(planKey: str) -> Optional[SubscriptionPlan]:
|
||||||
def _getSelectablePlans() -> List[SubscriptionPlan]:
|
def _getSelectablePlans() -> List[SubscriptionPlan]:
|
||||||
"""Return plans that users can choose in the UI."""
|
"""Return plans that users can choose in the UI."""
|
||||||
return [p for p in BUILTIN_PLANS.values() if p.selectableByUser]
|
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.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
CommCoach Feature - Data Models.
|
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 typing import Optional, List, Dict, Any
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
@ -16,22 +16,18 @@ import uuid
|
||||||
# Enums
|
# Enums
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
class CoachingContextStatus(str, Enum):
|
class TrainingModuleStatus(str, Enum):
|
||||||
ACTIVE = "active"
|
ACTIVE = "active"
|
||||||
PAUSED = "paused"
|
PAUSED = "paused"
|
||||||
ARCHIVED = "archived"
|
ARCHIVED = "archived"
|
||||||
COMPLETED = "completed"
|
COMPLETED = "completed"
|
||||||
|
|
||||||
|
|
||||||
class CoachingContextCategory(str, Enum):
|
class TrainingModuleType(str, Enum):
|
||||||
LEADERSHIP = "leadership"
|
COACHING = "coaching"
|
||||||
CONFLICT = "conflict"
|
TRAINING = "training"
|
||||||
NEGOTIATION = "negotiation"
|
EXAM = "exam"
|
||||||
PRESENTATION = "presentation"
|
ELEARNING = "elearning"
|
||||||
FEEDBACK = "feedback"
|
|
||||||
DELEGATION = "delegation"
|
|
||||||
CHANGE_MANAGEMENT = "changeManagement"
|
|
||||||
CUSTOM = "custom"
|
|
||||||
|
|
||||||
|
|
||||||
class CoachingSessionStatus(str, Enum):
|
class CoachingSessionStatus(str, Enum):
|
||||||
|
|
@ -75,19 +71,21 @@ class CoachingScoreTrend(str, Enum):
|
||||||
# Database Models
|
# Database Models
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
class CoachingContext(PowerOnModel):
|
class TrainingModule(PowerOnModel):
|
||||||
"""A coaching context/dossier representing a topic the user is working on."""
|
"""A training module representing a topic the user is working on."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
userId: str = Field(description="Owner user ID (strict ownership)")
|
userId: str = Field(description="Owner user ID (strict ownership)")
|
||||||
mandateId: str = Field(description="Mandate ID")
|
mandateId: str = Field(description="Mandate ID")
|
||||||
instanceId: str = Field(description="Feature instance 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")
|
description: Optional[str] = Field(default=None, description="Short description")
|
||||||
category: CoachingContextCategory = Field(default=CoachingContextCategory.CUSTOM)
|
moduleType: TrainingModuleType = Field(default=TrainingModuleType.COACHING)
|
||||||
status: CoachingContextStatus = Field(default=CoachingContextStatus.ACTIVE)
|
status: TrainingModuleStatus = Field(default=TrainingModuleStatus.ACTIVE)
|
||||||
goals: Optional[str] = Field(default=None, description="JSON array of goals [{id, text, status, createdAt}]")
|
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}]")
|
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")
|
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)
|
sessionCount: int = Field(default=0)
|
||||||
taskCount: int = Field(default=0)
|
taskCount: int = Field(default=0)
|
||||||
lastSessionAt: Optional[float] = Field(default=None, json_schema_extra={"frontend_type": "timestamp"})
|
lastSessionAt: Optional[float] = Field(default=None, json_schema_extra={"frontend_type": "timestamp"})
|
||||||
|
|
@ -96,9 +94,9 @@ class CoachingContext(PowerOnModel):
|
||||||
|
|
||||||
|
|
||||||
class CoachingSession(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()))
|
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")
|
userId: str = Field(description="Owner user ID")
|
||||||
mandateId: str = Field(description="Mandate ID")
|
mandateId: str = Field(description="Mandate ID")
|
||||||
instanceId: str = Field(description="Feature instance ID")
|
instanceId: str = Field(description="Feature instance ID")
|
||||||
|
|
@ -121,7 +119,7 @@ class CoachingMessage(PowerOnModel):
|
||||||
"""A single message in a coaching session."""
|
"""A single message in a coaching session."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
sessionId: str = Field(description="FK to CoachingSession")
|
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")
|
userId: str = Field(description="Owner user ID")
|
||||||
role: CoachingMessageRole = Field(description="Message author role")
|
role: CoachingMessageRole = Field(description="Message author role")
|
||||||
content: str = Field(description="Message content (Markdown)")
|
content: str = Field(description="Message content (Markdown)")
|
||||||
|
|
@ -131,9 +129,9 @@ class CoachingMessage(PowerOnModel):
|
||||||
|
|
||||||
|
|
||||||
class CoachingTask(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()))
|
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")
|
sessionId: Optional[str] = Field(default=None, description="FK to originating session")
|
||||||
userId: str = Field(description="Owner user ID")
|
userId: str = Field(description="Owner user ID")
|
||||||
mandateId: str = Field(description="Mandate ID")
|
mandateId: str = Field(description="Mandate ID")
|
||||||
|
|
@ -148,7 +146,7 @@ class CoachingTask(PowerOnModel):
|
||||||
class CoachingScore(PowerOnModel):
|
class CoachingScore(PowerOnModel):
|
||||||
"""A competence score for a dimension, recorded after a session."""
|
"""A competence score for a dimension, recorded after a session."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
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")
|
sessionId: str = Field(description="FK to CoachingSession")
|
||||||
userId: str = Field(description="Owner user ID")
|
userId: str = Field(description="Owner user ID")
|
||||||
mandateId: str = Field(description="Mandate ID")
|
mandateId: str = Field(description="Mandate ID")
|
||||||
|
|
@ -193,6 +191,22 @@ class CoachingPersona(PowerOnModel):
|
||||||
isActive: bool = Field(default=True)
|
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
|
# Iteration 2: Badges / Gamification
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
@ -211,18 +225,22 @@ class CoachingBadge(PowerOnModel):
|
||||||
# API Request/Response Models
|
# API Request/Response Models
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
class CreateContextRequest(BaseModel):
|
class CreateModuleRequest(BaseModel):
|
||||||
title: str = Field(description="Context title")
|
title: str = Field(description="Module title")
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
category: Optional[CoachingContextCategory] = CoachingContextCategory.CUSTOM
|
moduleType: Optional[TrainingModuleType] = TrainingModuleType.COACHING
|
||||||
goals: Optional[List[str]] = None
|
goals: Optional[str] = None
|
||||||
|
personaId: Optional[str] = None
|
||||||
|
kpiTargets: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class UpdateContextRequest(BaseModel):
|
class UpdateModuleRequest(BaseModel):
|
||||||
title: Optional[str] = None
|
title: Optional[str] = None
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
category: Optional[CoachingContextCategory] = None
|
moduleType: Optional[TrainingModuleType] = None
|
||||||
goals: Optional[str] = None
|
goals: Optional[str] = None
|
||||||
|
personaId: Optional[str] = None
|
||||||
|
kpiTargets: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class SendMessageRequest(BaseModel):
|
class SendMessageRequest(BaseModel):
|
||||||
|
|
@ -279,8 +297,8 @@ class UpdatePersonaRequest(BaseModel):
|
||||||
|
|
||||||
class DashboardData(BaseModel):
|
class DashboardData(BaseModel):
|
||||||
"""Aggregated dashboard data for the user."""
|
"""Aggregated dashboard data for the user."""
|
||||||
totalContexts: int = 0
|
totalModules: int = 0
|
||||||
activeContexts: int = 0
|
activeModules: int = 0
|
||||||
totalSessions: int = 0
|
totalSessions: int = 0
|
||||||
totalMinutes: int = 0
|
totalMinutes: int = 0
|
||||||
streakDays: int = 0
|
streakDays: int = 0
|
||||||
|
|
@ -289,4 +307,4 @@ class DashboardData(BaseModel):
|
||||||
recentScores: List[Dict[str, Any]] = Field(default_factory=list)
|
recentScores: List[Dict[str, Any]] = Field(default_factory=list)
|
||||||
openTasks: int = 0
|
openTasks: int = 0
|
||||||
completedTasks: 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 modules.shared.i18nRegistry import resolveText, t
|
||||||
|
|
||||||
from .datamodelCommcoach import (
|
from .datamodelCommcoach import (
|
||||||
CoachingContext, CoachingContextStatus,
|
TrainingModule, TrainingModuleStatus,
|
||||||
CoachingSession, CoachingSessionStatus,
|
CoachingSession, CoachingSessionStatus,
|
||||||
CoachingMessage,
|
CoachingMessage,
|
||||||
CoachingTask, CoachingTaskStatus,
|
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]]:
|
def getModules(self, instanceId: str, userId: str, includeArchived: bool = False) -> List[Dict[str, Any]]:
|
||||||
"""Get all coaching contexts for a user. Strict ownership."""
|
"""Get all training modules for a user. Enriches with live sessionCount from sessions table."""
|
||||||
records = self.db.getRecordset(
|
records = self.db.getRecordset(
|
||||||
CoachingContext,
|
TrainingModule,
|
||||||
recordFilter={"instanceId": instanceId, "userId": userId},
|
recordFilter={"instanceId": instanceId, "userId": userId},
|
||||||
)
|
)
|
||||||
if not includeArchived:
|
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)
|
records.sort(key=lambda r: r.get("updatedAt") or r.get("createdAt") or "", reverse=True)
|
||||||
return records
|
return records
|
||||||
|
|
||||||
def getContext(self, contextId: str) -> Optional[Dict[str, Any]]:
|
def getModule(self, moduleId: str) -> Optional[Dict[str, Any]]:
|
||||||
records = self.db.getRecordset(CoachingContext, recordFilter={"id": contextId})
|
records = self.db.getRecordset(TrainingModule, recordFilter={"id": moduleId})
|
||||||
return records[0] if records else None
|
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["createdAt"] = getIsoTimestamp()
|
||||||
data["updatedAt"] = 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()
|
updates["updatedAt"] = getIsoTimestamp()
|
||||||
return self.db.recordModify(CoachingContext, contextId, updates)
|
return self.db.recordModify(TrainingModule, moduleId, updates)
|
||||||
|
|
||||||
def deleteContext(self, contextId: str) -> bool:
|
def deleteModule(self, moduleId: str) -> bool:
|
||||||
self._deleteSessionsByContext(contextId)
|
self._deleteSessionsByModule(moduleId)
|
||||||
self._deleteTasksByContext(contextId)
|
self._deleteTasksByModule(moduleId)
|
||||||
self._deleteScoresByContext(contextId)
|
self._deleteScoresByModule(moduleId)
|
||||||
return self.db.recordDelete(CoachingContext, contextId)
|
return self.db.recordDelete(TrainingModule, moduleId)
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Sessions
|
# 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(
|
records = self.db.getRecordset(
|
||||||
CoachingSession,
|
CoachingSession,
|
||||||
recordFilter={"contextId": contextId, "userId": userId},
|
recordFilter={"moduleId": moduleId, "userId": userId},
|
||||||
)
|
)
|
||||||
records.sort(key=lambda r: r.get("startedAt") or 0, reverse=True)
|
records.sort(key=lambda r: r.get("startedAt") or 0, reverse=True)
|
||||||
return records
|
return records
|
||||||
|
|
@ -119,10 +132,10 @@ class CommcoachObjects:
|
||||||
records = self.db.getRecordset(CoachingSession, recordFilter={"id": sessionId})
|
records = self.db.getRecordset(CoachingSession, recordFilter={"id": sessionId})
|
||||||
return records[0] if records else None
|
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(
|
records = self.db.getRecordset(
|
||||||
CoachingSession,
|
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
|
return records[0] if records else None
|
||||||
|
|
||||||
|
|
@ -136,8 +149,8 @@ class CommcoachObjects:
|
||||||
updates["updatedAt"] = getIsoTimestamp()
|
updates["updatedAt"] = getIsoTimestamp()
|
||||||
return self.db.recordModify(CoachingSession, sessionId, updates)
|
return self.db.recordModify(CoachingSession, sessionId, updates)
|
||||||
|
|
||||||
def _deleteSessionsByContext(self, contextId: str) -> int:
|
def _deleteSessionsByModule(self, moduleId: str) -> int:
|
||||||
records = self.db.getRecordset(CoachingSession, recordFilter={"contextId": contextId})
|
records = self.db.getRecordset(CoachingSession, recordFilter={"moduleId": moduleId})
|
||||||
count = 0
|
count = 0
|
||||||
for record in records:
|
for record in records:
|
||||||
self._deleteMessagesBySession(record.get("id"))
|
self._deleteMessagesBySession(record.get("id"))
|
||||||
|
|
@ -174,10 +187,10 @@ class CommcoachObjects:
|
||||||
# Tasks
|
# 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(
|
records = self.db.getRecordset(
|
||||||
CoachingTask,
|
CoachingTask,
|
||||||
recordFilter={"contextId": contextId, "userId": userId},
|
recordFilter={"moduleId": moduleId, "userId": userId},
|
||||||
)
|
)
|
||||||
records.sort(key=lambda r: r.get("createdAt") or "", reverse=True)
|
records.sort(key=lambda r: r.get("createdAt") or "", reverse=True)
|
||||||
return records
|
return records
|
||||||
|
|
@ -198,8 +211,8 @@ class CommcoachObjects:
|
||||||
def deleteTask(self, taskId: str) -> bool:
|
def deleteTask(self, taskId: str) -> bool:
|
||||||
return self.db.recordDelete(CoachingTask, taskId)
|
return self.db.recordDelete(CoachingTask, taskId)
|
||||||
|
|
||||||
def _deleteTasksByContext(self, contextId: str) -> int:
|
def _deleteTasksByModule(self, moduleId: str) -> int:
|
||||||
records = self.db.getRecordset(CoachingTask, recordFilter={"contextId": contextId})
|
records = self.db.getRecordset(CoachingTask, recordFilter={"moduleId": moduleId})
|
||||||
count = 0
|
count = 0
|
||||||
for record in records:
|
for record in records:
|
||||||
self.db.recordDelete(CoachingTask, record.get("id"))
|
self.db.recordDelete(CoachingTask, record.get("id"))
|
||||||
|
|
@ -218,10 +231,10 @@ class CommcoachObjects:
|
||||||
# Scores
|
# 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(
|
records = self.db.getRecordset(
|
||||||
CoachingScore,
|
CoachingScore,
|
||||||
recordFilter={"contextId": contextId, "userId": userId},
|
recordFilter={"moduleId": moduleId, "userId": userId},
|
||||||
)
|
)
|
||||||
records.sort(key=lambda r: r.get("createdAt") or "")
|
records.sort(key=lambda r: r.get("createdAt") or "")
|
||||||
return records
|
return records
|
||||||
|
|
@ -235,8 +248,8 @@ class CommcoachObjects:
|
||||||
data["createdAt"] = getIsoTimestamp()
|
data["createdAt"] = getIsoTimestamp()
|
||||||
return self.db.recordCreate(CoachingScore, data)
|
return self.db.recordCreate(CoachingScore, data)
|
||||||
|
|
||||||
def _deleteScoresByContext(self, contextId: str) -> int:
|
def _deleteScoresByModule(self, moduleId: str) -> int:
|
||||||
records = self.db.getRecordset(CoachingScore, recordFilter={"contextId": contextId})
|
records = self.db.getRecordset(CoachingScore, recordFilter={"moduleId": moduleId})
|
||||||
count = 0
|
count = 0
|
||||||
for record in records:
|
for record in records:
|
||||||
self.db.recordDelete(CoachingScore, record.get("id"))
|
self.db.recordDelete(CoachingScore, record.get("id"))
|
||||||
|
|
@ -274,6 +287,39 @@ class CommcoachObjects:
|
||||||
from .datamodelCommcoach import CoachingPersona
|
from .datamodelCommcoach import CoachingPersona
|
||||||
return self.db.recordDelete(CoachingPersona, personaId)
|
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
|
# Badges
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
@ -299,8 +345,8 @@ class CommcoachObjects:
|
||||||
# Score History
|
# Score History
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
def getScoreHistory(self, contextId: str, userId: str) -> Dict[str, List[Dict[str, Any]]]:
|
def getScoreHistory(self, moduleId: str, userId: str) -> Dict[str, List[Dict[str, Any]]]:
|
||||||
scores = self.getScores(contextId, userId)
|
scores = self.getScores(moduleId, userId)
|
||||||
history: Dict[str, List[Dict[str, Any]]] = {}
|
history: Dict[str, List[Dict[str, Any]]] = {}
|
||||||
for s in scores:
|
for s in scores:
|
||||||
dim = s.get("dimension", "unknown")
|
dim = s.get("dimension", "unknown")
|
||||||
|
|
@ -344,16 +390,15 @@ class CommcoachObjects:
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
def getDashboardData(self, userId: str, instanceId: str) -> Dict[str, Any]:
|
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})
|
sessions = self.db.getRecordset(CoachingSession, recordFilter={"userId": userId, "instanceId": instanceId})
|
||||||
profile = self.getProfile(userId, instanceId)
|
profile = self.getProfile(userId, instanceId)
|
||||||
|
|
||||||
activeContexts = [c for c in contexts if c.get("status") == CoachingContextStatus.ACTIVE.value]
|
activeModules = [m for m in modules if m.get("status") == TrainingModuleStatus.ACTIVE.value]
|
||||||
completedSessions = [s for s in sessions if s.get("status") == CoachingSessionStatus.COMPLETED.value]
|
|
||||||
|
|
||||||
totalMinutes = sum(s.get("durationSeconds", 0) for s in completedSessions) // 60
|
totalMinutes = sum(s.get("durationSeconds", 0) for s in sessions) // 60
|
||||||
scores = []
|
scores = []
|
||||||
for s in completedSessions:
|
for s in sessions:
|
||||||
raw = s.get("competenceScore")
|
raw = s.get("competenceScore")
|
||||||
if raw is not None:
|
if raw is not None:
|
||||||
try:
|
try:
|
||||||
|
|
@ -364,29 +409,27 @@ class CommcoachObjects:
|
||||||
|
|
||||||
recentScores = self.getRecentScores(userId, limit=10)
|
recentScores = self.getRecentScores(userId, limit=10)
|
||||||
|
|
||||||
contextSummaries = []
|
countByModule: Dict[str, int] = {}
|
||||||
for ctx in activeContexts:
|
for s in sessions:
|
||||||
goalProgress = _calcGoalProgress(ctx.get("goals"))
|
mid = s.get("moduleId")
|
||||||
contextSummaries.append({
|
if mid:
|
||||||
"id": ctx.get("id"),
|
countByModule[mid] = countByModule.get(mid, 0) + 1
|
||||||
"title": ctx.get("title"),
|
|
||||||
"category": ctx.get("category"),
|
moduleSummaries = []
|
||||||
"sessionCount": ctx.get("sessionCount", 0),
|
for mod in activeModules:
|
||||||
"lastSessionAt": ctx.get("lastSessionAt"),
|
modId = mod.get("id", "")
|
||||||
"goalProgress": goalProgress,
|
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 {
|
return {
|
||||||
"totalContexts": len(contexts),
|
"totalModules": len(modules),
|
||||||
"activeContexts": len(activeContexts),
|
"activeModules": len(activeModules),
|
||||||
"totalSessions": len(completedSessions),
|
"totalSessions": len(sessions),
|
||||||
"totalMinutes": totalMinutes,
|
"totalMinutes": totalMinutes,
|
||||||
"streakDays": profile.get("streakDays", 0) if profile else 0,
|
"streakDays": profile.get("streakDays", 0) if profile else 0,
|
||||||
"longestStreak": profile.get("longestStreak", 0) if profile else 0,
|
"longestStreak": profile.get("longestStreak", 0) if profile else 0,
|
||||||
|
|
@ -394,29 +437,12 @@ class CommcoachObjects:
|
||||||
"recentScores": recentScores,
|
"recentScores": recentScores,
|
||||||
"openTasks": self.getOpenTaskCount(userId, instanceId),
|
"openTasks": self.getOpenTaskCount(userId, instanceId),
|
||||||
"completedTasks": self.getCompletedTaskCount(userId, instanceId),
|
"completedTasks": self.getCompletedTaskCount(userId, instanceId),
|
||||||
"contexts": contextSummaries,
|
"modules": moduleSummaries,
|
||||||
"goalProgress": overallGoalProgress,
|
|
||||||
"badges": self.getBadges(userId, instanceId),
|
"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 = [
|
_LEVELS = [
|
||||||
(50, 5, "master", "Meister"),
|
(50, 5, "master", "Meister"),
|
||||||
(25, 4, "expert", "Experte"),
|
(25, 4, "expert", "Experte"),
|
||||||
|
|
|
||||||
|
|
@ -23,9 +23,19 @@ UI_OBJECTS = [
|
||||||
"meta": {"area": "dashboard"}
|
"meta": {"area": "dashboard"}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"objectKey": "ui.feature.commcoach.coaching",
|
"objectKey": "ui.feature.commcoach.assistant",
|
||||||
"label": t("Arbeitsthemen", context="UI"),
|
"label": t("Assistent", context="UI"),
|
||||||
"meta": {"area": "coaching"}
|
"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",
|
"objectKey": "ui.feature.commcoach.settings",
|
||||||
|
|
@ -35,15 +45,15 @@ UI_OBJECTS = [
|
||||||
]
|
]
|
||||||
|
|
||||||
DATA_OBJECTS = [
|
DATA_OBJECTS = [
|
||||||
# ── Record-Hierarchie: Context → Session → Message/Score, Context → Task ──
|
# ── Record-Hierarchie: TrainingModule → Session → Message/Score, TrainingModule → Task ──
|
||||||
{
|
{
|
||||||
"objectKey": "data.feature.commcoach.CoachingContext",
|
"objectKey": "data.feature.commcoach.TrainingModule",
|
||||||
"label": t("Coaching-Kontext", context="UI"),
|
"label": t("Trainings-Modul", context="UI"),
|
||||||
"meta": {
|
"meta": {
|
||||||
"table": "CoachingContext",
|
"table": "TrainingModule",
|
||||||
"fields": ["id", "title", "category", "status", "lastSessionAt"],
|
"fields": ["id", "title", "moduleType", "status", "lastSessionAt"],
|
||||||
"isParent": True,
|
"isParent": True,
|
||||||
"displayFields": ["title", "category", "status"],
|
"displayFields": ["title", "moduleType", "status"],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -51,10 +61,10 @@ DATA_OBJECTS = [
|
||||||
"label": t("Coaching-Session", context="UI"),
|
"label": t("Coaching-Session", context="UI"),
|
||||||
"meta": {
|
"meta": {
|
||||||
"table": "CoachingSession",
|
"table": "CoachingSession",
|
||||||
"fields": ["id", "contextId", "status", "summary", "startedAt", "endedAt", "competenceScore"],
|
"fields": ["id", "moduleId", "status", "summary", "startedAt", "endedAt", "competenceScore"],
|
||||||
"isParent": True,
|
"isParent": True,
|
||||||
"parentTable": "CoachingContext",
|
"parentTable": "TrainingModule",
|
||||||
"parentKey": "contextId",
|
"parentKey": "moduleId",
|
||||||
"displayFields": ["startedAt", "status"],
|
"displayFields": ["startedAt", "status"],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -63,7 +73,7 @@ DATA_OBJECTS = [
|
||||||
"label": t("Coaching-Nachricht", context="UI"),
|
"label": t("Coaching-Nachricht", context="UI"),
|
||||||
"meta": {
|
"meta": {
|
||||||
"table": "CoachingMessage",
|
"table": "CoachingMessage",
|
||||||
"fields": ["id", "sessionId", "contextId", "role", "content", "contentType"],
|
"fields": ["id", "sessionId", "moduleId", "role", "content", "contentType"],
|
||||||
"parentTable": "CoachingSession",
|
"parentTable": "CoachingSession",
|
||||||
"parentKey": "sessionId",
|
"parentKey": "sessionId",
|
||||||
}
|
}
|
||||||
|
|
@ -73,7 +83,7 @@ DATA_OBJECTS = [
|
||||||
"label": t("Coaching-Score", context="UI"),
|
"label": t("Coaching-Score", context="UI"),
|
||||||
"meta": {
|
"meta": {
|
||||||
"table": "CoachingScore",
|
"table": "CoachingScore",
|
||||||
"fields": ["id", "sessionId", "contextId", "dimension", "score", "trend"],
|
"fields": ["id", "sessionId", "moduleId", "dimension", "score", "trend"],
|
||||||
"parentTable": "CoachingSession",
|
"parentTable": "CoachingSession",
|
||||||
"parentKey": "sessionId",
|
"parentKey": "sessionId",
|
||||||
}
|
}
|
||||||
|
|
@ -83,9 +93,9 @@ DATA_OBJECTS = [
|
||||||
"label": t("Coaching-Aufgabe", context="UI"),
|
"label": t("Coaching-Aufgabe", context="UI"),
|
||||||
"meta": {
|
"meta": {
|
||||||
"table": "CoachingTask",
|
"table": "CoachingTask",
|
||||||
"fields": ["id", "contextId", "title", "status", "priority", "dueDate"],
|
"fields": ["id", "moduleId", "title", "status", "priority", "dueDate"],
|
||||||
"parentTable": "CoachingContext",
|
"parentTable": "TrainingModule",
|
||||||
"parentKey": "contextId",
|
"parentKey": "moduleId",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
# ── Stammdaten (sessionübergreifend, scoped per userId) ──────────────────
|
# ── Stammdaten (sessionübergreifend, scoped per userId) ──────────────────
|
||||||
|
|
@ -112,6 +122,15 @@ DATA_OBJECTS = [
|
||||||
"fields": ["id", "key", "label", "gender", "category"],
|
"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",
|
"objectKey": "data.feature.commcoach.CoachingBadge",
|
||||||
"label": t("Coaching-Auszeichnung", context="UI"),
|
"label": t("Coaching-Auszeichnung", context="UI"),
|
||||||
|
|
@ -130,19 +149,19 @@ DATA_OBJECTS = [
|
||||||
|
|
||||||
RESOURCE_OBJECTS = [
|
RESOURCE_OBJECTS = [
|
||||||
{
|
{
|
||||||
"objectKey": "resource.feature.commcoach.context.create",
|
"objectKey": "resource.feature.commcoach.module.create",
|
||||||
"label": t("Kontext erstellen", context="UI"),
|
"label": t("Modul erstellen", context="UI"),
|
||||||
"meta": {"endpoint": "/api/commcoach/{instanceId}/contexts", "method": "POST"}
|
"meta": {"endpoint": "/api/commcoach/{instanceId}/modules", "method": "POST"}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"objectKey": "resource.feature.commcoach.context.archive",
|
"objectKey": "resource.feature.commcoach.module.archive",
|
||||||
"label": t("Kontext archivieren", context="UI"),
|
"label": t("Modul archivieren", context="UI"),
|
||||||
"meta": {"endpoint": "/api/commcoach/{instanceId}/contexts/{contextId}/archive", "method": "POST"}
|
"meta": {"endpoint": "/api/commcoach/{instanceId}/modules/{moduleId}/archive", "method": "POST"}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"objectKey": "resource.feature.commcoach.session.start",
|
"objectKey": "resource.feature.commcoach.session.start",
|
||||||
"label": t("Session starten", context="UI"),
|
"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",
|
"objectKey": "resource.feature.commcoach.session.complete",
|
||||||
|
|
@ -152,7 +171,17 @@ RESOURCE_OBJECTS = [
|
||||||
{
|
{
|
||||||
"objectKey": "resource.feature.commcoach.task.manage",
|
"objectKey": "resource.feature.commcoach.task.manage",
|
||||||
"label": t("Aufgaben verwalten", context="UI"),
|
"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)",
|
"description": "Kommunikations-Coach Betrachter - Coaching-Daten ansehen (nur lesen)",
|
||||||
"accessRules": [
|
"accessRules": [
|
||||||
{"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True},
|
{"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": "UI", "item": "ui.feature.commcoach.settings", "view": True},
|
||||||
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
{"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},
|
{"context": "RESOURCE", "item": None, "view": False},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"roleLabel": "commcoach-user",
|
"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": [
|
"accessRules": [
|
||||||
{"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True},
|
{"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": "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.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.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.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.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": "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.module.create", "view": True},
|
||||||
{"context": "RESOURCE", "item": "resource.feature.commcoach.context.archive", "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.start", "view": True},
|
||||||
{"context": "RESOURCE", "item": "resource.feature.commcoach.session.complete", "view": True},
|
{"context": "RESOURCE", "item": "resource.feature.commcoach.session.complete", "view": True},
|
||||||
{"context": "RESOURCE", "item": "resource.feature.commcoach.task.manage", "view": True},
|
{"context": "RESOURCE", "item": "resource.feature.commcoach.task.manage", "view": True},
|
||||||
|
|
@ -252,6 +284,7 @@ def registerFeature(catalogService) -> bool:
|
||||||
meta=dataObj.get("meta")
|
meta=dataObj.get("meta")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_runMigrations()
|
||||||
_syncTemplateRolesToDb()
|
_syncTemplateRolesToDb()
|
||||||
_seedBuiltinPersonas()
|
_seedBuiltinPersonas()
|
||||||
_registerScheduler()
|
_registerScheduler()
|
||||||
|
|
@ -264,6 +297,135 @@ def registerFeature(catalogService) -> bool:
|
||||||
return False
|
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():
|
def _seedBuiltinPersonas():
|
||||||
"""Seed builtin roleplay personas into the database."""
|
"""Seed builtin roleplay personas into the database."""
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
CommCoach routes for the backend API.
|
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
|
import logging
|
||||||
|
|
@ -23,14 +23,14 @@ from modules.interfaces.interfaceFeatures import getFeatureInterface
|
||||||
|
|
||||||
from . import interfaceFeatureCommcoach as interfaceDb
|
from . import interfaceFeatureCommcoach as interfaceDb
|
||||||
from .datamodelCommcoach import (
|
from .datamodelCommcoach import (
|
||||||
CoachingContext, CoachingContextStatus, CoachingSession, CoachingSessionStatus,
|
TrainingModule, TrainingModuleStatus, CoachingSession, CoachingSessionStatus,
|
||||||
CoachingMessage, CoachingMessageRole, CoachingMessageContentType,
|
CoachingMessage, CoachingMessageRole, CoachingMessageContentType,
|
||||||
CoachingTask, CoachingTaskStatus,
|
CoachingTask, CoachingTaskStatus,
|
||||||
CoachingPersona, CoachingBadge,
|
CoachingPersona, CoachingBadge, ModulePersonaMapping,
|
||||||
CreateContextRequest, UpdateContextRequest,
|
CreateModuleRequest, UpdateModuleRequest,
|
||||||
SendMessageRequest, CreateTaskRequest, UpdateTaskRequest, UpdateTaskStatusRequest,
|
SendMessageRequest, CreateTaskRequest, UpdateTaskRequest, UpdateTaskStatusRequest,
|
||||||
UpdateProfileRequest,
|
UpdateProfileRequest,
|
||||||
StartSessionRequest, CreatePersonaRequest, UpdatePersonaRequest,
|
StartSessionRequest, CreatePersonaRequest, UpdatePersonaRequest, SetModulePersonasRequest,
|
||||||
)
|
)
|
||||||
from .serviceCommcoach import CommcoachService, emitSessionEvent, getSessionEventQueue, cleanupSessionEvents
|
from .serviceCommcoach import CommcoachService, emitSessionEvent, getSessionEventQueue, cleanupSessionEvents
|
||||||
from modules.shared.i18nRegistry import apiRouteContext
|
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")
|
@limiter.limit("60/minute")
|
||||||
async def listContexts(
|
async def listModules(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str,
|
instanceId: str,
|
||||||
includeArchived: bool = False,
|
includeArchived: bool = False,
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
"""List all coaching contexts for the current user."""
|
"""List all training modules for the current user."""
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
interface = _getInterface(context, instanceId)
|
interface = _getInterface(context, instanceId)
|
||||||
userId = str(context.user.id)
|
userId = str(context.user.id)
|
||||||
contexts = interface.getContexts(instanceId, userId, includeArchived=includeArchived)
|
modules = interface.getModules(instanceId, userId, includeArchived=includeArchived)
|
||||||
return {"contexts": contexts}
|
return {"modules": modules}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{instanceId}/contexts")
|
@router.post("/{instanceId}/modules")
|
||||||
@limiter.limit("20/minute")
|
@limiter.limit("20/minute")
|
||||||
async def createContext(
|
async def createModule(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str,
|
instanceId: str,
|
||||||
body: CreateContextRequest,
|
body: CreateModuleRequest,
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
"""Create a new coaching context/dossier."""
|
"""Create a new training module."""
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
interface = _getInterface(context, instanceId)
|
interface = _getInterface(context, instanceId)
|
||||||
userId = str(context.user.id)
|
userId = str(context.user.id)
|
||||||
|
|
||||||
goalsJson = None
|
moduleData = TrainingModule(
|
||||||
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(
|
|
||||||
userId=userId,
|
userId=userId,
|
||||||
mandateId=mandateId,
|
mandateId=mandateId,
|
||||||
instanceId=instanceId,
|
instanceId=instanceId,
|
||||||
title=body.title,
|
title=body.title,
|
||||||
description=body.description,
|
description=body.description,
|
||||||
category=body.category,
|
moduleType=body.moduleType,
|
||||||
goals=goalsJson,
|
goals=body.goals,
|
||||||
|
personaId=body.personaId,
|
||||||
|
kpiTargets=body.kpiTargets,
|
||||||
).model_dump()
|
).model_dump()
|
||||||
|
|
||||||
created = interface.createContext(contextData)
|
created = interface.createModule(moduleData)
|
||||||
logger.info(f"CommCoach context created: {created.get('id')} for user {userId}")
|
logger.info(f"CommCoach module created: {created.get('id')} for user {userId}")
|
||||||
_audit(context, "commcoach.context.created", "CoachingContext", created.get("id"), f"Title: {body.title}")
|
_audit(context, "commcoach.module.created", "TrainingModule", created.get("id"), f"Title: {body.title}")
|
||||||
return {"context": created}
|
return {"module": created}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{instanceId}/contexts/{contextId}")
|
@router.get("/{instanceId}/modules/{moduleId}")
|
||||||
@limiter.limit("60/minute")
|
@limiter.limit("60/minute")
|
||||||
async def getContext(
|
async def getModuleDetail(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str,
|
instanceId: str,
|
||||||
contextId: str,
|
moduleId: str,
|
||||||
context: RequestContext = Depends(getRequestContext),
|
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)
|
_validateInstanceAccess(instanceId, context)
|
||||||
interface = _getInterface(context, instanceId)
|
interface = _getInterface(context, instanceId)
|
||||||
userId = str(context.user.id)
|
userId = str(context.user.id)
|
||||||
|
|
||||||
ctx = interface.getContext(contextId)
|
mod = interface.getModule(moduleId)
|
||||||
if not ctx:
|
if not mod:
|
||||||
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
|
raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
|
||||||
_validateOwnership(ctx, context)
|
_validateOwnership(mod, context)
|
||||||
|
|
||||||
tasks = interface.getTasks(contextId, userId)
|
tasks = interface.getTasks(moduleId, userId)
|
||||||
scores = interface.getScores(contextId, userId)
|
scores = interface.getScores(moduleId, userId)
|
||||||
sessions = interface.getSessions(contextId, userId)
|
sessions = interface.getSessions(moduleId, userId)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"context": ctx,
|
"module": mod,
|
||||||
"tasks": tasks,
|
"tasks": tasks,
|
||||||
"scores": scores,
|
"scores": scores,
|
||||||
"sessions": sessions,
|
"sessions": sessions,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{instanceId}/contexts/{contextId}")
|
@router.put("/{instanceId}/modules/{moduleId}")
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
async def updateContext(
|
async def updateModuleFields(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str,
|
instanceId: str,
|
||||||
contextId: str,
|
moduleId: str,
|
||||||
body: UpdateContextRequest,
|
body: UpdateModuleRequest,
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
_validateInstanceAccess(instanceId, context)
|
_validateInstanceAccess(instanceId, context)
|
||||||
interface = _getInterface(context, instanceId)
|
interface = _getInterface(context, instanceId)
|
||||||
|
|
||||||
ctx = interface.getContext(contextId)
|
mod = interface.getModule(moduleId)
|
||||||
if not ctx:
|
if not mod:
|
||||||
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
|
raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
|
||||||
_validateOwnership(ctx, context)
|
_validateOwnership(mod, context)
|
||||||
|
|
||||||
updates = body.model_dump(exclude_none=True)
|
updates = body.model_dump(exclude_none=True)
|
||||||
updated = interface.updateContext(contextId, updates)
|
updated = interface.updateModule(moduleId, updates)
|
||||||
return {"context": updated}
|
return {"module": updated}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{instanceId}/contexts/{contextId}")
|
@router.delete("/{instanceId}/modules/{moduleId}")
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
async def deleteContext(
|
async def deleteModuleAndData(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str,
|
instanceId: str,
|
||||||
contextId: str,
|
moduleId: str,
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
_validateInstanceAccess(instanceId, context)
|
_validateInstanceAccess(instanceId, context)
|
||||||
interface = _getInterface(context, instanceId)
|
interface = _getInterface(context, instanceId)
|
||||||
|
|
||||||
ctx = interface.getContext(contextId)
|
mod = interface.getModule(moduleId)
|
||||||
if not ctx:
|
if not mod:
|
||||||
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
|
raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
|
||||||
_validateOwnership(ctx, context)
|
_validateOwnership(mod, context)
|
||||||
|
|
||||||
interface.deleteContext(contextId)
|
interface.deleteModule(moduleId)
|
||||||
return {"deleted": True}
|
return {"deleted": True}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{instanceId}/contexts/{contextId}/archive")
|
@router.post("/{instanceId}/modules/{moduleId}/archive")
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
async def archiveContext(
|
async def archiveModule(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str,
|
instanceId: str,
|
||||||
contextId: str,
|
moduleId: str,
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
_validateInstanceAccess(instanceId, context)
|
_validateInstanceAccess(instanceId, context)
|
||||||
interface = _getInterface(context, instanceId)
|
interface = _getInterface(context, instanceId)
|
||||||
|
|
||||||
ctx = interface.getContext(contextId)
|
mod = interface.getModule(moduleId)
|
||||||
if not ctx:
|
if not mod:
|
||||||
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
|
raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
|
||||||
_validateOwnership(ctx, context)
|
_validateOwnership(mod, context)
|
||||||
|
|
||||||
updated = interface.updateContext(contextId, {"status": CoachingContextStatus.ARCHIVED.value})
|
updated = interface.updateModule(moduleId, {"status": TrainingModuleStatus.ARCHIVED.value})
|
||||||
_audit(context, "commcoach.context.archived", "CoachingContext", contextId)
|
_audit(context, "commcoach.module.archived", "TrainingModule", moduleId)
|
||||||
return {"context": updated}
|
return {"module": updated}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{instanceId}/contexts/{contextId}/activate")
|
@router.post("/{instanceId}/modules/{moduleId}/activate")
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
async def activateContext(
|
async def activateModule(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str,
|
instanceId: str,
|
||||||
contextId: str,
|
moduleId: str,
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
_validateInstanceAccess(instanceId, context)
|
_validateInstanceAccess(instanceId, context)
|
||||||
interface = _getInterface(context, instanceId)
|
interface = _getInterface(context, instanceId)
|
||||||
|
|
||||||
ctx = interface.getContext(contextId)
|
mod = interface.getModule(moduleId)
|
||||||
if not ctx:
|
if not mod:
|
||||||
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
|
raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
|
||||||
_validateOwnership(ctx, context)
|
_validateOwnership(mod, context)
|
||||||
|
|
||||||
updated = interface.updateContext(contextId, {"status": CoachingContextStatus.ACTIVE.value})
|
updated = interface.updateModule(moduleId, {"status": TrainingModuleStatus.ACTIVE.value})
|
||||||
return {"context": updated}
|
return {"module": updated}
|
||||||
|
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
# Session Endpoints
|
# Session Endpoints
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
@router.get("/{instanceId}/contexts/{contextId}/sessions")
|
@router.get("/{instanceId}/modules/{moduleId}/sessions")
|
||||||
@limiter.limit("60/minute")
|
@limiter.limit("60/minute")
|
||||||
async def listSessions(
|
async def listSessions(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str,
|
instanceId: str,
|
||||||
contextId: str,
|
moduleId: str,
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
_validateInstanceAccess(instanceId, context)
|
_validateInstanceAccess(instanceId, context)
|
||||||
interface = _getInterface(context, instanceId)
|
interface = _getInterface(context, instanceId)
|
||||||
userId = str(context.user.id)
|
userId = str(context.user.id)
|
||||||
|
|
||||||
ctx = interface.getContext(contextId)
|
mod = interface.getModule(moduleId)
|
||||||
if not ctx:
|
if not mod:
|
||||||
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
|
raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
|
||||||
_validateOwnership(ctx, context)
|
_validateOwnership(mod, context)
|
||||||
|
|
||||||
sessions = interface.getSessions(contextId, userId)
|
sessions = interface.getSessions(moduleId, userId)
|
||||||
return {"sessions": sessions}
|
return {"sessions": sessions}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{instanceId}/contexts/{contextId}/sessions/start")
|
@router.post("/{instanceId}/modules/{moduleId}/sessions/start")
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
async def startSession(
|
async def startSession(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str,
|
instanceId: str,
|
||||||
contextId: str,
|
moduleId: str,
|
||||||
personaId: Optional[str] = None,
|
personaId: Optional[str] = None,
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
|
|
@ -297,22 +293,22 @@ async def startSession(
|
||||||
interface = _getInterface(context, instanceId)
|
interface = _getInterface(context, instanceId)
|
||||||
userId = str(context.user.id)
|
userId = str(context.user.id)
|
||||||
|
|
||||||
ctx = interface.getContext(contextId)
|
mod = interface.getModule(moduleId)
|
||||||
if not ctx:
|
if not mod:
|
||||||
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
|
raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
|
||||||
_validateOwnership(ctx, context)
|
_validateOwnership(mod, context)
|
||||||
|
|
||||||
activeSession = interface.getActiveSession(contextId, userId)
|
activeSession = interface.getActiveSession(moduleId, userId)
|
||||||
if activeSession:
|
if activeSession:
|
||||||
sessionId = activeSession.get("id")
|
sessionId = activeSession.get("id")
|
||||||
messages = interface.getMessages(sessionId)
|
messages = interface.getMessages(sessionId)
|
||||||
|
|
||||||
async def _resumedEventGenerator():
|
async def _resumedEventGenerator():
|
||||||
service = CommcoachService(context.user, mandateId, instanceId)
|
service = CommcoachService(context.user, mandateId, instanceId)
|
||||||
greetingText = await service.generateResumeGreeting(sessionId, contextId, messages, interface)
|
greetingText = await service.generateResumeGreeting(sessionId, moduleId, messages, interface)
|
||||||
assistantMsg = CoachingMessage(
|
assistantMsg = CoachingMessage(
|
||||||
sessionId=sessionId,
|
sessionId=sessionId,
|
||||||
contextId=contextId,
|
moduleId=moduleId,
|
||||||
userId=userId,
|
userId=userId,
|
||||||
role=CoachingMessageRole.ASSISTANT,
|
role=CoachingMessageRole.ASSISTANT,
|
||||||
content=greetingText,
|
content=greetingText,
|
||||||
|
|
@ -323,7 +319,7 @@ async def startSession(
|
||||||
greetingForFrontend = {
|
greetingForFrontend = {
|
||||||
"id": createdGreeting.get("id"),
|
"id": createdGreeting.get("id"),
|
||||||
"sessionId": sessionId,
|
"sessionId": sessionId,
|
||||||
"contextId": contextId,
|
"moduleId": moduleId,
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": greetingText,
|
"content": greetingText,
|
||||||
"contentType": "text",
|
"contentType": "text",
|
||||||
|
|
@ -365,7 +361,7 @@ async def startSession(
|
||||||
)
|
)
|
||||||
|
|
||||||
sessionData = CoachingSession(
|
sessionData = CoachingSession(
|
||||||
contextId=contextId,
|
moduleId=moduleId,
|
||||||
userId=userId,
|
userId=userId,
|
||||||
mandateId=mandateId,
|
mandateId=mandateId,
|
||||||
instanceId=instanceId,
|
instanceId=instanceId,
|
||||||
|
|
@ -378,7 +374,7 @@ async def startSession(
|
||||||
await emitSessionEvent(sessionId, "sessionState", {"session": created, "resumed": False})
|
await emitSessionEvent(sessionId, "sessionState", {"session": created, "resumed": False})
|
||||||
|
|
||||||
service = CommcoachService(context.user, mandateId, instanceId)
|
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():
|
async def _newSessionEventGenerator():
|
||||||
from modules.shared.timeUtils import getIsoTimestamp
|
from modules.shared.timeUtils import getIsoTimestamp
|
||||||
|
|
@ -399,8 +395,8 @@ async def startSession(
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
logger.info(f"CommCoach session started (streaming): {sessionId} for context {contextId}")
|
logger.info(f"CommCoach session started (streaming): {sessionId} for module {moduleId}")
|
||||||
_audit(context, "commcoach.session.started", "CoachingSession", sessionId, f"Context: {contextId}")
|
_audit(context, "commcoach.session.started", "CoachingSession", sessionId, f"Module: {moduleId}")
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
_newSessionEventGenerator(),
|
_newSessionEventGenerator(),
|
||||||
media_type="text/event-stream",
|
media_type="text/event-stream",
|
||||||
|
|
@ -504,7 +500,7 @@ async def sendMessageStream(
|
||||||
if session.get("status") != CoachingSessionStatus.ACTIVE.value:
|
if session.get("status") != CoachingSessionStatus.ACTIVE.value:
|
||||||
raise HTTPException(status_code=400, detail=routeApiMsg("Session is not active"))
|
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)
|
service = CommcoachService(context.user, mandateId, instanceId)
|
||||||
|
|
||||||
existingTask = _activeProcessTasks.get(sessionId)
|
existingTask = _activeProcessTasks.get(sessionId)
|
||||||
|
|
@ -517,7 +513,7 @@ async def sendMessageStream(
|
||||||
|
|
||||||
task = asyncio.create_task(
|
task = asyncio.create_task(
|
||||||
service.processMessage(
|
service.processMessage(
|
||||||
sessionId, contextId, body.content, interface,
|
sessionId, moduleId, body.content, interface,
|
||||||
fileIds=body.fileIds,
|
fileIds=body.fileIds,
|
||||||
dataSourceIds=body.dataSourceIds,
|
dataSourceIds=body.dataSourceIds,
|
||||||
featureDataSourceIds=body.featureDataSourceIds,
|
featureDataSourceIds=body.featureDataSourceIds,
|
||||||
|
|
@ -587,11 +583,11 @@ async def sendAudioStream(
|
||||||
from .serviceCommcoach import getUserVoicePrefs
|
from .serviceCommcoach import getUserVoicePrefs
|
||||||
language, _ = getUserVoicePrefs(str(context.user.id), mandateId)
|
language, _ = getUserVoicePrefs(str(context.user.id), mandateId)
|
||||||
|
|
||||||
contextId = session.get("contextId")
|
moduleId = session.get("moduleId")
|
||||||
service = CommcoachService(context.user, mandateId, instanceId)
|
service = CommcoachService(context.user, mandateId, instanceId)
|
||||||
|
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
service.processAudioMessage(sessionId, contextId, audioBody, language, interface)
|
service.processAudioMessage(sessionId, moduleId, audioBody, language, interface)
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _eventGenerator():
|
async def _eventGenerator():
|
||||||
|
|
@ -680,27 +676,27 @@ async def streamSession(
|
||||||
# Task Endpoints
|
# Task Endpoints
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
@router.get("/{instanceId}/contexts/{contextId}/tasks")
|
@router.get("/{instanceId}/modules/{moduleId}/tasks")
|
||||||
@limiter.limit("60/minute")
|
@limiter.limit("60/minute")
|
||||||
async def listTasks(
|
async def listTasks(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str,
|
instanceId: str,
|
||||||
contextId: str,
|
moduleId: str,
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
_validateInstanceAccess(instanceId, context)
|
_validateInstanceAccess(instanceId, context)
|
||||||
interface = _getInterface(context, instanceId)
|
interface = _getInterface(context, instanceId)
|
||||||
userId = str(context.user.id)
|
userId = str(context.user.id)
|
||||||
tasks = interface.getTasks(contextId, userId)
|
tasks = interface.getTasks(moduleId, userId)
|
||||||
return {"tasks": tasks}
|
return {"tasks": tasks}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{instanceId}/contexts/{contextId}/tasks")
|
@router.post("/{instanceId}/modules/{moduleId}/tasks")
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
async def createTask(
|
async def createTask(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str,
|
instanceId: str,
|
||||||
contextId: str,
|
moduleId: str,
|
||||||
body: CreateTaskRequest,
|
body: CreateTaskRequest,
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
|
|
@ -708,13 +704,13 @@ async def createTask(
|
||||||
interface = _getInterface(context, instanceId)
|
interface = _getInterface(context, instanceId)
|
||||||
userId = str(context.user.id)
|
userId = str(context.user.id)
|
||||||
|
|
||||||
ctx = interface.getContext(contextId)
|
mod = interface.getModule(moduleId)
|
||||||
if not ctx:
|
if not mod:
|
||||||
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
|
raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
|
||||||
_validateOwnership(ctx, context)
|
_validateOwnership(mod, context)
|
||||||
|
|
||||||
taskData = CoachingTask(
|
taskData = CoachingTask(
|
||||||
contextId=contextId,
|
moduleId=moduleId,
|
||||||
userId=userId,
|
userId=userId,
|
||||||
mandateId=mandateId,
|
mandateId=mandateId,
|
||||||
title=body.title,
|
title=body.title,
|
||||||
|
|
@ -853,12 +849,12 @@ async def updateProfile(
|
||||||
# Export Endpoints (Iteration 2)
|
# Export Endpoints (Iteration 2)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
||||||
@router.get("/{instanceId}/contexts/{contextId}/export")
|
@router.get("/{instanceId}/modules/{moduleId}/export")
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
async def exportDossier(
|
async def exportDossier(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str,
|
instanceId: str,
|
||||||
contextId: str,
|
moduleId: str,
|
||||||
format: str = "md",
|
format: str = "md",
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
|
|
@ -867,26 +863,26 @@ async def exportDossier(
|
||||||
interface = _getInterface(context, instanceId)
|
interface = _getInterface(context, instanceId)
|
||||||
userId = str(context.user.id)
|
userId = str(context.user.id)
|
||||||
|
|
||||||
ctx = interface.getContext(contextId)
|
mod = interface.getModule(moduleId)
|
||||||
if not ctx:
|
if not mod:
|
||||||
raise HTTPException(status_code=404, detail=routeApiMsg("Context not found"))
|
raise HTTPException(status_code=404, detail=routeApiMsg("Module not found"))
|
||||||
_validateOwnership(ctx, context)
|
_validateOwnership(mod, context)
|
||||||
|
|
||||||
tasks = interface.getTasks(contextId, userId)
|
tasks = interface.getTasks(moduleId, userId)
|
||||||
scores = interface.getScores(contextId, userId)
|
scores = interface.getScores(moduleId, userId)
|
||||||
sessions = interface.getSessions(contextId, userId)
|
sessions = interface.getSessions(moduleId, userId)
|
||||||
|
|
||||||
from .serviceCommcoachExport import buildDossierMarkdown, renderDossierPdf
|
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":
|
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",
|
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",
|
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")
|
@router.get("/{instanceId}/sessions/{sessionId}/export")
|
||||||
|
|
@ -907,11 +903,11 @@ async def exportSession(
|
||||||
raise HTTPException(status_code=404, detail=routeApiMsg("Session not found"))
|
raise HTTPException(status_code=404, detail=routeApiMsg("Session not found"))
|
||||||
_validateOwnership(session, context)
|
_validateOwnership(session, context)
|
||||||
|
|
||||||
contextId = session.get("contextId")
|
moduleId = session.get("moduleId")
|
||||||
userId = str(context.user.id)
|
userId = str(context.user.id)
|
||||||
messages = interface.getMessages(sessionId)
|
messages = interface.getMessages(sessionId)
|
||||||
tasks = interface.getTasks(contextId, userId) if contextId else []
|
tasks = interface.getTasks(moduleId, userId) if moduleId else []
|
||||||
scores = interface.getScores(contextId, userId) if contextId else []
|
scores = interface.getScores(moduleId, userId) if moduleId else []
|
||||||
|
|
||||||
from .serviceCommcoachExport import buildSessionMarkdown, renderSessionPdf
|
from .serviceCommcoachExport import buildSessionMarkdown, renderSessionPdf
|
||||||
_audit(context, "commcoach.export.requested", "CoachingSession", sessionId, f"format={format}")
|
_audit(context, "commcoach.export.requested", "CoachingSession", sessionId, f"format={format}")
|
||||||
|
|
@ -935,13 +931,47 @@ async def exportSession(
|
||||||
async def listPersonas(
|
async def listPersonas(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str,
|
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),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
_validateInstanceAccess(instanceId, context)
|
_validateInstanceAccess(instanceId, context)
|
||||||
interface = _getInterface(context, instanceId)
|
interface = _getInterface(context, instanceId)
|
||||||
userId = str(context.user.id)
|
allPersonas = interface.getAllPersonas(instanceId)
|
||||||
personas = interface.getPersonas(userId, instanceId)
|
|
||||||
return {"personas": personas}
|
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")
|
@router.post("/{instanceId}/personas")
|
||||||
|
|
@ -1017,6 +1047,43 @@ async def deletePersonaRoute(
|
||||||
return {"deleted": True}
|
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)
|
# Badge + Score History Endpoints (Iteration 2)
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
@ -1035,16 +1102,46 @@ async def listBadges(
|
||||||
return {"badges": badges}
|
return {"badges": badges}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{instanceId}/contexts/{contextId}/scores/history")
|
@router.get("/{instanceId}/modules/{moduleId}/scores/history")
|
||||||
@limiter.limit("60/minute")
|
@limiter.limit("60/minute")
|
||||||
async def getScoreHistory(
|
async def getScoreHistory(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str,
|
instanceId: str,
|
||||||
contextId: str,
|
moduleId: str,
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
_validateInstanceAccess(instanceId, context)
|
_validateInstanceAccess(instanceId, context)
|
||||||
interface = _getInterface(context, instanceId)
|
interface = _getInterface(context, instanceId)
|
||||||
userId = str(context.user.id)
|
userId = str(context.user.id)
|
||||||
history = interface.getScoreHistory(contextId, userId)
|
history = interface.getScoreHistory(moduleId, userId)
|
||||||
return {"history": history}
|
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})")
|
logger.info(f"Document saved as platform FileItem: {fileItem.id} ({title})")
|
||||||
|
|
||||||
except Exception as e:
|
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 = ""
|
content = ""
|
||||||
try:
|
try:
|
||||||
from modules.datamodels.datamodelKnowledge import FileContentIndex
|
from modules.datamodels.datamodelKnowledge import FileContentIndex
|
||||||
idxRecords = mgmtIf.db.getRecordset(FileContentIndex, recordFilter={"fileId": fId})
|
idxRecords = mgmtIf.db.getRecordset(FileContentIndex, recordFilter={"id": fId})
|
||||||
if idxRecords:
|
if idxRecords:
|
||||||
idx = idxRecords[0] if isinstance(idxRecords[0], dict) else idxRecords[0].model_dump()
|
idx = idxRecords[0] if isinstance(idxRecords[0], dict) else idxRecords[0].model_dump()
|
||||||
content = (idx.get("extractedText") or "")[:DOC_CONTENT_MAX_CHARS]
|
content = (idx.get("extractedText") or "")[:DOC_CONTENT_MAX_CHARS]
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
logger.warning(f"Failed to load FileContentIndex for {fId}: {e}")
|
||||||
results.append({
|
results.append({
|
||||||
"id": fId,
|
"id": fId,
|
||||||
"title": f.get("fileName") or f.get("name") or "Dokument",
|
"title": f.get("fileName") or f.get("name") or "Dokument",
|
||||||
|
|
@ -557,13 +557,13 @@ def _getDocumentSummaries(contextId: str, userId: str, interface,
|
||||||
try:
|
try:
|
||||||
from modules.datamodels.datamodelKnowledge import FileContentIndex
|
from modules.datamodels.datamodelKnowledge import FileContentIndex
|
||||||
idxRecords = mgmtIf.db.getRecordset(
|
idxRecords = mgmtIf.db.getRecordset(
|
||||||
FileContentIndex, recordFilter={"fileId": fId}
|
FileContentIndex, recordFilter={"id": fId}
|
||||||
)
|
)
|
||||||
if idxRecords:
|
if idxRecords:
|
||||||
idx = idxRecords[0] if isinstance(idxRecords[0], dict) else idxRecords[0].model_dump()
|
idx = idxRecords[0] if isinstance(idxRecords[0], dict) else idxRecords[0].model_dump()
|
||||||
snippet = (idx.get("extractedText") or "")[:200]
|
snippet = (idx.get("extractedText") or "")[:200]
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
logger.warning(f"Failed to load FileContentIndex for {fId}: {e}")
|
||||||
if snippet:
|
if snippet:
|
||||||
summaries.append(f"[{name}] {snippet}...")
|
summaries.append(f"[{name}] {snippet}...")
|
||||||
else:
|
else:
|
||||||
|
|
@ -690,7 +690,7 @@ def _buildConversationHistory(messages: List[Dict[str, Any]]) -> List[Dict[str,
|
||||||
return history
|
return history
|
||||||
|
|
||||||
|
|
||||||
_TTS_WORD_LIMIT = 200
|
_TTS_WORD_LIMIT = 80
|
||||||
|
|
||||||
|
|
||||||
async def _prepareSpeechText(fullText: str, callAiFn) -> str:
|
async def _prepareSpeechText(fullText: str, callAiFn) -> str:
|
||||||
|
|
@ -748,7 +748,7 @@ class CommcoachService:
|
||||||
# Store user message
|
# Store user message
|
||||||
userMsg = CoachingMessage(
|
userMsg = CoachingMessage(
|
||||||
sessionId=sessionId,
|
sessionId=sessionId,
|
||||||
contextId=contextId,
|
moduleId=contextId,
|
||||||
userId=self.userId,
|
userId=self.userId,
|
||||||
role=CoachingMessageRole.USER,
|
role=CoachingMessageRole.USER,
|
||||||
content=userContent,
|
content=userContent,
|
||||||
|
|
@ -764,7 +764,7 @@ class CommcoachService:
|
||||||
})
|
})
|
||||||
|
|
||||||
# Build context
|
# Build context
|
||||||
context = interface.getContext(contextId)
|
context = interface.getModule(contextId)
|
||||||
if not context:
|
if not context:
|
||||||
logger.error(f"Context {contextId} not found")
|
logger.error(f"Context {contextId} not found")
|
||||||
return createdUserMsg
|
return createdUserMsg
|
||||||
|
|
@ -857,7 +857,7 @@ class CommcoachService:
|
||||||
|
|
||||||
assistantMsg = CoachingMessage(
|
assistantMsg = CoachingMessage(
|
||||||
sessionId=sessionId,
|
sessionId=sessionId,
|
||||||
contextId=contextId,
|
moduleId=contextId,
|
||||||
userId=self.userId,
|
userId=self.userId,
|
||||||
role=CoachingMessageRole.ASSISTANT,
|
role=CoachingMessageRole.ASSISTANT,
|
||||||
content=textContent,
|
content=textContent,
|
||||||
|
|
@ -906,10 +906,14 @@ class CommcoachService:
|
||||||
)
|
)
|
||||||
agentService = getService("agent", serviceContext)
|
agentService = getService("agent", serviceContext)
|
||||||
|
|
||||||
|
from modules.datamodels.datamodelAi import PriorityEnum, OperationTypeEnum
|
||||||
config = AgentConfig(
|
config = AgentConfig(
|
||||||
toolSet="commcoach" if useTools else "none",
|
toolSet="commcoach" if useTools else "none",
|
||||||
maxRounds=3 if useTools else 1,
|
maxRounds=3 if useTools else 1,
|
||||||
temperature=0.4,
|
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(
|
buildRagContextFn = _createCommcoachRagFn(
|
||||||
|
|
@ -946,6 +950,8 @@ class CommcoachService:
|
||||||
await emitSessionEvent(sessionId, "toolResult", event.data or {})
|
await emitSessionEvent(sessionId, "toolResult", event.data or {})
|
||||||
elif event.type == AgentEventTypeEnum.AGENT_PROGRESS:
|
elif event.type == AgentEventTypeEnum.AGENT_PROGRESS:
|
||||||
await emitSessionEvent(sessionId, "agentProgress", event.data or {})
|
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:
|
elif event.type == AgentEventTypeEnum.ERROR:
|
||||||
await emitSessionEvent(sessionId, "error", {"message": event.content or "Agent 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..."})
|
await emitSessionEvent(sessionId, "status", {"label": "Coach bereitet sich vor..."})
|
||||||
|
|
||||||
context = interface.getContext(contextId)
|
context = interface.getModule(contextId)
|
||||||
if not context:
|
if not context:
|
||||||
logger.error(f"Context {contextId} not found")
|
logger.error(f"Context {contextId} not found")
|
||||||
await emitSessionEvent(sessionId, "error", {"message": "Context not found"})
|
await emitSessionEvent(sessionId, "error", {"message": "Context not found"})
|
||||||
|
|
@ -987,10 +993,14 @@ class CommcoachService:
|
||||||
)
|
)
|
||||||
|
|
||||||
isFirstSession = not previousSessionSummaries or len(previousSessionSummaries) == 0
|
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":
|
if persona and persona.get("key") != "coach":
|
||||||
personaLabel = persona.get("label", "Gesprächspartner")
|
personaLabel = persona.get("label", "Gesprächspartner")
|
||||||
|
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."
|
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:
|
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."
|
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:
|
else:
|
||||||
|
|
@ -1024,7 +1034,7 @@ class CommcoachService:
|
||||||
|
|
||||||
assistantMsg = CoachingMessage(
|
assistantMsg = CoachingMessage(
|
||||||
sessionId=sessionId,
|
sessionId=sessionId,
|
||||||
contextId=contextId,
|
moduleId=contextId,
|
||||||
userId=self.userId,
|
userId=self.userId,
|
||||||
role=CoachingMessageRole.ASSISTANT,
|
role=CoachingMessageRole.ASSISTANT,
|
||||||
content=textContent,
|
content=textContent,
|
||||||
|
|
@ -1046,7 +1056,7 @@ class CommcoachService:
|
||||||
|
|
||||||
async def generateResumeGreeting(self, sessionId: str, contextId: str, messages: list, interface) -> str:
|
async def generateResumeGreeting(self, sessionId: str, contextId: str, messages: list, interface) -> str:
|
||||||
"""Generate a follow-up greeting when user returns to an active session."""
|
"""Generate a follow-up greeting when user returns to an active session."""
|
||||||
context = interface.getContext(contextId)
|
context = interface.getModule(contextId)
|
||||||
if not context:
|
if not context:
|
||||||
raise ValueError(f"Context {contextId} not found for resume greeting")
|
raise ValueError(f"Context {contextId} not found for resume greeting")
|
||||||
contextTitle = context.get("title", "Coaching")
|
contextTitle = context.get("title", "Coaching")
|
||||||
|
|
@ -1100,8 +1110,10 @@ class CommcoachService:
|
||||||
if not session:
|
if not session:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
contextId = session.get("contextId")
|
contextId = session.get("moduleId")
|
||||||
context = interface.getContext(contextId) if contextId else None
|
if not contextId:
|
||||||
|
logger.error(f"completeSession: session {sessionId} has no moduleId")
|
||||||
|
context = interface.getModule(contextId) if contextId else None
|
||||||
messages = interface.getMessages(sessionId)
|
messages = interface.getMessages(sessionId)
|
||||||
|
|
||||||
if len(messages) < 2:
|
if len(messages) < 2:
|
||||||
|
|
@ -1156,7 +1168,7 @@ class CommcoachService:
|
||||||
for taskData in extractedTasks[:3]:
|
for taskData in extractedTasks[:3]:
|
||||||
if isinstance(taskData, dict) and taskData.get("title"):
|
if isinstance(taskData, dict) and taskData.get("title"):
|
||||||
newTask = CoachingTask(
|
newTask = CoachingTask(
|
||||||
contextId=contextId,
|
moduleId=contextId,
|
||||||
sessionId=sessionId,
|
sessionId=sessionId,
|
||||||
userId=self.userId,
|
userId=self.userId,
|
||||||
mandateId=self.mandateId,
|
mandateId=self.mandateId,
|
||||||
|
|
@ -1181,7 +1193,7 @@ class CommcoachService:
|
||||||
for scoreData in scores:
|
for scoreData in scores:
|
||||||
if isinstance(scoreData, dict) and "dimension" in scoreData and "score" in scoreData:
|
if isinstance(scoreData, dict) and "dimension" in scoreData and "score" in scoreData:
|
||||||
newScore = CoachingScore(
|
newScore = CoachingScore(
|
||||||
contextId=contextId,
|
moduleId=contextId,
|
||||||
sessionId=sessionId,
|
sessionId=sessionId,
|
||||||
userId=self.userId,
|
userId=self.userId,
|
||||||
mandateId=self.mandateId,
|
mandateId=self.mandateId,
|
||||||
|
|
@ -1213,7 +1225,7 @@ class CommcoachService:
|
||||||
existingInsights.append({"text": insightText, "sessionId": sessionId, "createdAt": getIsoTimestamp()})
|
existingInsights.append({"text": insightText, "sessionId": sessionId, "createdAt": getIsoTimestamp()})
|
||||||
await emitSessionEvent(sessionId, "insightGenerated", {"text": insightText, "sessionId": sessionId})
|
await emitSessionEvent(sessionId, "insightGenerated", {"text": insightText, "sessionId": sessionId})
|
||||||
if contextId and existingInsights:
|
if contextId and existingInsights:
|
||||||
interface.updateContext(contextId, {"insights": json.dumps(existingInsights[-10:])})
|
interface.updateModule(contextId, {"insights": json.dumps(existingInsights[-10:])})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Insight generation failed: {e}")
|
logger.warning(f"Insight generation failed: {e}")
|
||||||
|
|
||||||
|
|
@ -1280,7 +1292,7 @@ class CommcoachService:
|
||||||
if contextId:
|
if contextId:
|
||||||
allSessions = interface.getSessions(contextId, self.userId)
|
allSessions = interface.getSessions(contextId, self.userId)
|
||||||
completedCount = len([s for s in allSessions if s.get("status") == CoachingSessionStatus.COMPLETED.value])
|
completedCount = len([s for s in allSessions if s.get("status") == CoachingSessionStatus.COMPLETED.value])
|
||||||
interface.updateContext(contextId, {
|
interface.updateModule(contextId, {
|
||||||
"sessionCount": completedCount,
|
"sessionCount": completedCount,
|
||||||
"lastSessionAt": getUtcTimestamp(),
|
"lastSessionAt": getUtcTimestamp(),
|
||||||
})
|
})
|
||||||
|
|
@ -1429,7 +1441,7 @@ class CommcoachService:
|
||||||
"sessionSummaries": [],
|
"sessionSummaries": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx = interface.getContext(contextId)
|
ctx = interface.getModule(contextId)
|
||||||
rollingOverview = ctx.get("rollingOverview") if ctx else None
|
rollingOverview = ctx.get("rollingOverview") if ctx else None
|
||||||
rollingUpTo = ctx.get("rollingOverviewUpToSessionCount") 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:
|
if overviewResponse and overviewResponse.errorCount == 0 and overviewResponse.content:
|
||||||
newOverview = overviewResponse.content.strip()
|
newOverview = overviewResponse.content.strip()
|
||||||
interface.updateContext(contextId, {
|
interface.updateModule(contextId, {
|
||||||
"rollingOverview": newOverview,
|
"rollingOverview": newOverview,
|
||||||
"rollingOverviewUpToSessionCount": len(completedSessions),
|
"rollingOverviewUpToSessionCount": len(completedSessions),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -143,7 +143,7 @@ async def checkAndAwardBadges(interface, userId: str, mandateId: str, instanceId
|
||||||
badgesToCheck.append(("roleplay_first", True))
|
badgesToCheck.append(("roleplay_first", True))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from .datamodelCommcoach import CoachingContextStatus
|
from .datamodelCommcoach import TrainingModuleStatus
|
||||||
allContexts = interface.db.getRecordset(
|
allContexts = interface.db.getRecordset(
|
||||||
interface.db.getRecordset.__self__.__class__.__mro__[0] # avoid import issues
|
interface.db.getRecordset.__self__.__class__.__mro__[0] # avoid import issues
|
||||||
) if False else []
|
) if False else []
|
||||||
|
|
|
||||||
|
|
@ -146,6 +146,57 @@ BUILTIN_PERSONAS: List[Dict[str, Any]] = [
|
||||||
"gender": "m",
|
"gender": "m",
|
||||||
"category": "builtin",
|
"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:
|
try:
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
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.interfaces.interfaceMessaging import getInterface as getMessagingInterface
|
||||||
from modules.shared.notifyMandateAdmins import renderHtmlEmail, resolveMandateName
|
from modules.shared.notifyMandateAdmins import renderHtmlEmail, resolveMandateName
|
||||||
|
|
||||||
|
|
@ -94,10 +94,10 @@ async def _runDailyReminders():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check if user has active contexts
|
# Check if user has active contexts
|
||||||
from .datamodelCommcoach import CoachingContext
|
from .datamodelCommcoach import TrainingModule
|
||||||
contexts = db.getRecordset(CoachingContext, recordFilter={
|
contexts = db.getRecordset(TrainingModule, recordFilter={
|
||||||
"userId": userId,
|
"userId": userId,
|
||||||
"status": CoachingContextStatus.ACTIVE.value,
|
"status": TrainingModuleStatus.ACTIVE.value,
|
||||||
})
|
})
|
||||||
if not contexts:
|
if not contexts:
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -12,17 +12,30 @@ import uuid
|
||||||
from typing import Dict, Any, List, Optional
|
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
|
Recursively convert bytes to base64 strings so structures can be JSON-serialized
|
||||||
for storage in JSONB columns.
|
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):
|
if isinstance(obj, bytes):
|
||||||
return base64.b64encode(obj).decode("ascii")
|
return base64.b64encode(obj).decode("ascii")
|
||||||
if isinstance(obj, dict):
|
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):
|
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
|
return obj
|
||||||
|
|
||||||
from modules.datamodels.datamodelUam import User
|
from modules.datamodels.datamodelUam import User
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
from modules.shared.i18nRegistry import t
|
from modules.shared.i18nRegistry import t
|
||||||
|
|
||||||
_AI_COMMON_PARAMS = [
|
_AI_COMMON_PARAMS = [
|
||||||
{"name": "requireNeutralization", "type": "boolean", "required": False,
|
{"name": "requireNeutralization", "type": "bool", "required": False,
|
||||||
"frontendType": "checkbox", "default": False,
|
"frontendType": "checkbox", "default": False,
|
||||||
"description": t("Eingaben fuer diesen Call neutralisieren")},
|
"description": t("Eingaben fuer diesen Call neutralisieren")},
|
||||||
{"name": "allowedModels", "type": "array", "required": False,
|
{"name": "allowedModels", "type": "array", "required": False,
|
||||||
|
|
@ -19,25 +19,25 @@ AI_NODES = [
|
||||||
"label": t("Prompt"),
|
"label": t("Prompt"),
|
||||||
"description": t("Prompt eingeben und KI führt aus"),
|
"description": t("Prompt eingeben und KI führt aus"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "aiPrompt", "type": "string", "required": True, "frontendType": "templateTextarea",
|
{"name": "aiPrompt", "type": "str", "required": True, "frontendType": "templateTextarea",
|
||||||
"description": t("KI-Prompt")},
|
"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"]},
|
"frontendOptions": {"options": ["txt", "json", "md", "csv", "xml", "html", "pdf", "docx", "xlsx", "pptx", "png", "jpg"]},
|
||||||
"description": t("Ausgabeformat"), "default": "txt"},
|
"description": t("Ausgabeformat"), "default": "txt"},
|
||||||
{"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "dataRef",
|
{"name": "documentList", "type": "DocumentList", "required": False, "frontendType": "hidden",
|
||||||
"description": t("Dokumentenliste (Upstream-Output binden)"), "default": ""},
|
"description": t("Dokumente aus vorherigen Schritten"), "default": ""},
|
||||||
{"name": "context", "type": "string", "required": False, "frontendType": "dataRef",
|
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
|
||||||
"description": t("Kontextdaten fuer den Prompt (Upstream-Output binden)"), "default": ""},
|
"description": t("Daten aus vorherigen Schritten"), "default": ""},
|
||||||
{"name": "documentTheme", "type": "string", "required": False, "frontendType": "select",
|
{"name": "documentTheme", "type": "str", "required": False, "frontendType": "select",
|
||||||
"frontendOptions": {"options": ["general", "finance", "legal", "technical", "hr"]},
|
"frontendOptions": {"options": ["general", "finance", "legal", "technical", "hr"]},
|
||||||
"description": t("Dokument-Thema (Style-Hinweis fuer den Renderer)"), "default": "general"},
|
"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},
|
"description": t("Einfacher Modus"), "default": True},
|
||||||
] + _AI_COMMON_PARAMS,
|
] + _AI_COMMON_PARAMS,
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": [
|
"inputPorts": {0: {"accepts": [
|
||||||
"DocumentList", "AiResult", "TextResult", "Transit", "LoopItem", "ActionResult",
|
"FormPayload", "DocumentList", "AiResult", "TextResult", "Transit", "LoopItem", "ActionResult",
|
||||||
]}},
|
]}},
|
||||||
"outputPorts": {0: {"schema": "AiResult"}},
|
"outputPorts": {0: {"schema": "AiResult"}},
|
||||||
"meta": {"icon": "mdi-robot", "color": "#9C27B0", "usesAi": True},
|
"meta": {"icon": "mdi-robot", "color": "#9C27B0", "usesAi": True},
|
||||||
|
|
@ -50,12 +50,18 @@ AI_NODES = [
|
||||||
"label": t("Web-Recherche"),
|
"label": t("Web-Recherche"),
|
||||||
"description": t("Recherche im Web"),
|
"description": t("Recherche im Web"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "prompt", "type": "string", "required": True, "frontendType": "textarea",
|
{"name": "prompt", "type": "str", "required": True, "frontendType": "textarea",
|
||||||
"description": t("Recherche-Anfrage")},
|
"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,
|
] + _AI_COMMON_PARAMS,
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": [
|
||||||
|
"FormPayload", "Transit", "AiResult", "DocumentList", "ActionResult", "LoopItem", "TextResult",
|
||||||
|
]}},
|
||||||
"outputPorts": {0: {"schema": "AiResult"}},
|
"outputPorts": {0: {"schema": "AiResult"}},
|
||||||
"meta": {"icon": "mdi-magnify", "color": "#9C27B0", "usesAi": True},
|
"meta": {"icon": "mdi-magnify", "color": "#9C27B0", "usesAi": True},
|
||||||
"_method": "ai",
|
"_method": "ai",
|
||||||
|
|
@ -68,14 +74,14 @@ AI_NODES = [
|
||||||
"description": t("Dokumentinhalt zusammenfassen"),
|
"description": t("Dokumentinhalt zusammenfassen"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef",
|
{"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef",
|
||||||
"description": t("Dokumentenliste (Upstream-Output binden)"), "default": ""},
|
"description": t("Dokumente aus vorherigen Schritten")},
|
||||||
{"name": "summaryLength", "type": "string", "required": False, "frontendType": "select",
|
{"name": "summaryLength", "type": "str", "required": False, "frontendType": "select",
|
||||||
"frontendOptions": {"options": ["brief", "medium", "detailed"]},
|
"frontendOptions": {"options": ["brief", "medium", "detailed"]},
|
||||||
"description": t("Kurz, mittel oder ausführlich"), "default": "medium"},
|
"description": t("Kurz, mittel oder ausführlich"), "default": "medium"},
|
||||||
] + _AI_COMMON_PARAMS,
|
] + _AI_COMMON_PARAMS,
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
|
"inputPorts": {0: {"accepts": ["DocumentList", "Transit", "LoopItem"]}},
|
||||||
"outputPorts": {0: {"schema": "AiResult"}},
|
"outputPorts": {0: {"schema": "AiResult"}},
|
||||||
"meta": {"icon": "mdi-file-document-outline", "color": "#9C27B0", "usesAi": True},
|
"meta": {"icon": "mdi-file-document-outline", "color": "#9C27B0", "usesAi": True},
|
||||||
"_method": "ai",
|
"_method": "ai",
|
||||||
|
|
@ -88,13 +94,13 @@ AI_NODES = [
|
||||||
"description": t("Dokument in Zielsprache übersetzen"),
|
"description": t("Dokument in Zielsprache übersetzen"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef",
|
{"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef",
|
||||||
"description": t("Dokumentenliste (Upstream-Output binden)"), "default": ""},
|
"description": t("Dokumente aus vorherigen Schritten")},
|
||||||
{"name": "targetLanguage", "type": "string", "required": True, "frontendType": "text",
|
{"name": "targetLanguage", "type": "str", "required": True, "frontendType": "text",
|
||||||
"description": t("Zielsprache (z.B. de, en, French)")},
|
"description": t("Zielsprache (z.B. de, en, French)")},
|
||||||
] + _AI_COMMON_PARAMS,
|
] + _AI_COMMON_PARAMS,
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
|
"inputPorts": {0: {"accepts": ["DocumentList", "Transit", "LoopItem"]}},
|
||||||
"outputPorts": {0: {"schema": "AiResult"}},
|
"outputPorts": {0: {"schema": "AiResult"}},
|
||||||
"meta": {"icon": "mdi-translate", "color": "#9C27B0", "usesAi": True},
|
"meta": {"icon": "mdi-translate", "color": "#9C27B0", "usesAi": True},
|
||||||
"_method": "ai",
|
"_method": "ai",
|
||||||
|
|
@ -107,14 +113,14 @@ AI_NODES = [
|
||||||
"description": t("Dokument in anderes Format konvertieren"),
|
"description": t("Dokument in anderes Format konvertieren"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef",
|
{"name": "documentList", "type": "DocumentList", "required": True, "frontendType": "dataRef",
|
||||||
"description": t("Dokumentenliste (Upstream-Output binden)"), "default": ""},
|
"description": t("Dokumente aus vorherigen Schritten")},
|
||||||
{"name": "targetFormat", "type": "string", "required": True, "frontendType": "select",
|
{"name": "targetFormat", "type": "str", "required": True, "frontendType": "select",
|
||||||
"frontendOptions": {"options": ["docx", "pdf", "xlsx", "csv", "txt", "html", "json", "md"]},
|
"frontendOptions": {"options": ["docx", "pdf", "xlsx", "csv", "txt", "html", "json", "md"]},
|
||||||
"description": t("Zielformat")},
|
"description": t("Zielformat")},
|
||||||
] + _AI_COMMON_PARAMS,
|
] + _AI_COMMON_PARAMS,
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
|
"inputPorts": {0: {"accepts": ["DocumentList", "Transit", "LoopItem"]}},
|
||||||
"outputPorts": {0: {"schema": "DocumentList"}},
|
"outputPorts": {0: {"schema": "DocumentList"}},
|
||||||
"meta": {"icon": "mdi-file-convert", "color": "#9C27B0", "usesAi": True},
|
"meta": {"icon": "mdi-file-convert", "color": "#9C27B0", "usesAi": True},
|
||||||
"_method": "ai",
|
"_method": "ai",
|
||||||
|
|
@ -126,12 +132,26 @@ AI_NODES = [
|
||||||
"label": t("Dokument generieren"),
|
"label": t("Dokument generieren"),
|
||||||
"description": t("Dokument aus Prompt generieren"),
|
"description": t("Dokument aus Prompt generieren"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "prompt", "type": "string", "required": True, "frontendType": "textarea",
|
{"name": "prompt", "type": "str", "required": True, "frontendType": "textarea",
|
||||||
"description": t("Generierungs-Prompt")},
|
"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,
|
] + _AI_COMMON_PARAMS,
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": [
|
||||||
|
"FormPayload", "Transit", "AiResult", "DocumentList", "ActionResult", "LoopItem", "TextResult",
|
||||||
|
]}},
|
||||||
"outputPorts": {0: {"schema": "DocumentList"}},
|
"outputPorts": {0: {"schema": "DocumentList"}},
|
||||||
"meta": {"icon": "mdi-file-plus", "color": "#9C27B0", "usesAi": True},
|
"meta": {"icon": "mdi-file-plus", "color": "#9C27B0", "usesAi": True},
|
||||||
"_method": "ai",
|
"_method": "ai",
|
||||||
|
|
@ -143,15 +163,21 @@ AI_NODES = [
|
||||||
"label": t("Code generieren"),
|
"label": t("Code generieren"),
|
||||||
"description": t("Code aus Beschreibung generieren"),
|
"description": t("Code aus Beschreibung generieren"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "prompt", "type": "string", "required": True, "frontendType": "textarea",
|
{"name": "prompt", "type": "str", "required": True, "frontendType": "textarea",
|
||||||
"description": t("Code-Generierungs-Prompt")},
|
"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"]},
|
"frontendOptions": {"options": ["py", "js", "ts", "html", "java", "cpp", "txt", "json", "csv", "xml"]},
|
||||||
"description": t("Datei-Endung der erzeugten Code-Datei"), "default": "py"},
|
"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,
|
] + _AI_COMMON_PARAMS,
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": [
|
||||||
|
"FormPayload", "Transit", "AiResult", "DocumentList", "ActionResult", "LoopItem", "TextResult",
|
||||||
|
]}},
|
||||||
"outputPorts": {0: {"schema": "AiResult"}},
|
"outputPorts": {0: {"schema": "AiResult"}},
|
||||||
"meta": {"icon": "mdi-code-tags", "color": "#9C27B0", "usesAi": True},
|
"meta": {"icon": "mdi-code-tags", "color": "#9C27B0", "usesAi": True},
|
||||||
"_method": "ai",
|
"_method": "ai",
|
||||||
|
|
@ -163,10 +189,10 @@ AI_NODES = [
|
||||||
"label": t("KI-Konsolidierung"),
|
"label": t("KI-Konsolidierung"),
|
||||||
"description": t("Gesammelte Ergebnisse mit KI zusammenfassen, klassifizieren oder semantisch zusammenführen"),
|
"description": t("Gesammelte Ergebnisse mit KI zusammenfassen, klassifizieren oder semantisch zusammenführen"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "mode", "type": "string", "required": False, "frontendType": "select",
|
{"name": "mode", "type": "str", "required": False, "frontendType": "select",
|
||||||
"frontendOptions": {"options": ["summarize", "classify", "semanticMerge"]},
|
"frontendOptions": {"options": ["summarize", "classify", "semanticMerge"]},
|
||||||
"description": t("Konsolidierungsmodus"), "default": "summarize"},
|
"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": ""},
|
"description": t("Optionaler Prompt für die Konsolidierung"), "default": ""},
|
||||||
] + _AI_COMMON_PARAMS,
|
] + _AI_COMMON_PARAMS,
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
|
|
|
||||||
|
|
@ -11,23 +11,23 @@ CLICKUP_NODES = [
|
||||||
"label": t("Aufgaben suchen"),
|
"label": t("Aufgaben suchen"),
|
||||||
"description": t("Aufgaben in einem Workspace suchen"),
|
"description": t("Aufgaben in einem Workspace suchen"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
|
{"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection",
|
||||||
"frontendOptions": {"authority": "clickup"},
|
"frontendOptions": {"authority": "clickup"},
|
||||||
"description": t("ClickUp-Verbindung")},
|
"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")},
|
"description": t("Team-/Workspace-ID")},
|
||||||
{"name": "query", "type": "string", "required": True, "frontendType": "text",
|
{"name": "query", "type": "str", "required": True, "frontendType": "text",
|
||||||
"description": t("Suchbegriff")},
|
"description": t("Suchbegriff")},
|
||||||
{"name": "page", "type": "number", "required": False, "frontendType": "number",
|
{"name": "page", "type": "int", "required": False, "frontendType": "number",
|
||||||
"description": t("Seite"), "default": 0},
|
"description": t("Seite"), "default": 0},
|
||||||
{"name": "listId", "type": "string", "required": False, "frontendType": "clickupList",
|
{"name": "listId", "type": "str", "required": False, "frontendType": "clickupList",
|
||||||
"frontendOptions": {"dependsOn": "connectionReference"},
|
"frontendOptions": {"dependsOn": "connectionReference"},
|
||||||
"description": t("In dieser Liste suchen")},
|
"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},
|
"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},
|
"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},
|
"description": t("Nur Titel"), "default": True},
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
|
|
@ -44,15 +44,15 @@ CLICKUP_NODES = [
|
||||||
"label": t("Aufgaben auflisten"),
|
"label": t("Aufgaben auflisten"),
|
||||||
"description": t("Aufgaben einer Liste auflisten"),
|
"description": t("Aufgaben einer Liste auflisten"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
|
{"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection",
|
||||||
"frontendOptions": {"authority": "clickup"},
|
"frontendOptions": {"authority": "clickup"},
|
||||||
"description": t("ClickUp-Verbindung")},
|
"description": t("ClickUp-Verbindung")},
|
||||||
{"name": "pathQuery", "type": "string", "required": True, "frontendType": "clickupList",
|
{"name": "pathQuery", "type": "str", "required": True, "frontendType": "clickupList",
|
||||||
"frontendOptions": {"dependsOn": "connectionReference"},
|
"frontendOptions": {"dependsOn": "connectionReference"},
|
||||||
"description": t("Pfad zur Liste")},
|
"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},
|
"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},
|
"description": t("Erledigte einbeziehen"), "default": False},
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
|
|
@ -69,12 +69,12 @@ CLICKUP_NODES = [
|
||||||
"label": t("Aufgabe abrufen"),
|
"label": t("Aufgabe abrufen"),
|
||||||
"description": t("Eine Aufgabe abrufen"),
|
"description": t("Eine Aufgabe abrufen"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
|
{"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection",
|
||||||
"frontendOptions": {"authority": "clickup"},
|
"frontendOptions": {"authority": "clickup"},
|
||||||
"description": t("ClickUp-Verbindung")},
|
"description": t("ClickUp-Verbindung")},
|
||||||
{"name": "taskId", "type": "string", "required": False, "frontendType": "text",
|
{"name": "taskId", "type": "str", "required": False, "frontendType": "text",
|
||||||
"description": t("Task-ID")},
|
"description": t("Task-ID")},
|
||||||
{"name": "pathQuery", "type": "string", "required": False, "frontendType": "text",
|
{"name": "pathQuery", "type": "str", "required": False, "frontendType": "text",
|
||||||
"description": t("Oder Pfad")},
|
"description": t("Oder Pfad")},
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
|
|
@ -91,34 +91,34 @@ CLICKUP_NODES = [
|
||||||
"label": t("Aufgabe erstellen"),
|
"label": t("Aufgabe erstellen"),
|
||||||
"description": t("Aufgabe erstellen"),
|
"description": t("Aufgabe erstellen"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
|
{"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection",
|
||||||
"frontendOptions": {"authority": "clickup"},
|
"frontendOptions": {"authority": "clickup"},
|
||||||
"description": t("ClickUp-Verbindung")},
|
"description": t("ClickUp-Verbindung")},
|
||||||
{"name": "pathQuery", "type": "string", "required": False, "frontendType": "clickupList",
|
{"name": "pathQuery", "type": "str", "required": False, "frontendType": "clickupList",
|
||||||
"frontendOptions": {"dependsOn": "connectionReference"},
|
"frontendOptions": {"dependsOn": "connectionReference"},
|
||||||
"description": t("Pfad zur Liste")},
|
"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")},
|
"description": t("Listen-ID")},
|
||||||
{"name": "name", "type": "string", "required": True, "frontendType": "text",
|
{"name": "name", "type": "str", "required": True, "frontendType": "text",
|
||||||
"description": t("Name")},
|
"description": t("Name")},
|
||||||
{"name": "description", "type": "string", "required": False, "frontendType": "textarea",
|
{"name": "description", "type": "str", "required": False, "frontendType": "textarea",
|
||||||
"description": t("Beschreibung")},
|
"description": t("Beschreibung")},
|
||||||
{"name": "taskStatus", "type": "string", "required": False, "frontendType": "text",
|
{"name": "taskStatus", "type": "str", "required": False, "frontendType": "text",
|
||||||
"description": t("Status")},
|
"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"]},
|
"frontendOptions": {"options": ["1", "2", "3", "4"]},
|
||||||
"description": t("Priorität 1-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)")},
|
"description": t("Fälligkeit (ms)")},
|
||||||
{"name": "taskAssigneeIds", "type": "object", "required": False, "frontendType": "json",
|
{"name": "taskAssigneeIds", "type": "object", "required": False, "frontendType": "json",
|
||||||
"description": t("Zugewiesene")},
|
"description": t("Zugewiesene")},
|
||||||
{"name": "taskTimeEstimateMs", "type": "string", "required": False, "frontendType": "text",
|
{"name": "taskTimeEstimateMs", "type": "str", "required": False, "frontendType": "text",
|
||||||
"description": t("Zeitschätzung (ms)")},
|
"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)")},
|
"description": t("Zeitschätzung (h)")},
|
||||||
{"name": "customFieldValues", "type": "object", "required": False, "frontendType": "json",
|
{"name": "customFieldValues", "type": "object", "required": False, "frontendType": "json",
|
||||||
"description": t("Benutzerdefinierte Felder")},
|
"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")},
|
"description": t("Zusätzliches JSON")},
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
|
|
@ -135,14 +135,14 @@ CLICKUP_NODES = [
|
||||||
"label": t("Aufgabe aktualisieren"),
|
"label": t("Aufgabe aktualisieren"),
|
||||||
"description": t("Felder der Aufgabe ändern"),
|
"description": t("Felder der Aufgabe ändern"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
|
{"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection",
|
||||||
"frontendOptions": {"authority": "clickup"},
|
"frontendOptions": {"authority": "clickup"},
|
||||||
"description": t("ClickUp-Verbindung")},
|
"description": t("ClickUp-Verbindung")},
|
||||||
{"name": "taskId", "type": "string", "required": False, "frontendType": "text",
|
{"name": "taskId", "type": "str", "required": False, "frontendType": "text",
|
||||||
"description": t("Task-ID")},
|
"description": t("Task-ID")},
|
||||||
{"name": "path", "type": "string", "required": False, "frontendType": "text",
|
{"name": "path", "type": "str", "required": False, "frontendType": "text",
|
||||||
"description": t("Oder Pfad")},
|
"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\":\"...\"}")},
|
"description": t("JSON-Body für PUT /task/{id}, z.B. {\"name\":\"...\",\"status\":\"...\"}")},
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
|
|
@ -159,16 +159,16 @@ CLICKUP_NODES = [
|
||||||
"label": t("Anhang hochladen"),
|
"label": t("Anhang hochladen"),
|
||||||
"description": t("Datei an Task anhängen"),
|
"description": t("Datei an Task anhängen"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
|
{"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection",
|
||||||
"frontendOptions": {"authority": "clickup"},
|
"frontendOptions": {"authority": "clickup"},
|
||||||
"description": t("ClickUp-Verbindung")},
|
"description": t("ClickUp-Verbindung")},
|
||||||
{"name": "taskId", "type": "string", "required": False, "frontendType": "text",
|
{"name": "taskId", "type": "str", "required": False, "frontendType": "text",
|
||||||
"description": t("Task-ID")},
|
"description": t("Task-ID")},
|
||||||
{"name": "path", "type": "string", "required": False, "frontendType": "text",
|
{"name": "path", "type": "str", "required": False, "frontendType": "text",
|
||||||
"description": t("Oder Pfad")},
|
"description": t("Oder Pfad")},
|
||||||
{"name": "fileName", "type": "string", "required": False, "frontendType": "text",
|
{"name": "fileName", "type": "str", "required": False, "frontendType": "text",
|
||||||
"description": t("Dateiname")},
|
"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": ""},
|
"description": t("Datei-Inhalt aus Upstream-Node (via Wire oder DataRef)"), "default": ""},
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ CONTEXT_NODES = [
|
||||||
"label": t("Inhalt extrahieren"),
|
"label": t("Inhalt extrahieren"),
|
||||||
"description": t("Dokumentstruktur extrahieren ohne KI (Seiten, Abschnitte, Bilder, Tabellen)"),
|
"description": t("Dokumentstruktur extrahieren ohne KI (Seiten, Abschnitte, Bilder, Tabellen)"),
|
||||||
"parameters": [
|
"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": ""},
|
"description": t("Dokumentenliste (via Wire oder DataRef)"), "default": ""},
|
||||||
{"name": "extractionOptions", "type": "object", "required": False, "frontendType": "json",
|
{"name": "extractionOptions", "type": "object", "required": False, "frontendType": "json",
|
||||||
"description": t(
|
"description": t(
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ DATA_NODES = [
|
||||||
"label": t("Sammeln"),
|
"label": t("Sammeln"),
|
||||||
"description": t("Ergebnisse aus Schleifen-Iterationen sammeln"),
|
"description": t("Ergebnisse aus Schleifen-Iterationen sammeln"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "mode", "type": "string", "required": False, "frontendType": "select",
|
{"name": "mode", "type": "str", "required": False, "frontendType": "select",
|
||||||
"frontendOptions": {"options": ["collect", "concat", "sum", "count"]},
|
"frontendOptions": {"options": ["collect", "concat", "sum", "count"]},
|
||||||
"description": t("Aggregationsmodus"), "default": "collect"},
|
"description": t("Aggregationsmodus"), "default": "collect"},
|
||||||
],
|
],
|
||||||
|
|
@ -27,9 +27,9 @@ DATA_NODES = [
|
||||||
"label": t("Filtern"),
|
"label": t("Filtern"),
|
||||||
"description": t("Elemente nach Bedingung filtern"),
|
"description": t("Elemente nach Bedingung filtern"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "condition", "type": "string", "required": True, "frontendType": "filterExpression",
|
{"name": "condition", "type": "str", "required": True, "frontendType": "filterExpression",
|
||||||
"description": t("Filterbedingung")},
|
"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"]},
|
"frontendOptions": {"options": ["", "text", "image", "table", "code", "media", "link", "formula"]},
|
||||||
"description": t("UDM-ContentType-Filter (optional, leer = kein UDM-Filter)"), "default": ""},
|
"description": t("UDM-ContentType-Filter (optional, leer = kein UDM-Filter)"), "default": ""},
|
||||||
],
|
],
|
||||||
|
|
@ -46,10 +46,10 @@ DATA_NODES = [
|
||||||
"label": t("Konsolidieren"),
|
"label": t("Konsolidieren"),
|
||||||
"description": t("Gesammelte Ergebnisse deterministisch zusammenführen (Tabelle, CSV, Merge)"),
|
"description": t("Gesammelte Ergebnisse deterministisch zusammenführen (Tabelle, CSV, Merge)"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "mode", "type": "string", "required": False, "frontendType": "select",
|
{"name": "mode", "type": "str", "required": False, "frontendType": "select",
|
||||||
"frontendOptions": {"options": ["table", "concat", "merge", "csvJoin"]},
|
"frontendOptions": {"options": ["table", "concat", "merge", "csvJoin"]},
|
||||||
"description": t("Konsolidierungsmodus"), "default": "table"},
|
"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"},
|
"description": t("Trennzeichen (für concat/csvJoin)"), "default": "\n"},
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,14 @@ EMAIL_NODES = [
|
||||||
"label": t("E-Mail prüfen"),
|
"label": t("E-Mail prüfen"),
|
||||||
"description": t("Neue E-Mails prüfen"),
|
"description": t("Neue E-Mails prüfen"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
|
{"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection",
|
||||||
"frontendOptions": {"authority": "msft"},
|
"frontendOptions": {"authority": "msft"},
|
||||||
"description": t("E-Mail-Konto Verbindung")},
|
"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"},
|
"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},
|
"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": ""},
|
"description": t("Filter-Ausdruck (z.B. 'from:max@example.com hasAttachment:true betreff')"), "default": ""},
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
|
|
@ -34,14 +34,14 @@ EMAIL_NODES = [
|
||||||
"label": t("E-Mail suchen"),
|
"label": t("E-Mail suchen"),
|
||||||
"description": t("E-Mails suchen"),
|
"description": t("E-Mails suchen"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
|
{"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection",
|
||||||
"frontendOptions": {"authority": "msft"},
|
"frontendOptions": {"authority": "msft"},
|
||||||
"description": t("E-Mail-Konto Verbindung")},
|
"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')")},
|
"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"},
|
"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},
|
"description": t("Max E-Mails"), "default": 100},
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
|
|
@ -59,19 +59,19 @@ EMAIL_NODES = [
|
||||||
"description": t(
|
"description": t(
|
||||||
"AI-gestützt einen E-Mail-Entwurf aus Kontext und optionalen Dokumenten erstellen"),
|
"AI-gestützt einen E-Mail-Entwurf aus Kontext und optionalen Dokumenten erstellen"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
|
{"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection",
|
||||||
"frontendOptions": {"authority": "msft"},
|
"frontendOptions": {"authority": "msft"},
|
||||||
"description": t("E-Mail-Konto")},
|
"description": t("E-Mail-Konto")},
|
||||||
{"name": "context", "type": "string", "required": False, "frontendType": "templateTextarea",
|
{"name": "context", "type": "Any", "required": False, "frontendType": "templateTextarea",
|
||||||
"description": t("Kontext / Brief-Beschreibung für die KI-Komposition"), "default": ""},
|
"description": t("Daten aus vorherigen Schritten (oder direkte Beschreibung)"), "default": ""},
|
||||||
{"name": "to", "type": "string", "required": False, "frontendType": "text",
|
{"name": "to", "type": "str", "required": False, "frontendType": "text",
|
||||||
"description": t("Empfänger (komma-separiert, optional für Entwurf)"), "default": ""},
|
"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": ""},
|
"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)"),
|
"description": t("Direkt vorbereiteter Inhalt {subject, body, to} (via Wire — überspringt KI)"),
|
||||||
"default": ""},
|
"default": ""},
|
||||||
{"name": "emailStyle", "type": "string", "required": False, "frontendType": "select",
|
{"name": "emailStyle", "type": "str", "required": False, "frontendType": "select",
|
||||||
"frontendOptions": {"options": ["formal", "casual", "business"]},
|
"frontendOptions": {"options": ["formal", "casual", "business"]},
|
||||||
"description": t("Stil"), "default": "business"},
|
"description": t("Stil"), "default": "business"},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -10,25 +10,17 @@ FILE_NODES = [
|
||||||
"label": t("Datei erstellen"),
|
"label": t("Datei erstellen"),
|
||||||
"description": t("Erstellt eine Datei aus Kontext (Text/Markdown von KI)."),
|
"description": t("Erstellt eine Datei aus Kontext (Text/Markdown von KI)."),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "contentSources", "type": "json", "required": False, "frontendType": "json",
|
{"name": "outputFormat", "type": "str", "required": True, "frontendType": "select",
|
||||||
"description": t("Kontext-Quellen"), "default": []},
|
|
||||||
{"name": "outputFormat", "type": "string", "required": True, "frontendType": "select",
|
|
||||||
"frontendOptions": {"options": ["docx", "pdf", "txt", "html", "md"]},
|
"frontendOptions": {"options": ["docx", "pdf", "txt", "html", "md"]},
|
||||||
"description": t("Ausgabeformat"), "default": "docx"},
|
"description": t("Ausgabeformat"), "default": "docx"},
|
||||||
{"name": "title", "type": "string", "required": False, "frontendType": "text",
|
{"name": "title", "type": "str", "required": False, "frontendType": "text",
|
||||||
"description": t("Dokumenttitel")},
|
"description": t("Dokumenttitel")},
|
||||||
{"name": "templateName", "type": "string", "required": False, "frontendType": "select",
|
{"name": "context", "type": "Any", "required": False, "frontendType": "contextBuilder",
|
||||||
"frontendOptions": {"options": ["default", "corporate", "minimal"]},
|
"description": t("Daten aus vorherigen Schritten"), "default": ""},
|
||||||
"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": ""},
|
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["AiResult", "TextResult", "Transit"]}},
|
"inputPorts": {0: {"accepts": ["AiResult", "TextResult", "Transit", "FormPayload", "LoopItem", "ActionResult"]}},
|
||||||
"outputPorts": {0: {"schema": "DocumentList"}},
|
"outputPorts": {0: {"schema": "DocumentList"}},
|
||||||
"meta": {"icon": "mdi-file-plus-outline", "color": "#2196F3", "usesAi": False},
|
"meta": {"icon": "mdi-file-plus-outline", "color": "#2196F3", "usesAi": False},
|
||||||
"_method": "file",
|
"_method": "file",
|
||||||
|
|
|
||||||
|
|
@ -3,25 +3,46 @@
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import t
|
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 = [
|
FLOW_NODES = [
|
||||||
{
|
{
|
||||||
"id": "flow.ifElse",
|
"id": "flow.ifElse",
|
||||||
"category": "flow",
|
"category": "flow",
|
||||||
"label": t("Wenn / Sonst"),
|
"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": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "condition",
|
"name": "condition",
|
||||||
"type": "string",
|
"type": "json",
|
||||||
"required": True,
|
"required": True,
|
||||||
"frontendType": "condition",
|
"frontendType": "condition",
|
||||||
"description": t("Bedingung"),
|
"description": t("Bedingung: Feld aus einem vorherigen Schritt und Vergleich"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 2,
|
"outputs": 2,
|
||||||
"outputLabels": [t("Ja"), t("Nein")],
|
"outputLabels": [t("Ja"), t("Nein")],
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": list(_FLOW_INPUT_SCHEMAS)}},
|
||||||
"outputPorts": {0: {"schema": "Transit"}, 1: {"schema": "Transit"}},
|
"outputPorts": {0: {"schema": "Transit"}, 1: {"schema": "Transit"}},
|
||||||
"executor": "flow",
|
"executor": "flow",
|
||||||
"meta": {"icon": "mdi-source-branch", "color": "#FF9800", "usesAi": False},
|
"meta": {"icon": "mdi-source-branch", "color": "#FF9800", "usesAi": False},
|
||||||
|
|
@ -30,26 +51,29 @@ FLOW_NODES = [
|
||||||
"id": "flow.switch",
|
"id": "flow.switch",
|
||||||
"category": "flow",
|
"category": "flow",
|
||||||
"label": t("Switch"),
|
"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": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "value",
|
"name": "value",
|
||||||
"type": "string",
|
"type": "Any",
|
||||||
"required": True,
|
"required": True,
|
||||||
"frontendType": "text",
|
"frontendType": "dataRef",
|
||||||
"description": t("Zu vergleichender Wert"),
|
"description": t("Wert zum Vergleichen (Feld aus einem vorherigen Schritt)"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "cases",
|
"name": "cases",
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"required": False,
|
"required": False,
|
||||||
"frontendType": "caseList",
|
"frontendType": "caseList",
|
||||||
"description": t("Fälle"),
|
"description": t("Fälle: Operator und Vergleichswert"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": list(_FLOW_INPUT_SCHEMAS)}},
|
||||||
"outputPorts": {0: {"schema": "Transit"}},
|
"outputPorts": {0: {"schema": "Transit"}},
|
||||||
"executor": "flow",
|
"executor": "flow",
|
||||||
"meta": {"icon": "mdi-swap-horizontal", "color": "#FF9800", "usesAi": False},
|
"meta": {"icon": "mdi-swap-horizontal", "color": "#FF9800", "usesAi": False},
|
||||||
|
|
@ -57,39 +81,43 @@ FLOW_NODES = [
|
||||||
{
|
{
|
||||||
"id": "flow.loop",
|
"id": "flow.loop",
|
||||||
"category": "flow",
|
"category": "flow",
|
||||||
"label": t("Schleife / Für Jedes"),
|
"label": t("Schleife / Für jedes"),
|
||||||
"description": t("Über Array-Elemente oder UDM-Strukturebenen iterieren"),
|
"description": t(
|
||||||
|
"Iteriert über ein Array aus einem vorherigen Schritt (z. B. documente, Zeilen, Listeneinträge). "
|
||||||
|
"Optional: UDM-Ebene für strukturierte Dokumente."
|
||||||
|
),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "items",
|
"name": "items",
|
||||||
"type": "string",
|
"type": "Any",
|
||||||
"required": True,
|
"required": True,
|
||||||
"frontendType": "text",
|
"frontendType": "dataRef",
|
||||||
"description": t("Pfad zum Array"),
|
"description": t("Liste oder Sammlung zum Durchlaufen (im Data Picker wählen)"),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "level",
|
"name": "level",
|
||||||
"type": "string",
|
"type": "str",
|
||||||
"required": False,
|
"required": False,
|
||||||
"frontendType": "select",
|
"frontendType": "select",
|
||||||
"frontendOptions": {"options": ["auto", "documents", "structuralNodes", "contentBlocks"]},
|
"frontendOptions": {"options": ["auto", "documents", "structuralNodes", "contentBlocks"]},
|
||||||
"description": t("UDM-Iterationsebene"),
|
"description": t("Nur bei UDM-Daten: welche Strukturebene als Elemente verwendet wird"),
|
||||||
"default": "auto",
|
"default": "auto",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "concurrency",
|
"name": "concurrency",
|
||||||
"type": "number",
|
"type": "int",
|
||||||
"required": False,
|
"required": False,
|
||||||
"frontendType": "number",
|
"frontendType": "number",
|
||||||
"frontendOptions": {"min": 1, "max": 20},
|
"frontendOptions": {"min": 1, "max": 20},
|
||||||
"description": t("Parallele Iterationen (1 = sequentiell)"),
|
"description": t("Parallele Durchläufe (1 = nacheinander)"),
|
||||||
"default": 1,
|
"default": 1,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": [
|
"inputPorts": {0: {"accepts": [
|
||||||
"Transit", "UdmDocument", "EmailList", "DocumentList", "FileList", "TaskList", "ActionResult",
|
"Transit", "UdmDocument", "EmailList", "DocumentList", "FileList", "TaskList",
|
||||||
|
"ActionResult", "AiResult", "QueryResult", "FormPayload",
|
||||||
]}},
|
]}},
|
||||||
"outputPorts": {0: {"schema": "LoopItem"}},
|
"outputPorts": {0: {"schema": "LoopItem"}},
|
||||||
"executor": "flow",
|
"executor": "flow",
|
||||||
|
|
@ -99,30 +127,36 @@ FLOW_NODES = [
|
||||||
"id": "flow.merge",
|
"id": "flow.merge",
|
||||||
"category": "flow",
|
"category": "flow",
|
||||||
"label": t("Zusammenführen"),
|
"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": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "mode",
|
"name": "mode",
|
||||||
"type": "string",
|
"type": "str",
|
||||||
"required": False,
|
"required": False,
|
||||||
"frontendType": "select",
|
"frontendType": "select",
|
||||||
"frontendOptions": {"options": ["first", "all", "append"]},
|
"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",
|
"default": "first",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "inputCount",
|
"name": "inputCount",
|
||||||
"type": "number",
|
"type": "int",
|
||||||
"required": False,
|
"required": False,
|
||||||
"frontendType": "number",
|
"frontendType": "number",
|
||||||
"frontendOptions": {"min": 2, "max": 5},
|
"frontendOptions": {"min": 2, "max": 5},
|
||||||
"description": t("Anzahl Eingänge"),
|
"description": t("Anzahl Eingänge dieses Nodes (2–5)"),
|
||||||
"default": 2,
|
"default": 2,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
"inputs": 2,
|
"inputs": 2,
|
||||||
"outputs": 1,
|
"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"}},
|
"outputPorts": {0: {"schema": "MergeResult"}},
|
||||||
"executor": "flow",
|
"executor": "flow",
|
||||||
"meta": {"icon": "mdi-call-merge", "color": "#FF9800", "usesAi": False},
|
"meta": {"icon": "mdi-call-merge", "color": "#FF9800", "usesAi": False},
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,18 @@
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import t
|
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 = [
|
INPUT_NODES = [
|
||||||
{
|
{
|
||||||
"id": "input.form",
|
"id": "input.form",
|
||||||
|
|
@ -32,11 +44,11 @@ INPUT_NODES = [
|
||||||
"label": t("Genehmigung"),
|
"label": t("Genehmigung"),
|
||||||
"description": t("Benutzer genehmigt oder lehnt ab"),
|
"description": t("Benutzer genehmigt oder lehnt ab"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "title", "type": "string", "required": True, "frontendType": "text",
|
{"name": "title", "type": "str", "required": True, "frontendType": "text",
|
||||||
"description": t("Genehmigungstitel")},
|
"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")},
|
"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"]},
|
"frontendOptions": {"options": ["generic", "document"]},
|
||||||
"description": t("Typ: document oder generic"), "default": "generic"},
|
"description": t("Typ: document oder generic"), "default": "generic"},
|
||||||
],
|
],
|
||||||
|
|
@ -53,14 +65,14 @@ INPUT_NODES = [
|
||||||
"label": t("Upload"),
|
"label": t("Upload"),
|
||||||
"description": t("Benutzer lädt Datei(en) hoch"),
|
"description": t("Benutzer lädt Datei(en) hoch"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "accept", "type": "string", "required": False, "frontendType": "text",
|
{"name": "accept", "type": "str", "required": False, "frontendType": "text",
|
||||||
"description": t("Accept-String"), "default": ""},
|
"description": t("Accept-String"), "default": ""},
|
||||||
{"name": "allowedTypes", "type": "json", "required": False, "frontendType": "multiselect",
|
{"name": "allowedTypes", "type": "json", "required": False, "frontendType": "multiselect",
|
||||||
"frontendOptions": {"options": ["pdf", "docx", "xlsx", "pptx", "txt", "csv", "jpg", "png", "gif"]},
|
"frontendOptions": {"options": ["pdf", "docx", "xlsx", "pptx", "txt", "csv", "jpg", "png", "gif"]},
|
||||||
"description": t("Ausgewählte Dateitypen"), "default": []},
|
"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},
|
"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},
|
"description": t("Mehrere Dateien erlauben"), "default": False},
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
|
|
@ -76,9 +88,9 @@ INPUT_NODES = [
|
||||||
"label": t("Kommentar"),
|
"label": t("Kommentar"),
|
||||||
"description": t("Benutzer fügt einen Kommentar hinzu"),
|
"description": t("Benutzer fügt einen Kommentar hinzu"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "placeholder", "type": "string", "required": False, "frontendType": "text",
|
{"name": "placeholder", "type": "str", "required": False, "frontendType": "text",
|
||||||
"description": t("Platzhalter"), "default": ""},
|
"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},
|
"description": t("Kommentar erforderlich"), "default": True},
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
|
|
@ -94,9 +106,9 @@ INPUT_NODES = [
|
||||||
"label": t("Prüfung"),
|
"label": t("Prüfung"),
|
||||||
"description": t("Benutzer prüft Inhalt"),
|
"description": t("Benutzer prüft Inhalt"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "contentRef", "type": "string", "required": True, "frontendType": "text",
|
{"name": "contentRef", "type": "str", "required": True, "frontendType": "text",
|
||||||
"description": t("Referenz auf Inhalt")},
|
"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"]},
|
"frontendOptions": {"options": ["generic", "document"]},
|
||||||
"description": t("Art der Prüfung"), "default": "generic"},
|
"description": t("Art der Prüfung"), "default": "generic"},
|
||||||
],
|
],
|
||||||
|
|
@ -115,7 +127,7 @@ INPUT_NODES = [
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "options", "type": "json", "required": True, "frontendType": "keyValueRows",
|
{"name": "options", "type": "json", "required": True, "frontendType": "keyValueRows",
|
||||||
"description": t("Optionen"), "default": []},
|
"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},
|
"description": t("Mehrfachauswahl erlauben"), "default": False},
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
|
|
@ -131,11 +143,11 @@ INPUT_NODES = [
|
||||||
"label": t("Bestätigung"),
|
"label": t("Bestätigung"),
|
||||||
"description": t("Benutzer bestätigt Ja/Nein"),
|
"description": t("Benutzer bestätigt Ja/Nein"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "question", "type": "string", "required": True, "frontendType": "text",
|
{"name": "question", "type": "str", "required": True, "frontendType": "text",
|
||||||
"description": t("Zu bestätigende Frage")},
|
"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"},
|
"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"},
|
"description": t("Label für Ablehnen-Button"), "default": "Reject"},
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ REDMINE_NODES = [
|
||||||
"description": t("Einzelnes Redmine-Ticket aus dem Mirror laden."),
|
"description": t("Einzelnes Redmine-Ticket aus dem Mirror laden."),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
dict(_REDMINE_INSTANCE_PARAM),
|
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")},
|
"description": t("Redmine-Ticket-ID")},
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
|
|
@ -43,17 +43,17 @@ REDMINE_NODES = [
|
||||||
"description": t("Tickets aus dem lokalen Mirror mit Filtern (Tracker, Status, Zeitraum, Zuweisung)."),
|
"description": t("Tickets aus dem lokalen Mirror mit Filtern (Tracker, Status, Zeitraum, Zuweisung)."),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
dict(_REDMINE_INSTANCE_PARAM),
|
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": ""},
|
"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": "*"},
|
"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": ""},
|
"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": ""},
|
"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)")},
|
"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},
|
"description": t("Max. Anzahl Tickets (1-500)"), "default": 100},
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
|
|
@ -71,21 +71,21 @@ REDMINE_NODES = [
|
||||||
"description": t("Neues Ticket in Redmine anlegen. Mirror wird sofort aktualisiert."),
|
"description": t("Neues Ticket in Redmine anlegen. Mirror wird sofort aktualisiert."),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
dict(_REDMINE_INSTANCE_PARAM),
|
dict(_REDMINE_INSTANCE_PARAM),
|
||||||
{"name": "subject", "type": "string", "required": True, "frontendType": "text",
|
{"name": "subject", "type": "str", "required": True, "frontendType": "text",
|
||||||
"description": t("Ticket-Titel")},
|
"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, ...)")},
|
"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": ""},
|
"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)")},
|
"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)")},
|
"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)")},
|
"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)")},
|
"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": ""},
|
"description": t("Custom Fields als JSON {id: value}"), "default": ""},
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
|
|
@ -103,25 +103,25 @@ REDMINE_NODES = [
|
||||||
"description": t("Felder eines Redmine-Tickets aktualisieren. Nur gesetzte Felder werden uebertragen."),
|
"description": t("Felder eines Redmine-Tickets aktualisieren. Nur gesetzte Felder werden uebertragen."),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
dict(_REDMINE_INSTANCE_PARAM),
|
dict(_REDMINE_INSTANCE_PARAM),
|
||||||
{"name": "ticketId", "type": "number", "required": True, "frontendType": "number",
|
{"name": "ticketId", "type": "int", "required": True, "frontendType": "number",
|
||||||
"description": t("Ticket-ID")},
|
"description": t("Ticket-ID")},
|
||||||
{"name": "subject", "type": "string", "required": False, "frontendType": "text",
|
{"name": "subject", "type": "str", "required": False, "frontendType": "text",
|
||||||
"description": t("Neuer Titel")},
|
"description": t("Neuer Titel")},
|
||||||
{"name": "description", "type": "string", "required": False, "frontendType": "textarea",
|
{"name": "description", "type": "str", "required": False, "frontendType": "textarea",
|
||||||
"description": t("Neue Beschreibung")},
|
"description": t("Neue Beschreibung")},
|
||||||
{"name": "trackerId", "type": "number", "required": False, "frontendType": "number",
|
{"name": "trackerId", "type": "int", "required": False, "frontendType": "number",
|
||||||
"description": t("Neuer Tracker")},
|
"description": t("Neuer Tracker")},
|
||||||
{"name": "statusId", "type": "number", "required": False, "frontendType": "number",
|
{"name": "statusId", "type": "int", "required": False, "frontendType": "number",
|
||||||
"description": t("Neuer Status")},
|
"description": t("Neuer Status")},
|
||||||
{"name": "priorityId", "type": "number", "required": False, "frontendType": "number",
|
{"name": "priorityId", "type": "int", "required": False, "frontendType": "number",
|
||||||
"description": t("Neue Prioritaet")},
|
"description": t("Neue Prioritaet")},
|
||||||
{"name": "assignedToId", "type": "number", "required": False, "frontendType": "number",
|
{"name": "assignedToId", "type": "int", "required": False, "frontendType": "number",
|
||||||
"description": t("Neue Zuweisung")},
|
"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")},
|
"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": ""},
|
"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": ""},
|
"description": t("Custom Fields als JSON {id: value}"), "default": ""},
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
|
|
@ -139,13 +139,13 @@ REDMINE_NODES = [
|
||||||
"description": t("Aggregierte Kennzahlen (KPIs, Durchsatz, Status-Verteilung, Backlog) aus dem Mirror."),
|
"description": t("Aggregierte Kennzahlen (KPIs, Durchsatz, Status-Verteilung, Backlog) aus dem Mirror."),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
dict(_REDMINE_INSTANCE_PARAM),
|
dict(_REDMINE_INSTANCE_PARAM),
|
||||||
{"name": "dateFrom", "type": "string", "required": False, "frontendType": "date",
|
{"name": "dateFrom", "type": "str", "required": False, "frontendType": "date",
|
||||||
"description": t("Zeitraum ab")},
|
"description": t("Zeitraum ab")},
|
||||||
{"name": "dateTo", "type": "string", "required": False, "frontendType": "date",
|
{"name": "dateTo", "type": "str", "required": False, "frontendType": "date",
|
||||||
"description": t("Zeitraum bis")},
|
"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"},
|
"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": ""},
|
"description": t("Tracker-IDs (Komma-separiert)"), "default": ""},
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
|
|
@ -163,7 +163,7 @@ REDMINE_NODES = [
|
||||||
"description": t("Tickets und Beziehungen aus Redmine in den lokalen Mirror uebernehmen."),
|
"description": t("Tickets und Beziehungen aus Redmine in den lokalen Mirror uebernehmen."),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
dict(_REDMINE_INSTANCE_PARAM),
|
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},
|
"description": t("Vollsync erzwingen (ignoriert lastSyncAt)"), "default": False},
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,14 @@ SHAREPOINT_NODES = [
|
||||||
"label": t("Datei finden"),
|
"label": t("Datei finden"),
|
||||||
"description": t("Datei nach Pfad oder Suche finden"),
|
"description": t("Datei nach Pfad oder Suche finden"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
|
{"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection",
|
||||||
"frontendOptions": {"authority": "msft"},
|
"frontendOptions": {"authority": "msft"},
|
||||||
"description": t("SharePoint-Verbindung")},
|
"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")},
|
"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": ""},
|
"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},
|
"description": t("Max Ergebnisse"), "default": 1000},
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
|
|
@ -34,10 +34,10 @@ SHAREPOINT_NODES = [
|
||||||
"label": t("Datei lesen"),
|
"label": t("Datei lesen"),
|
||||||
"description": t("Inhalt aus Datei extrahieren"),
|
"description": t("Inhalt aus Datei extrahieren"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
|
{"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection",
|
||||||
"frontendOptions": {"authority": "msft"},
|
"frontendOptions": {"authority": "msft"},
|
||||||
"description": t("SharePoint-Verbindung")},
|
"description": t("SharePoint-Verbindung")},
|
||||||
{"name": "pathQuery", "type": "string", "required": True, "frontendType": "sharepointFile",
|
{"name": "pathQuery", "type": "str", "required": True, "frontendType": "sharepointFile",
|
||||||
"frontendOptions": {"dependsOn": "connectionReference"},
|
"frontendOptions": {"dependsOn": "connectionReference"},
|
||||||
"description": t("Dateipfad")},
|
"description": t("Dateipfad")},
|
||||||
],
|
],
|
||||||
|
|
@ -55,13 +55,13 @@ SHAREPOINT_NODES = [
|
||||||
"label": t("Datei hochladen"),
|
"label": t("Datei hochladen"),
|
||||||
"description": t("Datei zu SharePoint hochladen"),
|
"description": t("Datei zu SharePoint hochladen"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
|
{"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection",
|
||||||
"frontendOptions": {"authority": "msft"},
|
"frontendOptions": {"authority": "msft"},
|
||||||
"description": t("SharePoint-Verbindung")},
|
"description": t("SharePoint-Verbindung")},
|
||||||
{"name": "pathQuery", "type": "string", "required": True, "frontendType": "sharepointFolder",
|
{"name": "pathQuery", "type": "str", "required": True, "frontendType": "sharepointFolder",
|
||||||
"frontendOptions": {"dependsOn": "connectionReference"},
|
"frontendOptions": {"dependsOn": "connectionReference"},
|
||||||
"description": t("Zielordner-Pfad")},
|
"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": ""},
|
"description": t("Datei-Inhalt aus Upstream-Node (via Wire oder DataRef)"), "default": ""},
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
|
|
@ -78,10 +78,10 @@ SHAREPOINT_NODES = [
|
||||||
"label": t("Dateien auflisten"),
|
"label": t("Dateien auflisten"),
|
||||||
"description": t("Dateien in Ordner auflisten"),
|
"description": t("Dateien in Ordner auflisten"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
|
{"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection",
|
||||||
"frontendOptions": {"authority": "msft"},
|
"frontendOptions": {"authority": "msft"},
|
||||||
"description": t("SharePoint-Verbindung")},
|
"description": t("SharePoint-Verbindung")},
|
||||||
{"name": "pathQuery", "type": "string", "required": False, "frontendType": "sharepointFolder",
|
{"name": "pathQuery", "type": "str", "required": False, "frontendType": "sharepointFolder",
|
||||||
"frontendOptions": {"dependsOn": "connectionReference"},
|
"frontendOptions": {"dependsOn": "connectionReference"},
|
||||||
"description": t("Ordnerpfad"), "default": "/"},
|
"description": t("Ordnerpfad"), "default": "/"},
|
||||||
],
|
],
|
||||||
|
|
@ -99,10 +99,10 @@ SHAREPOINT_NODES = [
|
||||||
"label": t("Datei herunterladen"),
|
"label": t("Datei herunterladen"),
|
||||||
"description": t("Datei vom Pfad herunterladen"),
|
"description": t("Datei vom Pfad herunterladen"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
|
{"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection",
|
||||||
"frontendOptions": {"authority": "msft"},
|
"frontendOptions": {"authority": "msft"},
|
||||||
"description": t("SharePoint-Verbindung")},
|
"description": t("SharePoint-Verbindung")},
|
||||||
{"name": "pathQuery", "type": "string", "required": True, "frontendType": "sharepointFile",
|
{"name": "pathQuery", "type": "str", "required": True, "frontendType": "sharepointFile",
|
||||||
"frontendOptions": {"dependsOn": "connectionReference"},
|
"frontendOptions": {"dependsOn": "connectionReference"},
|
||||||
"description": t("Vollständiger Dateipfad")},
|
"description": t("Vollständiger Dateipfad")},
|
||||||
],
|
],
|
||||||
|
|
@ -120,13 +120,13 @@ SHAREPOINT_NODES = [
|
||||||
"label": t("Datei kopieren"),
|
"label": t("Datei kopieren"),
|
||||||
"description": t("Datei an Ziel kopieren"),
|
"description": t("Datei an Ziel kopieren"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection",
|
{"name": "connectionReference", "type": "str", "required": True, "frontendType": "userConnection",
|
||||||
"frontendOptions": {"authority": "msft"},
|
"frontendOptions": {"authority": "msft"},
|
||||||
"description": t("SharePoint-Verbindung")},
|
"description": t("SharePoint-Verbindung")},
|
||||||
{"name": "sourcePath", "type": "string", "required": True, "frontendType": "sharepointFile",
|
{"name": "sourcePath", "type": "str", "required": True, "frontendType": "sharepointFile",
|
||||||
"frontendOptions": {"dependsOn": "connectionReference"},
|
"frontendOptions": {"dependsOn": "connectionReference"},
|
||||||
"description": t("Quelldatei-Pfad")},
|
"description": t("Quelldatei-Pfad")},
|
||||||
{"name": "destPath", "type": "string", "required": True, "frontendType": "sharepointFolder",
|
{"name": "destPath", "type": "str", "required": True, "frontendType": "sharepointFolder",
|
||||||
"frontendOptions": {"dependsOn": "connectionReference"},
|
"frontendOptions": {"dependsOn": "connectionReference"},
|
||||||
"description": t("Zielordner")},
|
"description": t("Zielordner")},
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ TRIGGER_NODES = [
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "cron",
|
"name": "cron",
|
||||||
"type": "string",
|
"type": "str",
|
||||||
"required": False,
|
"required": False,
|
||||||
"frontendType": "cron",
|
"frontendType": "cron",
|
||||||
"description": t("Cron-Ausdruck"),
|
"description": t("Cron-Ausdruck"),
|
||||||
|
|
|
||||||
|
|
@ -25,11 +25,11 @@ TRUSTEE_NODES = [
|
||||||
"description": t("Buchhaltungsdaten aus externem System importieren/aktualisieren."),
|
"description": t("Buchhaltungsdaten aus externem System importieren/aktualisieren."),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
dict(_TRUSTEE_INSTANCE_PARAM),
|
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},
|
"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": ""},
|
"description": t("Startdatum"), "default": ""},
|
||||||
{"name": "dateTo", "type": "string", "required": False, "frontendType": "date",
|
{"name": "dateTo", "type": "str", "required": False, "frontendType": "date",
|
||||||
"description": t("Enddatum"), "default": ""},
|
"description": t("Enddatum"), "default": ""},
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
|
|
@ -46,14 +46,14 @@ TRUSTEE_NODES = [
|
||||||
"label": t("Dokumente extrahieren"),
|
"label": t("Dokumente extrahieren"),
|
||||||
"description": t("Dokumenttyp und Daten aus PDF/JPG per AI extrahieren."),
|
"description": t("Dokumenttyp und Daten aus PDF/JPG per AI extrahieren."),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "connectionReference", "type": "string", "required": False, "frontendType": "userConnection",
|
{"name": "connectionReference", "type": "str", "required": False, "frontendType": "userConnection",
|
||||||
"frontendOptions": {"authority": "msft"},
|
"frontendOptions": {"authority": "msft"},
|
||||||
"description": t("SharePoint-Verbindung"), "default": ""},
|
"description": t("SharePoint-Verbindung"), "default": ""},
|
||||||
{"name": "sharepointFolder", "type": "string", "required": False, "frontendType": "sharepointFolder",
|
{"name": "sharepointFolder", "type": "str", "required": False, "frontendType": "sharepointFolder",
|
||||||
"frontendOptions": {"dependsOn": "connectionReference"},
|
"frontendOptions": {"dependsOn": "connectionReference"},
|
||||||
"description": t("SharePoint-Ordnerpfad"), "default": ""},
|
"description": t("SharePoint-Ordnerpfad"), "default": ""},
|
||||||
dict(_TRUSTEE_INSTANCE_PARAM),
|
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": ""},
|
"description": t("AI-Prompt für Extraktion"), "default": ""},
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
|
|
@ -77,7 +77,7 @@ TRUSTEE_NODES = [
|
||||||
# is List[ActionDocument] (see datamodelChat.ActionResult). The
|
# is List[ActionDocument] (see datamodelChat.ActionResult). The
|
||||||
# DataPicker uses this string to filter compatible upstream paths.
|
# DataPicker uses this string to filter compatible upstream paths.
|
||||||
{"name": "documentList", "type": "List[ActionDocument]", "required": True, "frontendType": "dataRef",
|
{"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),
|
dict(_TRUSTEE_INSTANCE_PARAM),
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
|
|
@ -95,7 +95,7 @@ TRUSTEE_NODES = [
|
||||||
"description": t("Trustee-Positionen in Buchhaltungssystem übertragen."),
|
"description": t("Trustee-Positionen in Buchhaltungssystem übertragen."),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "documentList", "type": "List[ActionDocument]", "required": True, "frontendType": "dataRef",
|
{"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),
|
dict(_TRUSTEE_INSTANCE_PARAM),
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"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."),
|
"description": t("Daten aus der Trustee-DB lesen (Lookup, Aggregation, Roh-Export). Pendant zu refreshAccountingData ohne externen Sync."),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
dict(_TRUSTEE_INSTANCE_PARAM),
|
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"]},
|
"frontendOptions": {"options": ["lookup", "raw", "aggregate"]},
|
||||||
"description": t("Abfragemodus"), "default": "lookup"},
|
"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"]},
|
"frontendOptions": {"options": ["tenantWithRent", "contact", "journalLines", "accounts", "balances"]},
|
||||||
"description": t("Entität, die gelesen werden soll"), "default": "tenantWithRent"},
|
"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"]},
|
"frontendOptions": {"dependsOn": "entity", "showWhen": ["tenantWithRent", "contact"]},
|
||||||
"description": t("Mietername (oder {{wire.feld}} aus Upstream)"), "default": ""},
|
"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"]},
|
"frontendOptions": {"dependsOn": "entity", "showWhen": ["tenantWithRent", "contact"]},
|
||||||
"description": t("Mieteradresse (Toleranz für Tippfehler)"), "default": ""},
|
"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"]},
|
"frontendOptions": {"dependsOn": "entity", "showWhen": ["tenantWithRent", "journalLines", "balances"]},
|
||||||
"description": t("Zeitraum (YYYY oder YYYY-MM-DD/YYYY-MM-DD)"), "default": ""},
|
"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"]},
|
"frontendOptions": {"dependsOn": "entity", "showWhen": ["tenantWithRent"]},
|
||||||
"description": t("Konto-Filter für Mietzins (z.B. '6000-6099' oder '6*')"), "default": ""},
|
"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"]},
|
"frontendOptions": {"dependsOn": "mode", "showWhen": ["raw", "aggregate"]},
|
||||||
"description": t("Optionaler JSON-Filter für mode=raw/aggregate"), "default": ""},
|
"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 typing import Dict, List, Any, Optional
|
||||||
|
|
||||||
from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES
|
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.nodeAdapter import bindsActionFromLegacy
|
||||||
from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG, SYSTEM_VARIABLES
|
from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG, SYSTEM_VARIABLES
|
||||||
from modules.shared.i18nRegistry import normalizePrimaryLanguageTag, resolveText
|
from modules.shared.i18nRegistry import normalizePrimaryLanguageTag, resolveText
|
||||||
|
|
@ -119,6 +120,7 @@ def getNodeTypesForApi(
|
||||||
"categories": categories,
|
"categories": categories,
|
||||||
"portTypeCatalog": catalogSerialized,
|
"portTypeCatalog": catalogSerialized,
|
||||||
"systemVariables": SYSTEM_VARIABLES,
|
"systemVariables": SYSTEM_VARIABLES,
|
||||||
|
"formFieldTypes": FORM_FIELD_TYPES,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,8 @@ class PortField(BaseModel):
|
||||||
# FeatureInstanceRef.featureCode). Pickers/validators use it to filter compatible
|
# FeatureInstanceRef.featureCode). Pickers/validators use it to filter compatible
|
||||||
# producers by sub-type. Type must be "str" when discriminator is True.
|
# producers by sub-type. Type must be "str" when discriminator is True.
|
||||||
discriminator: bool = False
|
discriminator: bool = False
|
||||||
|
# Surfaces this field at the top of the DataPicker list as the most common pick.
|
||||||
|
recommended: bool = False
|
||||||
|
|
||||||
|
|
||||||
class PortSchema(BaseModel):
|
class PortSchema(BaseModel):
|
||||||
|
|
@ -153,7 +155,7 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
|
||||||
]),
|
]),
|
||||||
"DocumentList": PortSchema(name="DocumentList", fields=[
|
"DocumentList": PortSchema(name="DocumentList", fields=[
|
||||||
PortField(name="documents", type="List[Document]",
|
PortField(name="documents", type="List[Document]",
|
||||||
description="Dokumentenliste"),
|
description="Dokumente aus vorherigen Schritten", recommended=True),
|
||||||
PortField(name="connection", type="ConnectionRef", required=False,
|
PortField(name="connection", type="ConnectionRef", required=False,
|
||||||
description="Verbindung, mit der die Liste erzeugt wurde"),
|
description="Verbindung, mit der die Liste erzeugt wurde"),
|
||||||
PortField(name="source", type="SharePointFolderRef", required=False,
|
PortField(name="source", type="SharePointFolderRef", required=False,
|
||||||
|
|
@ -219,9 +221,9 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
|
||||||
PortField(name="prompt", type="str",
|
PortField(name="prompt", type="str",
|
||||||
description="Prompt"),
|
description="Prompt"),
|
||||||
PortField(name="response", type="str",
|
PortField(name="response", type="str",
|
||||||
description="Antworttext"),
|
description="Antworttext", recommended=True),
|
||||||
PortField(name="responseData", type="Dict", required=False,
|
PortField(name="responseData", type="Dict", required=False,
|
||||||
description="Strukturierte Antwort"),
|
description="Strukturierte Antwort (nur bei JSON-Ausgabe)"),
|
||||||
PortField(name="context", type="str",
|
PortField(name="context", type="str",
|
||||||
description="Kontext"),
|
description="Kontext"),
|
||||||
PortField(name="documents", type="List[Document]",
|
PortField(name="documents", type="List[Document]",
|
||||||
|
|
@ -642,6 +644,69 @@ def resolveSystemVariable(variable: str, context: Dict[str, Any]) -> Any:
|
||||||
# Output normalizers
|
# 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]:
|
def normalizeToSchema(raw: Any, schemaName: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Normalize raw executor output to match the declared port schema.
|
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":
|
if not schema or schemaName == "Transit":
|
||||||
return result
|
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:
|
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)
|
result[field.name] = _defaultForType(field.type)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
@ -740,6 +811,9 @@ def _resolveTransitChain(
|
||||||
|
|
||||||
def deriveFormPayloadSchemaFromParam(node: Dict[str, Any], param_key: str) -> Optional[PortSchema]:
|
def deriveFormPayloadSchemaFromParam(node: Dict[str, Any], param_key: str) -> Optional[PortSchema]:
|
||||||
"""Derive output schema from a field-builder JSON list (``fields``, ``formFields``, …)."""
|
"""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)
|
fields_param = (node.get("parameters") or {}).get(param_key)
|
||||||
if not fields_param or not isinstance(fields_param, list):
|
if not fields_param or not isinstance(fields_param, list):
|
||||||
return None
|
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
|
_desc = resolveText(lab) if lab is not None else fname
|
||||||
if not str(_desc).strip():
|
if not str(_desc).strip():
|
||||||
_desc = fname
|
_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(
|
portFields.append(PortField(
|
||||||
name=fname,
|
name=fname,
|
||||||
type=str(ftype) if ftype is not None else "str",
|
type=port_type,
|
||||||
description=_desc,
|
description=_desc,
|
||||||
required=required,
|
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})
|
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})
|
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})
|
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(
|
closedOnTs: Optional[float] = Field(
|
||||||
default=None,
|
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.",
|
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
|
fixedVersionName: Optional[str] = None
|
||||||
categoryId: Optional[int] = None
|
categoryId: Optional[int] = None
|
||||||
categoryName: Optional[str] = None
|
categoryName: Optional[str] = None
|
||||||
|
doneRatio: Optional[int] = None
|
||||||
createdOn: Optional[str] = None
|
createdOn: Optional[str] = None
|
||||||
updatedOn: Optional[str] = None
|
updatedOn: Optional[str] = None
|
||||||
customFields: List[RedmineCustomFieldValueDto] = Field(default_factory=list)
|
customFields: List[RedmineCustomFieldValueDto] = Field(default_factory=list)
|
||||||
|
|
|
||||||
|
|
@ -222,6 +222,7 @@ def _mirroredRowToDto(
|
||||||
fixedVersionName=row.get("fixedVersionName"),
|
fixedVersionName=row.get("fixedVersionName"),
|
||||||
categoryId=row.get("categoryId"),
|
categoryId=row.get("categoryId"),
|
||||||
categoryName=row.get("categoryName"),
|
categoryName=row.get("categoryName"),
|
||||||
|
doneRatio=row.get("doneRatio"),
|
||||||
createdOn=row.get("createdOn"),
|
createdOn=row.get("createdOn"),
|
||||||
updatedOn=row.get("updatedOn"),
|
updatedOn=row.get("updatedOn"),
|
||||||
customFields=[
|
customFields=[
|
||||||
|
|
|
||||||
|
|
@ -402,6 +402,7 @@ def _ticketRecordFromIssue(
|
||||||
"fixedVersionName": fixed_version.get("name"),
|
"fixedVersionName": fixed_version.get("name"),
|
||||||
"categoryId": category.get("id"),
|
"categoryId": category.get("id"),
|
||||||
"categoryName": category.get("name"),
|
"categoryName": category.get("name"),
|
||||||
|
"doneRatio": issue.get("done_ratio"),
|
||||||
"createdOn": created_on,
|
"createdOn": created_on,
|
||||||
"updatedOn": updated_on,
|
"updatedOn": updated_on,
|
||||||
"createdOnTs": _parseRedmineDateToEpoch(created_on),
|
"createdOnTs": _parseRedmineDateToEpoch(created_on),
|
||||||
|
|
|
||||||
|
|
@ -79,15 +79,47 @@ class TeamsbotTransferMode(str, Enum):
|
||||||
AUTO = "auto" # Automatic: anonymous → audio, authenticated → caption
|
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)
|
# 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):
|
class TeamsbotSession(PowerOnModel):
|
||||||
"""A Teams Bot meeting session."""
|
"""A Teams Bot meeting session."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Session ID")
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Session ID")
|
||||||
instanceId: str = Field(description="Feature instance ID (FK)")
|
instanceId: str = Field(description="Feature instance ID (FK)")
|
||||||
mandateId: str = Field(description="Mandate 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")
|
meetingLink: str = Field(description="Teams meeting join link")
|
||||||
botName: str = Field(default="AI Assistant", description="Display name of the bot in the meeting")
|
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")
|
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)")
|
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):
|
class TeamsbotConfigUpdateRequest(BaseModel):
|
||||||
"""Request to update teamsbot configuration."""
|
"""Request to update teamsbot configuration."""
|
||||||
botName: Optional[str] = None
|
botName: Optional[str] = None
|
||||||
|
|
@ -339,7 +392,7 @@ class BridgeJoinRequest(BaseModel):
|
||||||
gatewayCallbackUrl: str = Field(description="Gateway URL for bridge callbacks")
|
gatewayCallbackUrl: str = Field(description="Gateway URL for bridge callbacks")
|
||||||
gatewayWsUrl: str = Field(description="Gateway WebSocket URL for audio streaming")
|
gatewayWsUrl: str = Field(description="Gateway WebSocket URL for audio streaming")
|
||||||
sessionId: str = Field(description="Session ID for correlation")
|
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):
|
class BridgeStatusResponse(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ from .datamodelTeamsbot import (
|
||||||
TeamsbotDirectorPrompt,
|
TeamsbotDirectorPrompt,
|
||||||
TeamsbotDirectorPromptStatus,
|
TeamsbotDirectorPromptStatus,
|
||||||
TeamsbotDirectorPromptMode,
|
TeamsbotDirectorPromptMode,
|
||||||
|
TeamsbotMeetingModule,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -330,6 +331,36 @@ class TeamsbotObjects:
|
||||||
count += 1
|
count += 1
|
||||||
return count
|
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
|
# Stats / Aggregation
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
@ -338,14 +369,23 @@ class TeamsbotObjects:
|
||||||
"""Get aggregated statistics for a session."""
|
"""Get aggregated statistics for a session."""
|
||||||
transcripts = self.db.getRecordset(TeamsbotTranscript, recordFilter={"sessionId": sessionId})
|
transcripts = self.db.getRecordset(TeamsbotTranscript, recordFilter={"sessionId": sessionId})
|
||||||
responses = self.db.getRecordset(TeamsbotBotResponse, 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)
|
totalCost = sum(r.get("priceCHF", 0) for r in responses)
|
||||||
totalProcessingTime = sum(r.get("processingTime", 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 {
|
return {
|
||||||
"transcriptSegments": len(transcripts),
|
"transcriptSegments": len(transcripts),
|
||||||
"botResponses": len(responses),
|
"botResponses": len(responses),
|
||||||
|
"directorPrompts": len(prompts),
|
||||||
"totalCostCHF": round(totalCost, 4),
|
"totalCostCHF": round(totalCost, 4),
|
||||||
"totalProcessingTime": round(totalProcessingTime, 2),
|
"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"),
|
"label": t("Dashboard", context="UI"),
|
||||||
"meta": {"area": "dashboard"}
|
"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",
|
"objectKey": "ui.feature.teamsbot.sessions",
|
||||||
"label": t("Sitzungen", context="UI"),
|
"label": t("Sitzungen", context="UI"),
|
||||||
|
|
@ -38,13 +48,24 @@ UI_OBJECTS = [
|
||||||
|
|
||||||
# DATA Objects for RBAC catalog (tables/entities)
|
# DATA Objects for RBAC catalog (tables/entities)
|
||||||
DATA_OBJECTS = [
|
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",
|
"objectKey": "data.feature.teamsbot.TeamsbotSession",
|
||||||
"label": t("Sitzung", context="UI"),
|
"label": t("Sitzung", context="UI"),
|
||||||
"meta": {
|
"meta": {
|
||||||
"table": "TeamsbotSession",
|
"table": "TeamsbotSession",
|
||||||
"fields": ["id", "meetingLink", "botName", "status", "startedAt", "endedAt"],
|
"fields": ["id", "meetingLink", "botName", "status", "startedAt", "endedAt"],
|
||||||
"isParent": True,
|
"parentTable": "TeamsbotMeetingModule",
|
||||||
|
"parentKey": "moduleId",
|
||||||
"displayFields": ["botName", "status", "startedAt"],
|
"displayFields": ["botName", "status", "startedAt"],
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -97,6 +118,16 @@ RESOURCE_OBJECTS = [
|
||||||
"label": t("Konfiguration bearbeiten", context="UI"),
|
"label": t("Konfiguration bearbeiten", context="UI"),
|
||||||
"meta": {"endpoint": "/api/teamsbot/{instanceId}/config", "method": "PUT", "admin_only": True}
|
"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
|
# 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.stop", "view": True},
|
||||||
{"context": "RESOURCE", "item": "resource.feature.teamsbot.session.delete", "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.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)",
|
"description": "Teams Bot Betrachter - Sitzungen und Transkripte ansehen (nur lesen)",
|
||||||
"accessRules": [
|
"accessRules": [
|
||||||
{"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True},
|
{"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": "UI", "item": "ui.feature.teamsbot.sessions", "view": True},
|
||||||
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
|
{"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",
|
"description": "Teams Bot Benutzer - Kann Sitzungen starten/stoppen und Transkripte einsehen",
|
||||||
"accessRules": [
|
"accessRules": [
|
||||||
{"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True},
|
{"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": "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.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.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": "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.start", "view": True},
|
||||||
{"context": "RESOURCE", "item": "resource.feature.teamsbot.session.stop", "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")
|
meta=dataObj.get("meta")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_runMigrations()
|
||||||
_syncTemplateRolesToDb()
|
_syncTemplateRolesToDb()
|
||||||
|
|
||||||
logger.info(f"Feature '{FEATURE_CODE}' registered {len(UI_OBJECTS)} UI, {len(RESOURCE_OBJECTS)} resource, {len(DATA_OBJECTS)} data objects")
|
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
|
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:
|
def _syncTemplateRolesToDb() -> int:
|
||||||
"""Sync template roles and their AccessRules to the database."""
|
"""Sync template roles and their AccessRules to the database."""
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,9 @@ from .datamodelTeamsbot import (
|
||||||
TeamsbotDirectorPromptCreateRequest,
|
TeamsbotDirectorPromptCreateRequest,
|
||||||
TeamsbotDirectorPromptMode,
|
TeamsbotDirectorPromptMode,
|
||||||
TeamsbotDirectorPromptStatus,
|
TeamsbotDirectorPromptStatus,
|
||||||
|
TeamsbotMeetingModule,
|
||||||
|
CreateMeetingModuleRequest,
|
||||||
|
UpdateMeetingModuleRequest,
|
||||||
DIRECTOR_PROMPT_FILE_LIMIT,
|
DIRECTOR_PROMPT_FILE_LIMIT,
|
||||||
DIRECTOR_PROMPT_TEXT_LIMIT,
|
DIRECTOR_PROMPT_TEXT_LIMIT,
|
||||||
)
|
)
|
||||||
|
|
@ -167,6 +170,100 @@ def _getInstanceConfig(instanceId: str) -> TeamsbotConfig:
|
||||||
return 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
|
# Session Endpoints
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
@ -385,8 +482,9 @@ async def streamSession(
|
||||||
"""Generate SSE events from the session event queue."""
|
"""Generate SSE events from the session event queue."""
|
||||||
from .service import sessionEvents
|
from .service import sessionEvents
|
||||||
|
|
||||||
# Send initial session state
|
# Send initial session state with stats
|
||||||
yield f"data: {json.dumps({'type': 'sessionState', 'data': session})}\n\n"
|
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
|
# Send current bot WebSocket connection state so the operator UI can
|
||||||
# render the live indicator without waiting for the next connect/disconnect.
|
# render the live indicator without waiting for the next connect/disconnect.
|
||||||
|
|
|
||||||
|
|
@ -3409,6 +3409,8 @@ class TeamsbotService:
|
||||||
"status": "toolCall",
|
"status": "toolCall",
|
||||||
"toolName": toolName,
|
"toolName": toolName,
|
||||||
})
|
})
|
||||||
|
elif event.type == AgentEventTypeEnum.FILE_CREATED:
|
||||||
|
await _emitSessionEvent(sessionId, "documentCreated", event.data or {})
|
||||||
elif event.type == AgentEventTypeEnum.FINAL:
|
elif event.type == AgentEventTypeEnum.FINAL:
|
||||||
finalText = (event.content or "").strip()
|
finalText = (event.content or "").strip()
|
||||||
elif event.type == AgentEventTypeEnum.ERROR:
|
elif event.type == AgentEventTypeEnum.ERROR:
|
||||||
|
|
|
||||||
|
|
@ -4028,58 +4028,92 @@ class AppObjects:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
# Table Grouping (user-defined groups for FormGeneratorTable instances)
|
# Table List Views (saved display presets: filters, sort, groupByLevels)
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
def getTableGrouping(self, contextKey: str):
|
def getTableListViews(self, contextKey: str) -> list:
|
||||||
"""
|
"""Return all saved views for the current user and contextKey."""
|
||||||
Load the group tree for the current user and the given contextKey.
|
from modules.datamodels.datamodelPagination import TableListView
|
||||||
|
|
||||||
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
|
|
||||||
try:
|
try:
|
||||||
records = self.db.getRecordset(
|
rows = self.db.getRecordset(
|
||||||
TableGrouping,
|
TableListView,
|
||||||
recordFilter={"userId": str(self.userId), "contextKey": contextKey},
|
recordFilter={"userId": str(self.userId), "contextKey": contextKey},
|
||||||
)
|
)
|
||||||
if not records:
|
result = []
|
||||||
return None
|
for row in (rows or []):
|
||||||
row = records[0]
|
|
||||||
return TableGrouping.model_validate(row) if isinstance(row, dict) else row
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"getTableGrouping failed for user={self.userId} key={contextKey}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def upsertTableGrouping(self, contextKey: str, rootGroups: list):
|
|
||||||
"""
|
|
||||||
Create or replace the group tree for the current user and contextKey.
|
|
||||||
|
|
||||||
rootGroups is a list of TableGroupNode-compatible dicts (the full tree).
|
|
||||||
Returns the saved TableGrouping instance.
|
|
||||||
"""
|
|
||||||
from modules.datamodels.datamodelPagination import TableGrouping
|
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
|
||||||
try:
|
try:
|
||||||
existing = self.getTableGrouping(contextKey)
|
result.append(TableListView.model_validate(row) if isinstance(row, dict) else row)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return result
|
||||||
|
except Exception as 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 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 = {
|
data = {
|
||||||
"id": existing.id if existing else str(uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
"userId": str(self.userId),
|
"userId": str(self.userId),
|
||||||
"contextKey": contextKey,
|
"contextKey": contextKey,
|
||||||
"rootGroups": rootGroups,
|
"viewKey": viewKey,
|
||||||
|
"displayName": displayName,
|
||||||
|
"config": config,
|
||||||
"updatedAt": getUtcTimestamp(),
|
"updatedAt": getUtcTimestamp(),
|
||||||
}
|
}
|
||||||
if existing:
|
try:
|
||||||
self.db.recordModify(TableGrouping, existing.id, data)
|
self.db.recordCreate(TableListView, data)
|
||||||
else:
|
return TableListView.model_validate(data)
|
||||||
self.db.recordCreate(TableGrouping, data)
|
|
||||||
return TableGrouping.model_validate(data)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"upsertTableGrouping failed for user={self.userId} key={contextKey}: {e}")
|
logger.error(f"createTableListView failed: {e}")
|
||||||
raise
|
raise
|
||||||
|
|
||||||
|
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:
|
||||||
|
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"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
|
# Public Methods
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -908,18 +908,22 @@ class BillingObjects:
|
||||||
)
|
)
|
||||||
|
|
||||||
def reconcileMandateStorageBilling(self, mandateId: str) -> Optional[Dict[str, Any]]:
|
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)
|
settings = self.getSettings(mandateId)
|
||||||
if not settings:
|
if not settings:
|
||||||
return None
|
return None
|
||||||
from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot
|
from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot
|
||||||
from modules.datamodels.datamodelSubscription import getPlan
|
from modules.datamodels.datamodelSubscription import getPlan, getEffectiveLimits
|
||||||
|
|
||||||
subIface = _getSubRoot()
|
subIface = _getSubRoot()
|
||||||
usedMB = float(subIface.getMandateDataVolumeMB(mandateId))
|
usedMB = float(subIface.getMandateDataVolumeMB(mandateId))
|
||||||
sub = subIface.getOperativeForMandate(mandateId)
|
sub = subIface.getOperativeForMandate(mandateId)
|
||||||
|
if sub and sub.get("isEnterprise"):
|
||||||
|
return None
|
||||||
plan = getPlan(sub.get("planKey", "")) if sub else 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:
|
if includedMB is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -966,12 +970,22 @@ class BillingObjects:
|
||||||
# Subscription AI-Budget Credit
|
# 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.
|
"""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).
|
Should be called once per billing period (initial activation + each invoice.paid).
|
||||||
Returns the created CREDIT transaction or None if budget is 0."""
|
Returns the created CREDIT transaction or None if budget is 0."""
|
||||||
|
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
|
from modules.datamodels.datamodelSubscription import getPlan
|
||||||
|
|
||||||
plan = getPlan(planKey)
|
plan = getPlan(planKey)
|
||||||
|
|
@ -982,12 +996,12 @@ class BillingObjects:
|
||||||
subRoot = _getSubRoot()
|
subRoot = _getSubRoot()
|
||||||
activeUsers = max(subRoot.countActiveUsers(mandateId), 1)
|
activeUsers = max(subRoot.countActiveUsers(mandateId), 1)
|
||||||
amount = plan.budgetAiPerUserCHF * activeUsers
|
amount = plan.budgetAiPerUserCHF * activeUsers
|
||||||
|
|
||||||
poolAccount = self.getOrCreateMandateAccount(mandateId)
|
|
||||||
description = f"AI-Budget ({planKey}, {activeUsers} User)"
|
description = f"AI-Budget ({planKey}, {activeUsers} User)"
|
||||||
if periodLabel:
|
if periodLabel:
|
||||||
description += f" – {periodLabel}"
|
description += f" – {periodLabel}"
|
||||||
|
|
||||||
|
poolAccount = self.getOrCreateMandateAccount(mandateId)
|
||||||
|
|
||||||
transaction = BillingTransaction(
|
transaction = BillingTransaction(
|
||||||
accountId=poolAccount["id"],
|
accountId=poolAccount["id"],
|
||||||
transactionType=TransactionTypeEnum.CREDIT,
|
transactionType=TransactionTypeEnum.CREDIT,
|
||||||
|
|
@ -998,8 +1012,8 @@ class BillingObjects:
|
||||||
)
|
)
|
||||||
created = self.createTransaction(transaction)
|
created = self.createTransaction(transaction)
|
||||||
logger.info(
|
logger.info(
|
||||||
"AI-Budget credited mandate=%s plan=%s users=%d amount=%.2f CHF",
|
"AI-Budget credited mandate=%s plan=%s amount=%.2f CHF",
|
||||||
mandateId, planKey, activeUsers, amount,
|
mandateId, planKey, amount,
|
||||||
)
|
)
|
||||||
return created
|
return created
|
||||||
|
|
||||||
|
|
@ -1027,7 +1041,8 @@ class BillingObjects:
|
||||||
|
|
||||||
delta > 0: user added -> CREDIT pro-rata portion
|
delta > 0: user added -> CREDIT pro-rata portion
|
||||||
delta < 0: user removed -> DEBIT 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
|
from modules.datamodels.datamodelSubscription import getPlan
|
||||||
|
|
||||||
plan = getPlan(planKey)
|
plan = getPlan(planKey)
|
||||||
|
|
@ -1039,6 +1054,8 @@ class BillingObjects:
|
||||||
operative = subRoot.getOperativeForMandate(mandateId)
|
operative = subRoot.getOperativeForMandate(mandateId)
|
||||||
if not operative:
|
if not operative:
|
||||||
return None
|
return None
|
||||||
|
if operative.get("isEnterprise"):
|
||||||
|
return None
|
||||||
|
|
||||||
periodStart = operative.get("currentPeriodStart")
|
periodStart = operative.get("currentPeriodStart")
|
||||||
periodEnd = operative.get("currentPeriodEnd")
|
periodEnd = operative.get("currentPeriodEnd")
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,6 @@ logger = logging.getLogger(__name__)
|
||||||
managementDatabase = "poweron_management"
|
managementDatabase = "poweron_management"
|
||||||
registerDatabase(managementDatabase)
|
registerDatabase(managementDatabase)
|
||||||
|
|
||||||
# Singleton factory for Management instances with AI service per context
|
|
||||||
_instancesManagement = {}
|
|
||||||
|
|
||||||
# Custom exceptions for file handling
|
# Custom exceptions for file handling
|
||||||
class FileError(Exception):
|
class FileError(Exception):
|
||||||
|
|
@ -115,15 +113,21 @@ class ComponentObjects:
|
||||||
# Update database context
|
# Update database context
|
||||||
self.db.updateContext(self.userId)
|
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):
|
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:
|
if hasattr(self, 'db') and self.db is not None:
|
||||||
try:
|
try:
|
||||||
self.db.close()
|
self.db.close()
|
||||||
except Exception as e:
|
except Exception:
|
||||||
logger.error(f"Error closing database connection: {e}")
|
pass
|
||||||
|
|
||||||
logger.debug(f"User context set: userId={self.userId}")
|
|
||||||
|
|
||||||
def _initializeDatabase(self):
|
def _initializeDatabase(self):
|
||||||
"""Initializes the database connection directly."""
|
"""Initializes the database connection directly."""
|
||||||
|
|
@ -1379,9 +1383,30 @@ class ComponentObjects:
|
||||||
fileSize=fileSize,
|
fileSize=fileSize,
|
||||||
fileHash=fileHash,
|
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
|
# Store in database
|
||||||
self.db.recordCreate(FileItem, fileItem)
|
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
|
return fileItem
|
||||||
|
|
||||||
|
|
@ -1503,43 +1528,7 @@ class ComponentObjects:
|
||||||
raise FileDeletionError(f"Error deleting files in batch: {str(e)}")
|
raise FileDeletionError(f"Error deleting files in batch: {str(e)}")
|
||||||
|
|
||||||
def _ensureFeatureInstanceGroup(self, featureInstanceId: str, contextKey: str = "files/list") -> Optional[str]:
|
def _ensureFeatureInstanceGroup(self, featureInstanceId: str, contextKey: str = "files/list") -> Optional[str]:
|
||||||
"""Return the groupId of the default group for a feature instance.
|
"""Stub — file group tree removed. Returns None."""
|
||||||
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
|
return None
|
||||||
|
|
||||||
def copyFile(self, sourceFileId: str, newFileName: Optional[str] = None) -> FileItem:
|
def copyFile(self, sourceFileId: str, newFileName: Optional[str] = None) -> FileItem:
|
||||||
|
|
@ -1580,13 +1569,133 @@ class ComponentObjects:
|
||||||
|
|
||||||
# FileData methods - data operations
|
# 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:
|
def createFileData(self, fileId: str, data: bytes) -> bool:
|
||||||
"""Stores the binary data of a file in the database."""
|
"""Stores the binary data of a file in the database."""
|
||||||
try:
|
try:
|
||||||
|
logger.info(
|
||||||
|
"[FileData] createFileData enter fileId=%s bytes=%s",
|
||||||
|
fileId,
|
||||||
|
len(data) if data is not None else 0,
|
||||||
|
)
|
||||||
# Check file access
|
# Check file access
|
||||||
file = self.getFile(fileId)
|
file = self._getFileItemForDataWrite(fileId)
|
||||||
if not file:
|
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
|
return False
|
||||||
|
|
||||||
# Determine if this is a text-based format
|
# Determine if this is a text-based format
|
||||||
|
|
@ -1631,12 +1740,10 @@ class ComponentObjects:
|
||||||
|
|
||||||
self.db.recordCreate(FileData, fileDataObj)
|
self.db.recordCreate(FileData, fileDataObj)
|
||||||
|
|
||||||
# Clear cache to ensure fresh data
|
logger.info("[FileData] recordCreate OK fileId=%s base64Encoded=%s", fileId, base64Encoded)
|
||||||
|
|
||||||
logger.debug(f"Successfully stored data for file {fileId} (base64Encoded: {base64Encoded})")
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
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
|
return False
|
||||||
|
|
||||||
def getFileData(self, fileId: str) -> Optional[bytes]:
|
def getFileData(self, fileId: str) -> Optional[bytes]:
|
||||||
|
|
@ -2162,9 +2269,10 @@ class ComponentObjects:
|
||||||
|
|
||||||
def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> 'ComponentObjects':
|
def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> 'ComponentObjects':
|
||||||
"""
|
"""
|
||||||
Returns a ComponentObjects instance.
|
Returns a ComponentObjects instance scoped to the given user/mandate/featureInstance.
|
||||||
If currentUser is provided, initializes with user context.
|
|
||||||
Otherwise, returns an instance with only database access.
|
Each call creates a lightweight instance whose DB connector is already
|
||||||
|
cached inside ``getCachedConnector``, so the overhead is minimal.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
currentUser: The authenticated user
|
currentUser: The authenticated user
|
||||||
|
|
@ -2174,11 +2282,7 @@ def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] =
|
||||||
effectiveMandateId = str(mandateId) if mandateId else None
|
effectiveMandateId = str(mandateId) if mandateId else None
|
||||||
effectiveFeatureInstanceId = str(featureInstanceId) if featureInstanceId else None
|
effectiveFeatureInstanceId = str(featureInstanceId) if featureInstanceId else None
|
||||||
|
|
||||||
# Create new instance if not exists
|
interface = ComponentObjects()
|
||||||
if "default" not in _instancesManagement:
|
|
||||||
_instancesManagement["default"] = ComponentObjects()
|
|
||||||
|
|
||||||
interface = _instancesManagement["default"]
|
|
||||||
|
|
||||||
if currentUser:
|
if currentUser:
|
||||||
interface.setUserContext(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
|
interface.setUserContext(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId)
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ from modules.datamodels.datamodelSubscription import (
|
||||||
BUILTIN_PLANS,
|
BUILTIN_PLANS,
|
||||||
getPlan as getPlanFromCatalog,
|
getPlan as getPlanFromCatalog,
|
||||||
_getSelectablePlans,
|
_getSelectablePlans,
|
||||||
|
getEffectiveLimits,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -276,33 +277,42 @@ class SubscriptionObjects:
|
||||||
)
|
)
|
||||||
|
|
||||||
plan = self.getPlan(sub.get("planKey", ""))
|
plan = self.getPlan(sub.get("planKey", ""))
|
||||||
if not plan:
|
limits = getEffectiveLimits(sub, plan)
|
||||||
return True
|
isEnterprise = sub.get("isEnterprise", False)
|
||||||
|
|
||||||
if resourceType == "users":
|
if resourceType == "users":
|
||||||
cap = plan.maxUsers
|
cap = limits["maxUsers"]
|
||||||
if cap is None:
|
if cap is None:
|
||||||
return True
|
return True
|
||||||
current = self.countActiveUsers(mandateId)
|
current = self.countActiveUsers(mandateId)
|
||||||
if current + delta > cap:
|
if current + delta > cap:
|
||||||
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
|
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":
|
elif resourceType == "featureInstances":
|
||||||
cap = plan.maxFeatureInstances
|
cap = limits["maxFeatureInstances"]
|
||||||
if cap is None:
|
if cap is None:
|
||||||
return True
|
return True
|
||||||
current = self.countActiveFeatureInstances(mandateId)
|
current = self.countActiveFeatureInstances(mandateId)
|
||||||
if current + delta > cap:
|
if current + delta > cap:
|
||||||
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
|
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":
|
elif resourceType == "dataVolumeMB":
|
||||||
cap = plan.maxDataVolumeMB
|
cap = limits["maxDataVolumeMB"]
|
||||||
if cap is None:
|
if cap is None:
|
||||||
return True
|
return True
|
||||||
currentMB = self.getMandateDataVolumeMB(mandateId)
|
currentMB = self.getMandateDataVolumeMB(mandateId)
|
||||||
if currentMB + delta > cap:
|
if currentMB + delta > cap:
|
||||||
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
|
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
|
return True
|
||||||
|
|
||||||
|
|
@ -325,10 +335,11 @@ class SubscriptionObjects:
|
||||||
if not sub:
|
if not sub:
|
||||||
return None
|
return None
|
||||||
plan = self.getPlan(sub.get("planKey", ""))
|
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
|
return None
|
||||||
usedMB = self.getMandateDataVolumeMB(mandateId)
|
usedMB = self.getMandateDataVolumeMB(mandateId)
|
||||||
limitMB = plan.maxDataVolumeMB
|
|
||||||
percent = (usedMB / limitMB * 100) if limitMB > 0 else 0
|
percent = (usedMB / limitMB * 100) if limitMB > 0 else 0
|
||||||
if percent >= 80:
|
if percent >= 80:
|
||||||
return {"usedMB": round(usedMB, 2), "limitMB": limitMB, "percent": round(percent, 1), "warning": True}
|
return {"usedMB": round(usedMB, 2), "limitMB": limitMB, "percent": round(percent, 1), "warning": True}
|
||||||
|
|
|
||||||
|
|
@ -747,6 +747,7 @@ def buildFilesScopeWhereClause(
|
||||||
Only own files: sysCreatedBy = currentUser
|
Only own files: sysCreatedBy = currentUser
|
||||||
|
|
||||||
WITH instance context (Instanz-Seiten):
|
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)
|
- sysCreatedBy = me AND featureInstanceId = X (own personal files of this instance)
|
||||||
- scope = 'featureInstance' AND featureInstanceId = X
|
- scope = 'featureInstance' AND featureInstanceId = X
|
||||||
- scope = 'mandate' AND mandateId = M (M = mandate of the instance)
|
- scope = 'mandate' AND mandateId = M (M = mandate of the instance)
|
||||||
|
|
@ -780,6 +781,15 @@ def buildFilesScopeWhereClause(
|
||||||
scopeParts: List[str] = []
|
scopeParts: List[str] = []
|
||||||
scopeValues: List = []
|
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:
|
if featureInstanceId:
|
||||||
# 1) Own personal files of this specific instance
|
# 1) Own personal files of this specific instance
|
||||||
scopeParts.append('("sysCreatedBy" = %s AND "featureInstanceId" = %s)')
|
scopeParts.append('("sysCreatedBy" = %s AND "featureInstanceId" = %s)')
|
||||||
|
|
|
||||||
|
|
@ -658,6 +658,11 @@
|
||||||
"key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden",
|
"key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden",
|
||||||
"value": ""
|
"value": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Dossiers",
|
||||||
|
"value": "UDB tab label for chat workflows / cases"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"context": "ui",
|
"context": "ui",
|
||||||
"key": "Dokument",
|
"key": "Dokument",
|
||||||
|
|
@ -4046,6 +4051,11 @@
|
||||||
"key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden",
|
"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"
|
"value": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Dossiers",
|
||||||
|
"value": "Dossiers"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"context": "ui",
|
"context": "ui",
|
||||||
"key": "Dokument",
|
"key": "Dokument",
|
||||||
|
|
@ -7404,6 +7414,11 @@
|
||||||
"key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden",
|
"key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden",
|
||||||
"value": "This field is managed by {provider} and cannot be changed"
|
"value": "This field is managed by {provider} and cannot be changed"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Dossiers",
|
||||||
|
"value": "Dossiers"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"context": "ui",
|
"context": "ui",
|
||||||
"key": "Dokument",
|
"key": "Dokument",
|
||||||
|
|
@ -10617,6 +10632,11 @@
|
||||||
"key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden",
|
"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é"
|
"value": "Ce champ est géré par {provider} et ne peut pas être modifié"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"context": "ui",
|
||||||
|
"key": "Dossiers",
|
||||||
|
"value": "Dossiers"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"context": "ui",
|
"context": "ui",
|
||||||
"key": "Dokument",
|
"key": "Dokument",
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,9 @@ Features:
|
||||||
- Admin endpoints: Manage settings, add credits, view all accounts
|
- 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 typing import List, Dict, Any, Optional
|
||||||
from fastapi import status
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import date, datetime, timezone
|
from datetime import date, datetime, timezone
|
||||||
from pydantic import BaseModel, Field
|
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
|
from modules.serviceCenter.services.serviceBilling.mainServiceBilling import getService as getBillingService
|
||||||
import json
|
import json
|
||||||
import math
|
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 (
|
from modules.datamodels.datamodelBilling import (
|
||||||
BillingAccount,
|
BillingAccount,
|
||||||
BillingTransaction,
|
BillingTransaction,
|
||||||
|
|
@ -478,31 +484,172 @@ def getBalanceForMandate(
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
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")
|
@limiter.limit("30/minute")
|
||||||
def getTransactions(
|
def getTransactions(
|
||||||
request: Request,
|
request: Request,
|
||||||
limit: int = Query(default=50, ge=1, le=500),
|
limit: int = Query(default=50, ge=1, le=500),
|
||||||
offset: int = Query(default=0, ge=0),
|
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.
|
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:
|
try:
|
||||||
billingService = getBillingService(
|
billingService = getBillingService(
|
||||||
ctx.user,
|
ctx.user,
|
||||||
ctx.mandateId,
|
ctx.mandateId,
|
||||||
featureCode="billing"
|
featureCode="billing",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fetch enough transactions for pagination
|
if pagination:
|
||||||
transactions = billingService.getTransactionHistory(limit=offset + limit)
|
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
|
||||||
|
|
||||||
# Convert to response model
|
CONTEXT_KEY = "billing/transactions"
|
||||||
result = []
|
|
||||||
for t in transactions[offset:offset + limit]:
|
try:
|
||||||
result.append(TransactionResponse(
|
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)
|
||||||
|
result: List[TransactionResponse] = []
|
||||||
|
for t in transactions[offset : offset + limit]:
|
||||||
|
result.append(
|
||||||
|
TransactionResponse(
|
||||||
id=t.get("id"),
|
id=t.get("id"),
|
||||||
accountId=t.get("accountId"),
|
accountId=t.get("accountId"),
|
||||||
transactionType=TransactionTypeEnum(t.get("transactionType", "DEBIT")),
|
transactionType=TransactionTypeEnum(t.get("transactionType", "DEBIT")),
|
||||||
|
|
@ -517,11 +664,13 @@ def getTransactions(
|
||||||
createdByUserId=t.get("createdByUserId"),
|
createdByUserId=t.get("createdByUserId"),
|
||||||
sysCreatedAt=t.get("sysCreatedAt"),
|
sysCreatedAt=t.get("sysCreatedAt"),
|
||||||
mandateId=t.get("mandateId"),
|
mandateId=t.get("mandateId"),
|
||||||
mandateName=t.get("mandateName")
|
mandateName=t.get("mandateName"),
|
||||||
))
|
)
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting billing transactions: {e}")
|
logger.error(f"Error getting billing transactions: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
@ -1757,7 +1906,7 @@ def getUserViewStatistics(
|
||||||
|
|
||||||
|
|
||||||
@router.get("/view/users/transactions", response_model=PaginatedResponse[UserTransactionResponse])
|
@router.get("/view/users/transactions", response_model=PaginatedResponse[UserTransactionResponse])
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("120/minute")
|
||||||
def getUserViewTransactions(
|
def getUserViewTransactions(
|
||||||
request: Request,
|
request: Request,
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
|
||||||
|
|
@ -1808,7 +1957,6 @@ def getUserViewTransactions(
|
||||||
if mode == "filterValues":
|
if mode == "filterValues":
|
||||||
if not column:
|
if not column:
|
||||||
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
crossFilterParams = parseCrossFilterPagination(column, pagination)
|
crossFilterParams = parseCrossFilterPagination(column, pagination)
|
||||||
values = billingInterface.getTransactionDistinctValues(
|
values = billingInterface.getTransactionDistinctValues(
|
||||||
mandateIds=loadMandateIds,
|
mandateIds=loadMandateIds,
|
||||||
|
|
@ -1820,7 +1968,6 @@ def getUserViewTransactions(
|
||||||
return JSONResponse(content=values)
|
return JSONResponse(content=values)
|
||||||
|
|
||||||
if mode == "ids":
|
if mode == "ids":
|
||||||
from fastapi.responses import JSONResponse
|
|
||||||
paginationParams = None
|
paginationParams = None
|
||||||
if pagination:
|
if pagination:
|
||||||
import json as _json
|
import json as _json
|
||||||
|
|
@ -1835,6 +1982,66 @@ def getUserViewTransactions(
|
||||||
) if hasattr(billingInterface, 'getTransactionIds') else []
|
) if hasattr(billingInterface, 'getTransactionIds') else []
|
||||||
return JSONResponse(content=ids)
|
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
|
paginationParams = None
|
||||||
if pagination:
|
if pagination:
|
||||||
import json as _json
|
import json as _json
|
||||||
|
|
@ -1847,15 +2054,21 @@ def getUserViewTransactions(
|
||||||
if not paginationParams:
|
if not paginationParams:
|
||||||
paginationParams = PaginationParams(page=1, pageSize=50)
|
paginationParams = PaginationParams(page=1, pageSize=50)
|
||||||
|
|
||||||
result = billingInterface.getTransactionsForMandatesPaginated(
|
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
||||||
mandateIds=loadMandateIds,
|
from modules.routes.routeHelpers import (
|
||||||
pagination=paginationParams,
|
applyViewToParams,
|
||||||
scope=effectiveScope,
|
buildGroupLayout,
|
||||||
userId=personalUserId,
|
effective_group_by_levels,
|
||||||
|
resolveView,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.debug(f"SQL-paginated {result.totalItems} transactions for user {ctx.user.id} "
|
CONTEXT_KEY = "billing/view/users/transactions"
|
||||||
f"(scope={scope}, mandateId={mandateId}, page={paginationParams.page})")
|
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):
|
def _toResponse(d):
|
||||||
return UserTransactionResponse(
|
return UserTransactionResponse(
|
||||||
|
|
@ -1875,7 +2088,54 @@ def getUserViewTransactions(
|
||||||
mandateId=d.get("mandateId"),
|
mandateId=d.get("mandateId"),
|
||||||
mandateName=d.get("mandateName"),
|
mandateName=d.get("mandateName"),
|
||||||
userId=d.get("userId"),
|
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(
|
return PaginatedResponse(
|
||||||
|
|
@ -1887,7 +2147,7 @@ def getUserViewTransactions(
|
||||||
totalPages=result.totalPages,
|
totalPages=result.totalPages,
|
||||||
sort=paginationParams.sort,
|
sort=paginationParams.sort,
|
||||||
filters=paginationParams.filters,
|
filters=paginationParams.filters,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import logging
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
from modules.datamodels.datamodelUam import User, UserConnection, AuthAuthority, ConnectionStatus
|
from modules.datamodels.datamodelUam import User, UserConnection, AuthAuthority, ConnectionStatus
|
||||||
from modules.datamodels.datamodelSecurity import Token
|
from modules.datamodels.datamodelSecurity import Token
|
||||||
|
|
@ -154,12 +155,12 @@ async def get_connections(
|
||||||
"""
|
"""
|
||||||
from modules.routes.routeHelpers import (
|
from modules.routes.routeHelpers import (
|
||||||
handleFilterValuesInMemory, handleIdsInMemory, enrichRowsWithFkLabels,
|
handleFilterValuesInMemory, handleIdsInMemory, enrichRowsWithFkLabels,
|
||||||
handleGroupingInRequest, applyGroupScopeFilter,
|
resolveView, applyViewToParams, buildGroupLayout, effective_group_by_levels,
|
||||||
)
|
)
|
||||||
|
from modules.datamodels.datamodelPagination import AppliedViewMeta
|
||||||
|
|
||||||
CONTEXT_KEY = "connections"
|
CONTEXT_KEY = "connections"
|
||||||
|
|
||||||
# Parse pagination params early — needed for grouping in all modes
|
|
||||||
paginationParams = None
|
paginationParams = None
|
||||||
if pagination:
|
if pagination:
|
||||||
try:
|
try:
|
||||||
|
|
@ -171,7 +172,13 @@ async def get_connections(
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}")
|
raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}")
|
||||||
|
|
||||||
interface = getInterface(currentUser)
|
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():
|
def _buildEnhancedItems():
|
||||||
connections = interface.getUserConnections(currentUser.id)
|
connections = interface.getUserConnections(currentUser.id)
|
||||||
|
|
@ -200,7 +207,6 @@ async def get_connections(
|
||||||
try:
|
try:
|
||||||
items = _buildEnhancedItems()
|
items = _buildEnhancedItems()
|
||||||
enrichRowsWithFkLabels(items, UserConnection)
|
enrichRowsWithFkLabels(items, UserConnection)
|
||||||
items = applyGroupScopeFilter(items, groupCtx.itemIds)
|
|
||||||
return handleFilterValuesInMemory(items, column, pagination)
|
return handleFilterValuesInMemory(items, column, pagination)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting filter values for connections: {str(e)}")
|
logger.error(f"Error getting filter values for connections: {str(e)}")
|
||||||
|
|
@ -208,19 +214,60 @@ async def get_connections(
|
||||||
|
|
||||||
if mode == "ids":
|
if mode == "ids":
|
||||||
try:
|
try:
|
||||||
items = applyGroupScopeFilter(_buildEnhancedItems(), groupCtx.itemIds)
|
return handleIdsInMemory(_buildEnhancedItems(), pagination)
|
||||||
return handleIdsInMemory(items, pagination)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error getting IDs for connections: {str(e)}")
|
logger.error(f"Error getting IDs for connections: {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
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:
|
try:
|
||||||
# NOTE: Cannot use db.getRecordsetPaginated() here because each connection
|
refresh_result = await token_refresh_service.refresh_expired_tokens(currentUser.id)
|
||||||
# is enriched with computed tokenStatus/tokenExpiresAt (requires per-row DB lookup).
|
if refresh_result.get("refreshed", 0) > 0:
|
||||||
# Token refresh also may trigger re-fetch. Connections per user are typically < 10,
|
logger.info(
|
||||||
# so in-memory pagination is acceptable.
|
"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)
|
connections = interface.getUserConnections(currentUser.id)
|
||||||
|
|
||||||
# Perform silent token refresh for expired OAuth connections
|
# Perform silent token refresh for expired OAuth connections
|
||||||
|
|
@ -235,7 +282,7 @@ async def get_connections(
|
||||||
enhanced_connections_dict = []
|
enhanced_connections_dict = []
|
||||||
for connection in connections:
|
for connection in connections:
|
||||||
tokenStatus, tokenExpiresAt = getTokenStatusForConnection(interface, connection.id)
|
tokenStatus, tokenExpiresAt = getTokenStatusForConnection(interface, connection.id)
|
||||||
connection_dict = {
|
enhanced_connections_dict.append({
|
||||||
"id": connection.id,
|
"id": connection.id,
|
||||||
"userId": connection.userId,
|
"userId": connection.userId,
|
||||||
"authority": connection.authority.value if hasattr(connection.authority, 'value') else str(connection.authority),
|
"authority": connection.authority.value if hasattr(connection.authority, 'value') else str(connection.authority),
|
||||||
|
|
@ -248,46 +295,31 @@ async def get_connections(
|
||||||
"expiresAt": connection.expiresAt,
|
"expiresAt": connection.expiresAt,
|
||||||
"tokenStatus": tokenStatus,
|
"tokenStatus": tokenStatus,
|
||||||
"tokenExpiresAt": tokenExpiresAt
|
"tokenExpiresAt": tokenExpiresAt
|
||||||
}
|
})
|
||||||
enhanced_connections_dict.append(connection_dict)
|
|
||||||
|
|
||||||
enrichRowsWithFkLabels(enhanced_connections_dict, UserConnection)
|
enrichRowsWithFkLabels(enhanced_connections_dict, UserConnection)
|
||||||
enhanced_connections_dict = applyGroupScopeFilter(enhanced_connections_dict, groupCtx.itemIds)
|
|
||||||
|
|
||||||
if paginationParams is None:
|
if paginationParams is None:
|
||||||
return {
|
return {"items": enhanced_connections_dict, "pagination": None}
|
||||||
"items": enhanced_connections_dict,
|
|
||||||
"pagination": None,
|
|
||||||
"groupTree": groupCtx.groupTree,
|
|
||||||
}
|
|
||||||
|
|
||||||
# Apply filtering if provided
|
# Apply filtering and sorting over full list (Strategy B)
|
||||||
|
component_interface = ComponentObjects()
|
||||||
|
component_interface.setUserContext(currentUser)
|
||||||
if paginationParams.filters:
|
if paginationParams.filters:
|
||||||
component_interface = ComponentObjects()
|
enhanced_connections_dict = component_interface._applyFilters(enhanced_connections_dict, paginationParams.filters)
|
||||||
component_interface.setUserContext(currentUser)
|
|
||||||
enhanced_connections_dict = component_interface._applyFilters(
|
|
||||||
enhanced_connections_dict,
|
|
||||||
paginationParams.filters
|
|
||||||
)
|
|
||||||
|
|
||||||
# Apply sorting if provided
|
|
||||||
if paginationParams.sort:
|
if paginationParams.sort:
|
||||||
component_interface = ComponentObjects()
|
enhanced_connections_dict = component_interface._applySorting(enhanced_connections_dict, paginationParams.sort)
|
||||||
component_interface.setUserContext(currentUser)
|
|
||||||
enhanced_connections_dict = component_interface._applySorting(
|
|
||||||
enhanced_connections_dict,
|
|
||||||
paginationParams.sort
|
|
||||||
)
|
|
||||||
|
|
||||||
totalItems = len(enhanced_connections_dict)
|
totalItems = len(enhanced_connections_dict)
|
||||||
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
|
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
|
||||||
|
|
||||||
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
|
# Strategy B grouping: operates on full filtered+sorted list, then slices
|
||||||
endIdx = startIdx + paginationParams.pageSize
|
page_items, groupLayout = buildGroupLayout(
|
||||||
paged_connections = enhanced_connections_dict[startIdx:endIdx]
|
enhanced_connections_dict, groupByLevels, paginationParams.page, paginationParams.pageSize
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
response: dict = {
|
||||||
"items": paged_connections,
|
"items": page_items,
|
||||||
"pagination": PaginationMetadata(
|
"pagination": PaginationMetadata(
|
||||||
currentPage=paginationParams.page,
|
currentPage=paginationParams.page,
|
||||||
pageSize=paginationParams.pageSize,
|
pageSize=paginationParams.pageSize,
|
||||||
|
|
@ -296,8 +328,12 @@ async def get_connections(
|
||||||
sort=paginationParams.sort,
|
sort=paginationParams.sort,
|
||||||
filters=paginationParams.filters
|
filters=paginationParams.filters
|
||||||
).model_dump(),
|
).model_dump(),
|
||||||
"groupTree": groupCtx.groupTree,
|
|
||||||
}
|
}
|
||||||
|
if groupLayout:
|
||||||
|
response["groupLayout"] = groupLayout.model_dump()
|
||||||
|
if viewMeta:
|
||||||
|
response["appliedView"] = viewMeta.model_dump()
|
||||||
|
return response
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ from fastapi.responses import JSONResponse
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
|
|
||||||
# Import auth module
|
# Import auth module
|
||||||
from modules.auth import limiter, getCurrentUser, getRequestContext, RequestContext
|
from modules.auth import limiter, getCurrentUser, getRequestContext, RequestContext
|
||||||
|
|
@ -500,9 +501,10 @@ def get_files(
|
||||||
from modules.routes.routeHelpers import (
|
from modules.routes.routeHelpers import (
|
||||||
handleIdsMode,
|
handleIdsMode,
|
||||||
handleFilterValuesInMemory,
|
handleFilterValuesInMemory,
|
||||||
handleGroupingInRequest, applyGroupScopeFilter,
|
resolveView, applyViewToParams, buildGroupLayout, effective_group_by_levels,
|
||||||
)
|
)
|
||||||
import modules.interfaces.interfaceDbApp as _appIface
|
import modules.interfaces.interfaceDbApp as _appIface
|
||||||
|
from modules.datamodels.datamodelPagination import AppliedViewMeta
|
||||||
|
|
||||||
managementInterface = interfaceDbManagement.getInterface(
|
managementInterface = interfaceDbManagement.getInterface(
|
||||||
currentUser,
|
currentUser,
|
||||||
|
|
@ -510,11 +512,40 @@ def get_files(
|
||||||
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
|
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
|
||||||
)
|
)
|
||||||
appInterface = _appIface.getInterface(currentUser)
|
appInterface = _appIface.getInterface(currentUser)
|
||||||
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):
|
def _filesToDicts(fileItems):
|
||||||
return [f.model_dump() if hasattr(f, "model_dump") else (dict(f) if not isinstance(f, dict) else f) for f in fileItems]
|
return [f.model_dump() if hasattr(f, "model_dump") else (dict(f) if not isinstance(f, dict) else f) for f in fileItems]
|
||||||
|
|
||||||
|
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 mode == "filterValues":
|
||||||
if not column:
|
if not column:
|
||||||
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
||||||
|
|
@ -522,18 +553,18 @@ def get_files(
|
||||||
items = allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])
|
items = allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])
|
||||||
itemDicts = _filesToDicts(items)
|
itemDicts = _filesToDicts(items)
|
||||||
enrichRowsWithFkLabels(itemDicts, FileItem)
|
enrichRowsWithFkLabels(itemDicts, FileItem)
|
||||||
itemDicts = applyGroupScopeFilter(itemDicts, groupCtx.itemIds)
|
|
||||||
return handleFilterValuesInMemory(itemDicts, column, pagination)
|
return handleFilterValuesInMemory(itemDicts, column, pagination)
|
||||||
|
|
||||||
if mode == "ids":
|
if mode == "ids":
|
||||||
recordFilter = {"sysCreatedBy": managementInterface.userId}
|
recordFilter = {"sysCreatedBy": managementInterface.userId}
|
||||||
return handleIdsMode(managementInterface.db, FileItem, pagination, recordFilter)
|
return handleIdsMode(managementInterface.db, FileItem, pagination, recordFilter)
|
||||||
|
|
||||||
|
if not groupByLevels:
|
||||||
|
# No grouping: let DB handle pagination directly (fastest path)
|
||||||
result = managementInterface.getAllFiles(pagination=paginationParams)
|
result = managementInterface.getAllFiles(pagination=paginationParams)
|
||||||
|
if paginationParams and hasattr(result, 'items'):
|
||||||
if paginationParams:
|
enriched = enrichRowsWithFkLabels(_filesToDicts(result.items), FileItem)
|
||||||
enriched = applyGroupScopeFilter(enrichRowsWithFkLabels(_filesToDicts(result.items), FileItem), groupCtx.itemIds)
|
resp: dict = {
|
||||||
return {
|
|
||||||
"items": enriched,
|
"items": enriched,
|
||||||
"pagination": PaginationMetadata(
|
"pagination": PaginationMetadata(
|
||||||
currentPage=paginationParams.page,
|
currentPage=paginationParams.page,
|
||||||
|
|
@ -543,12 +574,51 @@ def get_files(
|
||||||
sort=paginationParams.sort,
|
sort=paginationParams.sort,
|
||||||
filters=paginationParams.filters
|
filters=paginationParams.filters
|
||||||
).model_dump(),
|
).model_dump(),
|
||||||
"groupTree": groupCtx.groupTree,
|
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
items = result if isinstance(result, list) else (result.items if hasattr(result, "items") else [result])
|
items = result if isinstance(result, list) else (result.items if hasattr(result, "items") else [result])
|
||||||
enriched = applyGroupScopeFilter(enrichRowsWithFkLabels(_filesToDicts(items), FileItem), groupCtx.itemIds)
|
resp = {"items": enrichRowsWithFkLabels(_filesToDicts(items), FileItem), "pagination": None}
|
||||||
return {"items": enriched, "pagination": None, "groupTree": groupCtx.groupTree}
|
if viewMeta:
|
||||||
|
resp["appliedView"] = viewMeta.model_dump()
|
||||||
|
return resp
|
||||||
|
|
||||||
|
# Strategy B grouping: load full list, group, then slice
|
||||||
|
allFiles = managementInterface.getAllFiles()
|
||||||
|
allItems = enrichRowsWithFkLabels(
|
||||||
|
_filesToDicts(allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])),
|
||||||
|
FileItem,
|
||||||
|
)
|
||||||
|
|
||||||
|
from modules.routes.routeHelpers import apply_strategy_b_filters_and_sort
|
||||||
|
if paginationParams.filters or paginationParams.sort:
|
||||||
|
allItems = apply_strategy_b_filters_and_sort(allItems, paginationParams, currentUser)
|
||||||
|
|
||||||
|
if not paginationParams:
|
||||||
|
resp = {"items": allItems, "pagination": None}
|
||||||
|
if viewMeta:
|
||||||
|
resp["appliedView"] = viewMeta.model_dump()
|
||||||
|
return resp
|
||||||
|
|
||||||
|
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:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -559,34 +629,11 @@ def get_files(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _addFileToGroup(appInterface, fileId: str, groupId: str, contextKey: str = "files/list"):
|
def _LEGACY_addFileToGroup_REMOVED():
|
||||||
"""Add a file to a group in the persisted groupTree (upsert)."""
|
"""Removed — file-group tree no longer exists. Use multi-select bulk operations."""
|
||||||
from modules.routes.routeHelpers import _collectItemIds
|
pass
|
||||||
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}")
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/upload", status_code=status.HTTP_201_CREATED)
|
@router.post("/upload", status_code=status.HTTP_201_CREATED)
|
||||||
|
|
@ -596,7 +643,6 @@ async def upload_file(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
workflowId: Optional[str] = Form(None),
|
workflowId: Optional[str] = Form(None),
|
||||||
featureInstanceId: Optional[str] = Form(None),
|
featureInstanceId: Optional[str] = Form(None),
|
||||||
groupId: Optional[str] = Form(None),
|
|
||||||
currentUser: User = Depends(getCurrentUser),
|
currentUser: User = Depends(getCurrentUser),
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
|
|
@ -630,12 +676,6 @@ async def upload_file(
|
||||||
managementInterface.updateFile(fileItem.id, {"featureInstanceId": featureInstanceId})
|
managementInterface.updateFile(fileItem.id, {"featureInstanceId": featureInstanceId})
|
||||||
fileItem.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
|
# Determine response message based on duplicate type
|
||||||
if duplicateType == "exact_duplicate":
|
if duplicateType == "exact_duplicate":
|
||||||
message = f"File '{file.filename}' already exists with identical content. Reusing existing file."
|
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))
|
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:
|
@router.post("/bulk/scope")
|
||||||
"""Collect all file IDs in a group and its sub-groups from the stored groupTree."""
|
@limiter.limit("30/minute")
|
||||||
from modules.routes.routeHelpers import _collectItemIds
|
def bulk_set_scope(
|
||||||
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(
|
|
||||||
request: Request,
|
request: Request,
|
||||||
groupId: str = Path(..., description="Group ID"),
|
|
||||||
body: dict = Body(...),
|
body: dict = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser),
|
currentUser: User = Depends(getCurrentUser),
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
"""Set scope for all files in a group (recursive)."""
|
"""Set scope for a list of files by their IDs."""
|
||||||
scope = body.get("scope")
|
fileIds: list = body.get("fileIds") or []
|
||||||
if not scope:
|
scope: str = body.get("scope") or ""
|
||||||
raise HTTPException(status_code=400, detail="scope is required")
|
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:
|
try:
|
||||||
import modules.interfaces.interfaceDbApp as _appIface
|
|
||||||
managementInterface = interfaceDbManagement.getInterface(
|
managementInterface = interfaceDbManagement.getInterface(
|
||||||
currentUser,
|
currentUser,
|
||||||
mandateId=str(context.mandateId) if context.mandateId else None,
|
mandateId=str(context.mandateId) if context.mandateId else None,
|
||||||
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId 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
|
updated = 0
|
||||||
for fid in fileIds:
|
for fid in fileIds:
|
||||||
try:
|
try:
|
||||||
managementInterface.updateFile(fid, {"scope": scope})
|
managementInterface.updateFile(fid, {"scope": scope})
|
||||||
updated += 1
|
updated += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"patch_group_scope: failed to update file {fid}: {e}")
|
logger.error(f"bulk_set_scope: failed for file {fid}: {e}")
|
||||||
return {"groupId": groupId, "scope": scope, "filesUpdated": updated}
|
return {"scope": scope, "filesUpdated": updated}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
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))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/groups/{groupId}/neutralize")
|
@router.post("/bulk/neutralize")
|
||||||
@limiter.limit("60/minute")
|
@limiter.limit("30/minute")
|
||||||
def patch_group_neutralize(
|
def bulk_set_neutralize(
|
||||||
request: Request,
|
request: Request,
|
||||||
groupId: str = Path(..., description="Group ID"),
|
|
||||||
body: dict = Body(...),
|
body: dict = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser),
|
currentUser: User = Depends(getCurrentUser),
|
||||||
context: RequestContext = Depends(getRequestContext),
|
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")
|
neutralize = body.get("neutralize")
|
||||||
|
if not fileIds:
|
||||||
|
raise HTTPException(status_code=400, detail="fileIds is required")
|
||||||
if neutralize is None:
|
if neutralize is None:
|
||||||
raise HTTPException(status_code=400, detail="neutralize is required")
|
raise HTTPException(status_code=400, detail="neutralize is required")
|
||||||
try:
|
try:
|
||||||
import modules.interfaces.interfaceDbApp as _appIface
|
|
||||||
managementInterface = interfaceDbManagement.getInterface(
|
managementInterface = interfaceDbManagement.getInterface(
|
||||||
currentUser,
|
currentUser,
|
||||||
mandateId=str(context.mandateId) if context.mandateId else None,
|
mandateId=str(context.mandateId) if context.mandateId else None,
|
||||||
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId 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
|
updated = 0
|
||||||
for fid in fileIds:
|
for fid in fileIds:
|
||||||
try:
|
try:
|
||||||
|
|
@ -929,39 +955,37 @@ def patch_group_neutralize(
|
||||||
kIface = interfaceDbKnowledge.getInterface(currentUser)
|
kIface = interfaceDbKnowledge.getInterface(currentUser)
|
||||||
kIface.purgeFileKnowledge(fid)
|
kIface.purgeFileKnowledge(fid)
|
||||||
except Exception as ke:
|
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
|
updated += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"patch_group_neutralize: failed for file {fid}: {e}")
|
logger.error(f"bulk_set_neutralize: failed for file {fid}: {e}")
|
||||||
return {"groupId": groupId, "neutralize": neutralize, "filesUpdated": updated}
|
return {"neutralize": neutralize, "filesUpdated": updated}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
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))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/groups/{groupId}/download")
|
@router.post("/bulk/download-zip")
|
||||||
@limiter.limit("20/minute")
|
@limiter.limit("10/minute")
|
||||||
async def download_group_zip(
|
async def bulk_download_zip(
|
||||||
request: Request,
|
request: Request,
|
||||||
groupId: str = Path(..., description="Group ID"),
|
body: dict = Body(...),
|
||||||
currentUser: User = Depends(getCurrentUser),
|
currentUser: User = Depends(getCurrentUser),
|
||||||
context: RequestContext = Depends(getRequestContext),
|
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
|
import io, zipfile
|
||||||
|
fileIds: list = body.get("fileIds") or []
|
||||||
|
if not fileIds:
|
||||||
|
raise HTTPException(status_code=400, detail="fileIds is required")
|
||||||
try:
|
try:
|
||||||
import modules.interfaces.interfaceDbApp as _appIface
|
|
||||||
managementInterface = interfaceDbManagement.getInterface(
|
managementInterface = interfaceDbManagement.getInterface(
|
||||||
currentUser,
|
currentUser,
|
||||||
mandateId=str(context.mandateId) if context.mandateId else None,
|
mandateId=str(context.mandateId) if context.mandateId else None,
|
||||||
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId 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()
|
buf = io.BytesIO()
|
||||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
for fid in fileIds:
|
for fid in fileIds:
|
||||||
|
|
@ -969,63 +993,21 @@ async def download_group_zip(
|
||||||
fileMeta = managementInterface.getFile(fid)
|
fileMeta = managementInterface.getFile(fid)
|
||||||
fileData = managementInterface.getFileData(fid)
|
fileData = managementInterface.getFileData(fid)
|
||||||
if fileMeta and fileData:
|
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)
|
zf.writestr(name, fileData)
|
||||||
except Exception as fe:
|
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)
|
buf.seek(0)
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
buf,
|
buf,
|
||||||
media_type="application/zip",
|
media_type="application/zip",
|
||||||
headers={"Content-Disposition": f'attachment; filename="group-{groupId}.zip"'},
|
headers={"Content-Disposition": 'attachment; filename="files.zip"'},
|
||||||
)
|
)
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"download_group_zip error: {e}")
|
logger.error(f"bulk_download_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}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -131,11 +131,9 @@ def get_mandates(
|
||||||
handleFilterValuesInMemory, handleIdsInMemory,
|
handleFilterValuesInMemory, handleIdsInMemory,
|
||||||
handleFilterValuesMode, handleIdsMode,
|
handleFilterValuesMode, handleIdsMode,
|
||||||
parseCrossFilterPagination,
|
parseCrossFilterPagination,
|
||||||
handleGroupingInRequest, applyGroupScopeFilter,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
appInterface = interfaceDbApp.getRootInterface()
|
appInterface = interfaceDbApp.getRootInterface()
|
||||||
groupCtx = handleGroupingInRequest(paginationParams, appInterface, "mandates")
|
|
||||||
|
|
||||||
def _mandateItemsForAdmin():
|
def _mandateItemsForAdmin():
|
||||||
items = []
|
items = []
|
||||||
|
|
@ -154,23 +152,18 @@ def get_mandates(
|
||||||
values = appInterface.db.getDistinctColumnValues(Mandate, column, crossPagination)
|
values = appInterface.db.getDistinctColumnValues(Mandate, column, crossPagination)
|
||||||
return JSONResponse(content=sorted(values, key=lambda v: str(v).lower()))
|
return JSONResponse(content=sorted(values, key=lambda v: str(v).lower()))
|
||||||
else:
|
else:
|
||||||
mandateItems = applyGroupScopeFilter(_mandateItemsForAdmin(), groupCtx.itemIds)
|
return handleFilterValuesInMemory(_mandateItemsForAdmin(), column, pagination)
|
||||||
return handleFilterValuesInMemory(mandateItems, column, pagination)
|
|
||||||
|
|
||||||
if mode == "ids":
|
if mode == "ids":
|
||||||
if isPlatformAdmin:
|
if isPlatformAdmin:
|
||||||
return handleIdsMode(appInterface.db, Mandate, pagination)
|
return handleIdsMode(appInterface.db, Mandate, pagination)
|
||||||
else:
|
else:
|
||||||
mandateItems = applyGroupScopeFilter(_mandateItemsForAdmin(), groupCtx.itemIds)
|
return handleIdsInMemory(_mandateItemsForAdmin(), pagination)
|
||||||
return handleIdsInMemory(mandateItems, pagination)
|
|
||||||
|
|
||||||
if isPlatformAdmin:
|
if isPlatformAdmin:
|
||||||
result = appInterface.getAllMandates(pagination=paginationParams)
|
result = appInterface.getAllMandates(pagination=paginationParams)
|
||||||
items = result.items if hasattr(result, 'items') else (result if isinstance(result, list) else [])
|
items = result.items if hasattr(result, 'items') else (result if isinstance(result, list) else [])
|
||||||
items = applyGroupScopeFilter(
|
items = [i.model_dump() if hasattr(i, 'model_dump') else (i if isinstance(i, dict) else vars(i)) for i in items]
|
||||||
[i.model_dump() if hasattr(i, 'model_dump') else (i if isinstance(i, dict) else vars(i)) for i in items],
|
|
||||||
groupCtx.itemIds,
|
|
||||||
)
|
|
||||||
if paginationParams and hasattr(result, 'items'):
|
if paginationParams and hasattr(result, 'items'):
|
||||||
return PaginatedResponse(
|
return PaginatedResponse(
|
||||||
items=items,
|
items=items,
|
||||||
|
|
@ -182,13 +175,11 @@ def get_mandates(
|
||||||
sort=paginationParams.sort,
|
sort=paginationParams.sort,
|
||||||
filters=paginationParams.filters
|
filters=paginationParams.filters
|
||||||
),
|
),
|
||||||
groupTree=groupCtx.groupTree,
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return PaginatedResponse(items=items, pagination=None, groupTree=groupCtx.groupTree)
|
return PaginatedResponse(items=items, pagination=None)
|
||||||
else:
|
else:
|
||||||
mandateItems = applyGroupScopeFilter(_mandateItemsForAdmin(), groupCtx.itemIds)
|
return PaginatedResponse(items=_mandateItemsForAdmin(), pagination=None)
|
||||||
return PaginatedResponse(items=mandateItems, pagination=None, groupTree=groupCtx.groupTree)
|
|
||||||
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Query
|
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Query
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
from fastapi import status
|
from fastapi import status
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
|
|
||||||
# Import auth module
|
# Import auth module
|
||||||
from modules.auth import limiter, getCurrentUser
|
from modules.auth import limiter, getCurrentUser
|
||||||
|
|
@ -46,13 +48,13 @@ def get_prompts(
|
||||||
"""
|
"""
|
||||||
from modules.routes.routeHelpers import (
|
from modules.routes.routeHelpers import (
|
||||||
handleFilterValuesInMemory, handleIdsInMemory, enrichRowsWithFkLabels,
|
handleFilterValuesInMemory, handleIdsInMemory, enrichRowsWithFkLabels,
|
||||||
handleGroupingInRequest, applyGroupScopeFilter,
|
resolveView, applyViewToParams, buildGroupLayout, effective_group_by_levels,
|
||||||
)
|
)
|
||||||
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
||||||
|
from modules.datamodels.datamodelPagination import AppliedViewMeta
|
||||||
|
|
||||||
CONTEXT_KEY = "prompts"
|
CONTEXT_KEY = "prompts"
|
||||||
|
|
||||||
# Parse pagination params early — needed for grouping in all modes
|
|
||||||
paginationParams = None
|
paginationParams = None
|
||||||
if pagination:
|
if pagination:
|
||||||
try:
|
try:
|
||||||
|
|
@ -64,7 +66,13 @@ def get_prompts(
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}")
|
raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(e)}")
|
||||||
|
|
||||||
appInterface = getAppInterface(currentUser)
|
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):
|
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]
|
dicts = [r.model_dump() if hasattr(r, 'model_dump') else (dict(r) if not isinstance(r, dict) else r) for r in promptItems]
|
||||||
|
|
@ -73,26 +81,44 @@ def get_prompts(
|
||||||
|
|
||||||
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
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 mode == "filterValues":
|
||||||
if not column:
|
if not column:
|
||||||
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
||||||
result = managementInterface.getAllPrompts(pagination=None)
|
result = managementInterface.getAllPrompts(pagination=None)
|
||||||
items = _promptsToEnrichedDicts(result)
|
return handleFilterValuesInMemory(_promptsToEnrichedDicts(result), column, pagination)
|
||||||
items = applyGroupScopeFilter(items, groupCtx.itemIds)
|
|
||||||
return handleFilterValuesInMemory(items, column, pagination)
|
|
||||||
|
|
||||||
if mode == "ids":
|
if mode == "ids":
|
||||||
result = managementInterface.getAllPrompts(pagination=None)
|
result = managementInterface.getAllPrompts(pagination=None)
|
||||||
items = _promptsToEnrichedDicts(result)
|
return handleIdsInMemory(_promptsToEnrichedDicts(result), pagination)
|
||||||
items = applyGroupScopeFilter(items, groupCtx.itemIds)
|
|
||||||
return handleIdsInMemory(items, pagination)
|
|
||||||
|
|
||||||
|
if not groupByLevels:
|
||||||
|
# No grouping: let DB handle pagination directly
|
||||||
result = managementInterface.getAllPrompts(pagination=paginationParams)
|
result = managementInterface.getAllPrompts(pagination=paginationParams)
|
||||||
|
if paginationParams and hasattr(result, 'items'):
|
||||||
if paginationParams:
|
response: dict = {
|
||||||
items = applyGroupScopeFilter(_promptsToEnrichedDicts(result.items), groupCtx.itemIds)
|
"items": _promptsToEnrichedDicts(result.items),
|
||||||
return {
|
|
||||||
"items": items,
|
|
||||||
"pagination": PaginationMetadata(
|
"pagination": PaginationMetadata(
|
||||||
currentPage=paginationParams.page,
|
currentPage=paginationParams.page,
|
||||||
pageSize=paginationParams.pageSize,
|
pageSize=paginationParams.pageSize,
|
||||||
|
|
@ -101,15 +127,52 @@ def get_prompts(
|
||||||
sort=paginationParams.sort,
|
sort=paginationParams.sort,
|
||||||
filters=paginationParams.filters
|
filters=paginationParams.filters
|
||||||
).model_dump(),
|
).model_dump(),
|
||||||
"groupTree": groupCtx.groupTree,
|
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
items = applyGroupScopeFilter(_promptsToEnrichedDicts(result), groupCtx.itemIds)
|
response = {"items": _promptsToEnrichedDicts(result if isinstance(result, list) else [result]), "pagination": None}
|
||||||
return {
|
if viewMeta:
|
||||||
"items": items,
|
response["appliedView"] = viewMeta.model_dump()
|
||||||
"pagination": None,
|
return response
|
||||||
"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)
|
@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/ (no pagination - returns all users in mandate)
|
||||||
- GET /api/users/?pagination={"page":1,"pageSize":10,"sort":[]}
|
- GET /api/users/?pagination={"page":1,"pageSize":10,"sort":[]}
|
||||||
"""
|
"""
|
||||||
# Parse pagination early — needed for grouping in all modes
|
|
||||||
_paginationParams = None
|
_paginationParams = None
|
||||||
if pagination:
|
if pagination:
|
||||||
try:
|
try:
|
||||||
|
|
@ -219,10 +218,6 @@ def get_users(
|
||||||
except (json.JSONDecodeError, ValueError) as e:
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid pagination parameter: {str(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 mode == "filterValues":
|
||||||
if not column:
|
if not column:
|
||||||
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
||||||
|
|
@ -233,14 +228,12 @@ def get_users(
|
||||||
|
|
||||||
try:
|
try:
|
||||||
paginationParams = _paginationParams
|
paginationParams = _paginationParams
|
||||||
appInterface = _appInterfaceForGrouping
|
appInterface = interfaceDbApp.getInterface(context.user, mandateId=context.mandateId)
|
||||||
|
|
||||||
if context.mandateId:
|
if context.mandateId:
|
||||||
# Get users for specific mandate using getUsersByMandate
|
|
||||||
result = appInterface.getUsersByMandate(str(context.mandateId), paginationParams)
|
result = appInterface.getUsersByMandate(str(context.mandateId), paginationParams)
|
||||||
|
|
||||||
if paginationParams and hasattr(result, 'items'):
|
if paginationParams and hasattr(result, 'items'):
|
||||||
enriched = _applyGroupScope(enrichRowsWithFkLabels(_usersToDicts(result.items), User), _groupCtx.itemIds)
|
enriched = enrichRowsWithFkLabels(_usersToDicts(result.items), User)
|
||||||
return {
|
return {
|
||||||
"items": enriched,
|
"items": enriched,
|
||||||
"pagination": PaginationMetadata(
|
"pagination": PaginationMetadata(
|
||||||
|
|
@ -251,18 +244,14 @@ def get_users(
|
||||||
sort=paginationParams.sort,
|
sort=paginationParams.sort,
|
||||||
filters=paginationParams.filters
|
filters=paginationParams.filters
|
||||||
).model_dump(),
|
).model_dump(),
|
||||||
"groupTree": _groupCtx.groupTree,
|
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
users = result if isinstance(result, list) else result.items if hasattr(result, 'items') 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": enrichRowsWithFkLabels(_usersToDicts(users), User), "pagination": None}
|
||||||
return {"items": enriched, "pagination": None, "groupTree": _groupCtx.groupTree}
|
|
||||||
elif context.isPlatformAdmin:
|
elif context.isPlatformAdmin:
|
||||||
# PlatformAdmin without mandateId — DB-level pagination via interface
|
|
||||||
result = appInterface.getAllUsers(paginationParams)
|
result = appInterface.getAllUsers(paginationParams)
|
||||||
|
|
||||||
if paginationParams and hasattr(result, 'items'):
|
if paginationParams and hasattr(result, 'items'):
|
||||||
enriched = _applyGroupScope(enrichRowsWithFkLabels(_usersToDicts(result.items), User), _groupCtx.itemIds)
|
enriched = enrichRowsWithFkLabels(_usersToDicts(result.items), User)
|
||||||
return {
|
return {
|
||||||
"items": enriched,
|
"items": enriched,
|
||||||
"pagination": PaginationMetadata(
|
"pagination": PaginationMetadata(
|
||||||
|
|
@ -273,18 +262,13 @@ def get_users(
|
||||||
sort=paginationParams.sort,
|
sort=paginationParams.sort,
|
||||||
filters=paginationParams.filters
|
filters=paginationParams.filters
|
||||||
).model_dump(),
|
).model_dump(),
|
||||||
"groupTree": _groupCtx.groupTree,
|
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
users = result if isinstance(result, list) else (result.items if hasattr(result, 'items') 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": enrichRowsWithFkLabels(_usersToDicts(users), User), "pagination": None}
|
||||||
return {"items": enriched, "pagination": None, "groupTree": _groupCtx.groupTree}
|
|
||||||
else:
|
else:
|
||||||
# Non-SysAdmin without mandateId: aggregate users across all admin mandates
|
|
||||||
rootInterface = getRootInterface()
|
rootInterface = getRootInterface()
|
||||||
userMandates = rootInterface.getUserMandates(str(context.user.id))
|
userMandates = rootInterface.getUserMandates(str(context.user.id))
|
||||||
|
|
||||||
# Find mandates where user has admin role
|
|
||||||
adminMandateIds = []
|
adminMandateIds = []
|
||||||
for um in userMandates:
|
for um in userMandates:
|
||||||
umId = getattr(um, 'id', None)
|
umId = getattr(um, 'id', None)
|
||||||
|
|
@ -299,10 +283,7 @@ def get_users(
|
||||||
break
|
break
|
||||||
|
|
||||||
if not adminMandateIds:
|
if not adminMandateIds:
|
||||||
raise HTTPException(
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("No admin access to any mandate"))
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail=routeApiMsg("No admin access to any mandate")
|
|
||||||
)
|
|
||||||
|
|
||||||
from modules.datamodels.datamodelMembership import UserMandate as UserMandateModel
|
from modules.datamodels.datamodelMembership import UserMandate as UserMandateModel
|
||||||
allUM = rootInterface.db.getRecordset(UserMandateModel, recordFilter={"mandateId": adminMandateIds})
|
allUM = rootInterface.db.getRecordset(UserMandateModel, recordFilter={"mandateId": adminMandateIds})
|
||||||
|
|
@ -312,13 +293,10 @@ def get_users(
|
||||||
if (um.get("userId") if isinstance(um, dict) else getattr(um, "userId", None))
|
if (um.get("userId") if isinstance(um, dict) else getattr(um, "userId", None))
|
||||||
})
|
})
|
||||||
batchUsers = rootInterface.getUsersByIds(uniqueUserIds) if uniqueUserIds else {}
|
batchUsers = rootInterface.getUsersByIds(uniqueUserIds) if uniqueUserIds else {}
|
||||||
allUsers = [
|
allUsers = [u.model_dump() if hasattr(u, 'model_dump') else vars(u) for u in batchUsers.values()]
|
||||||
u.model_dump() if hasattr(u, 'model_dump') else vars(u)
|
|
||||||
for u in batchUsers.values()
|
|
||||||
]
|
|
||||||
|
|
||||||
from modules.routes.routeHelpers import applyFiltersAndSort as _applyFiltersAndSortHelper
|
from modules.routes.routeHelpers import applyFiltersAndSort as _applyFiltersAndSortHelper
|
||||||
filteredUsers = _applyGroupScope(_applyFiltersAndSortHelper(allUsers, paginationParams), _groupCtx.itemIds)
|
filteredUsers = _applyFiltersAndSortHelper(allUsers, paginationParams)
|
||||||
enriched = enrichRowsWithFkLabels(filteredUsers, User)
|
enriched = enrichRowsWithFkLabels(filteredUsers, User)
|
||||||
|
|
||||||
if paginationParams:
|
if paginationParams:
|
||||||
|
|
@ -327,7 +305,6 @@ def get_users(
|
||||||
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
|
totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0
|
||||||
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
|
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
|
||||||
endIdx = startIdx + paginationParams.pageSize
|
endIdx = startIdx + paginationParams.pageSize
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"items": enriched[startIdx:endIdx],
|
"items": enriched[startIdx:endIdx],
|
||||||
"pagination": PaginationMetadata(
|
"pagination": PaginationMetadata(
|
||||||
|
|
@ -338,10 +315,9 @@ def get_users(
|
||||||
sort=paginationParams.sort,
|
sort=paginationParams.sort,
|
||||||
filters=paginationParams.filters
|
filters=paginationParams.filters
|
||||||
).model_dump(),
|
).model_dump(),
|
||||||
"groupTree": _groupCtx.groupTree,
|
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
return {"items": enriched, "pagination": None, "groupTree": _groupCtx.groupTree}
|
return {"items": enriched, "pagination": None}
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
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
|
def resolveView(interface, contextKey: str, viewKey: Optional[str]):
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class GroupingContext:
|
|
||||||
"""
|
"""
|
||||||
Result of handleGroupingInRequest.
|
Load a TableListView for the current user and contextKey.
|
||||||
Carries the group tree for the response and the resolved item-ID set for
|
|
||||||
group-scope filtering (None = no active group scope).
|
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
|
from fastapi import HTTPException
|
||||||
itemIds: Optional[set] # Set[str] when groupId was set, else None
|
if not viewKey:
|
||||||
|
return None, None
|
||||||
|
|
||||||
def _collectItemIds(nodes: list, groupId: str) -> Optional[set]:
|
|
||||||
"""
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
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:
|
|
||||||
"""
|
|
||||||
Central grouping handler — call at the start of every list route that
|
|
||||||
supports table grouping.
|
|
||||||
|
|
||||||
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).
|
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
from modules.datamodels.datamodelPagination import TableGroupNode
|
|
||||||
|
|
||||||
groupTree = None
|
|
||||||
itemIds = None
|
|
||||||
|
|
||||||
if paginationParams is None:
|
|
||||||
try:
|
try:
|
||||||
existing = interface.getTableGrouping(contextKey)
|
view = interface.getTableListView(contextKey=contextKey, viewKey=viewKey)
|
||||||
if existing:
|
|
||||||
groupTree = [n.model_dump() if hasattr(n, "model_dump") else n for n in existing.rootGroups]
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"handleGroupingInRequest: getTableGrouping failed: {e}")
|
logger.warning(f"resolveView: store lookup failed for key={viewKey!r} context={contextKey!r}: {e}")
|
||||||
return GroupingContext(groupTree=groupTree, itemIds=None)
|
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
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# Step 2: load current tree (only if not already set from save above)
|
def effective_group_by_levels(
|
||||||
if groupTree is None:
|
pagination_params: Optional["PaginationParams"],
|
||||||
try:
|
view_config: Optional[dict],
|
||||||
existing = interface.getTableGrouping(contextKey)
|
) -> List[Dict[str, Any]]:
|
||||||
if existing:
|
"""
|
||||||
groupTree = [n.model_dump() if hasattr(n, "model_dump") else n for n in existing.rootGroups]
|
Choose grouping levels for this request.
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"handleGroupingInRequest: getTableGrouping failed: {e}")
|
|
||||||
|
|
||||||
# Step 3: resolve groupId to itemIds set
|
If the client sends ``groupByLevels`` (including ``[]``), it wins over the
|
||||||
if paginationParams.groupId is not None:
|
saved view. If the key is omitted (``None``), use the view's levels.
|
||||||
targetGroupId = paginationParams.groupId
|
"""
|
||||||
paginationParams.groupId = None # remove so it is not treated as a normal filter
|
if pagination_params is not None:
|
||||||
if groupTree:
|
req = getattr(pagination_params, "groupByLevels", None)
|
||||||
itemIds = _collectItemIds(groupTree, targetGroupId)
|
if req is not None:
|
||||||
if itemIds is None:
|
out: List[Dict[str, Any]] = []
|
||||||
logger.warning(
|
for lvl in req:
|
||||||
f"handleGroupingInRequest: groupId={targetGroupId!r} not found in tree "
|
if hasattr(lvl, "model_dump"):
|
||||||
f"for contextKey={contextKey!r} — returning empty set"
|
out.append(lvl.model_dump())
|
||||||
)
|
elif isinstance(lvl, dict):
|
||||||
itemIds = set() # unknown group → show nothing rather than everything
|
out.append(dict(lvl))
|
||||||
else:
|
else:
|
||||||
# groupId sent but no tree saved yet → return empty (nothing belongs to any group)
|
out.append(dict(lvl)) # type: ignore[arg-type]
|
||||||
logger.warning(
|
return out
|
||||||
f"handleGroupingInRequest: groupId={targetGroupId!r} set but no tree exists "
|
vc = (view_config or {}).get("groupByLevels") if view_config else None
|
||||||
f"for contextKey={contextKey!r} — returning empty set"
|
return list(vc or [])
|
||||||
|
|
||||||
|
|
||||||
|
def applyViewToParams(params: Optional["PaginationParams"], viewConfig: Optional[dict]) -> Optional["PaginationParams"]:
|
||||||
|
"""
|
||||||
|
Merge a view's saved configuration into PaginationParams.
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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 PaginationParams, SortField
|
||||||
|
if not viewConfig:
|
||||||
|
return params
|
||||||
|
|
||||||
|
if params is None:
|
||||||
|
params = PaginationParams(page=1, pageSize=25)
|
||||||
|
|
||||||
|
# Sort: request wins if non-empty
|
||||||
|
if not params.sort and viewConfig.get("sort"):
|
||||||
|
try:
|
||||||
|
params.sort = [
|
||||||
|
SortField(**s) if isinstance(s, dict) else s
|
||||||
|
for s in viewConfig["sort"]
|
||||||
|
]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"applyViewToParams: could not parse view sort: {e}")
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
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
|
||||||
|
|
||||||
|
ordered_keys = sorted(
|
||||||
|
counts.keys(),
|
||||||
|
key=lambda x: (x == null_key, str(display_by_key.get(x, x)).lower()),
|
||||||
)
|
)
|
||||||
itemIds = set()
|
return [
|
||||||
|
{
|
||||||
return GroupingContext(groupTree=groupTree, itemIds=itemIds)
|
"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.
|
Apply multi-level grouping to all_items, slice to the requested page,
|
||||||
Returns items unchanged when itemIds is None (no active group scope).
|
and return (page_items, GroupLayout | None).
|
||||||
Works for both normal list items and for mode=ids / mode=filterValues flows
|
|
||||||
— call it before handleIdsInMemory / handleFilterValuesInMemory.
|
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:
|
from functools import cmp_to_key
|
||||||
return items
|
from modules.datamodels.datamodelPagination import GroupBand, GroupLayout
|
||||||
return [item for item in items if str(item.get("id", "")) in itemIds]
|
|
||||||
|
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
|
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."""
|
"""Compute current usage metrics for a mandate's subscription."""
|
||||||
try:
|
try:
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
from modules.datamodels.datamodelMembership import UserMandate
|
from modules.datamodels.datamodelMembership import UserMandate
|
||||||
from modules.datamodels.datamodelFeatures import FeatureInstance
|
from modules.datamodels.datamodelFeatures import FeatureInstance
|
||||||
from modules.interfaces.interfaceDbKnowledge import aggregateMandateRagTotalBytes
|
from modules.interfaces.interfaceDbKnowledge import aggregateMandateRagTotalBytes
|
||||||
|
from modules.datamodels.datamodelSubscription import getEffectiveLimits
|
||||||
|
|
||||||
rootIf = getRootInterface()
|
rootIf = getRootInterface()
|
||||||
|
|
||||||
|
|
@ -128,7 +129,8 @@ def _computeUsage(mandateId: str, plan) -> SubscriptionUsage:
|
||||||
ragBytes = aggregateMandateRagTotalBytes(mandateId)
|
ragBytes = aggregateMandateRagTotalBytes(mandateId)
|
||||||
usedMB = round(ragBytes / (1024 * 1024), 2)
|
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
|
storagePercent = round((usedMB / maxMB) * 100, 1) if maxMB else None
|
||||||
|
|
||||||
return SubscriptionUsage(
|
return SubscriptionUsage(
|
||||||
|
|
@ -207,7 +209,7 @@ def getStatus(request: Request, context: RequestContext = Depends(getRequestCont
|
||||||
|
|
||||||
plan = subService.getPlan(operative.get("planKey", ""))
|
plan = subService.getPlan(operative.get("planKey", ""))
|
||||||
|
|
||||||
usage = _computeUsage(mandateId, plan)
|
usage = _computeUsage(mandateId, plan, operative)
|
||||||
|
|
||||||
return SubscriptionStatusResponse(
|
return SubscriptionStatusResponse(
|
||||||
active=True,
|
active=True,
|
||||||
|
|
@ -451,10 +453,13 @@ def _buildEnrichedSubscriptions() -> List[Dict[str, Any]]:
|
||||||
sub["planTitle"] = resolveText(plan.title) if plan else planKey
|
sub["planTitle"] = resolveText(plan.title) if plan else planKey
|
||||||
|
|
||||||
if sub.get("status") in operativeValues:
|
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)
|
userCount = userCountMap.get(mid, 0)
|
||||||
instanceCount = instanceCountMap.get(mid, 0)
|
instanceCount = instanceCountMap.get(mid, 0)
|
||||||
|
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
|
includedModules = plan.includedModules if plan else 0
|
||||||
billableModules = max(0, instanceCount - includedModules)
|
billableModules = max(0, instanceCount - includedModules)
|
||||||
sub["monthlyRevenueCHF"] = round(userPrice * userCount + instPrice * billableModules, 2)
|
sub["monthlyRevenueCHF"] = round(userPrice * userCount + instPrice * billableModules, 2)
|
||||||
|
|
@ -570,13 +575,16 @@ def _getDataVolumeUsage(
|
||||||
ragBytes = aggregateMandateRagTotalBytes(mandateId)
|
ragBytes = aggregateMandateRagTotalBytes(mandateId)
|
||||||
ragMB = round(ragBytes / (1024 * 1024), 2)
|
ragMB = round(ragBytes / (1024 * 1024), 2)
|
||||||
|
|
||||||
|
from modules.datamodels.datamodelSubscription import getEffectiveLimits
|
||||||
|
|
||||||
maxMB = None
|
maxMB = None
|
||||||
subIf = _getSubRootIf()
|
subIf = _getSubRootIf()
|
||||||
operative = subIf.getOperativeForMandate(mandateId)
|
operative = subIf.getOperativeForMandate(mandateId)
|
||||||
if operative:
|
if operative:
|
||||||
plan = subIf.getPlan(operative.get("planKey") or "")
|
plan = subIf.getPlan(operative.get("planKey") or "")
|
||||||
if plan and plan.maxDataVolumeMB is not None:
|
limits = getEffectiveLimits(operative, plan)
|
||||||
maxMB = int(plan.maxDataVolumeMB)
|
if limits["maxDataVolumeMB"] is not None:
|
||||||
|
maxMB = int(limits["maxDataVolumeMB"])
|
||||||
|
|
||||||
usedMB = ragMB
|
usedMB = ragMB
|
||||||
percentUsed = round((usedMB / maxMB) * 100, 1) if maxMB else None
|
percentUsed = round((usedMB / maxMB) * 100, 1) if maxMB else None
|
||||||
|
|
@ -593,3 +601,147 @@ def _getDataVolumeUsage(
|
||||||
"percentUsed": percentUsed,
|
"percentUsed": percentUsed,
|
||||||
"warning": (percentUsed or 0) >= 80,
|
"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"]
|
args["featureInstanceId"] = context["featureInstanceId"]
|
||||||
if "mandateId" not in args and context.get("mandateId"):
|
if "mandateId" not in args and context.get("mandateId"):
|
||||||
args["mandateId"] = context["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)
|
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(
|
return ToolResult(
|
||||||
toolCallId="",
|
toolCallId="",
|
||||||
toolName=f"{methodName}_{actionName}",
|
toolName=f"{methodName}_{actionName}",
|
||||||
success=result.success,
|
success=result.success,
|
||||||
data=data,
|
data=data,
|
||||||
error=result.error
|
error=result.error,
|
||||||
|
sideEvents=sideEvents or None,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
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}")
|
logger.error(f"ActionToolAdapter dispatch failed for {methodName}_{actionName}: {e}")
|
||||||
return ToolResult(
|
return ToolResult(
|
||||||
toolCallId="",
|
toolCallId="",
|
||||||
|
|
@ -226,11 +250,12 @@ def _createDispatchHandler(actionExecutor, methodName: str, actionName: str, ser
|
||||||
_INLINE_CONTENT_LIMIT = 2000
|
_INLINE_CONTENT_LIMIT = 2000
|
||||||
|
|
||||||
|
|
||||||
def _persistLargeDocument(doc, services, context: Dict[str, Any]) -> Optional[str]:
|
def _persistLargeDocument(doc, services, context: Dict[str, Any]) -> Optional[Dict[str, Any]]:
|
||||||
"""Save an ActionDocument with large content as a workspace file.
|
"""Save an ActionDocument as a workspace file.
|
||||||
|
|
||||||
Returns a formatted result line (with file id + docItem ref) or None
|
Handles both str and bytes documentData.
|
||||||
if persistence is not possible.
|
Returns a dict with 'line' (formatted result) and 'fileInfo' (for sideEvents),
|
||||||
|
or None if persistence is not possible.
|
||||||
"""
|
"""
|
||||||
if not services:
|
if not services:
|
||||||
return None
|
return None
|
||||||
|
|
@ -238,49 +263,77 @@ def _persistLargeDocument(doc, services, context: Dict[str, Any]) -> Optional[st
|
||||||
if not chatService:
|
if not chatService:
|
||||||
return None
|
return None
|
||||||
docData = getattr(doc, "documentData", 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
|
return None
|
||||||
docName = getattr(doc, "documentName", "unnamed")
|
docName = getattr(doc, "documentName", "unnamed")
|
||||||
docBytes = docData.encode("utf-8")
|
docMime = getattr(doc, "mimeType", "application/octet-stream")
|
||||||
try:
|
try:
|
||||||
fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(docBytes, docName)
|
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 (
|
from modules.serviceCenter.services.serviceAgent.coreTools._helpers import (
|
||||||
_attachFileAsChatDocument,
|
_attachFileAsChatDocument,
|
||||||
_formatToolFileResult,
|
_formatToolFileResult,
|
||||||
_getOrCreateTempFolder,
|
_getOrCreateTempFolder,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
updateFields = {}
|
||||||
tempFolderId = _getOrCreateTempFolder(chatService)
|
tempFolderId = _getOrCreateTempFolder(chatService)
|
||||||
if tempFolderId:
|
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(
|
chatDocId = _attachFileAsChatDocument(
|
||||||
services, fileItem,
|
services, fileItem,
|
||||||
label=f"action_doc:{docName}",
|
label=f"action_doc:{docName}",
|
||||||
userMessage=f"Action document: {docName}",
|
userMessage=f"Action document: {docName}",
|
||||||
)
|
)
|
||||||
return _formatToolFileResult(
|
line = _formatToolFileResult(
|
||||||
fileItem=fileItem,
|
fileItem=fileItem,
|
||||||
chatDocId=chatDocId,
|
chatDocId=chatDocId,
|
||||||
actionLabel="Produced",
|
actionLabel="Produced",
|
||||||
extraInfo="Use readFile to read the content.",
|
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:
|
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
|
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.
|
"""Format an ActionResult into a text representation for the agent.
|
||||||
|
|
||||||
Documents whose content exceeds the inline limit are persisted as
|
Documents whose content exceeds the inline limit (or is binary bytes)
|
||||||
workspace files so the agent can access them via readFile /
|
are persisted as workspace files.
|
||||||
ai_process / searchInFileContent.
|
|
||||||
|
Returns (str, list) – the formatted text and a list of sideEvent dicts.
|
||||||
"""
|
"""
|
||||||
parts = []
|
parts = []
|
||||||
|
sideEvents = []
|
||||||
ctx = context or {}
|
ctx = context or {}
|
||||||
|
|
||||||
if result.resultLabel:
|
if result.resultLabel:
|
||||||
|
|
@ -296,12 +349,22 @@ def _formatActionResult(result, services=None, context: Optional[Dict[str, Any]]
|
||||||
docType = getattr(doc, "mimeType", "unknown")
|
docType = getattr(doc, "mimeType", "unknown")
|
||||||
docData = getattr(doc, "documentData", None)
|
docData = getattr(doc, "documentData", None)
|
||||||
|
|
||||||
isLarge = docData and isinstance(docData, str) and len(docData) >= _INLINE_CONTENT_LIMIT
|
needsPersist = (
|
||||||
if isLarge:
|
(isinstance(docData, bytes) and len(docData) > 0) or
|
||||||
persistedLine = _persistLargeDocument(doc, services, ctx)
|
(isinstance(docData, str) and len(docData) >= _INLINE_CONTENT_LIMIT)
|
||||||
if persistedLine:
|
)
|
||||||
|
if needsPersist:
|
||||||
|
persisted = _persistLargeDocument(doc, services, ctx)
|
||||||
|
if persisted:
|
||||||
parts.append(f" - {docName} ({docType})")
|
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
|
continue
|
||||||
|
|
||||||
parts.append(f" - {docName} ({docType})")
|
parts.append(f" - {docName} ({docType})")
|
||||||
|
|
@ -311,4 +374,4 @@ def _formatActionResult(result, services=None, context: Optional[Dict[str, Any]]
|
||||||
if not parts:
|
if not parts:
|
||||||
parts.append("Action completed successfully." if result.success else "Action failed.")
|
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__)
|
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(
|
async def runAgentLoop(
|
||||||
prompt: str,
|
prompt: str,
|
||||||
toolRegistry: ToolRegistry,
|
toolRegistry: ToolRegistry,
|
||||||
|
|
@ -75,6 +113,11 @@ async def runAgentLoop(
|
||||||
featureInstanceId=featureInstanceId
|
featureInstanceId=featureInstanceId
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if config and config.excludeAllTools:
|
||||||
|
tools = []
|
||||||
|
toolDefinitions = None
|
||||||
|
toolsText = ""
|
||||||
|
else:
|
||||||
activeToolSet = config.toolSet if config else None
|
activeToolSet = config.toolSet if config else None
|
||||||
tools = toolRegistry.getTools(toolSet=activeToolSet)
|
tools = toolRegistry.getTools(toolSet=activeToolSet)
|
||||||
toolDefinitions = toolRegistry.formatToolsForFunctionCalling(toolSet=activeToolSet)
|
toolDefinitions = toolRegistry.formatToolsForFunctionCalling(toolSet=activeToolSet)
|
||||||
|
|
@ -100,7 +143,7 @@ async def runAgentLoop(
|
||||||
roundStartTime = time.time()
|
roundStartTime = time.time()
|
||||||
roundLog = AgentRoundLog(roundNumber=state.currentRound)
|
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:
|
if buildRagContextFn:
|
||||||
try:
|
try:
|
||||||
latestUserMsg = ""
|
latestUserMsg = ""
|
||||||
|
|
@ -108,9 +151,12 @@ async def runAgentLoop(
|
||||||
if msg.get("role") == "user":
|
if msg.get("role") == "user":
|
||||||
latestUserMsg = msg.get("content", "")
|
latestUserMsg = msg.get("content", "")
|
||||||
break
|
break
|
||||||
ragContext = await buildRagContextFn(
|
isConversational = config and config.excludeAllTools
|
||||||
|
ragContext = await _getOrRefreshRag(
|
||||||
|
workflowId,
|
||||||
|
buildRagContextFn,
|
||||||
|
forceRefresh=not isConversational,
|
||||||
currentPrompt=latestUserMsg or prompt,
|
currentPrompt=latestUserMsg or prompt,
|
||||||
workflowId=workflowId,
|
|
||||||
userId=userId,
|
userId=userId,
|
||||||
featureInstanceId=featureInstanceId,
|
featureInstanceId=featureInstanceId,
|
||||||
mandateId=mandateId,
|
mandateId=mandateId,
|
||||||
|
|
@ -166,12 +212,15 @@ async def runAgentLoop(
|
||||||
)
|
)
|
||||||
|
|
||||||
# AI call
|
# AI call
|
||||||
|
aiOptions = AiCallOptions(
|
||||||
|
operationType=config.operationType or OperationTypeEnum.AGENT,
|
||||||
|
temperature=config.temperature,
|
||||||
|
)
|
||||||
|
if config.priority:
|
||||||
|
aiOptions.priority = config.priority
|
||||||
aiRequest = AiCallRequest(
|
aiRequest = AiCallRequest(
|
||||||
prompt="",
|
prompt="",
|
||||||
options=AiCallOptions(
|
options=aiOptions,
|
||||||
operationType=config.operationType or OperationTypeEnum.AGENT,
|
|
||||||
temperature=config.temperature
|
|
||||||
),
|
|
||||||
messages=conversation.messages,
|
messages=conversation.messages,
|
||||||
tools=toolDefinitions if toolDefinitions else None,
|
tools=toolDefinitions if toolDefinitions else None,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -228,14 +228,17 @@ def _registerDataSourceTools(registry: ToolRegistry, services):
|
||||||
fileName = f"{fileName}.zip"
|
fileName = f"{fileName}.zip"
|
||||||
chatService = services.chat
|
chatService = services.chat
|
||||||
fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(fileBytes, fileName)
|
fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(fileBytes, fileName)
|
||||||
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
|
updateFields = {}
|
||||||
if fiId:
|
|
||||||
chatService.interfaceDbComponent.updateFile(fileItem.id, {"featureInstanceId": fiId})
|
|
||||||
if _sourceNeutralize:
|
|
||||||
chatService.interfaceDbComponent.updateFile(fileItem.id, {"neutralize": True})
|
|
||||||
tempFolderId = _getOrCreateTempFolder(chatService)
|
tempFolderId = _getOrCreateTempFolder(chatService)
|
||||||
if tempFolderId:
|
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(
|
chatDocId = _attachFileAsChatDocument(
|
||||||
services, fileItem,
|
services, fileItem,
|
||||||
|
|
|
||||||
|
|
@ -47,12 +47,28 @@ def _looksLikeBinary(data: bytes, sampleSize: int = 1024) -> bool:
|
||||||
|
|
||||||
|
|
||||||
def _getOrCreateTempFolder(chatService) -> Optional[str]:
|
def _getOrCreateTempFolder(chatService) -> Optional[str]:
|
||||||
"""Deprecated stub: folder-based organisation has been replaced by grouping.
|
"""Return the ID of the user's 'Temp' folder, creating it if it doesn't exist."""
|
||||||
|
ifc = getattr(chatService, "interfaceDbComponent", None)
|
||||||
Returns None unconditionally so callers skip the (now removed) folderId
|
if not ifc:
|
||||||
assignment. Remove callers incrementally and delete this stub afterwards.
|
logger.warning("_getOrCreateTempFolder: no interfaceDbComponent on chatService")
|
||||||
"""
|
return None
|
||||||
logger.debug("_getOrCreateTempFolder called – folder support removed, returning 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
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -61,33 +77,7 @@ async def _getOrCreateInstanceGroup(
|
||||||
featureInstanceId: str,
|
featureInstanceId: str,
|
||||||
contextKey: str = "files/list",
|
contextKey: str = "files/list",
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""Return groupId of the default group for a feature instance; create if needed."""
|
"""Stub — file group tree removed. Returns None; callers that checked the result will skip group assignment."""
|
||||||
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -96,8 +86,8 @@ async def _getOrCreateTempGroup(
|
||||||
sessionId: str,
|
sessionId: str,
|
||||||
contextKey: str = "files/list",
|
contextKey: str = "files/list",
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""Return groupId of a temporary group for a session; create if needed."""
|
"""Stub — file group tree removed. Returns None."""
|
||||||
return await _getOrCreateInstanceGroup(appInterface, f"_temp_{sessionId}", contextKey)
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _attachFileAsChatDocument(
|
def _attachFileAsChatDocument(
|
||||||
|
|
|
||||||
|
|
@ -222,12 +222,15 @@ def _registerMediaTools(registry: ToolRegistry, services):
|
||||||
|
|
||||||
if fileItem:
|
if fileItem:
|
||||||
fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?")
|
fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?")
|
||||||
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
|
updateFields = {}
|
||||||
if fiId:
|
|
||||||
chatService.interfaceDbComponent.updateFile(fid, {"featureInstanceId": fiId})
|
|
||||||
tempFolderId = _getOrCreateTempFolder(chatService)
|
tempFolderId = _getOrCreateTempFolder(chatService)
|
||||||
if tempFolderId:
|
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(
|
chatDocId = _attachFileAsChatDocument(
|
||||||
services, fileItem,
|
services, fileItem,
|
||||||
label=f"renderDocument:{docName}",
|
label=f"renderDocument:{docName}",
|
||||||
|
|
@ -517,12 +520,15 @@ def _registerMediaTools(registry: ToolRegistry, services):
|
||||||
|
|
||||||
if fileItem:
|
if fileItem:
|
||||||
fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?")
|
fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?")
|
||||||
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
|
updateFields = {}
|
||||||
if fiId:
|
|
||||||
chatService.interfaceDbComponent.updateFile(fid, {"featureInstanceId": fiId})
|
|
||||||
tempFolderId = _getOrCreateTempFolder(chatService)
|
tempFolderId = _getOrCreateTempFolder(chatService)
|
||||||
if tempFolderId:
|
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(
|
chatDocId = _attachFileAsChatDocument(
|
||||||
services, fileItem,
|
services, fileItem,
|
||||||
label=f"generateImage:{docName}",
|
label=f"generateImage:{docName}",
|
||||||
|
|
@ -679,12 +685,16 @@ def _registerMediaTools(registry: ToolRegistry, services):
|
||||||
fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(pngData, fileName)
|
fileItem, _ = chatService.interfaceDbComponent.saveUploadedFile(pngData, fileName)
|
||||||
|
|
||||||
fid = fileItem.id if hasattr(fileItem, "id") else fileItem.get("id", "?") if isinstance(fileItem, dict) else "?"
|
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 fid != "?":
|
||||||
if fiId and fid != "?":
|
updateFields = {}
|
||||||
chatService.interfaceDbComponent.updateFile(fid, {"featureInstanceId": fiId})
|
|
||||||
tempFolderId = _getOrCreateTempFolder(chatService)
|
tempFolderId = _getOrCreateTempFolder(chatService)
|
||||||
if tempFolderId and fid != "?":
|
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(
|
chatDocId = _attachFileAsChatDocument(
|
||||||
services, fileItem,
|
services, fileItem,
|
||||||
|
|
|
||||||
|
|
@ -312,52 +312,7 @@ def _registerWorkspaceTools(registry: ToolRegistry, services):
|
||||||
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
|
fiId = context.get("featureInstanceId") or (services.featureInstanceId if services else "")
|
||||||
if fiId:
|
if fiId:
|
||||||
dbMgmt.updateFile(fileItem.id, {"featureInstanceId": fiId})
|
dbMgmt.updateFile(fileItem.id, {"featureInstanceId": fiId})
|
||||||
if args.get("groupId"):
|
# File group tree removed — groupId arg and instance-group assignment no longer apply
|
||||||
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}")
|
|
||||||
if args.get("tags"):
|
if args.get("tags"):
|
||||||
dbMgmt.updateFile(fileItem.id, {"tags": args["tags"]})
|
dbMgmt.updateFile(fileItem.id, {"tags": args["tags"]})
|
||||||
|
|
||||||
|
|
@ -746,136 +701,7 @@ def _registerWorkspaceTools(registry: ToolRegistry, services):
|
||||||
readOnly=False
|
readOnly=False
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---- Group tools (replaces folder-based tools) ----
|
# Group tree tools removed — file grouping now uses view-based display grouping (TableListView)
|
||||||
|
|
||||||
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
|
|
||||||
)
|
|
||||||
|
|
||||||
registry.register(
|
registry.register(
|
||||||
"replaceInFile", _replaceInFile,
|
"replaceInFile", _replaceInFile,
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ from typing import List, Dict, Any, Optional
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
from modules.shared.timeUtils import getUtcTimestamp
|
||||||
from modules.datamodels.datamodelAi import OperationTypeEnum
|
from modules.datamodels.datamodelAi import OperationTypeEnum, PriorityEnum
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -101,6 +101,18 @@ class AgentConfig(BaseModel):
|
||||||
"manipulate the workflow graph, not execute its actions."
|
"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):
|
class AgentState(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -203,7 +203,8 @@ class AgentService:
|
||||||
# ContentParts" symptom we see when the workspace route calls
|
# ContentParts" symptom we see when the workspace route calls
|
||||||
# runAgent for an attached single-file data source.
|
# runAgent for an attached single-file data source.
|
||||||
# Mirrors workflowManager._propagateWorkflowToContext.
|
# Mirrors workflowManager._propagateWorkflowToContext.
|
||||||
if workflowId and workflowId != "unknown":
|
isChatWorkflowId = workflowId and workflowId != "unknown" and ":" not in workflowId
|
||||||
|
if isChatWorkflowId:
|
||||||
try:
|
try:
|
||||||
workflow = getattr(self.services, "workflow", None)
|
workflow = getattr(self.services, "workflow", None)
|
||||||
if workflow is None or getattr(workflow, "id", None) != workflowId:
|
if workflow is None or getattr(workflow, "id", None) != workflowId:
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ SANDBOX_ALLOWED_MODULES = {
|
||||||
}
|
}
|
||||||
|
|
||||||
_PYTHON_BLOCKED_BUILTINS = {
|
_PYTHON_BLOCKED_BUILTINS = {
|
||||||
"open", "exec", "eval", "compile", "__import__", "globals", "locals",
|
"exec", "eval", "compile", "__import__", "globals", "locals",
|
||||||
"getattr", "setattr", "delattr", "breakpoint", "exit", "quit",
|
"getattr", "setattr", "delattr", "breakpoint", "exit", "quit",
|
||||||
"input", "memoryview",
|
"input", "memoryview",
|
||||||
}
|
}
|
||||||
|
|
@ -73,6 +73,29 @@ def _buildRestrictedGlobals() -> Dict[str, Any]:
|
||||||
return {"__builtins__": safeBuiltins}
|
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):
|
def _makeReadFile(services):
|
||||||
"""Create a readFile(fileId) closure bound to the current services context."""
|
"""Create a readFile(fileId) closure bound to the current services context."""
|
||||||
def readFile(fileId: str) -> str:
|
def readFile(fileId: str) -> str:
|
||||||
|
|
@ -92,6 +115,8 @@ async def executePython(code: str, *, services=None) -> Dict[str, Any]:
|
||||||
|
|
||||||
def _run():
|
def _run():
|
||||||
restrictedGlobals = _buildRestrictedGlobals()
|
restrictedGlobals = _buildRestrictedGlobals()
|
||||||
|
vfs = _VirtualFS()
|
||||||
|
restrictedGlobals["__builtins__"]["open"] = vfs.open
|
||||||
if services:
|
if services:
|
||||||
restrictedGlobals["__builtins__"]["readFile"] = _makeReadFile(services)
|
restrictedGlobals["__builtins__"]["readFile"] = _makeReadFile(services)
|
||||||
capturedOutput = io.StringIO()
|
capturedOutput = io.StringIO()
|
||||||
|
|
|
||||||
|
|
@ -57,8 +57,7 @@ from .subJsonResponseHandling import JsonResponseHandler
|
||||||
from .subLoopingUseCases import LoopingUseCaseRegistry
|
from .subLoopingUseCases import LoopingUseCaseRegistry
|
||||||
from modules.workflows.processing.shared.stateTools import checkWorkflowStopped
|
from modules.workflows.processing.shared.stateTools import checkWorkflowStopped
|
||||||
from modules.shared.jsonContinuation import getContexts
|
from modules.shared.jsonContinuation import getContexts
|
||||||
from modules.shared.jsonUtils import buildContinuationContext, extractJsonString, tryParseJson
|
from modules.shared.jsonUtils import buildContinuationContext, tryParseJson
|
||||||
from modules.shared.jsonUtils import tryParseJson
|
|
||||||
from modules.shared.jsonUtils import closeJsonStructures
|
from modules.shared.jsonUtils import closeJsonStructures
|
||||||
from modules.shared.jsonUtils import stripCodeFences, normalizeJsonText
|
from modules.shared.jsonUtils import stripCodeFences, normalizeJsonText
|
||||||
|
|
||||||
|
|
@ -142,6 +141,8 @@ class AiCallLooper:
|
||||||
MAX_MERGE_FAILS = 3
|
MAX_MERGE_FAILS = 3
|
||||||
mergeFailCount = 0 # Global counter for merge failures across entire loop
|
mergeFailCount = 0 # Global counter for merge failures across entire loop
|
||||||
lastValidCompletePart = None # Store last successfully parsed completePart for fallback
|
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)
|
# Get parent operation ID for iteration operations (parentId should be operationId, not log entry ID)
|
||||||
parentOperationId = operationId # Use the parent's operationId directly
|
parentOperationId = operationId # Use the parent's operationId directly
|
||||||
|
|
@ -284,8 +285,26 @@ class AiCallLooper:
|
||||||
break
|
break
|
||||||
|
|
||||||
if not result or not result.strip():
|
if not result or not result.strip():
|
||||||
logger.warning(f"Iteration {iteration}: Empty response, stopping")
|
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
|
break
|
||||||
|
continue
|
||||||
|
|
||||||
|
consecutive_empty_responses = 0
|
||||||
|
|
||||||
# Check if this is a text response (not document generation)
|
# Check if this is a text response (not document generation)
|
||||||
# Text responses don't need JSON parsing - return immediately after first successful response
|
# Text responses don't need JSON parsing - return immediately after first successful response
|
||||||
|
|
@ -354,9 +373,8 @@ class AiCallLooper:
|
||||||
|
|
||||||
if lastValidCompletePart:
|
if lastValidCompletePart:
|
||||||
try:
|
try:
|
||||||
extracted = extractJsonString(lastValidCompletePart)
|
parsed, parseErr, _ = tryParseJson(lastValidCompletePart)
|
||||||
parsed, parseErr, _ = tryParseJson(extracted)
|
if parseErr is None:
|
||||||
if parseErr is None and parsed:
|
|
||||||
normalized = self._normalizeJsonStructure(parsed, useCase)
|
normalized = self._normalizeJsonStructure(parsed, useCase)
|
||||||
return json.dumps(normalized, indent=2, ensure_ascii=False)
|
return json.dumps(normalized, indent=2, ensure_ascii=False)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -384,11 +402,10 @@ class AiCallLooper:
|
||||||
# This ensures retry iterations use the correct base context
|
# This ensures retry iterations use the correct base context
|
||||||
lastRawResponse = candidateJson
|
lastRawResponse = candidateJson
|
||||||
|
|
||||||
# Try direct parse of candidate
|
# Try direct parse of candidate (same pipeline as structure filling / getContexts)
|
||||||
try:
|
try:
|
||||||
extracted = extractJsonString(candidateJson)
|
parsed, parseErr, extracted = tryParseJson(candidateJson)
|
||||||
parsed, parseErr, _ = tryParseJson(extracted)
|
if parseErr is None:
|
||||||
if parseErr is None and parsed:
|
|
||||||
# Direct parse succeeded - FINISHED
|
# Direct parse succeeded - FINISHED
|
||||||
# Commit candidate to jsonBase
|
# Commit candidate to jsonBase
|
||||||
jsonBase = candidateJson
|
jsonBase = candidateJson
|
||||||
|
|
@ -421,18 +438,21 @@ class AiCallLooper:
|
||||||
|
|
||||||
# STEP 6: DECIDE based on jsonParsingSuccess and overlapContext
|
# STEP 6: DECIDE based on jsonParsingSuccess and overlapContext
|
||||||
if contexts.jsonParsingSuccess and contexts.overlapContext == "":
|
if contexts.jsonParsingSuccess and contexts.overlapContext == "":
|
||||||
# JSON is complete (no cut point) - FINISHED
|
# getContexts and downstream must agree with tryParseJson (same as structure filling).
|
||||||
# Use completePart for final result (closed, repaired JSON)
|
|
||||||
# No more merging needed, so we don't need the cut version
|
|
||||||
jsonBase = contexts.completePart
|
|
||||||
logger.info(f"Iteration {iteration}: jsonParsingSuccess=true, overlapContext='', JSON complete")
|
logger.info(f"Iteration {iteration}: jsonParsingSuccess=true, overlapContext='', JSON complete")
|
||||||
|
|
||||||
# Store and parse completePart
|
|
||||||
lastValidCompletePart = contexts.completePart
|
lastValidCompletePart = contexts.completePart
|
||||||
|
|
||||||
try:
|
try:
|
||||||
extracted = extractJsonString(contexts.completePart)
|
extracted = extractJsonString(contexts.completePart)
|
||||||
parsed, parseErr, _ = tryParseJson(extracted)
|
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:
|
if parseErr is None and parsed:
|
||||||
normalized = self._normalizeJsonStructure(parsed, useCase)
|
normalized = self._normalizeJsonStructure(parsed, useCase)
|
||||||
result = json.dumps(normalized, indent=2, ensure_ascii=False)
|
result = json.dumps(normalized, indent=2, ensure_ascii=False)
|
||||||
|
|
@ -447,13 +467,30 @@ class AiCallLooper:
|
||||||
return useCase.finalResultHandler(
|
return useCase.finalResultHandler(
|
||||||
result, normalized, extracted, debugPrefix, self.services
|
result, normalized, extracted, debugPrefix, self.services
|
||||||
)
|
)
|
||||||
|
return useCase.finalResultHandler(
|
||||||
|
result, normalized, extracted, debugPrefix, self.services
|
||||||
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Iteration {iteration}: Failed to parse completePart: {e}")
|
logger.warning(
|
||||||
|
f"Iteration {iteration}: completePart not serializable after getContexts success: {e}"
|
||||||
# Fallback: return completePart as-is
|
)
|
||||||
|
mergeFailCount += 1
|
||||||
|
if mergeFailCount >= MAX_MERGE_FAILS:
|
||||||
|
logger.error(
|
||||||
|
f"Iteration {iteration}: Max failures ({MAX_MERGE_FAILS}) "
|
||||||
|
"after output pipeline mismatch"
|
||||||
|
)
|
||||||
if iterationOperationId:
|
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)
|
self.services.chat.progressLogFinish(iterationOperationId, True)
|
||||||
return contexts.completePart
|
continue
|
||||||
|
|
||||||
elif contexts.jsonParsingSuccess and contexts.overlapContext != "":
|
elif contexts.jsonParsingSuccess and contexts.overlapContext != "":
|
||||||
# JSON parseable but has cut point - CONTINUE to next iteration
|
# JSON parseable but has cut point - CONTINUE to next iteration
|
||||||
|
|
@ -502,9 +539,8 @@ class AiCallLooper:
|
||||||
|
|
||||||
if lastValidCompletePart:
|
if lastValidCompletePart:
|
||||||
try:
|
try:
|
||||||
extracted = extractJsonString(lastValidCompletePart)
|
parsed, parseErr, _ = tryParseJson(lastValidCompletePart)
|
||||||
parsed, parseErr, _ = tryParseJson(extracted)
|
if parseErr is None:
|
||||||
if parseErr is None and parsed:
|
|
||||||
normalized = self._normalizeJsonStructure(parsed, useCase)
|
normalized = self._normalizeJsonStructure(parsed, useCase)
|
||||||
return json.dumps(normalized, indent=2, ensure_ascii=False)
|
return json.dumps(normalized, indent=2, ensure_ascii=False)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -532,10 +568,30 @@ class AiCallLooper:
|
||||||
if iteration >= maxIterations:
|
if iteration >= maxIterations:
|
||||||
logger.warning(f"AI call stopped after maximum iterations ({maxIterations})")
|
logger.warning(f"AI call stopped after maximum iterations ({maxIterations})")
|
||||||
|
|
||||||
# This code path should never be reached because all registered use cases
|
# Prefer last repaired complete JSON from getContexts (raw `result` is only the last fragment).
|
||||||
# return early when JSON is complete. This would only execute for use cases that
|
if lastValidCompletePart and useCase and not useCase.requiresExtraction:
|
||||||
# require section extraction, but no such use cases are currently registered.
|
try:
|
||||||
logger.error(f"Unexpected code path: reached end of loop without return for use case '{useCaseId}'")
|
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 ""
|
return result if result else ""
|
||||||
|
|
||||||
def _isJsonStringIncomplete(self, jsonString: str) -> bool:
|
def _isJsonStringIncomplete(self, jsonString: str) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,15 @@ def _handleCodeContentFinalResult(result: str, parsedJsonForUseCase: Any, extrac
|
||||||
return final_json
|
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:
|
def _normalizeSectionContentJson(parsed: Any, useCaseId: str) -> Any:
|
||||||
"""Normalize JSON structure for section_content use case."""
|
"""Normalize JSON structure for section_content use case."""
|
||||||
# For section_content, expect {"elements": [...]} structure
|
# For section_content, expect {"elements": [...]} structure
|
||||||
|
|
@ -77,14 +86,28 @@ def _normalizeSectionContentJson(parsed: Any, useCaseId: str) -> Any:
|
||||||
# Convert plain list of elements to elements structure
|
# Convert plain list of elements to elements structure
|
||||||
return {"elements": parsed}
|
return {"elements": parsed}
|
||||||
elif isinstance(parsed, dict):
|
elif isinstance(parsed, dict):
|
||||||
# If it already has "elements", return as-is
|
|
||||||
if "elements" in parsed:
|
if "elements" in parsed:
|
||||||
|
els = parsed.get("elements")
|
||||||
|
if isinstance(els, list) and len(els) > 0:
|
||||||
return parsed
|
return parsed
|
||||||
# If it has "type" and looks like an element, wrap in elements array
|
lifted = _lift_section_plain_text(parsed)
|
||||||
elif parsed.get("type"):
|
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 parsed.get("type"):
|
||||||
return {"elements": [parsed]}
|
return {"elements": [parsed]}
|
||||||
# Otherwise, assume it's already in correct format
|
lifted = _lift_section_plain_text(parsed)
|
||||||
else:
|
if lifted:
|
||||||
|
return {
|
||||||
|
**parsed,
|
||||||
|
"elements": [{"type": "paragraph", "content": {"text": lifted}}],
|
||||||
|
}
|
||||||
return parsed
|
return parsed
|
||||||
|
|
||||||
# For other use cases, return as-is (they have their own structures)
|
# For other use cases, return as-is (they have their own structures)
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,36 @@ class _AiResponseFallback:
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
class StructureFiller:
|
||||||
"""Handles filling document structure with content."""
|
"""Handles filling document structure with content."""
|
||||||
|
|
||||||
|
|
@ -524,36 +554,10 @@ class StructureFiller:
|
||||||
if generatedElements:
|
if generatedElements:
|
||||||
elements.extend(generatedElements)
|
elements.extend(generatedElements)
|
||||||
else:
|
else:
|
||||||
# Fallback: Try to parse JSON response directly with repair logic
|
logger.error(f"No elements produced for section {sectionId} (callAiWithLooping must return parseable JSON)")
|
||||||
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({
|
elements.append({
|
||||||
"type": "error",
|
"type": "error",
|
||||||
"message": f"Failed to parse JSON response: {str(json_error)}",
|
"message": f"No parsed content for section {sectionId}",
|
||||||
"sectionId": sectionId
|
"sectionId": sectionId
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -671,7 +675,7 @@ class StructureFiller:
|
||||||
try:
|
try:
|
||||||
self.services.chat.progressLogUpdate(sectionOperationId, 0.4, "Calling AI for content generation")
|
self.services.chat.progressLogUpdate(sectionOperationId, 0.4, "Calling AI for content generation")
|
||||||
|
|
||||||
operationType = OperationTypeEnum.DATA_ANALYSE
|
operationType = OperationTypeEnum.DATA_GENERATE
|
||||||
options = AiCallOptions(
|
options = AiCallOptions(
|
||||||
operationType=operationType,
|
operationType=operationType,
|
||||||
priority=PriorityEnum.BALANCED,
|
priority=PriorityEnum.BALANCED,
|
||||||
|
|
@ -703,22 +707,17 @@ class StructureFiller:
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
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):
|
if isinstance(aiResponseJson, str) and ("---" in aiResponseJson or aiResponseJson.count("```json") > 1):
|
||||||
generatedElements = self._extractAndMergeMultipleJsonBlocks(aiResponseJson, contentType, sectionId)
|
generatedElements = self._extractAndMergeMultipleJsonBlocks(aiResponseJson, contentType, sectionId)
|
||||||
else:
|
else:
|
||||||
parsedResponse, parseError, cleanedStr = tryParseJson(aiResponseJson)
|
parsedResponse, parseError, _ = tryParseJson(aiResponseJson)
|
||||||
if parsedResponse is None:
|
if parseError is not None:
|
||||||
logger.warning(f"Section {sectionId}: tryParseJson failed, attempting repair")
|
logger.error(f"Section {sectionId}: tryParseJson failed: {parseError}")
|
||||||
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:
|
|
||||||
generatedElements = []
|
generatedElements = []
|
||||||
|
else:
|
||||||
|
generatedElements = _elements_from_section_content_ai_json(parsedResponse)
|
||||||
except Exception as parseErr:
|
except Exception as parseErr:
|
||||||
logger.error(f"Section {sectionId}: JSON parse error: {parseErr}")
|
logger.error(f"Section {sectionId}: JSON parse error: {parseErr}")
|
||||||
generatedElements = []
|
generatedElements = []
|
||||||
|
|
@ -930,22 +929,13 @@ class StructureFiller:
|
||||||
|
|
||||||
self.services.chat.progressLogUpdate(sectionOperationId, 0.4, "Calling AI for content generation")
|
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:
|
if operationType == OperationTypeEnum.IMAGE_GENERATE:
|
||||||
maxPromptLength = 4000
|
imagePrompt = self._buildImagePrompt(section, generationHint, language)
|
||||||
if len(generationPrompt) > maxPromptLength:
|
self.services.utils.writeDebugFile(imagePrompt, f"{chapterId}_section_{sectionId}_prompt")
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
request = AiCallRequest(
|
request = AiCallRequest(
|
||||||
prompt=generationPrompt,
|
prompt=imagePrompt,
|
||||||
contentParts=[],
|
contentParts=[],
|
||||||
options=AiCallOptions(
|
options=AiCallOptions(
|
||||||
operationType=operationType,
|
operationType=operationType,
|
||||||
|
|
@ -956,8 +946,6 @@ class StructureFiller:
|
||||||
checkWorkflowStopped(self.services)
|
checkWorkflowStopped(self.services)
|
||||||
aiResponse = await self.aiService.callAi(request)
|
aiResponse = await self.aiService.callAi(request)
|
||||||
generatedElements = []
|
generatedElements = []
|
||||||
|
|
||||||
# Write debug file for IMAGE_GENERATE response (direct callAi, no _callAiWithLooping)
|
|
||||||
self.services.utils.writeDebugFile(
|
self.services.utils.writeDebugFile(
|
||||||
aiResponse.content if hasattr(aiResponse, 'content') else str(aiResponse),
|
aiResponse.content if hasattr(aiResponse, 'content') else str(aiResponse),
|
||||||
f"{chapterId}_section_{sectionId}_response"
|
f"{chapterId}_section_{sectionId}_response"
|
||||||
|
|
@ -996,47 +984,20 @@ class StructureFiller:
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Use tryParseJson which handles extraction and basic parsing
|
from modules.shared.jsonUtils import tryParseJson
|
||||||
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
|
|
||||||
if isinstance(aiResponseJson, str) and ("---" in aiResponseJson or aiResponseJson.count("```json") > 1):
|
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")
|
logger.info(f"Section {sectionId}: Detected multiple JSON blocks in response, attempting to merge")
|
||||||
generatedElements = self._extractAndMergeMultipleJsonBlocks(aiResponseJson, contentType, sectionId)
|
generatedElements = self._extractAndMergeMultipleJsonBlocks(aiResponseJson, contentType, sectionId)
|
||||||
else:
|
else:
|
||||||
parsedResponse, parseError, cleanedStr = tryParseJson(aiResponseJson)
|
parsedResponse, parseError, _ = tryParseJson(aiResponseJson)
|
||||||
|
if parseError is not None:
|
||||||
# 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:
|
|
||||||
raise parseError
|
raise parseError
|
||||||
|
generatedElements = _elements_from_section_content_ai_json(parsedResponse)
|
||||||
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 = []
|
|
||||||
|
|
||||||
aiResponse = _AiResponseFallback(aiResponseJson)
|
aiResponse = _AiResponseFallback(aiResponseJson)
|
||||||
except Exception as parseError:
|
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)
|
aiResponse = _AiResponseFallback(aiResponseJson)
|
||||||
generatedElements = []
|
generatedElements = []
|
||||||
|
|
||||||
|
|
@ -1112,22 +1073,13 @@ class StructureFiller:
|
||||||
|
|
||||||
self.services.chat.progressLogUpdate(sectionOperationId, 0.4, "Calling AI for content generation")
|
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:
|
if operationType == OperationTypeEnum.IMAGE_GENERATE:
|
||||||
maxPromptLength = 4000
|
imagePrompt = self._buildImagePrompt(section, generationHint, language)
|
||||||
if len(generationPrompt) > maxPromptLength:
|
self.services.utils.writeDebugFile(imagePrompt, f"{chapterId}_section_{sectionId}_prompt")
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
request = AiCallRequest(
|
request = AiCallRequest(
|
||||||
prompt=generationPrompt,
|
prompt=imagePrompt,
|
||||||
contentParts=[],
|
contentParts=[],
|
||||||
options=AiCallOptions(
|
options=AiCallOptions(
|
||||||
operationType=operationType,
|
operationType=operationType,
|
||||||
|
|
@ -1135,10 +1087,9 @@ class StructureFiller:
|
||||||
processingMode=ProcessingModeEnum.DETAILED
|
processingMode=ProcessingModeEnum.DETAILED
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
checkWorkflowStopped(self.services)
|
||||||
aiResponse = await self.aiService.callAi(request)
|
aiResponse = await self.aiService.callAi(request)
|
||||||
generatedElements = []
|
generatedElements = []
|
||||||
|
|
||||||
# Write debug file for IMAGE_GENERATE response (direct callAi, no _callAiWithLooping)
|
|
||||||
self.services.utils.writeDebugFile(
|
self.services.utils.writeDebugFile(
|
||||||
aiResponse.content if hasattr(aiResponse, 'content') else str(aiResponse),
|
aiResponse.content if hasattr(aiResponse, 'content') else str(aiResponse),
|
||||||
f"{chapterId}_section_{sectionId}_response"
|
f"{chapterId}_section_{sectionId}_response"
|
||||||
|
|
@ -1179,25 +1130,19 @@ class StructureFiller:
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
parsedResponse = json.loads(self.services.utils.jsonExtractString(aiResponseJson))
|
from modules.shared.jsonUtils import tryParseJson
|
||||||
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 = []
|
|
||||||
|
|
||||||
|
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)
|
aiResponse = _AiResponseFallback(aiResponseJson)
|
||||||
except Exception as parseError:
|
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)
|
aiResponse = _AiResponseFallback(aiResponseJson)
|
||||||
generatedElements = []
|
generatedElements = []
|
||||||
|
|
||||||
|
|
@ -1371,22 +1316,13 @@ class StructureFiller:
|
||||||
|
|
||||||
self.services.chat.progressLogUpdate(sectionOperationId, 0.4, "Calling AI for content generation")
|
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:
|
if operationType == OperationTypeEnum.IMAGE_GENERATE:
|
||||||
maxPromptLength = 4000
|
imagePrompt = self._buildImagePrompt(section, generationHint, language)
|
||||||
if len(generationPrompt) > maxPromptLength:
|
self.services.utils.writeDebugFile(imagePrompt, f"{chapterId}_section_{sectionId}_prompt")
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
request = AiCallRequest(
|
request = AiCallRequest(
|
||||||
prompt=generationPrompt,
|
prompt=imagePrompt,
|
||||||
contentParts=[],
|
contentParts=[],
|
||||||
options=AiCallOptions(
|
options=AiCallOptions(
|
||||||
operationType=operationType,
|
operationType=operationType,
|
||||||
|
|
@ -1396,8 +1332,6 @@ class StructureFiller:
|
||||||
)
|
)
|
||||||
aiResponse = await self.aiService.callAi(request)
|
aiResponse = await self.aiService.callAi(request)
|
||||||
generatedElements = []
|
generatedElements = []
|
||||||
|
|
||||||
# Write debug file for IMAGE_GENERATE response (direct callAi, no _callAiWithLooping)
|
|
||||||
self.services.utils.writeDebugFile(
|
self.services.utils.writeDebugFile(
|
||||||
aiResponse.content if hasattr(aiResponse, 'content') else str(aiResponse),
|
aiResponse.content if hasattr(aiResponse, 'content') else str(aiResponse),
|
||||||
f"{chapterId}_section_{sectionId}_response"
|
f"{chapterId}_section_{sectionId}_response"
|
||||||
|
|
@ -1439,25 +1373,19 @@ class StructureFiller:
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
parsedResponse = json.loads(self.services.utils.jsonExtractString(aiResponseJson))
|
from modules.shared.jsonUtils import tryParseJson
|
||||||
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 = []
|
|
||||||
|
|
||||||
|
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)
|
aiResponse = _AiResponseFallback(aiResponseJson)
|
||||||
except Exception as parseError:
|
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)
|
aiResponse = _AiResponseFallback(aiResponseJson)
|
||||||
generatedElements = []
|
generatedElements = []
|
||||||
|
|
||||||
|
|
@ -2166,6 +2094,14 @@ Return only valid JSON. Do not include any explanatory text outside the JSON.
|
||||||
"""
|
"""
|
||||||
return prompt
|
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:
|
def _getContentStructureExample(self, contentType: str) -> str:
|
||||||
"""Get the JSON structure example for a specific content type."""
|
"""Get the JSON structure example for a specific content type."""
|
||||||
structures = {
|
structures = {
|
||||||
|
|
|
||||||
|
|
@ -90,8 +90,7 @@ class StructureGenerator:
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Baue Chapter-Struktur-Prompt mit Content-Index
|
structurePrompt, templateStructure = self._buildChapterStructurePrompt(
|
||||||
structurePrompt = self._buildChapterStructurePrompt(
|
|
||||||
userPrompt=userPrompt,
|
userPrompt=userPrompt,
|
||||||
contentParts=contentParts,
|
contentParts=contentParts,
|
||||||
outputFormat=outputFormat
|
outputFormat=outputFormat
|
||||||
|
|
@ -108,12 +107,6 @@ class StructureGenerator:
|
||||||
resultFormat="json"
|
resultFormat="json"
|
||||||
)
|
)
|
||||||
|
|
||||||
structurePrompt, templateStructure = self._buildChapterStructurePrompt(
|
|
||||||
userPrompt=userPrompt,
|
|
||||||
contentParts=contentParts,
|
|
||||||
outputFormat=outputFormat
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create prompt builder for continuation support
|
# Create prompt builder for continuation support
|
||||||
async def buildChapterStructurePromptWithContinuation(
|
async def buildChapterStructurePromptWithContinuation(
|
||||||
continuationContext: Any,
|
continuationContext: Any,
|
||||||
|
|
@ -196,6 +189,13 @@ CRITICAL:
|
||||||
contentParts=None # Do not pass ContentParts - only metadata needed, not content extraction
|
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)
|
# Parse the complete JSON response (looping system already handles completion)
|
||||||
extractedJson = self.services.utils.jsonExtractString(aiResponseJson)
|
extractedJson = self.services.utils.jsonExtractString(aiResponseJson)
|
||||||
parsedJson, parseError, cleanedJson = self.services.utils.jsonTryParse(extractedJson)
|
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)}")
|
raise ValueError(f"Failed to parse JSON structure after repair: {str(parseError)}")
|
||||||
else:
|
else:
|
||||||
logger.error(f"Failed to repair JSON. Parse error: {str(parseError)}")
|
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)}")
|
raise ValueError(f"Failed to parse JSON structure: {str(parseError)}")
|
||||||
else:
|
else:
|
||||||
structure = parsedJson
|
structure = parsedJson
|
||||||
|
|
|
||||||
|
|
@ -183,8 +183,8 @@ def _normalizeReturnUrl(returnUrl: str) -> str:
|
||||||
Validate and normalize an absolute frontend return URL.
|
Validate and normalize an absolute frontend return URL.
|
||||||
|
|
||||||
Allowed examples:
|
Allowed examples:
|
||||||
- https://nyla.poweron-center.net/billing/transactions
|
- https://nyla.poweron.swiss/billing/transactions
|
||||||
- https://nyla-int.poweron-center.net/billing/transactions?tab=overview
|
- https://nyla-int.poweron.swiss/billing/transactions?tab=overview
|
||||||
"""
|
"""
|
||||||
if not returnUrl:
|
if not returnUrl:
|
||||||
raise ValueError("returnUrl is required")
|
raise ValueError("returnUrl is required")
|
||||||
|
|
@ -309,7 +309,7 @@ def create_checkout_session(
|
||||||
"footer": (
|
"footer": (
|
||||||
"Diese Rechnung wurde bereits via Kreditkarte bezahlt. "
|
"Diese Rechnung wurde bereits via Kreditkarte bezahlt. "
|
||||||
"MWST-Nr. PowerOn: siehe Stripe-Rechnungs-Template. "
|
"MWST-Nr. PowerOn: siehe Stripe-Rechnungs-Template. "
|
||||||
"Bei Fragen: billing@poweron-center.net"
|
"Bei Fragen: billing@poweron.swiss"
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
customFields: List[Dict[str, str]] = []
|
customFields: List[Dict[str, str]] = []
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ class ChatService:
|
||||||
from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface
|
from modules.interfaces.interfaceDbManagement import getInterface as getComponentInterface
|
||||||
from modules.interfaces.interfaceDbChat import getInterface as getChatInterface
|
from modules.interfaces.interfaceDbChat import getInterface as getChatInterface
|
||||||
self.interfaceDbApp = getAppInterface(context.user, mandateId=context.mandate_id)
|
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(
|
self.interfaceDbChat = getChatInterface(
|
||||||
context.user,
|
context.user,
|
||||||
mandateId=context.mandate_id,
|
mandateId=context.mandate_id,
|
||||||
|
|
@ -36,6 +36,26 @@ class ChatService:
|
||||||
"""Workflow from context (stable during workflow execution)."""
|
"""Workflow from context (stable during workflow execution)."""
|
||||||
return self._context.workflow
|
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]:
|
def getChatDocumentsFromDocumentList(self, documentList) -> List[ChatDocument]:
|
||||||
"""Get ChatDocuments from a DocumentReferenceList.
|
"""Get ChatDocuments from a DocumentReferenceList.
|
||||||
|
|
||||||
|
|
@ -126,14 +146,28 @@ class ChatService:
|
||||||
|
|
||||||
if message.documents:
|
if message.documents:
|
||||||
for doc in message.documents:
|
for doc in message.documents:
|
||||||
if doc.id == docId:
|
if doc.id == docId or getattr(doc, "fileId", None) == docId:
|
||||||
allDocuments.append(doc)
|
allDocuments.append(doc)
|
||||||
docFound = True
|
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
|
break
|
||||||
if docFound:
|
if docFound:
|
||||||
break
|
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
|
# 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
|
# This handles cases where AI incorrectly generates docItem:filename.docx
|
||||||
if not docFound and '.' in docId and len(parts) == 2:
|
if not docFound and '.' in docId and len(parts) == 2:
|
||||||
|
|
@ -485,33 +519,11 @@ class ChatService:
|
||||||
return results
|
return results
|
||||||
|
|
||||||
def listGroups(self, contextKey: str = "files/list") -> list:
|
def listGroups(self, contextKey: str = "files/list") -> list:
|
||||||
"""List all groups in the groupTree for the current context."""
|
"""Stub — file group tree removed. Returns empty list."""
|
||||||
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 []
|
return []
|
||||||
|
|
||||||
def listFilesInGroup(self, groupId: str, contextKey: str = "files/list") -> list:
|
def listFilesInGroup(self, groupId: str, contextKey: str = "files/list") -> list:
|
||||||
"""List file IDs in a specific group (recursive)."""
|
"""Stub — file group tree removed. Returns empty list."""
|
||||||
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 []
|
return []
|
||||||
|
|
||||||
# ---- DataSource CRUD ----
|
# ---- DataSource CRUD ----
|
||||||
|
|
|
||||||
|
|
@ -118,30 +118,57 @@ class BaseRenderer(ABC):
|
||||||
para = style["paragraph"]
|
para = style["paragraph"]
|
||||||
lst = style["list"]
|
lst = style["list"]
|
||||||
cb = style["codeBlock"]
|
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 {
|
return {
|
||||||
"title": {
|
"title": {
|
||||||
"font_size": h1["sizePt"], "color": h1["color"],
|
"font_size": titleSizePt,
|
||||||
"bold": h1.get("weight") == "bold", "align": "left",
|
"color": titleColor,
|
||||||
|
"bold": titleBold,
|
||||||
|
"align": titleAlign,
|
||||||
|
"space_before": titleSpaceBefore,
|
||||||
|
"space_after": titleSpaceAfter,
|
||||||
},
|
},
|
||||||
"heading1": {
|
"heading1": {
|
||||||
"font_size": h1["sizePt"], "color": h1["color"],
|
"font_size": h1["sizePt"], "color": h1["color"],
|
||||||
"bold": h1.get("weight") == "bold", "align": "left",
|
"bold": h1.get("weight") == "bold", "align": "left",
|
||||||
|
"space_before": h1.get("spaceBeforePt", 24),
|
||||||
|
"space_after": h1.get("spaceAfterPt", 8),
|
||||||
},
|
},
|
||||||
"heading2": {
|
"heading2": {
|
||||||
"font_size": h2["sizePt"], "color": h2["color"],
|
"font_size": h2["sizePt"], "color": h2["color"],
|
||||||
"bold": h2.get("weight") == "bold", "align": "left",
|
"bold": h2.get("weight") == "bold", "align": "left",
|
||||||
|
"space_before": h2.get("spaceBeforePt", 20),
|
||||||
|
"space_after": h2.get("spaceAfterPt", 6),
|
||||||
},
|
},
|
||||||
"heading3": {
|
"heading3": {
|
||||||
"font_size": h3["sizePt"], "color": h3["color"],
|
"font_size": h3["sizePt"], "color": h3["color"],
|
||||||
"bold": h3.get("weight") == "bold", "align": "left",
|
"bold": h3.get("weight") == "bold", "align": "left",
|
||||||
|
"space_before": h3.get("spaceBeforePt", 16),
|
||||||
|
"space_after": h3.get("spaceAfterPt", 4),
|
||||||
},
|
},
|
||||||
"heading4": {
|
"heading4": {
|
||||||
"font_size": h4["sizePt"], "color": h4["color"],
|
"font_size": h4["sizePt"], "color": h4["color"],
|
||||||
"bold": h4.get("weight") == "bold", "align": "left",
|
"bold": h4.get("weight") == "bold", "align": "left",
|
||||||
|
"space_before": h4.get("spaceBeforePt", 12),
|
||||||
|
"space_after": h4.get("spaceAfterPt", 3),
|
||||||
},
|
},
|
||||||
"paragraph": {
|
"paragraph": {
|
||||||
"font_size": para["sizePt"], "color": para["color"],
|
"font_size": para["sizePt"], "color": para["color"],
|
||||||
"bold": False, "align": "left",
|
"bold": False, "align": "left",
|
||||||
|
"line_height": para.get("lineSpacing", 1.15),
|
||||||
},
|
},
|
||||||
"table_header": {
|
"table_header": {
|
||||||
"background": tbl["headerBg"], "text_color": tbl["headerFg"],
|
"background": tbl["headerBg"], "text_color": tbl["headerFg"],
|
||||||
|
|
@ -157,6 +184,7 @@ class BaseRenderer(ABC):
|
||||||
"bullet_list": {
|
"bullet_list": {
|
||||||
"font_size": lst["sizePt"], "color": para["color"],
|
"font_size": lst["sizePt"], "color": para["color"],
|
||||||
"indent": lst["indentPt"],
|
"indent": lst["indentPt"],
|
||||||
|
"bullet_char": lst.get("bulletChar", "\u2022"),
|
||||||
},
|
},
|
||||||
"code_block": {
|
"code_block": {
|
||||||
"font": style["fonts"]["monospace"],
|
"font": style["fonts"]["monospace"],
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,15 @@ class RendererCodeCsv(BaseCodeRenderer):
|
||||||
|
|
||||||
return renderedDocs
|
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.
|
Render method for document generation compatibility.
|
||||||
Delegates to document renderer if needed, or handles code files directly.
|
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
|
# Document generation path - delegate to document renderer
|
||||||
from .rendererCsv import RendererCsv
|
from .rendererCsv import RendererCsv
|
||||||
documentRenderer = RendererCsv(self.services)
|
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:
|
def _validateAndFixCsv(self, content: str) -> str:
|
||||||
"""Validate CSV structure and fix common issues."""
|
"""Validate CSV structure and fix common issues."""
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,15 @@ class RendererCodeJson(BaseCodeRenderer):
|
||||||
|
|
||||||
return renderedDocs
|
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.
|
Render method for document generation compatibility.
|
||||||
Delegates to document renderer if needed, or handles code files directly.
|
Delegates to document renderer if needed, or handles code files directly.
|
||||||
|
|
@ -107,7 +115,7 @@ class RendererCodeJson(BaseCodeRenderer):
|
||||||
# Import here to avoid circular dependency
|
# Import here to avoid circular dependency
|
||||||
from .rendererJson import RendererJson
|
from .rendererJson import RendererJson
|
||||||
documentRenderer = RendererJson(self.services)
|
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]:
|
def _extractJsonStatistics(self, parsed: Any) -> Dict[str, Any]:
|
||||||
"""Extract JSON statistics for validation (object count, array count, key count)."""
|
"""Extract JSON statistics for validation (object count, array count, key count)."""
|
||||||
|
|
|
||||||
|
|
@ -78,11 +78,20 @@ class RendererCodeXml(BaseCodeRenderer):
|
||||||
|
|
||||||
return renderedDocs
|
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.
|
Render method for document generation compatibility.
|
||||||
For XML, we only support code generation (no document renderer exists yet).
|
For XML, we only support code generation (no document renderer exists yet).
|
||||||
"""
|
"""
|
||||||
|
_ = style
|
||||||
# Check if this is code generation (has files array)
|
# Check if this is code generation (has files array)
|
||||||
if "files" in extractedContent:
|
if "files" in extractedContent:
|
||||||
# Code generation path - use renderCodeFiles
|
# Code generation path - use renderCodeFiles
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,17 @@ class RendererCsv(BaseRenderer):
|
||||||
"""
|
"""
|
||||||
return ["table", "code_block"]
|
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."""
|
"""Render extracted JSON content to CSV format. Produces one CSV file per table section."""
|
||||||
|
_ = style
|
||||||
try:
|
try:
|
||||||
# Validate JSON structure
|
# Validate JSON structure
|
||||||
if not self._validateJsonStructure(extractedContent):
|
if not self._validateJsonStructure(extractedContent):
|
||||||
|
|
|
||||||
|
|
@ -146,8 +146,8 @@ class RendererHtml(BaseRenderer):
|
||||||
htmlParts.append('</head>')
|
htmlParts.append('</head>')
|
||||||
htmlParts.append('<body>')
|
htmlParts.append('<body>')
|
||||||
|
|
||||||
# Document header
|
# Document header (not an h1 — body headings keep a single outline level scale)
|
||||||
htmlParts.append(f'<header><h1 class="document-title">{documentTitle}</h1></header>')
|
htmlParts.append(f'<header><p class="document-title">{documentTitle}</p></header>')
|
||||||
|
|
||||||
# Main content
|
# Main content
|
||||||
htmlParts.append('<main>')
|
htmlParts.append('<main>')
|
||||||
|
|
@ -412,16 +412,27 @@ class RendererHtml(BaseRenderer):
|
||||||
css_parts.append(" margin: 0; padding: 20px;")
|
css_parts.append(" margin: 0; padding: 20px;")
|
||||||
css_parts.append("}")
|
css_parts.append("}")
|
||||||
|
|
||||||
# Document title (uses h1 style)
|
docTitle = style.get("documentTitle") if isinstance(style.get("documentTitle"), dict) else {}
|
||||||
h1 = headings.get("h1", {})
|
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(".document-title {")
|
||||||
css_parts.append(f" font-size: {h1.get('sizePt', 24)}pt;")
|
css_parts.append(f" font-size: {dtSize}pt;")
|
||||||
css_parts.append(f" color: {h1.get('color', primaryColor)};")
|
css_parts.append(f" color: {dtColor};")
|
||||||
css_parts.append(f" font-weight: {h1.get('weight', 'bold')};")
|
css_parts.append(f" font-weight: {dtWeight};")
|
||||||
css_parts.append(" margin: 0 0 1em 0;")
|
css_parts.append(f" text-align: {dtAlign};")
|
||||||
|
css_parts.append(" margin: 0;")
|
||||||
|
css_parts.append(f" margin-bottom: {dtSpaceAfter}pt;")
|
||||||
css_parts.append("}")
|
css_parts.append("}")
|
||||||
|
|
||||||
# Headings h1-h4
|
# Headings h1-h4
|
||||||
|
h1 = headings.get("h1", {})
|
||||||
for level in range(1, 5):
|
for level in range(1, 5):
|
||||||
key = f"h{level}"
|
key = f"h{level}"
|
||||||
h = headings.get(key, h1 if level == 1 else headings.get(f"h{level-1}", {}))
|
h = headings.get(key, h1 if level == 1 else headings.get(f"h{level-1}", {}))
|
||||||
|
|
|
||||||
|
|
@ -43,8 +43,17 @@ class RendererImage(BaseRenderer):
|
||||||
"""
|
"""
|
||||||
return ["image"]
|
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."""
|
"""Render extracted JSON content to image format using AI image generation."""
|
||||||
|
_ = style
|
||||||
try:
|
try:
|
||||||
# Generate AI image from content
|
# Generate AI image from content
|
||||||
imageContent = await self._generateAiImage(extractedContent, title, userPrompt, aiService)
|
imageContent = await self._generateAiImage(extractedContent, title, userPrompt, aiService)
|
||||||
|
|
|
||||||
|
|
@ -42,8 +42,17 @@ class RendererJson(BaseRenderer):
|
||||||
# Return all types except image
|
# Return all types except image
|
||||||
return [st for st in supportedSectionTypes if st != "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."""
|
"""Render extracted JSON content to JSON format."""
|
||||||
|
_ = style
|
||||||
try:
|
try:
|
||||||
# The extracted content should already be JSON from the AI
|
# The extracted content should already be JSON from the AI
|
||||||
# Just validate and format it
|
# Just validate and format it
|
||||||
|
|
|
||||||
|
|
@ -40,8 +40,17 @@ class RendererMarkdown(BaseRenderer):
|
||||||
from modules.datamodels.datamodelJson import supportedSectionTypes
|
from modules.datamodels.datamodelJson import supportedSectionTypes
|
||||||
return [st for st in supportedSectionTypes if st != "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 Markdown format."""
|
"""Render extracted JSON content to Markdown format."""
|
||||||
|
_ = style
|
||||||
try:
|
try:
|
||||||
# Generate markdown from JSON structure
|
# Generate markdown from JSON structure
|
||||||
markdownContent = self._generateMarkdownFromJson(extractedContent, title)
|
markdownContent = self._generateMarkdownFromJson(extractedContent, title)
|
||||||
|
|
@ -280,7 +289,8 @@ class RendererMarkdown(BaseRenderer):
|
||||||
|
|
||||||
if text:
|
if text:
|
||||||
level = max(1, min(6, level))
|
level = max(1, min(6, level))
|
||||||
return f"{'#' * level} {text}"
|
md_level = min(6, level + 1)
|
||||||
|
return f"{'#' * md_level} {text}"
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -192,6 +192,7 @@ class RendererPdf(BaseRenderer):
|
||||||
|
|
||||||
# Extract sections and metadata from standardized schema
|
# Extract sections and metadata from standardized schema
|
||||||
sections = self._extractSections(json_content)
|
sections = self._extractSections(json_content)
|
||||||
|
metadata = self._extractMetadata(json_content)
|
||||||
|
|
||||||
# Create a buffer to hold the PDF
|
# Create a buffer to hold the PDF
|
||||||
buffer = io.BytesIO()
|
buffer = io.BytesIO()
|
||||||
|
|
@ -204,8 +205,13 @@ class RendererPdf(BaseRenderer):
|
||||||
else:
|
else:
|
||||||
doc = SimpleDocTemplate(buffer, pagesize=A4, rightMargin=72, leftMargin=72, topMargin=72, bottomMargin=18)
|
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 = []
|
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)
|
# Process each section (sections already extracted above)
|
||||||
self.services.utils.debugLogToFile(f"PDF SECTIONS TO PROCESS: {len(sections)} sections", "PDF_RENDERER")
|
self.services.utils.debugLogToFile(f"PDF SECTIONS TO PROCESS: {len(sections)} sections", "PDF_RENDERER")
|
||||||
|
|
@ -561,6 +567,22 @@ class RendererPdf(BaseRenderer):
|
||||||
"space_before": sb,
|
"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:
|
def _createHeadingStyle(self, styles: Dict[str, Any], level: int) -> ParagraphStyle:
|
||||||
"""Create heading style from style definitions."""
|
"""Create heading style from style definitions."""
|
||||||
heading_key = f"heading{level}"
|
heading_key = f"heading{level}"
|
||||||
|
|
@ -851,25 +873,35 @@ class RendererPdf(BaseRenderer):
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _renderJsonBulletList(self, list_data: Dict[str, Any], styles: Dict[str, Any]) -> List[Any]:
|
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:
|
try:
|
||||||
content = list_data.get("content", {})
|
content = list_data.get("content", {})
|
||||||
if not isinstance(content, dict):
|
if not isinstance(content, dict):
|
||||||
return []
|
return []
|
||||||
items = content.get("items", [])
|
items = content.get("items", [])
|
||||||
bulletStyleDef = styles.get("bullet_list", {})
|
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 = []
|
elements = []
|
||||||
for item in items:
|
for item in items:
|
||||||
runs = self._inlineRunsForListItem(item)
|
runs = self._inlineRunsForListItem(item)
|
||||||
if isinstance(item, list):
|
if isinstance(item, list):
|
||||||
xml = self._renderInlineRunsToPdfXml(runs)
|
xml = self._renderInlineRunsToPdfXml(runs)
|
||||||
elements.append(Paragraph(f"\u2022 {_wrapEmojiSpansInXml(xml)}", normalStyle))
|
elements.append(Paragraph(f"{bulletChar} {_wrapEmojiSpansInXml(xml)}", bulletStyle))
|
||||||
elif isinstance(item, str):
|
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:
|
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:
|
if elements:
|
||||||
elements.append(Spacer(1, bulletStyleDef.get("space_after", 3)))
|
elements.append(Spacer(1, bulletStyleDef.get("space_after", 3)))
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import re
|
||||||
|
|
||||||
from .documentRendererBaseTemplate import BaseRenderer
|
from .documentRendererBaseTemplate import BaseRenderer
|
||||||
from modules.datamodels.datamodelDocument import RenderedDocument
|
from modules.datamodels.datamodelDocument import RenderedDocument
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional, Union
|
||||||
|
|
||||||
class RendererText(BaseRenderer):
|
class RendererText(BaseRenderer):
|
||||||
"""Renders content to plain text format with format-specific extraction."""
|
"""Renders content to plain text format with format-specific extraction."""
|
||||||
|
|
@ -76,8 +76,17 @@ class RendererText(BaseRenderer):
|
||||||
# Text renderer accepts all types except images
|
# Text renderer accepts all types except images
|
||||||
return [st for st in supportedSectionTypes if st != "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 plain text format."""
|
"""Render extracted JSON content to plain text format."""
|
||||||
|
_ = style # unified style from renderReport; plain text ignores formatting hints
|
||||||
try:
|
try:
|
||||||
# Generate text from JSON structure
|
# Generate text from JSON structure
|
||||||
textContent = self._generateTextFromJson(extractedContent, title)
|
textContent = self._generateTextFromJson(extractedContent, title)
|
||||||
|
|
@ -187,8 +196,10 @@ class RendererText(BaseRenderer):
|
||||||
textParts.append(f"[Reference: {label}]")
|
textParts.append(f"[Reference: {label}]")
|
||||||
continue
|
continue
|
||||||
elif element_type == "extracted_text":
|
elif element_type == "extracted_text":
|
||||||
# Extracted text format
|
# Extracted text format (str or raw bytes from ContentPart)
|
||||||
content = element.get("content", "")
|
content = element.get("content", "")
|
||||||
|
if isinstance(content, (bytes, bytearray, memoryview)):
|
||||||
|
content = bytes(content).decode("utf-8", errors="replace")
|
||||||
source = element.get("source", "")
|
source = element.get("source", "")
|
||||||
if content:
|
if content:
|
||||||
source_text = f" (Source: {source})" if source else ""
|
source_text = f" (Source: {source})" if source else ""
|
||||||
|
|
@ -263,16 +274,16 @@ class RendererText(BaseRenderer):
|
||||||
textParts = []
|
textParts = []
|
||||||
|
|
||||||
# Create table header
|
# Create table header
|
||||||
headerLine = " | ".join(str(header) for header in headers)
|
headerLine = " | ".join(self._tableCellToPlainText(h) for h in headers)
|
||||||
textParts.append(headerLine)
|
textParts.append(headerLine)
|
||||||
|
|
||||||
# Add separator line
|
# 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)
|
textParts.append(separatorLine)
|
||||||
|
|
||||||
# Add data rows
|
# Add data rows
|
||||||
for row in 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)
|
textParts.append(rowLine)
|
||||||
|
|
||||||
return '\n'.join(textParts)
|
return '\n'.join(textParts)
|
||||||
|
|
@ -299,6 +310,9 @@ class RendererText(BaseRenderer):
|
||||||
textParts.append(f"- {self._stripMarkdownForPlainText(item)}")
|
textParts.append(f"- {self._stripMarkdownForPlainText(item)}")
|
||||||
elif isinstance(item, dict) and "text" in item:
|
elif isinstance(item, dict) and "text" in item:
|
||||||
textParts.append(f"- {self._stripMarkdownForPlainText(item['text'])}")
|
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)
|
return '\n'.join(textParts)
|
||||||
|
|
||||||
|
|
@ -311,22 +325,24 @@ class RendererText(BaseRenderer):
|
||||||
try:
|
try:
|
||||||
# Extract from nested content structure: element.content.{text, level}
|
# Extract from nested content structure: element.content.{text, level}
|
||||||
content = headingData.get("content", {})
|
content = headingData.get("content", {})
|
||||||
if not isinstance(content, dict):
|
if isinstance(content, dict) and content:
|
||||||
return ""
|
|
||||||
text = self._stripMarkdownForPlainText(content.get("text", ""))
|
text = self._stripMarkdownForPlainText(content.get("text", ""))
|
||||||
level = content.get("level", 1)
|
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:
|
else:
|
||||||
return f"{'#' * level} {text}"
|
# AI shorthand: {"type":"heading","text":"...","level":2}
|
||||||
|
text = self._stripMarkdownForPlainText(str(headingData.get("text", "") or ""))
|
||||||
|
level = headingData.get("level", 1)
|
||||||
|
if not text:
|
||||||
return ""
|
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:
|
except Exception as e:
|
||||||
self.logger.warning(f"Error rendering heading: {str(e)}")
|
self.logger.warning(f"Error rendering heading: {str(e)}")
|
||||||
return ""
|
return ""
|
||||||
|
|
@ -345,12 +361,65 @@ class RendererText(BaseRenderer):
|
||||||
text = re.sub(r'`([^`]+)`', r'\1', text)
|
text = re.sub(r'`([^`]+)`', r'\1', text)
|
||||||
return text.strip()
|
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:
|
def _renderJsonParagraph(self, paragraphData: Dict[str, Any]) -> str:
|
||||||
"""Render a JSON paragraph to text. Strips markdown for plain text output."""
|
"""Render a JSON paragraph to text. Strips markdown for plain text output."""
|
||||||
try:
|
try:
|
||||||
# Extract from nested content structure
|
# Models often return {"type":"paragraph","text":"..."} without nested "content"
|
||||||
content = paragraphData.get("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):
|
if isinstance(content, dict):
|
||||||
|
runs = self._inlineRunsFromContent(content)
|
||||||
|
if runs:
|
||||||
|
return self._stripMarkdownForPlainText(self._inlineRunsToPlainText(runs))
|
||||||
text = content.get("text", "")
|
text = content.get("text", "")
|
||||||
elif isinstance(content, str):
|
elif isinstance(content, str):
|
||||||
text = content
|
text = content
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,19 @@ DEFAULT_STYLE: Dict[str, Any] = {
|
||||||
"accent": "#2980B9",
|
"accent": "#2980B9",
|
||||||
"background": "#FFFFFF",
|
"background": "#FFFFFF",
|
||||||
},
|
},
|
||||||
|
"documentTitle": {
|
||||||
|
"sizePt": 28,
|
||||||
|
"weight": "bold",
|
||||||
|
"color": "#1F3864",
|
||||||
|
"spaceBeforePt": 0,
|
||||||
|
"spaceAfterPt": 18,
|
||||||
|
"align": "center",
|
||||||
|
},
|
||||||
"headings": {
|
"headings": {
|
||||||
"h1": {"sizePt": 24, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 12, "spaceAfterPt": 6},
|
"h1": {"sizePt": 22, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 22, "spaceAfterPt": 8},
|
||||||
"h2": {"sizePt": 18, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 10, "spaceAfterPt": 4},
|
"h2": {"sizePt": 18, "weight": "bold", "color": "#1F3864", "spaceBeforePt": 20, "spaceAfterPt": 6},
|
||||||
"h3": {"sizePt": 14, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 8, "spaceAfterPt": 3},
|
"h3": {"sizePt": 14, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 16, "spaceAfterPt": 4},
|
||||||
"h4": {"sizePt": 12, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 6, "spaceAfterPt": 2},
|
"h4": {"sizePt": 12, "weight": "bold", "color": "#2C3E50", "spaceBeforePt": 12, "spaceAfterPt": 3},
|
||||||
},
|
},
|
||||||
"paragraph": {"sizePt": 11, "lineSpacing": 1.15, "color": "#333333"},
|
"paragraph": {"sizePt": 11, "lineSpacing": 1.15, "color": "#333333"},
|
||||||
"table": {
|
"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,
|
getInterface as getSubscriptionInterface,
|
||||||
InvalidTransitionError,
|
InvalidTransitionError,
|
||||||
)
|
)
|
||||||
|
from modules.shared.i18nRegistry import t
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -581,6 +582,256 @@ class SubscriptionService:
|
||||||
def syncStripeQuantity(self, subscriptionId: str):
|
def syncStripeQuantity(self, subscriptionId: str):
|
||||||
self._interface.syncQuantityToStripe(subscriptionId)
|
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
|
# Notifications
|
||||||
|
|
@ -608,66 +859,66 @@ def _notifySubscriptionChange(
|
||||||
|
|
||||||
templates: Dict[str, Dict[str, Any]] = {
|
templates: Dict[str, Dict[str, Any]] = {
|
||||||
"activated": {
|
"activated": {
|
||||||
"subject": f"[PowerOn] Abonnement aktiviert — {planLabel}",
|
"subject": f"[PowerOn] {t('Abonnement aktiviert')} — {planLabel}",
|
||||||
"headline": "Abonnement aktiviert",
|
"headline": t("Abonnement aktiviert"),
|
||||||
"paragraphs": [
|
"paragraphs": [
|
||||||
p for p in [
|
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,
|
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
|
] if p
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"cancelled": {
|
"cancelled": {
|
||||||
"subject": f"[PowerOn] Abonnement gekündigt — {planLabel}",
|
"subject": f"[PowerOn] {t('Abonnement gekündigt')} — {planLabel}",
|
||||||
"headline": "Abonnement gekündigt",
|
"headline": t("Abonnement gekündigt"),
|
||||||
"paragraphs": [
|
"paragraphs": [
|
||||||
p for p in [
|
p for p in [
|
||||||
f"Das Abonnement «{planLabel}» wurde gekündigt.",
|
t("Das Abonnement «{planLabel}» wurde gekündigt.").format(planLabel=planLabel),
|
||||||
platformHint,
|
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
|
] if p
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"force_cancelled": {
|
"force_cancelled": {
|
||||||
"subject": f"[PowerOn] Abonnement sofort beendet — {planLabel}",
|
"subject": f"[PowerOn] {t('Abonnement sofort beendet')} — {planLabel}",
|
||||||
"headline": "Abonnement sofort beendet",
|
"headline": t("Abonnement sofort beendet"),
|
||||||
"paragraphs": [
|
"paragraphs": [
|
||||||
p for p in [
|
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,
|
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
|
] if p
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"trial_expired": {
|
"trial_expired": {
|
||||||
"subject": "[PowerOn] Testphase abgelaufen",
|
"subject": f"[PowerOn] {t('Testphase abgelaufen')}",
|
||||||
"headline": "Testphase abgelaufen",
|
"headline": t("Testphase abgelaufen"),
|
||||||
"paragraphs": [
|
"paragraphs": [
|
||||||
p for p in [
|
p for p in [
|
||||||
"Die kostenlose Testphase ist abgelaufen.",
|
t("Die kostenlose Testphase ist abgelaufen."),
|
||||||
platformHint,
|
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
|
] if p
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"payment_failed": {
|
"payment_failed": {
|
||||||
"subject": f"[PowerOn] Zahlung fehlgeschlagen — {planLabel}",
|
"subject": f"[PowerOn] {t('Zahlung fehlgeschlagen')} — {planLabel}",
|
||||||
"headline": "Zahlung fehlgeschlagen",
|
"headline": t("Zahlung fehlgeschlagen"),
|
||||||
"paragraphs": [
|
"paragraphs": [
|
||||||
p for p in [
|
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,
|
platformHint,
|
||||||
"Bitte aktualisieren Sie Ihr Zahlungsmittel unter Billing-Verwaltung.",
|
t("Bitte aktualisieren Sie Ihr Zahlungsmittel unter Billing-Verwaltung."),
|
||||||
] if p
|
] if p
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
tpl = templates.get(event, {
|
tpl = templates.get(event, {
|
||||||
"subject": f"[PowerOn] Abonnement-Änderung — {planLabel}",
|
"subject": f"[PowerOn] {t('Abonnement-Änderung')} — {planLabel}",
|
||||||
"headline": "Abonnement-Änderung",
|
"headline": t("Abonnement-Änderung"),
|
||||||
"paragraphs": [f"Änderung am Abonnement «{planLabel}»."],
|
"paragraphs": [t("Änderung am Abonnement «{planLabel}».").format(planLabel=planLabel)],
|
||||||
})
|
})
|
||||||
|
|
||||||
notifyMandateAdmins(
|
notifyMandateAdmins(
|
||||||
|
|
@ -699,7 +950,7 @@ def _buildInvoiceSummaryHtml(
|
||||||
instanceTotal = billableModules * instancePrice
|
instanceTotal = billableModules * instancePrice
|
||||||
netTotal = userTotal + instanceTotal
|
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:
|
def _chf(amount: float) -> str:
|
||||||
return f"CHF {amount:,.2f}".replace(",", "'")
|
return f"CHF {amount:,.2f}".replace(",", "'")
|
||||||
|
|
@ -707,13 +958,13 @@ def _buildInvoiceSummaryHtml(
|
||||||
rows = ""
|
rows = ""
|
||||||
if userPrice > 0:
|
if userPrice > 0:
|
||||||
rows += (
|
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 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'
|
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:
|
if instancePrice > 0 and billableModules > 0:
|
||||||
rows += (
|
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 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'
|
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 = (
|
invoiceLink = (
|
||||||
f'<p style="margin:12px 0 0 0;font-size:14px;">'
|
f'<p style="margin:12px 0 0 0;font-size:14px;">'
|
||||||
f'<a href="{htmlmod.escape(hostedUrl)}" style="color:#3b82f6;text-decoration:underline;">'
|
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:
|
except Exception as e:
|
||||||
logger.warning("Could not fetch Stripe invoice URL for sub %s: %s", stripeSubId, e)
|
logger.warning("Could not fetch Stripe invoice URL for sub %s: %s", stripeSubId, e)
|
||||||
|
|
@ -741,13 +992,13 @@ def _buildInvoiceSummaryHtml(
|
||||||
return (
|
return (
|
||||||
f'<table style="width:100%;border-collapse:collapse;font-size:14px;margin:8px 0;">'
|
f'<table style="width:100%;border-collapse:collapse;font-size:14px;margin:8px 0;">'
|
||||||
f'<thead><tr style="border-bottom:2px solid #e5e7eb;">'
|
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: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;">Menge × Preis</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;">Total</th>'
|
f'<th style="text-align:right;padding:8px 0;color:#6b7280;font-weight:500;">{t("Total")}</th>'
|
||||||
f'</tr></thead>'
|
f'</tr></thead>'
|
||||||
f'<tbody>{rows}</tbody>'
|
f'<tbody>{rows}</tbody>'
|
||||||
f'<tfoot><tr style="border-top:2px solid #1a1a2e;">'
|
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></td>'
|
||||||
f'<td style="padding:10px 0;text-align:right;font-weight:700;color:#1a1a2e;font-size:16px;">{_chf(netTotal)}</td>'
|
f'<td style="padding:10px 0;text-align:right;font-weight:700;color:#1a1a2e;font-size:16px;">{_chf(netTotal)}</td>'
|
||||||
f'</tr></tfoot>'
|
f'</tr></tfoot>'
|
||||||
|
|
@ -776,7 +1027,7 @@ def _buildCancelSummaryHtml(subRecord: Dict[str, Any], platformUrl: str = "") ->
|
||||||
parts.append(
|
parts.append(
|
||||||
f'<p style="margin:4px 0;font-size:14px;">'
|
f'<p style="margin:4px 0;font-size:14px;">'
|
||||||
f'<a href="{htmlmod.escape(hostedUrl)}" style="color:#3b82f6;text-decoration:underline;">'
|
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:
|
except Exception as e:
|
||||||
logger.warning("Could not fetch Stripe invoice URL for sub %s: %s", stripeSubId, 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.mandateId = mandateId
|
||||||
self.reason = _subscriptionReasonForStatus(status)
|
self.reason = _subscriptionReasonForStatus(status)
|
||||||
self.userAction = _subscriptionUserActionForStatus(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."
|
"Kein aktives Abonnement für diesen Mandanten. Bitte wählen Sie einen Plan unter Billing."
|
||||||
)
|
)
|
||||||
super().__init__(self.message)
|
super().__init__(self.message)
|
||||||
|
|
@ -837,47 +1088,62 @@ class SubscriptionInactiveException(Exception):
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
_SUBSCRIPTION_LIMITS_UI_HINT_DE = (
|
SUBSCRIPTION_USER_ACTION_CONTACT_ADMIN = "CONTACT_ADMIN"
|
||||||
" Details zu Ihrem Abonnement, den enthaltenen Limits und Upgrade-Optionen: "
|
|
||||||
|
|
||||||
|
def _subscriptionLimitsHint() -> str:
|
||||||
|
return " " + t(
|
||||||
|
"Details zu Ihrem Abonnement, den enthaltenen Limits und Upgrade-Optionen: "
|
||||||
"Menü «Administration» → «Billing» → Registerkarte «Abonnement»."
|
"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):
|
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.resourceType = resourceType
|
||||||
self.currentCount = currentCount
|
self.currentCount = currentCount
|
||||||
self.maxAllowed = maxAllowed
|
self.maxAllowed = maxAllowed
|
||||||
|
self.isEnterprise = isEnterprise
|
||||||
|
hint = _enterpriseLimitsHint() if isEnterprise else _subscriptionLimitsHint()
|
||||||
if message is not None:
|
if message is not None:
|
||||||
self.message = message
|
self.message = message
|
||||||
elif resourceType == "users":
|
elif resourceType == "users":
|
||||||
self.message = (
|
self.message = t(
|
||||||
f"Mit dem aktuellen Abonnement sind für diesen Mandanten höchstens {maxAllowed} "
|
"Mit dem aktuellen Abonnement sind für diesen Mandanten höchstens {maxAllowed} "
|
||||||
f"Benutzer zulässig (derzeit {currentCount}). "
|
"Benutzer zulässig (derzeit {currentCount}). "
|
||||||
f"Ohne Planwechsel können keine weiteren Benutzer hinzugefügt werden."
|
"Ohne Planwechsel können keine weiteren Benutzer hinzugefügt werden."
|
||||||
) + _SUBSCRIPTION_LIMITS_UI_HINT_DE
|
).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint
|
||||||
elif resourceType == "featureInstances":
|
elif resourceType == "featureInstances":
|
||||||
self.message = (
|
self.message = t(
|
||||||
f"Es sind höchstens {maxAllowed} aktive Module erlaubt (derzeit {currentCount}). "
|
"Es sind höchstens {maxAllowed} aktive Module erlaubt (derzeit {currentCount}). "
|
||||||
f"Bitte Abonnement erweitern oder ein Modul entfernen."
|
"Bitte Abonnement erweitern oder ein Modul entfernen."
|
||||||
) + _SUBSCRIPTION_LIMITS_UI_HINT_DE
|
).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint
|
||||||
elif resourceType == "dataVolumeMB":
|
elif resourceType == "dataVolumeMB":
|
||||||
self.message = (
|
self.message = t(
|
||||||
f"Das im Abonnement enthaltene Datenvolumen ({maxAllowed} MB) reicht nicht "
|
"Das im Abonnement enthaltene Datenvolumen ({maxAllowed} MB) reicht nicht "
|
||||||
f"(aktuell ca. {currentCount} MB). Bitte Speicher-Limit oder Plan anpassen."
|
"(aktuell ca. {currentCount} MB). Bitte Speicher-Limit oder Plan anpassen."
|
||||||
) + _SUBSCRIPTION_LIMITS_UI_HINT_DE
|
).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint
|
||||||
else:
|
else:
|
||||||
self.message = (
|
self.message = t(
|
||||||
f"Abonnement-Limit überschritten (Ressource «{resourceType}»: "
|
"Abonnement-Limit überschritten (Ressource «{resourceType}»: "
|
||||||
f"aktuell {currentCount}, erlaubt {maxAllowed})."
|
"aktuell {currentCount}, erlaubt {maxAllowed})."
|
||||||
) + _SUBSCRIPTION_LIMITS_UI_HINT_DE
|
).format(resourceType=resourceType, currentCount=currentCount, maxAllowed=maxAllowed) + hint
|
||||||
super().__init__(self.message)
|
super().__init__(self.message)
|
||||||
|
|
||||||
def toClientDict(self) -> Dict[str, Any]:
|
def toClientDict(self) -> Dict[str, Any]:
|
||||||
|
action = SUBSCRIPTION_USER_ACTION_CONTACT_ADMIN if self.isEnterprise else SUBSCRIPTION_USER_ACTION_UPGRADE
|
||||||
return {
|
return {
|
||||||
"error": f"SUBSCRIPTION_{self.resourceType.upper()}_LIMIT",
|
"error": f"SUBSCRIPTION_{self.resourceType.upper()}_LIMIT",
|
||||||
"currentCount": self.currentCount, "maxAllowed": self.maxAllowed,
|
"currentCount": self.currentCount, "maxAllowed": self.maxAllowed,
|
||||||
"message": self.message, "userAction": SUBSCRIPTION_USER_ACTION_UPGRADE,
|
"message": self.message, "userAction": action,
|
||||||
"subscriptionUiPath": "/admin/billing?tab=subscription",
|
"subscriptionUiPath": "/admin/billing?tab=subscription",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2172,11 +2172,13 @@ def getContexts(
|
||||||
>>> print(contexts.overlapContext) # "" (empty - JSON is complete)
|
>>> print(contexts.overlapContext) # "" (empty - JSON is complete)
|
||||||
>>> print(contexts.jsonParsingSuccess) # True
|
>>> 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
|
jsonIsComplete = False
|
||||||
if truncatedJson and truncatedJson.strip():
|
if truncatedJson and truncatedJson.strip():
|
||||||
parsed, error = _tryParseJson(truncatedJson.strip())
|
_parsed_hdr, error_hdr, _ = _utils_try_parse_json(truncatedJson)
|
||||||
if error is None:
|
if error_hdr is None:
|
||||||
jsonIsComplete = True
|
jsonIsComplete = True
|
||||||
logger.debug("Original JSON is already complete (no cut point)")
|
logger.debug("Original JSON is already complete (no cut point)")
|
||||||
|
|
||||||
|
|
@ -2193,28 +2195,27 @@ def getContexts(
|
||||||
jsonParsingSuccess = False
|
jsonParsingSuccess = False
|
||||||
|
|
||||||
if completePart and completePart.strip():
|
if completePart and completePart.strip():
|
||||||
# First attempt: parse as-is
|
parsed, error, _ = _utils_try_parse_json(completePart)
|
||||||
parsed, error = _tryParseJson(completePart)
|
|
||||||
|
|
||||||
if error is None:
|
if error is None:
|
||||||
jsonParsingSuccess = True
|
jsonParsingSuccess = True
|
||||||
else:
|
else:
|
||||||
# Second attempt: repair internal errors and retry
|
logger.debug(f"Initial parse failed: {error}, attempting internal repair")
|
||||||
logger.debug(f"Initial parse failed: {error}, attempting repair")
|
|
||||||
repairedCompletePart = _repairInternalJsonErrors(completePart)
|
repairedCompletePart = _repairInternalJsonErrors(completePart)
|
||||||
|
parsed, error, _ = _utils_try_parse_json(repairedCompletePart)
|
||||||
parsed, error = _tryParseJson(repairedCompletePart)
|
|
||||||
|
|
||||||
if error is None:
|
if error is None:
|
||||||
# Repair succeeded - use repaired version
|
|
||||||
completePart = repairedCompletePart
|
completePart = repairedCompletePart
|
||||||
jsonParsingSuccess = True
|
jsonParsingSuccess = True
|
||||||
logger.debug("JSON repair successful")
|
logger.debug("JSON repair successful")
|
||||||
else:
|
else:
|
||||||
# Repair also failed - keep original completePart, mark as failed
|
|
||||||
logger.debug(f"JSON repair also failed: {error}")
|
logger.debug(f"JSON repair also failed: {error}")
|
||||||
jsonParsingSuccess = False
|
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(
|
return JsonContinuationContexts(
|
||||||
overlapContext=overlap,
|
overlapContext=overlap,
|
||||||
hierarchyContext=hierarchy,
|
hierarchyContext=hierarchy,
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue