Merge pull request #116 from valueonag/int

Int
This commit is contained in:
Patrick Motsch 2026-04-04 16:20:30 +02:00 committed by GitHub
commit b4243e1589
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
188 changed files with 13657 additions and 3742 deletions

51
app.py
View file

@ -21,6 +21,7 @@ from datetime import datetime
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.shared.eventManagement import eventManager from modules.shared.eventManagement import eventManager
from modules.workflows.automation import subAutomationSchedule from modules.workflows.automation import subAutomationSchedule
from modules.workflows.automation2 import subAutomation2Schedule
from modules.features.automation2.emailPoller import start as startAutomation2EmailPoller from modules.features.automation2.emailPoller import start as startAutomation2EmailPoller
from modules.features.automation2.emailPoller import stop as stopAutomation2EmailPoller from modules.features.automation2.emailPoller import stop as stopAutomation2EmailPoller
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
@ -355,7 +356,15 @@ async def lifespan(app: FastAPI):
logger.warning(f"Could not initialize feature containers: {e}") logger.warning(f"Could not initialize feature containers: {e}")
# --- Init Managers --- # --- Init Managers ---
import asyncio
try:
main_loop = asyncio.get_running_loop()
eventManager.set_event_loop(main_loop)
subAutomation2Schedule.set_main_loop(main_loop)
except RuntimeError:
pass
subAutomationSchedule.start(eventUser) # Automation scheduler subAutomationSchedule.start(eventUser) # Automation scheduler
subAutomation2Schedule.start(eventUser) # Automation2 schedule trigger (cron)
# Automation2 email poller: started on-demand when a run pauses for email.checkEmail # Automation2 email poller: started on-demand when a run pauses for email.checkEmail
eventManager.start() eventManager.start()
@ -374,7 +383,7 @@ async def lifespan(app: FastAPI):
if settingsCreated > 0: if settingsCreated > 0:
logger.info(f"Billing startup: Created {settingsCreated} missing mandate billing settings") logger.info(f"Billing startup: Created {settingsCreated} missing mandate billing settings")
# Step 2: Ensure all users have billing accounts (for PREPAY_USER mandates) # Step 2: Ensure all users have billing audit accounts
accountsCreated = billingInterface.ensureAllUserAccountsExist() accountsCreated = billingInterface.ensureAllUserAccountsExist()
if accountsCreated > 0: if accountsCreated > 0:
logger.info(f"Billing startup: Created {accountsCreated} missing user accounts") logger.info(f"Billing startup: Created {accountsCreated} missing user accounts")
@ -386,6 +395,7 @@ async def lifespan(app: FastAPI):
# --- Stop Managers --- # --- Stop Managers ---
stopAutomation2EmailPoller(eventUser) # Automation2 email poller (no-op if not running) stopAutomation2EmailPoller(eventUser) # Automation2 email poller (no-op if not running)
subAutomation2Schedule.stop(eventUser) # Automation2 schedule
eventManager.stop() eventManager.stop()
subAutomationSchedule.stop(eventUser) # Automation scheduler subAutomationSchedule.stop(eventUser) # Automation scheduler
@ -479,18 +489,6 @@ def getAllowedOrigins():
CORS_ORIGIN_REGEX = r"https://.*\.(poweron\.swiss|poweron-center\.net)" CORS_ORIGIN_REGEX = r"https://.*\.(poweron\.swiss|poweron-center\.net)"
# CORS configuration using environment variables
app.add_middleware(
CORSMiddleware,
allow_origins=getAllowedOrigins(),
allow_origin_regex=CORS_ORIGIN_REGEX,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["*"],
expose_headers=["*"],
max_age=86400, # Increased caching for preflight requests
)
# SlowAPI rate limiter initialization # SlowAPI rate limiter initialization
from modules.auth import limiter from modules.auth import limiter
from slowapi.errors import RateLimitExceeded from slowapi.errors import RateLimitExceeded
@ -500,7 +498,7 @@ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
async def _insufficientBalanceHandler(request: Request, exc: Exception): async def _insufficientBalanceHandler(request: Request, exc: Exception):
"""HTTP 402 with structured billing hint (PREPAY_USER vs PREPAY_MANDATE).""" """HTTP 402 with structured billing hint."""
payload = exc.toClientDict() if hasattr(exc, "toClientDict") else {"error": "INSUFFICIENT_BALANCE", "message": str(exc)} payload = exc.toClientDict() if hasattr(exc, "toClientDict") else {"error": "INSUFFICIENT_BALANCE", "message": str(exc)}
return JSONResponse(status_code=402, content={"detail": payload}) return JSONResponse(status_code=402, content={"detail": payload})
@ -528,6 +526,19 @@ app.add_middleware(
ProactiveTokenRefreshMiddleware, enabled=True, check_interval_minutes=5 ProactiveTokenRefreshMiddleware, enabled=True, check_interval_minutes=5
) )
# CORS must be registered LAST so it wraps the whole stack: every response (errors, CSRF 403,
# rate limits) still gets Access-Control-Allow-Origin for browser cross-origin calls.
app.add_middleware(
CORSMiddleware,
allow_origins=getAllowedOrigins(),
allow_origin_regex=CORS_ORIGIN_REGEX,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["*"],
expose_headers=["*"],
max_age=86400,
)
# Include all routers # Include all routers
from modules.routes.routeAdmin import router as generalRouter from modules.routes.routeAdmin import router as generalRouter
@ -545,6 +556,9 @@ app.include_router(userRouter)
from modules.routes.routeDataFiles import router as fileRouter from modules.routes.routeDataFiles import router as fileRouter
app.include_router(fileRouter) app.include_router(fileRouter)
from modules.routes.routeDataSources import router as dataSourceRouter
app.include_router(dataSourceRouter)
from modules.routes.routeDataPrompts import router as promptRouter from modules.routes.routeDataPrompts import router as promptRouter
app.include_router(promptRouter) app.include_router(promptRouter)
@ -560,9 +574,18 @@ app.include_router(msftRouter)
from modules.routes.routeSecurityGoogle import router as googleRouter from modules.routes.routeSecurityGoogle import router as googleRouter
app.include_router(googleRouter) app.include_router(googleRouter)
from modules.routes.routeSecurityClickup import router as clickupRouter
app.include_router(clickupRouter)
from modules.routes.routeClickup import router as clickupApiRouter
app.include_router(clickupApiRouter)
from modules.routes.routeVoiceGoogle import router as voiceGoogleRouter from modules.routes.routeVoiceGoogle import router as voiceGoogleRouter
app.include_router(voiceGoogleRouter) app.include_router(voiceGoogleRouter)
from modules.routes.routeVoiceUser import router as voiceUserRouter
app.include_router(voiceUserRouter)
from modules.routes.routeSecurityAdmin import router as adminSecurityRouter from modules.routes.routeSecurityAdmin import router as adminSecurityRouter
app.include_router(adminSecurityRouter) app.include_router(adminSecurityRouter)

View file

@ -46,15 +46,20 @@ Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.ap
Service_GOOGLE_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETDJhbGVQMHlFQzNPVFI1ZzBMa3pNMGlQUHhaQm10eVl1bFlSeTBybzlTOWE2MURXQ0hkRlo0NlNGbHQxWEl1OVkxQnVKYlhhOXR1cUF4T3k0WDdscktkY1oyYllRTmdDTWpfbUdwWGtSd1JvNlYxeTBJdEtaaS1vYnItcW0yaFM= Service_GOOGLE_DATA_CLIENT_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpETDJhbGVQMHlFQzNPVFI1ZzBMa3pNMGlQUHhaQm10eVl1bFlSeTBybzlTOWE2MURXQ0hkRlo0NlNGbHQxWEl1OVkxQnVKYlhhOXR1cUF4T3k0WDdscktkY1oyYllRTmdDTWpfbUdwWGtSd1JvNlYxeTBJdEtaaS1vYnItcW0yaFM=
Service_GOOGLE_DATA_REDIRECT_URI = http://localhost:8000/api/google/auth/connect/callback 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
# Stripe Billing (both end with _SECRET for encryption script) # Stripe Billing (both end with _SECRET for encryption script)
STRIPE_SECRET_KEY_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGWDkxSldfM0NCZ3dmbHY5cS1nQlI3UWZ4ZWRrNVdUdEFKa25RckRiQWY0c1E5MjVsZzlfRkZEU0VFU2tNQ01qZnRNQ0pZVU9hVFN6OEU0RXhwdTl3algzLWJlSXRhYmZlMHltSC1XejlGWEU5TDF1LUlYNEh1aG9tRFI4YmlCYzUyei02U1dabWoyb0N2dVFSb1RhWTNnQjBCZkFjV0FfOWdYdDVpX1k5R2pYM1R6SHRiaE10V1l1dnQybjVHWDRiQUJLM0UxRDZnczhJZGFsc3JhOU82QT09 STRIPE_SECRET_KEY_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5aHNGejgzQmpTdmprdzQxR19KZkh3MlhYUTNseFN3WnlaWjh2SDZyalN6aU9xSktkbUQwUnZrVnlvbGVRQm4yZFdiRU5aSEk5WVJuUnR4VUwtTm9OVk1WWmJQeU5QaDdib0hfVWV5U1BfYTFXRmdoOWdnOWxkb3JFQmF3bm45UjFUVUxmWGtGRkFKUGd6bmhpQlFnaVI3Q2lLdDlsY1VESk1vOEM0ZFBJNW1qcVZ0N2tPYmRLNmVKajZ2M3o3S05lWnRRVG5LdkRseW4wQ3VjNHNQZTZUdz09
STRIPE_WEBHOOK_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGcHNWTWpBWkFHRExtdU01N3RyZzNsMjhUS3NiVTNCZmMwN2NEcFZ6UkQ1a2I0aUkyNU4wR2dUdHJXYmtkaEFRUnFpcThObHBEQmJkdEFnT1FXeUxOTlU3UDFNRzl6LWdpRFpYdExvY3FTTG9MTkswdEhrVkNKQVFucnBjSnhLNm4= STRIPE_WEBHOOK_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5dDJMSHBrVk8wTzJhU2xzTTZCZWdvWmU2NGI2WklfRXRJZVUzaVYyOU9GLUZsalUwa2lPdEgtUHo0dVVvRDU1cy1saHJyU0Rxa2xQZjBuakExQzk3bmxBcU9WbEIxUEtpR1JoUFMxZG9ISGRZUXFhdFpSMGxvQUV3a0VLQllfUUtCOHZwTGdteV9rYTFOazBfSlN3ekNWblFpakJlZVlCTmNkWWQ4Sm01a1RCWTlnTlFHWVA0MkZYMlprUExrWFN2V0NVU1BTd1NKczFJbVo3VHpLdlc4UT09
STRIPE_API_VERSION = 2026-01-28.clover STRIPE_API_VERSION = 2026-01-28.clover
# AI configuration # AI configuration
Connector_AiOpenai_API_SECRET = DEV_ENC:Z0FBQUFBQnBaSnM4TWFRRmxVQmNQblVIYmc1Y0Q3aW9zZUtDWlNWdGZjbFpncGp2NHN2QjkxMWxibUJnZDBId252MWk5TXN3Yk14ajFIdi1CTkx2ZWx2QzF5OFR6LUx5azQ3dnNLaXJBOHNxc0tlWmtZcTFVelF4eXBSM2JkbHd2eTM0VHNXdHNtVUprZWtPVzctNlJsZHNmM20tU1N6Q1Q2cHFYSi1tNlhZNDNabTVuaEVGWmIydEhadTcyMlBURmw2aUJxOF9GTzR0dTZiNGZfOFlHaVpPZ1A1LXhhOEFtN1J5TEVNNWtMcGpyNkMzSl8xRnZsaTF1WTZrOUZmb0cxVURjSGFLS2dIYTQyZEJtTm90bEYxVWxNNXVPdTVjaVhYbXhxT3JsVDM5VjZMVFZKSE1tZnM9 Connector_AiOpenai_API_SECRET = DEV_ENC:Z0FBQUFBQnBaSnM4TWFRRmxVQmNQblVIYmc1Y0Q3aW9zZUtDWlNWdGZjbFpncGp2NHN2QjkxMWxibUJnZDBId252MWk5TXN3Yk14ajFIdi1CTkx2ZWx2QzF5OFR6LUx5azQ3dnNLaXJBOHNxc0tlWmtZcTFVelF4eXBSM2JkbHd2eTM0VHNXdHNtVUprZWtPVzctNlJsZHNmM20tU1N6Q1Q2cHFYSi1tNlhZNDNabTVuaEVGWmIydEhadTcyMlBURmw2aUJxOF9GTzR0dTZiNGZfOFlHaVpPZ1A1LXhhOEFtN1J5TEVNNWtMcGpyNkMzSl8xRnZsaTF1WTZrOUZmb0cxVURjSGFLS2dIYTQyZEJtTm90bEYxVWxNNXVPdTVjaVhYbXhxT3JsVDM5VjZMVFZKSE1tZnM9
Connector_AiAnthropic_API_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpENmFBWG16STFQUVZxNzZZRzRLYTA4X3lRanF1VkF4cU45OExNMzlsQmdISGFxTUxud1dXODBKcFhMVG9KNjdWVnlTTFFROVc3NDlsdlNHLUJXeG41NDBHaXhHR0VHVWl5UW9RNkVWbmlhakRKVW5pM0R4VHk0LUw0TV9LdkljNHdBLXJua21NQkl2b3l4UkVkMGN1YjBrMmJEeWtMay1jbmxrYWJNbUV0aktCXzU1djR2d2RSQXZORTNwcG92ZUVvVGMtQzQzTTVncEZTRGRtZUFIZWQ0dz09 Connector_AiAnthropic_API_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpENmFBWG16STFQUVZxNzZZRzRLYTA4X3lRanF1VkF4cU45OExNMzlsQmdISGFxTUxud1dXODBKcFhMVG9KNjdWVnlTTFFROVc3NDlsdlNHLUJXeG41NDBHaXhHR0VHVWl5UW9RNkVWbmlhakRKVW5pM0R4VHk0LUw0TV9LdkljNHdBLXJua21NQkl2b3l4UkVkMGN1YjBrMmJEeWtMay1jbmxrYWJNbUV0aktCXzU1djR2d2RSQXZORTNwcG92ZUVvVGMtQzQzTTVncEZTRGRtZUFIZWQ0dz09
Connector_AiPerplexity_API_SECRET = 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 Connector_AiPerplexity_API_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5ZmdDZ3hrSElrMnQzNFAtel9wX191VjVzN2g1LWZoa0V1YklubEdmMEJDdEZiR1RWeVZrM3V3enBHX3p6WUtTS0kwYkFyVEF0Nm8zX05CelVQcFJUc0lwVW5iNFczc1p1WWJ2WFBmd0lpLUxxWndEeUh0b2hGUHVpN19vb19nMTBnV1A1VmNpWERVX05lQ29VS20wTjZ3PT0=
Connector_AiTavily_API_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEQTdnUHMwd2pIaXNtMmtCTFREd0pyQXRKb1F5eGtHSnkyOGZiUnlBOFc0b3Vzcndrc3ViRm1nMDJIOEZKYWxqdWNkZGh5N0Z4R0JlQmxXSG5pVnJUR2VYckZhMWNMZ1FNeXJ3enJLVlpiblhOZTNleUg3ZzZyUzRZanFSeDlVMkI= Connector_AiTavily_API_SECRET = DEV_ENC:Z0FBQUFBQm8xSUpEQTdnUHMwd2pIaXNtMmtCTFREd0pyQXRKb1F5eGtHSnkyOGZiUnlBOFc0b3Vzcndrc3ViRm1nMDJIOEZKYWxqdWNkZGh5N0Z4R0JlQmxXSG5pVnJUR2VYckZhMWNMZ1FNeXJ3enJLVlpiblhOZTNleUg3ZzZyUzRZanFSeDlVMkI=
Connector_AiPrivateLlm_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGRHM5eFdUVmVZU1R1cHBwN1RlMUx4T0NlLTJLUFFVX3J2OElDWFpuZmJHVmp4Z3BNNWMwZUVVZUd2TFhRSjVmVkVlcFlVRWtybXh0ZHloZ01ZcnVvX195YjdlWVdEcjZSWFFTTlNBWUlaTlNoLWhqVFBIb0thVlBiaWhjYjFQOFY= Connector_AiPrivateLlm_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGRHM5eFdUVmVZU1R1cHBwN1RlMUx4T0NlLTJLUFFVX3J2OElDWFpuZmJHVmp4Z3BNNWMwZUVVZUd2TFhRSjVmVkVlcFlVRWtybXh0ZHloZ01ZcnVvX195YjdlWVdEcjZSWFFTTlNBWUlaTlNoLWhqVFBIb0thVlBiaWhjYjFQOFY=
Connector_AiMistral_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGeEQxYUIxOHhia0JlQWpWQ2dWQWZzY3l6SWwyUnJoR1hRQWloX2lxb2lGNkc4UnA4U2tWNjJaYzB1d1hvNG9fWUp1N3V4OW9FMGhaWVhjSlVwWEc1X2loVDBSZDEtdHdfcTA5QkcxQTR4OHc4RkRzclJrU2d1RFZpNDJkRDRURlE= Connector_AiMistral_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGeEQxYUIxOHhia0JlQWpWQ2dWQWZzY3l6SWwyUnJoR1hRQWloX2lxb2lGNkc4UnA4U2tWNjJaYzB1d1hvNG9fWUp1N3V4OW9FMGhaWVhjSlVwWEc1X2loVDBSZDEtdHdfcTA5QkcxQTR4OHc4RkRzclJrU2d1RFZpNDJkRDRURlE=

View file

@ -46,15 +46,20 @@ Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.ap
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-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 = http://localhost:8000/api/clickup/auth/connect/callback
# Stripe Billing (both end with _SECRET for encryption script) # Stripe Billing (both end with _SECRET for encryption script)
STRIPE_SECRET_KEY_SECRET = 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 STRIPE_SECRET_KEY_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5ekdBaGNGVUlOQUpncTlzLWlTV0V5OWZzQkpDczhCUGw4U1JpTHZ0d3pfYlFNWElLRlNiNlNsaDRYTGZUTkg2OUFrTW1GZXpOUjBVbmRQWjN6ekhHd2ZSQ195OHlaeWh1TmxrUm10V2R3YmdncmFLbFMzVjdqcWJMSUJPR2xuSEozclNoZG1rZVBTaWg3OFQ1Qzdxb0wyQ2RKazc2dG1aZXBUTXlvbDZqLS1KOVI5M3BGc3NQZkZRbnFpRjIwWmh2ZHlVNlpxZVo2dWNmMjQ5eW02QmtzUT09
STRIPE_WEBHOOK_SECRET = INT_ENC:Z0FBQUFBQnBudkpGamJBNW91VUdEaThWRTFiTWpyb3NqSDJJcGtjNkhUVVZqVElxUWExY05KcllSYVk1SkRuS1NjYWpZUk1uU29nb2pzdXUxRzBsOEgyRWtmUEw3dUF4ejFIXzNwTVZRM1R1bVVhTUs4ZHJMT0V4Xy1pcHVfWlBaQV9wVXo5MGlQYXA= STRIPE_WEBHOOK_SECRET = whsec_2agCQEbDPSOn2C40EJcwoPCqlvaPLF7M
STRIPE_API_VERSION = 2026-01-28.clover STRIPE_API_VERSION = 2026-01-28.clover
# AI configuration # AI configuration
Connector_AiOpenai_API_SECRET = INT_ENC:Z0FBQUFBQnBaSnM4MENkQ2xJVmE5WFZKUkh2SHJFby1YVXN3ZmVxRkptS3ZWRmlwdU93ZEJjSjlMV2NGbU5mS3NCdmFfcmFYTEJNZXFIQ3ozTWE4ZC1pemlQNk9wbjU1d3BPS0ZCTTZfOF8yWmVXMWx0TU1DamlJLVFhSTJXclZsY3hMVWlPcXVqQWtMdER4T252NHZUWEhUOTdIN1VGR3ltazEweXFqQ0lvb0hYWmxQQnpxb0JwcFNhRDNGWXdoRTVJWm9FalZpTUF5b1RqZlRaYnVKYkp0NWR5Vko1WWJ0Wmg2VWJzYXZ0Z3Q4UkpsTldDX2dsekhKMmM4YjRoa2RwemMwYVQwM2cyMFlvaU5mOTVTWGlROU8xY2ZVRXlxZzJqWkxURWlGZGI2STZNb0NpdEtWUnM9 Connector_AiOpenai_API_SECRET = INT_ENC:Z0FBQUFBQnBaSnM4MENkQ2xJVmE5WFZKUkh2SHJFby1YVXN3ZmVxRkptS3ZWRmlwdU93ZEJjSjlMV2NGbU5mS3NCdmFfcmFYTEJNZXFIQ3ozTWE4ZC1pemlQNk9wbjU1d3BPS0ZCTTZfOF8yWmVXMWx0TU1DamlJLVFhSTJXclZsY3hMVWlPcXVqQWtMdER4T252NHZUWEhUOTdIN1VGR3ltazEweXFqQ0lvb0hYWmxQQnpxb0JwcFNhRDNGWXdoRTVJWm9FalZpTUF5b1RqZlRaYnVKYkp0NWR5Vko1WWJ0Wmg2VWJzYXZ0Z3Q4UkpsTldDX2dsekhKMmM4YjRoa2RwemMwYVQwM2cyMFlvaU5mOTVTWGlROU8xY2ZVRXlxZzJqWkxURWlGZGI2STZNb0NpdEtWUnM9
Connector_AiAnthropic_API_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjT1ZlRWVJdVZMT3ljSFJDcFdxRFBRVkZhS204NnN5RDBlQ0tpenhTM0FFVktuWW9mWHNwRWx2dHB0eDBSZ0JFQnZKWlp6c01pVGREWHd1eGpERnU0Q2xhaks1clQ1ZXVsdnd2ZzhpNXNQS1BhY3FjSkdkVEhHalNaRGR4emhpakZncnpDQUVxOHVXQzVUWmtQc0FsYmFwTF9TSG5FOUFtWk5Ick1NcHFvY2s1T1c2WXlRUFFJZnh6TWhuaVpMYmppcDR0QUx0a0R6RXlwbGRYb1R4dzJkUT09 Connector_AiAnthropic_API_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjT1ZlRWVJdVZMT3ljSFJDcFdxRFBRVkZhS204NnN5RDBlQ0tpenhTM0FFVktuWW9mWHNwRWx2dHB0eDBSZ0JFQnZKWlp6c01pVGREWHd1eGpERnU0Q2xhaks1clQ1ZXVsdnd2ZzhpNXNQS1BhY3FjSkdkVEhHalNaRGR4emhpakZncnpDQUVxOHVXQzVUWmtQc0FsYmFwTF9TSG5FOUFtWk5Ick1NcHFvY2s1T1c2WXlRUFFJZnh6TWhuaVpMYmppcDR0QUx0a0R6RXlwbGRYb1R4dzJkUT09
Connector_AiPerplexity_API_SECRET = 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 Connector_AiPerplexity_API_SECRET = INT_ENC:Z0FBQUFBQnB5dkd6UkhtU3lhYmZMSlo0bklQZ2s3UTFBSkprZTNwWkg5Q2lVa0wtenhxWXpva21xVDVMRjdKSmhpTmxWS05IUTRoRHdCbktSRVVjcVFnY1RfV0N2S2dyV0dTMlhxQlRFVm41RkFTWVQzQThuVkZwdlNuVC05QlVRVXB6Qjk3akNpYmY1MFR6R1ByMzlIMllRZlRRYVVRN2ZBPT0=
Connector_AiTavily_API_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkdkJMTDY0akhXNzZDWHVYSEt1cDZoOWEzSktneHZEV2JndTNmWlNSMV9KbFNIZmQzeVlrNE5qUEIwcUlBSGM1a0hOZ3J6djIyOVhnZzI3M1dIUkdicl9FVXF3RGktMmlEYmhnaHJfWTdGUkktSXVUSGdQMC1vSEV6VE8zR2F1SVk= Connector_AiTavily_API_SECRET = INT_ENC:Z0FBQUFBQm8xSVRkdkJMTDY0akhXNzZDWHVYSEt1cDZoOWEzSktneHZEV2JndTNmWlNSMV9KbFNIZmQzeVlrNE5qUEIwcUlBSGM1a0hOZ3J6djIyOVhnZzI3M1dIUkdicl9FVXF3RGktMmlEYmhnaHJfWTdGUkktSXVUSGdQMC1vSEV6VE8zR2F1SVk=
Connector_AiPrivateLlm_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGSjZ1NWh0aWc1R3Z4MHNaeS1HamtUbndhcUZFZDlqUDhjSmg5eHFfdlVkU0RsVkJ2UVRaMWs3aWhraG5jSlc0YkxNWHVmR2JoSW5ENFFCdkJBM0VienlKSnhzNnBKbTJOUTFKczRfWlQ3bWpmUkRTT1I1OGNUSTlQdExacGRpeXg= Connector_AiPrivateLlm_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGSjZ1NWh0aWc1R3Z4MHNaeS1HamtUbndhcUZFZDlqUDhjSmg5eHFfdlVkU0RsVkJ2UVRaMWs3aWhraG5jSlc0YkxNWHVmR2JoSW5ENFFCdkJBM0VienlKSnhzNnBKbTJOUTFKczRfWlQ3bWpmUkRTT1I1OGNUSTlQdExacGRpeXg=
Connector_AiMistral_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGZTNtZ1E4TWIxSEU1OUlreUpxZkJIR0Vxcm9xRHRUbnBxbTQ1cXlkbnltWkJVdTdMYWZ4c3Fsam42TERWUTVhNzZFMU9xVjdyRGFCYml6bmZsZFd2YmJzemlrSWN6Q3o3X0NXX2xXNUQteTNONHdKYzJ5YVpLLWdhU2JhSTJQZnI= Connector_AiMistral_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGZTNtZ1E4TWIxSEU1OUlreUpxZkJIR0Vxcm9xRHRUbnBxbTQ1cXlkbnltWkJVdTdMYWZ4c3Fsam42TERWUTVhNzZFMU9xVjdyRGFCYml6bmZsZFd2YmJzemlrSWN6Q3o3X0NXX2xXNUQteTNONHdKYzJ5YVpLLWdhU2JhSTJQZnI=

View file

@ -46,15 +46,20 @@ Service_GOOGLE_DATA_CLIENT_ID = 354925410565-aqs2b2qaiqmm73qpjnel6al8eid78uvg.ap
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-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 = http://localhost:8000/api/clickup/auth/connect/callback
# Stripe Billing (both end with _SECRET for encryption script) # Stripe Billing (both end with _SECRET for encryption script)
STRIPE_SECRET_KEY_SECRET = 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 STRIPE_SECRET_KEY_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6aVA3R3VRS3VHMUgzUEVjYkR4eUZKWFhPUzFTTVlHNnBvT3FienNQaUlBWVpPLXJyVGpGMWk4LXktMXphX0J6ZTVESkJxdjNNa3ZJbF9wX2ppYzdjYlF0cmdVamlEWWJDSmJYYkJseHctTlh4dnNoQWs4SG5haVl2TTNDdXpuaFpqeDBtNkFCbUxMa0RaWG14dmxyOEdILTNrZ2licmNpbXVkN2lFSWoxZW1BODNpV0ZTQ0VaeXRmR1d4RjExMlVFS3MtQU9zZXZlZE1mTmY3OWctUXJHdz09
STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGNUpTWldsakYydFhFelBrR1lSaWxYT3kyMENOMUljZTJUZHBWcEhhdWVCMzYxZXQ5b3VlTFVRalFiTVdsbGxrdUx0RDFwSEpsOC1sTDJRTEJNQlA3S3ZaQzBtV1h6bWp5VnlMZUgwUlF3cXYxcnljZVE5SWdzLVg3V0syOWRYS08= STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGNUpTWldsakYydFhFelBrR1lSaWxYT3kyMENOMUljZTJUZHBWcEhhdWVCMzYxZXQ5b3VlTFVRalFiTVdsbGxrdUx0RDFwSEpsOC1sTDJRTEJNQlA3S3ZaQzBtV1h6bWp5VnlMZUgwUlF3cXYxcnljZVE5SWdzLVg3V0syOWRYS08=
STRIPE_API_VERSION = 2026-01-28.clover STRIPE_API_VERSION = 2026-01-28.clover
# AI configuration # AI configuration
Connector_AiOpenai_API_SECRET = PROD_ENC:Z0FBQUFBQnBaSnM4TWJOVm4xVkx6azRlNDdxN3UxLUdwY2hhdGYxRGp4VFJqYXZIcmkxM1ZyOWV2M0Z4MHdFNkVYQ0ROb1d6LUZFUEdvMHhLMEtXYVBCRzM5TlYyY3ROYWtJRk41cDZxd0tYYi00MjVqMTh4QVcyTXl0bmVocEFHbXQwREpwNi1vODdBNmwzazE5bkpNelE2WXpvblIzWlQwbGdEelI2WXFqT1RibXVHcjNWbVhwYzBOM25XTzNmTDAwUjRvYk4yNjIyZHc5c2RSZzREQUFCdUwyb0ZuOXN1dzI2c2FKdXI4NGxEbk92czZWamJXU3ZSbUlLejZjRklRRk4tLV9aVUFZekI2bTU4OHYxNTUybDg3RVo0ZTh6dXNKRW5GNXVackZvcm9laGI0X3R6V3M9 Connector_AiOpenai_API_SECRET = PROD_ENC:Z0FBQUFBQnBaSnM4TWJOVm4xVkx6azRlNDdxN3UxLUdwY2hhdGYxRGp4VFJqYXZIcmkxM1ZyOWV2M0Z4MHdFNkVYQ0ROb1d6LUZFUEdvMHhLMEtXYVBCRzM5TlYyY3ROYWtJRk41cDZxd0tYYi00MjVqMTh4QVcyTXl0bmVocEFHbXQwREpwNi1vODdBNmwzazE5bkpNelE2WXpvblIzWlQwbGdEelI2WXFqT1RibXVHcjNWbVhwYzBOM25XTzNmTDAwUjRvYk4yNjIyZHc5c2RSZzREQUFCdUwyb0ZuOXN1dzI2c2FKdXI4NGxEbk92czZWamJXU3ZSbUlLejZjRklRRk4tLV9aVUFZekI2bTU4OHYxNTUybDg3RVo0ZTh6dXNKRW5GNXVackZvcm9laGI0X3R6V3M9
Connector_AiAnthropic_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3TnhYdlhSLW5RbXJyMHFXX0V0bHhuTDlTaFJsRDl2dTdIUTFtVFAwTE8tY3hLbzNSMnVTLXd3RUZualN3MGNzc1kwOTIxVUN2WW1rYi1TendFRVVBSVNqRFVjckEzNExyTGNaUkJLMmozazUwemI1cnhrcEtZVXJrWkdaVFFramp3MWZ6RmY2aGlRMXVEYjM2M3ZlbmxMdnNCRDM1QWR0Wmd6MWVnS1I1c01nV3hRLXg3d2NTZXVfTi1Wdm16UnRyNGsyRTZ0bG9TQ1g1OFB5Z002bmQ3QT09 Connector_AiAnthropic_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3TnhYdlhSLW5RbXJyMHFXX0V0bHhuTDlTaFJsRDl2dTdIUTFtVFAwTE8tY3hLbzNSMnVTLXd3RUZualN3MGNzc1kwOTIxVUN2WW1rYi1TendFRVVBSVNqRFVjckEzNExyTGNaUkJLMmozazUwemI1cnhrcEtZVXJrWkdaVFFramp3MWZ6RmY2aGlRMXVEYjM2M3ZlbmxMdnNCRDM1QWR0Wmd6MWVnS1I1c01nV3hRLXg3d2NTZXVfTi1Wdm16UnRyNGsyRTZ0bG9TQ1g1OFB5Z002bmQ3QT09
Connector_AiPerplexity_API_SECRET = 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 Connector_AiPerplexity_API_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6NG5CTm9QOFZRV1BIVC0tV2RKTGtCQWFOUXlpRnhEdjN1U2x3VUdDamtIZV9CQzQ5ZmRmcUh3ZUVUa0NxbGhlenVVdWtaYjdpcnhvUlNFLXZfOWh2dWFZai0xUGU5cWpuYmpnRVRWakh0RVNUUTFyX0w5V0NXVWFrQlZuOTd5TkI0eVRoQ0ZBSm9HYUlYamoyY1FCMmlBPT0=
Connector_AiTavily_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3NmItcDh6V0JpcE5Jc0NlUWZqcmllRHB5eDlNZmVnUlNVenhNTm5xWExzbjJqdE1GZ0hTSUYtb2dvdWNhTnlQNmVWQ2NGVDgwZ0MwMWZBMlNKWEhzdlF3TlZzTXhCZWM4Z1Uwb18tSTRoU1JBVTVkSkJHOTJwX291b3dPaVphVFg= Connector_AiTavily_API_SECRET = PROD_ENC:Z0FBQUFBQnBDM1Z3NmItcDh6V0JpcE5Jc0NlUWZqcmllRHB5eDlNZmVnUlNVenhNTm5xWExzbjJqdE1GZ0hTSUYtb2dvdWNhTnlQNmVWQ2NGVDgwZ0MwMWZBMlNKWEhzdlF3TlZzTXhCZWM4Z1Uwb18tSTRoU1JBVTVkSkJHOTJwX291b3dPaVphVFg=
Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo= Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo=
Connector_AiMistral_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGc2tQc2lvMk1YZk01Q1dob1U5cnR0dG03WWE3WkpoOWo0SEpvLU9Rc2lCNDExdy1wZExaN3lpT2FEQkxnaHRmWmZUUUZUUUJmblZreGlpaFpOdnFhbzlEd1RsVVJtX216cmhxTm5BcTN2eUZ2T054cDE5bmlEamJ3NGR6MVpFQnA= Connector_AiMistral_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGc2tQc2lvMk1YZk01Q1dob1U5cnR0dG03WWE3WkpoOWo0SEpvLU9Rc2lCNDExdy1wZExaN3lpT2FEQkxnaHRmWmZUUUZUUUJmblZreGlpaFpOdnFhbzlEd1RsVVJtX216cmhxTm5BcTN2eUZ2T054cDE5bmlEamJ3NGR6MVpFQnA=

View file

@ -18,7 +18,9 @@ from typing import List, Dict, Any, Optional, AsyncGenerator, Union
from modules.datamodels.datamodelAi import AiModel, AiModelCall, AiModelResponse from modules.datamodels.datamodelAi import AiModel, AiModelCall, AiModelResponse
_RETRY_AFTER_PATTERN = _re.compile(r"try again in (\d+(?:\.\d+)?)\s*s", _re.IGNORECASE) _RETRY_AFTER_PATTERN = _re.compile(
r"(?:try again in|retry after)\s+(\d+(?:\.\d+)?)\s*s", _re.IGNORECASE
)
def _parseRetryAfterSeconds(message: str) -> float: def _parseRetryAfterSeconds(message: str) -> float:

View file

@ -7,9 +7,9 @@ Connects to the private-llm service running on-premise with Ollama backend.
Provides OCR and Vision capabilities via local AI models. Provides OCR and Vision capabilities via local AI models.
Models: Models:
- poweron-ocr-general: Text extraction and OCR (deepseek backend) - poweron-text-general: Text (qwen2.5); NEUTRALIZATION_TEXT + data/plan ops
- poweron-vision-general: General vision tasks (qwen2.5vl backend) - poweron-vision-general: Vision (qwen2.5vl); IMAGE_ANALYSE + NEUTRALIZATION_IMAGE
- poweron-vision-deep: Deep vision analysis (granite3.2 backend) - poweron-vision-deep: Vision (granite3.2); IMAGE_ANALYSE + NEUTRALIZATION_IMAGE
Pricing (CHF per call): Pricing (CHF per call):
- Text models: CHF 0.010 - Text models: CHF 0.010
@ -22,7 +22,7 @@ import time
from typing import List, Optional, Dict, Any from typing import List, Optional, Dict, Any
from fastapi import HTTPException from fastapi import HTTPException
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from .aicoreBase import BaseConnectorAi from .aicoreBase import BaseConnectorAi, RateLimitExceededException
from modules.datamodels.datamodelAi import ( from modules.datamodels.datamodelAi import (
AiModel, AiModel,
PriorityEnum, PriorityEnum,
@ -245,6 +245,7 @@ class AiPrivateLlm(BaseConnectorAi):
(OperationTypeEnum.DATA_ANALYSE, 8), (OperationTypeEnum.DATA_ANALYSE, 8),
(OperationTypeEnum.DATA_GENERATE, 8), (OperationTypeEnum.DATA_GENERATE, 8),
(OperationTypeEnum.DATA_EXTRACT, 8), (OperationTypeEnum.DATA_EXTRACT, 8),
(OperationTypeEnum.NEUTRALIZATION_TEXT, 9),
), ),
version="qwen2.5:7b", version="qwen2.5:7b",
calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: PRICE_TEXT_PER_CALL calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: PRICE_TEXT_PER_CALL
@ -270,6 +271,7 @@ class AiPrivateLlm(BaseConnectorAi):
processingMode=ProcessingModeEnum.ADVANCED, processingMode=ProcessingModeEnum.ADVANCED,
operationTypes=createOperationTypeRatings( operationTypes=createOperationTypeRatings(
(OperationTypeEnum.IMAGE_ANALYSE, 9), (OperationTypeEnum.IMAGE_ANALYSE, 9),
(OperationTypeEnum.NEUTRALIZATION_IMAGE, 9),
), ),
version="qwen2.5vl:7b", version="qwen2.5vl:7b",
calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: PRICE_VISION_PER_CALL calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: PRICE_VISION_PER_CALL
@ -295,6 +297,7 @@ class AiPrivateLlm(BaseConnectorAi):
processingMode=ProcessingModeEnum.DETAILED, processingMode=ProcessingModeEnum.DETAILED,
operationTypes=createOperationTypeRatings( operationTypes=createOperationTypeRatings(
(OperationTypeEnum.IMAGE_ANALYSE, 9), (OperationTypeEnum.IMAGE_ANALYSE, 9),
(OperationTypeEnum.NEUTRALIZATION_IMAGE, 9),
), ),
version="granite3.2-vision", version="granite3.2-vision",
calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: PRICE_VISION_PER_CALL calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: PRICE_VISION_PER_CALL
@ -367,6 +370,9 @@ class AiPrivateLlm(BaseConnectorAi):
if response.status_code != 200: if response.status_code != 200:
errorMessage = f"Private-LLM API error: {response.status_code} - {response.text}" errorMessage = f"Private-LLM API error: {response.status_code} - {response.text}"
if response.status_code == 429:
logger.warning(errorMessage)
raise RateLimitExceededException(errorMessage)
logger.error(errorMessage) logger.error(errorMessage)
raise HTTPException(status_code=500, detail=errorMessage) raise HTTPException(status_code=500, detail=errorMessage)
@ -458,6 +464,9 @@ class AiPrivateLlm(BaseConnectorAi):
if response.status_code != 200: if response.status_code != 200:
errorMessage = f"Private-LLM API error: {response.status_code} - {response.text}" errorMessage = f"Private-LLM API error: {response.status_code} - {response.text}"
if response.status_code == 429:
logger.warning(errorMessage)
raise RateLimitExceededException(errorMessage)
logger.error(errorMessage) logger.error(errorMessage)
raise HTTPException(status_code=500, detail=errorMessage) raise HTTPException(status_code=500, detail=errorMessage)

View file

@ -35,6 +35,8 @@ class CSRFMiddleware(BaseHTTPMiddleware):
"/api/google/auth/login/callback", "/api/google/auth/login/callback",
"/api/google/auth/connect", "/api/google/auth/connect",
"/api/google/auth/connect/callback", "/api/google/auth/connect/callback",
"/api/clickup/auth/connect",
"/api/clickup/auth/connect/callback",
"/api/billing/webhook/stripe", # Stripe webhook (auth via Stripe-Signature) "/api/billing/webhook/stripe", # Stripe webhook (auth via Stripe-Signature)
} }
@ -86,12 +88,15 @@ class CSRFMiddleware(BaseHTTPMiddleware):
content={"detail": "Invalid CSRF token format"} content={"detail": "Invalid CSRF token format"}
) )
# Additional CSRF validation could be added here: try:
# - Check token against session return await call_next(request)
# - Validate token expiration except Exception as exc:
# - Verify token origin logger.error("Unhandled exception in %s %s: %s", request.method, request.url.path, exc)
from fastapi.responses import JSONResponse
return await call_next(request) return JSONResponse(
status_code=500,
content={"detail": "Internal server error"},
)
def _is_valid_csrf_token(self, token: str) -> bool: def _is_valid_csrf_token(self, token: str) -> bool:
""" """

View file

@ -181,7 +181,7 @@ class TokenManager:
# Only allow a new refresh if at least 10 minutes passed since the token was created/refreshed # Only allow a new refresh if at least 10 minutes passed since the token was created/refreshed
try: try:
nowTs = getUtcTimestamp() nowTs = getUtcTimestamp()
createdTs = parseTimestamp(oldToken.createdAt, default=0.0) createdTs = parseTimestamp(oldToken.sysCreatedAt, default=0.0)
secondsSinceLastRefresh = nowTs - createdTs secondsSinceLastRefresh = nowTs - createdTs
if secondsSinceLastRefresh < 10 * 60: if secondsSinceLastRefresh < 10 * 60:
logger.info( logger.info(

View file

@ -5,13 +5,14 @@ import re
import psycopg2 import psycopg2
import psycopg2.extras import psycopg2.extras
import logging import logging
from typing import List, Dict, Any, Optional, Union, get_origin, get_args, Type from typing import List, Dict, Any, Optional, Union, get_origin, get_args, Type, Set, Tuple
import uuid import uuid
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
import threading import threading
from modules.shared.timeUtils import getUtcTimestamp from modules.shared.timeUtils import getUtcTimestamp
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.datamodels.datamodelBase import PowerOnModel
from modules.datamodels.datamodelUam import User, AccessLevel, UserPermissions from modules.datamodels.datamodelUam import User, AccessLevel, UserPermissions
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext
@ -20,7 +21,7 @@ logger = logging.getLogger(__name__)
# No mapping needed - table name = Pydantic model name exactly # No mapping needed - table name = Pydantic model name exactly
class SystemTable(BaseModel): class SystemTable(PowerOnModel):
"""Data model for system table entries""" """Data model for system table entries"""
table_name: str = Field( table_name: str = Field(
@ -157,6 +158,88 @@ def _parseRecordFields(record: Dict[str, Any], fields: Dict[str, str], context:
logger.warning(f"Could not parse JSONB field {fieldName}, keeping as string ({context})") logger.warning(f"Could not parse JSONB field {fieldName}, keeping as string ({context})")
# Legacy column names (historical _* internal names and old camelCase audit fields) -> PowerOn sys* columns.
# Order matters: more specific / underscore names first; first successful copy wins per cell via IS NULL on target.
_LEGACY_FIELD_TO_SYS: Tuple[Tuple[str, str], ...] = (
("_createdAt", "sysCreatedAt"),
("_createdBy", "sysCreatedBy"),
("_modifiedAt", "sysModifiedAt"),
("_modifiedBy", "sysModifiedBy"),
("createdAt", "sysCreatedAt"),
("creationDate", "sysCreatedAt"),
("updatedAt", "sysModifiedAt"),
("lastModified", "sysModifiedAt"),
)
def _quotePgIdent(name: str) -> str:
return '"' + str(name).replace('"', '""') + '"'
def _resolveColumnCaseInsensitive(cols: Set[str], logicalName: str) -> Optional[str]:
"""Match information_schema column_name to logical CamelCase (PG folds unquoted legacy names to lowercase)."""
if not logicalName or not cols:
return None
for c in cols:
if c.lower() == logicalName.lower():
return c
return None
def _pgColumnDataType(cursor, tablePg: str, colPg: str) -> Optional[str]:
cursor.execute(
"""
SELECT data_type FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = %s AND column_name = %s
""",
(tablePg, colPg),
)
row = cursor.fetchone()
return row["data_type"] if row else None
def _legacySourceToSysSqlExpr(srcIdent: str, srcType: Optional[str], tgtType: Optional[str]) -> str:
"""Build RHS for UPDATE sys* = expr from legacy _* column (handles text/timestamp -> double precision)."""
s = _quotePgIdent(srcIdent)
sl = (srcType or "").lower()
tl = (tgtType or "").lower()
if "double" in tl or tl == "real" or tl == "numeric":
if any(x in sl for x in ("double precision", "real", "numeric", "integer", "bigint", "smallint")):
return f"{s}::double precision"
if "timestamp" in sl or sl == "date":
return f"EXTRACT(EPOCH FROM {s}::timestamptz)"
if "text" in sl or "character" in sl or sl == "uuid":
return (
f"CASE WHEN trim({s}::text) ~ '^[+-]?[0-9]+(\\.[0-9]*)?([eE][+-]?[0-9]+)?$' "
f"THEN trim({s}::text)::double precision "
f"ELSE EXTRACT(EPOCH FROM trim({s}::text)::timestamptz) END"
)
return s
return s
def _listPublicBaseTableNames(cursor) -> List[str]:
cursor.execute(
"""
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' AND table_type = 'BASE TABLE'
ORDER BY table_name
"""
)
return [row["table_name"] for row in cursor.fetchall()]
def _listTableColumnNames(cursor, tableName: str) -> Set[str]:
cursor.execute(
"""
SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = %s
""",
(tableName,),
)
return {row["column_name"] for row in cursor.fetchall()}
# Cache connectors by (host, database, port) to avoid duplicate inits for same database. # Cache connectors by (host, database, port) to avoid duplicate inits for same database.
# Thread safety: _connector_cache_lock protects cache access. userId is request-scoped via # Thread safety: _connector_cache_lock protects cache access. userId is request-scoped via
# contextvars to avoid races when concurrent requests share the same connector. # contextvars to avoid races when concurrent requests share the same connector.
@ -178,7 +261,7 @@ def _get_cached_connector(
userId: str = None, userId: str = None,
) -> "DatabaseConnector": ) -> "DatabaseConnector":
"""Return cached DatabaseConnector for same (host, database, port) to avoid duplicate PostgreSQL inits. """Return cached DatabaseConnector for same (host, database, port) to avoid duplicate PostgreSQL inits.
Uses contextvars for userId so concurrent requests sharing the same connector get correct _createdBy/_modifiedBy. Uses contextvars for userId so concurrent requests sharing the same connector get correct sysCreatedBy/sysModifiedBy.
""" """
port = int(dbPort) if dbPort is not None else 5432 port = int(dbPort) if dbPort is not None else 5432
key = (dbHost, dbDatabase, port) key = (dbHost, dbDatabase, port)
@ -327,8 +410,10 @@ class DatabaseConnector:
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
table_name VARCHAR(255) UNIQUE NOT NULL, table_name VARCHAR(255) UNIQUE NOT NULL,
initial_id VARCHAR(255) NOT NULL, initial_id VARCHAR(255) NOT NULL,
_createdAt DOUBLE PRECISION, "sysCreatedAt" DOUBLE PRECISION,
_modifiedAt DOUBLE PRECISION "sysCreatedBy" VARCHAR(255),
"sysModifiedAt" DOUBLE PRECISION,
"sysModifiedBy" VARCHAR(255)
) )
""") """)
conn.close() conn.close()
@ -371,6 +456,63 @@ class DatabaseConnector:
logger.warning(f"Connection lost, reconnecting: {e}") logger.warning(f"Connection lost, reconnecting: {e}")
self._connect() self._connect()
def migrateLegacyUnderscoreSysColumns(self) -> int:
"""
Scan all public base tables on this connection's database. Where both a legacy
source column (any case: _createdAt, createdAt, creationDate, ) and the matching
sys* column exist, UPDATE sys* from legacy where sys* IS NULL AND legacy IS NOT NULL.
Idempotent; run after schema adds sys* columns (see _ensureTableExists).
"""
self._ensure_connection()
total = 0
try:
with self.connection.cursor() as cursor:
tableNames = _listPublicBaseTableNames(cursor)
for table in tableNames:
with self.connection.cursor() as cursor:
cols = _listTableColumnNames(cursor, table)
for legacyLogical, sysLogical in _LEGACY_FIELD_TO_SYS:
src = _resolveColumnCaseInsensitive(cols, legacyLogical)
tgt = _resolveColumnCaseInsensitive(cols, sysLogical)
if not src or not tgt or src == tgt:
continue
try:
with self.connection.cursor() as cursor:
srcType = _pgColumnDataType(cursor, table, src)
tgtType = _pgColumnDataType(cursor, table, tgt)
expr = _legacySourceToSysSqlExpr(src, srcType, tgtType)
tq = _quotePgIdent(table)
tr = _quotePgIdent(tgt)
sr = _quotePgIdent(src)
sql = (
f"UPDATE {tq} SET {tr} = {expr} "
f"WHERE {tr} IS NULL AND {sr} IS NOT NULL"
)
cursor.execute(sql)
n = cursor.rowcount
self.connection.commit()
total += n
except Exception as e:
try:
self.connection.rollback()
except Exception:
pass
logger.debug(
f"migrateLegacyUnderscoreSysColumns skip {self.dbDatabase}.{table} "
f"{src}->{tgt}: {e}"
)
except Exception as e:
logger.error(f"migrateLegacyUnderscoreSysColumns failed on {self.dbDatabase}: {e}")
try:
self.connection.rollback()
except Exception:
pass
if total:
logger.info(
f"migrateLegacyUnderscoreSysColumns: {total} cell(s) in {self.dbDatabase}"
)
return total
def _initializeSystemTable(self): def _initializeSystemTable(self):
"""Initializes the system table if it doesn't exist yet.""" """Initializes the system table if it doesn't exist yet."""
try: try:
@ -416,7 +558,7 @@ class DatabaseConnector:
for table_name, initial_id in data.items(): for table_name, initial_id in data.items():
cursor.execute( cursor.execute(
""" """
INSERT INTO "_system" ("table_name", "initial_id", "_modifiedAt") INSERT INTO "_system" ("table_name", "initial_id", "sysModifiedAt")
VALUES (%s, %s, %s) VALUES (%s, %s, %s)
""", """,
(table_name, initial_id, getUtcTimestamp()), (table_name, initial_id, getUtcTimestamp()),
@ -448,8 +590,10 @@ class DatabaseConnector:
CREATE TABLE "{self._systemTableName}" ( CREATE TABLE "{self._systemTableName}" (
"table_name" VARCHAR(255) PRIMARY KEY, "table_name" VARCHAR(255) PRIMARY KEY,
"initial_id" VARCHAR(255), "initial_id" VARCHAR(255),
"_createdAt" DOUBLE PRECISION, "sysCreatedAt" DOUBLE PRECISION,
"_modifiedAt" DOUBLE PRECISION "sysCreatedBy" VARCHAR(255),
"sysModifiedAt" DOUBLE PRECISION,
"sysModifiedBy" VARCHAR(255)
) )
""") """)
logger.info("System table created successfully") logger.info("System table created successfully")
@ -464,10 +608,16 @@ class DatabaseConnector:
) )
existing_columns = [row["column_name"] for row in cursor.fetchall()] existing_columns = [row["column_name"] for row in cursor.fetchall()]
if "_modifiedAt" not in existing_columns: for sys_col, sys_sql in [
cursor.execute( ("sysCreatedAt", "DOUBLE PRECISION"),
f'ALTER TABLE "{self._systemTableName}" ADD COLUMN "_modifiedAt" DOUBLE PRECISION' ("sysCreatedBy", "VARCHAR(255)"),
) ("sysModifiedAt", "DOUBLE PRECISION"),
("sysModifiedBy", "VARCHAR(255)"),
]:
if sys_col not in existing_columns:
cursor.execute(
f'ALTER TABLE "{self._systemTableName}" ADD COLUMN "{sys_col}" {sys_sql}'
)
return True return True
except Exception as e: except Exception as e:
@ -484,6 +634,7 @@ class DatabaseConnector:
try: try:
self._ensure_connection() self._ensure_connection()
schemaTouched = False
with self.connection.cursor() as cursor: with self.connection.cursor() as cursor:
# Check if table exists by querying information_schema with case-insensitive search # Check if table exists by querying information_schema with case-insensitive search
@ -502,6 +653,7 @@ class DatabaseConnector:
logger.info( logger.info(
f"Created table '{table}' with columns from Pydantic model" f"Created table '{table}' with columns from Pydantic model"
) )
schemaTouched = True
else: else:
# Table exists: ensure all columns from model are present (simple additive migration) # Table exists: ensure all columns from model are present (simple additive migration)
try: try:
@ -518,11 +670,7 @@ class DatabaseConnector:
# Desired columns based on model # Desired columns based on model
model_fields = _get_model_fields(model_class) model_fields = _get_model_fields(model_class)
desired_columns = ( desired_columns = set(["id"]) | set(model_fields.keys())
set(["id"])
| set(model_fields.keys())
| {"_createdAt", "_modifiedAt", "_createdBy", "_modifiedBy"}
)
# Add missing columns # Add missing columns
for col in sorted(desired_columns - existing_columns): for col in sorted(desired_columns - existing_columns):
@ -530,12 +678,6 @@ class DatabaseConnector:
if col in ["id"]: if col in ["id"]:
continue # primary key exists already continue # primary key exists already
sql_type = model_fields.get(col) sql_type = model_fields.get(col)
if col in ["_createdAt"]:
sql_type = "DOUBLE PRECISION"
elif col in ["_modifiedAt"]:
sql_type = "DOUBLE PRECISION"
elif col in ["_createdBy", "_modifiedBy"]:
sql_type = "VARCHAR(255)"
if not sql_type: if not sql_type:
sql_type = "TEXT" sql_type = "TEXT"
try: try:
@ -545,6 +687,7 @@ class DatabaseConnector:
logger.info( logger.info(
f"Added missing column '{col}' ({sql_type}) to '{table}'" f"Added missing column '{col}' ({sql_type}) to '{table}'"
) )
schemaTouched = True
except Exception as add_err: except Exception as add_err:
logger.warning( logger.warning(
f"Could not add column '{col}' to '{table}': {add_err}" f"Could not add column '{col}' to '{table}': {add_err}"
@ -555,6 +698,23 @@ class DatabaseConnector:
) )
self.connection.commit() self.connection.commit()
if schemaTouched:
try:
n = self.migrateLegacyUnderscoreSysColumns()
if n:
logger.info(
"After schema change on %s.%s: legacy -> sys* migration wrote %s cell(s)",
self.dbDatabase,
table,
n,
)
except Exception as mig_err:
logger.error(
"migrateLegacyUnderscoreSysColumns failed after schema change %s.%s: %s",
self.dbDatabase,
table,
mig_err,
)
return True return True
except Exception as e: except Exception as e:
logger.error(f"Error ensuring table {table} exists: {e}") logger.error(f"Error ensuring table {table} exists: {e}")
@ -594,16 +754,6 @@ class DatabaseConnector:
if field_name != "id": # Skip id, already defined if field_name != "id": # Skip id, already defined
columns.append(f'"{field_name}" {sql_type}') columns.append(f'"{field_name}" {sql_type}')
# Add metadata columns
columns.extend(
[
'"_createdAt" DOUBLE PRECISION',
'"_modifiedAt" DOUBLE PRECISION',
'"_createdBy" VARCHAR(255)',
'"_modifiedBy" VARCHAR(255)',
]
)
# Create table # Create table
sql = f'CREATE TABLE IF NOT EXISTS "{table}" ({", ".join(columns)})' sql = f'CREATE TABLE IF NOT EXISTS "{table}" ({", ".join(columns)})'
cursor.execute(sql) cursor.execute(sql)
@ -626,11 +776,7 @@ class DatabaseConnector:
"""Save record to normalized table with explicit columns.""" """Save record to normalized table with explicit columns."""
# Get columns from Pydantic model instead of database schema # Get columns from Pydantic model instead of database schema
fields = _get_model_fields(model_class) fields = _get_model_fields(model_class)
columns = ( columns = ["id"] + [field for field in fields.keys() if field != "id"]
["id"]
+ [field for field in fields.keys() if field != "id"]
+ ["_createdAt", "_createdBy", "_modifiedAt", "_modifiedBy"]
)
if not columns: if not columns:
logger.error(f"No columns found for table {table}") logger.error(f"No columns found for table {table}")
@ -648,7 +794,7 @@ class DatabaseConnector:
value = filtered_record.get(col) value = filtered_record.get(col)
# Handle timestamp fields - store as Unix timestamps (floats) for consistency # Handle timestamp fields - store as Unix timestamps (floats) for consistency
if col in ["_createdAt", "_modifiedAt"] and value is not None: if col in ["sysCreatedAt", "sysModifiedAt"] and value is not None:
if isinstance(value, str): if isinstance(value, str):
# Try to parse string as timestamp # Try to parse string as timestamp
try: try:
@ -690,7 +836,7 @@ class DatabaseConnector:
[ [
f'"{col}" = EXCLUDED."{col}"' f'"{col}" = EXCLUDED."{col}"'
for col in columns[1:] for col in columns[1:]
if col not in ["_createdAt", "_createdBy"] if col not in ["sysCreatedAt", "sysCreatedBy"]
] ]
) )
@ -723,6 +869,10 @@ class DatabaseConnector:
logger.error(f"Error loading record {recordId} from table {table}: {e}") logger.error(f"Error loading record {recordId} from table {table}: {e}")
return None return None
def getRecord(self, model_class: type, recordId: str) -> Optional[Dict[str, Any]]:
"""Load one row by primary key (routes / services; wraps _loadRecord)."""
return self._loadRecord(model_class, str(recordId))
def _saveRecord( def _saveRecord(
self, model_class: type, recordId: str, record: Dict[str, Any] self, model_class: type, recordId: str, record: Dict[str, Any]
) -> bool: ) -> bool:
@ -742,17 +892,19 @@ class DatabaseConnector:
if effective_user_id is None: if effective_user_id is None:
effective_user_id = self.userId effective_user_id = self.userId
currentTime = getUtcTimestamp() currentTime = getUtcTimestamp()
# Set _createdAt and _createdBy if this is a new record (record doesn't have _createdAt) # Set sysCreatedAt/sysCreatedBy on first persist; always refresh modified fields.
if "_createdAt" not in record: # Treat None and 0 as unset (legacy rows / bad defaults); model_dump often has sysCreatedAt=None.
record["_createdAt"] = currentTime createdTs = record.get("sysCreatedAt")
if createdTs is None or createdTs == 0 or createdTs == 0.0:
record["sysCreatedAt"] = currentTime
if effective_user_id: if effective_user_id:
record["_createdBy"] = effective_user_id record["sysCreatedBy"] = effective_user_id
elif "_createdBy" not in record or not record.get("_createdBy"): elif not record.get("sysCreatedBy"):
if effective_user_id: if effective_user_id:
record["_createdBy"] = effective_user_id record["sysCreatedBy"] = effective_user_id
record["_modifiedAt"] = currentTime record["sysModifiedAt"] = currentTime
if effective_user_id: if effective_user_id:
record["_modifiedBy"] = effective_user_id record["sysModifiedBy"] = effective_user_id
with self.connection.cursor() as cursor: with self.connection.cursor() as cursor:
self._save_record(cursor, table, recordId, record, model_class) self._save_record(cursor, table, recordId, record, model_class)
@ -840,6 +992,26 @@ class DatabaseConnector:
logger.error(f"Error removing initial ID for table {table}: {e}") logger.error(f"Error removing initial ID for table {table}: {e}")
return False return False
def buildRbacWhereClause(
self,
permissions: UserPermissions,
currentUser: User,
table: str,
mandateId: Optional[str] = None,
featureInstanceId: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Delegate to interfaceRbac.buildRbacWhereClause (tests and call sites use connector as entry)."""
from modules.interfaces.interfaceRbac import buildRbacWhereClause as _buildRbacWhereClause
return _buildRbacWhereClause(
permissions,
currentUser,
table,
self,
mandateId=mandateId,
featureInstanceId=featureInstanceId,
)
def updateContext(self, userId: str) -> None: def updateContext(self, userId: str) -> None:
"""Updates the context of the database connector. """Updates the context of the database connector.
Sets both instance userId and contextvar for request-scoped use when connector is shared. Sets both instance userId and contextvar for request-scoped use when connector is shared.
@ -992,10 +1164,6 @@ class DatabaseConnector:
Returns (where_clause, order_clause, limit_clause, values, count_values). Returns (where_clause, order_clause, limit_clause, values, count_values).
""" """
fields = _get_model_fields(model_class) fields = _get_model_fields(model_class)
fields["_createdAt"] = "DOUBLE PRECISION"
fields["_modifiedAt"] = "DOUBLE PRECISION"
fields["_createdBy"] = "TEXT"
fields["_modifiedBy"] = "TEXT"
validColumns = set(fields.keys()) validColumns = set(fields.keys())
where_parts: List[str] = [] where_parts: List[str] = []
values: List[Any] = [] values: List[Any] = []
@ -1026,6 +1194,9 @@ class DatabaseConnector:
continue continue
colType = fields.get(key, "TEXT") colType = fields.get(key, "TEXT")
logger.debug(f"_buildPaginationClauses: filter key='{key}' val={val!r} type(val)={type(val).__name__} colType={colType}") logger.debug(f"_buildPaginationClauses: filter key='{key}' val={val!r} type(val)={type(val).__name__} colType={colType}")
if val is None:
where_parts.append(f'"{key}" IS NULL')
continue
if isinstance(val, dict): if isinstance(val, dict):
op = val.get("operator", "equals") op = val.get("operator", "equals")
v = val.get("value", "") v = val.get("value", "")
@ -1190,10 +1361,6 @@ class DatabaseConnector:
""" """
table = model_class.__name__ table = model_class.__name__
fields = _get_model_fields(model_class) fields = _get_model_fields(model_class)
fields["_createdAt"] = "DOUBLE PRECISION"
fields["_modifiedAt"] = "DOUBLE PRECISION"
fields["_createdBy"] = "TEXT"
fields["_modifiedBy"] = "TEXT"
if column not in fields: if column not in fields:
return [] return []

View file

@ -52,6 +52,12 @@ class ConnectorResolver:
except ImportError: except ImportError:
logger.debug("FtpConnector not available (stub)") logger.debug("FtpConnector not available (stub)")
try:
from modules.connectors.providerClickup.connectorClickup import ClickupConnector
ConnectorResolver._providerRegistry["clickup"] = ClickupConnector
except ImportError:
logger.warning("ClickupConnector not available")
async def resolve(self, connectionId: str) -> ProviderConnector: async def resolve(self, connectionId: str) -> ProviderConnector:
"""Resolve connectionId to a ProviderConnector with a fresh access token.""" """Resolve connectionId to a ProviderConnector with a fresh access token."""
connection = await self._loadConnection(connectionId) connection = await self._loadConnection(connectionId)

View file

@ -9,6 +9,7 @@ from typing import Optional
import logging import logging
import aiohttp import aiohttp
from modules.datamodels.datamodelTickets import TicketBase, TicketFieldAttribute from modules.datamodels.datamodelTickets import TicketBase, TicketFieldAttribute
from modules.serviceCenter.services.serviceClickup.mainServiceClickup import clickup_authorization_header
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -30,7 +31,7 @@ class ConnectorTicketClickup(TicketBase):
def _headers(self) -> dict: def _headers(self) -> dict:
return { return {
"Authorization": self.apiToken, "Authorization": clickup_authorization_header(self.apiToken),
"Content-Type": "application/json", "Content-Type": "application/json",
} }

View file

@ -18,6 +18,11 @@ from modules.shared.configuration import APP_CONFIG
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Gemini-TTS speaker IDs from voices.list use short names (e.g. "Kore") and require model_name + prompt.
_GEMINI_TTS_DEFAULT_MODEL = "gemini-2.5-flash-tts"
_GEMINI_TTS_NEUTRAL_PROMPT = "Say the following"
class ConnectorGoogleSpeech: class ConnectorGoogleSpeech:
""" """
Google Cloud Speech-to-Text and Translation connector. Google Cloud Speech-to-Text and Translation connector.
@ -902,6 +907,13 @@ class ConnectorGoogleSpeech:
"error": f"Validation error: {e}" "error": f"Validation error: {e}"
} }
def _isGeminiTtsSpeakerVoiceName(self, voiceName: str) -> bool:
"""True when voice name is a Gemini-TTS speaker id (no BCP-47 prefix like en-US-...)."""
if not voiceName or not isinstance(voiceName, str):
return False
stripped = voiceName.strip()
return bool(stripped) and "-" not in stripped
async def textToSpeech(self, text: str, languageCode: str = "de-DE", voiceName: str = None) -> Dict[str, Any]: async def textToSpeech(self, text: str, languageCode: str = "de-DE", voiceName: str = None) -> Dict[str, Any]:
""" """
Convert text to speech using Google Cloud Text-to-Speech. Convert text to speech using Google Cloud Text-to-Speech.
@ -917,9 +929,6 @@ class ConnectorGoogleSpeech:
try: try:
logger.info(f"Converting text to speech: '{text[:50]}...' in {languageCode}") logger.info(f"Converting text to speech: '{text[:50]}...' in {languageCode}")
# Set up the synthesis input
synthesisInput = texttospeech.SynthesisInput(text=text)
# Build the voice request # Build the voice request
selectedVoice = voiceName or self._getDefaultVoice(languageCode) selectedVoice = voiceName or self._getDefaultVoice(languageCode)
@ -931,11 +940,24 @@ class ConnectorGoogleSpeech:
logger.info(f"Using TTS voice: {selectedVoice} for language: {languageCode}") logger.info(f"Using TTS voice: {selectedVoice} for language: {languageCode}")
voice = texttospeech.VoiceSelectionParams( if self._isGeminiTtsSpeakerVoiceName(selectedVoice):
language_code=languageCode, synthesisInput = texttospeech.SynthesisInput(
name=selectedVoice, text=text,
ssml_gender=texttospeech.SsmlVoiceGender.NEUTRAL prompt=_GEMINI_TTS_NEUTRAL_PROMPT,
) )
voice = texttospeech.VoiceSelectionParams(
language_code=languageCode,
name=selectedVoice,
model_name=_GEMINI_TTS_DEFAULT_MODEL,
ssml_gender=texttospeech.SsmlVoiceGender.NEUTRAL,
)
else:
synthesisInput = texttospeech.SynthesisInput(text=text)
voice = texttospeech.VoiceSelectionParams(
language_code=languageCode,
name=selectedVoice,
ssml_gender=texttospeech.SsmlVoiceGender.NEUTRAL,
)
# Select the type of audio file to return # Select the type of audio file to return
audioConfig = texttospeech.AudioConfig( audioConfig = texttospeech.AudioConfig(
@ -1059,7 +1081,8 @@ class ConnectorGoogleSpeech:
"language_codes": list(voice.language_codes) if voice.language_codes else [], "language_codes": list(voice.language_codes) if voice.language_codes else [],
"gender": gender, "gender": gender,
"ssml_gender": voice.ssml_gender.name if voice.ssml_gender else "NEUTRAL", "ssml_gender": voice.ssml_gender.name if voice.ssml_gender else "NEUTRAL",
"natural_sample_rate_hertz": voice.natural_sample_rate_hertz "natural_sample_rate_hertz": voice.natural_sample_rate_hertz,
"geminiTts": self._isGeminiTtsSpeakerVoiceName(voice.name or ""),
} }
# Include any additional fields if available from Google API # Include any additional fields if available from Google API

View file

@ -0,0 +1,7 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""ClickUp provider connector."""
from .connectorClickup import ClickupConnector
__all__ = ["ClickupConnector"]

View file

@ -0,0 +1,268 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""ClickUp ProviderConnector — virtual paths for teams → lists → tasks (table rows).
Path convention (leading slash, no trailing slash except root):
/ authorized workspaces (teams)
/team/{teamId} spaces in the workspace
/team/{teamId}/space/{spaceId} folders + folderless lists
/team/{teamId}/space/{spaceId}/folder/{folderId} lists in folder
/team/{teamId}/list/{listId} tasks in list (rows)
/team/{teamId}/list/{listId}/task/{taskId} single task (download = JSON)
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any, Dict, List, Optional
from modules.connectors.connectorProviderBase import (
ProviderConnector,
ServiceAdapter,
DownloadResult,
)
from modules.datamodels.datamodelDataSource import ExternalEntry
from modules.serviceCenter.services.serviceClickup.mainServiceClickup import ClickupService
logger = logging.getLogger(__name__)
# type metadata for ExternalEntry.metadata["cuType"]
_CU_TEAM = "team"
_CU_SPACE = "space"
_CU_FOLDER = "folder"
_CU_LIST = "list"
_CU_TASK = "task"
def _norm(path: str) -> str:
p = (path or "").strip() or "/"
if not p.startswith("/"):
p = "/" + p
if p != "/" and p.endswith("/"):
p = p.rstrip("/")
return p
class ClickupListsAdapter(ServiceAdapter):
"""Maps ClickUp hierarchy + list tasks to browse/download/upload/search."""
def __init__(self, access_token: str):
self._token = access_token
# Minimal service instance for API calls (no ServiceCenter context)
self._svc = ClickupService(context=None, get_service=lambda _: None)
self._svc.setAccessToken(access_token)
async def browse(self, path: str, filter: Optional[str] = None) -> List[ExternalEntry]:
p = _norm(path)
out: List[ExternalEntry] = []
if p == "/":
data = await self._svc.getAuthorizedTeams()
if isinstance(data, dict) and data.get("error"):
logger.warning(f"ClickUp browse root: {data.get('error')}")
return []
teams = data.get("teams", []) if isinstance(data, dict) else []
for t in teams:
tid = str(t.get("id", ""))
name = t.get("name") or tid
out.append(
ExternalEntry(
name=name,
path=f"/team/{tid}",
isFolder=True,
metadata={"cuType": _CU_TEAM, "id": tid, "raw": t},
)
)
return out
m = re.match(r"^/team/([^/]+)$", p)
if m:
team_id = m.group(1)
data = await self._svc.getSpaces(team_id)
if isinstance(data, dict) and data.get("error"):
return []
spaces = data.get("spaces", []) if isinstance(data, dict) else []
for s in spaces:
sid = str(s.get("id", ""))
name = s.get("name") or sid
out.append(
ExternalEntry(
name=name,
path=f"/team/{team_id}/space/{sid}",
isFolder=True,
metadata={"cuType": _CU_SPACE, "id": sid, "raw": s},
)
)
return out
m = re.match(r"^/team/([^/]+)/space/([^/]+)$", p)
if m:
team_id, space_id = m.group(1), m.group(2)
folders_r = await self._svc.getFolders(space_id)
lists_r = await self._svc.getFolderlessLists(space_id)
if isinstance(folders_r, dict) and not folders_r.get("error"):
for f in folders_r.get("folders", []) or []:
fid = str(f.get("id", ""))
name = f.get("name") or fid
out.append(
ExternalEntry(
name=name,
path=f"/team/{team_id}/space/{space_id}/folder/{fid}",
isFolder=True,
metadata={"cuType": _CU_FOLDER, "id": fid, "raw": f},
)
)
if isinstance(lists_r, dict) and not lists_r.get("error"):
for lst in lists_r.get("lists", []) or []:
lid = str(lst.get("id", ""))
name = lst.get("name") or lid
out.append(
ExternalEntry(
name=name,
path=f"/team/{team_id}/list/{lid}",
isFolder=True,
metadata={"cuType": _CU_LIST, "id": lid, "raw": lst},
)
)
return out
m = re.match(r"^/team/([^/]+)/space/([^/]+)/folder/([^/]+)$", p)
if m:
team_id, _space_id, folder_id = m.group(1), m.group(2), m.group(3)
data = await self._svc.getListsInFolder(folder_id)
if isinstance(data, dict) and data.get("error"):
return []
for lst in data.get("lists", []) or []:
lid = str(lst.get("id", ""))
name = lst.get("name") or lid
out.append(
ExternalEntry(
name=name,
path=f"/team/{team_id}/list/{lid}",
isFolder=True,
metadata={"cuType": _CU_LIST, "id": lid, "raw": lst},
)
)
return out
m = re.match(r"^/team/([^/]+)/list/([^/]+)$", p)
if m:
team_id, list_id = m.group(1), m.group(2)
page = 0
while True:
data = await self._svc.getTasksInList(list_id, page=page)
if isinstance(data, dict) and data.get("error"):
break
tasks = data.get("tasks", []) if isinstance(data, dict) else []
for task in tasks:
tid = str(task.get("id", ""))
name = task.get("name") or tid
out.append(
ExternalEntry(
name=name,
path=f"/team/{team_id}/list/{list_id}/task/{tid}",
isFolder=False,
metadata={
"cuType": _CU_TASK,
"id": tid,
"task": task,
},
)
)
if len(tasks) < 100:
break
page += 1
return out
m = re.match(r"^/team/([^/]+)/list/([^/]+)/task/([^/]+)$", p)
if m:
team_id, list_id, task_id = m.group(1), m.group(2), m.group(3)
out.append(
ExternalEntry(
name=f"task-{task_id}.json",
path=p,
isFolder=False,
metadata={"cuType": _CU_TASK, "id": task_id, "listId": list_id, "teamId": team_id},
)
)
return out
logger.warning(f"ClickUp browse: unsupported path {p}")
return []
async def download(self, path: str) -> Any:
p = _norm(path)
m = re.match(r"^/team/([^/]+)/list/([^/]+)/task/([^/]+)$", p)
if not m:
return b""
task_id = m.group(3)
data = await self._svc.getTask(task_id)
if isinstance(data, dict) and data.get("error"):
return json.dumps(data).encode("utf-8")
payload = json.dumps(data, indent=2).encode("utf-8")
return DownloadResult(data=payload, fileName=f"task-{task_id}.json", mimeType="application/json")
async def upload(self, path: str, data: bytes, fileName: str) -> dict:
"""Upload attachment to a task. Path must be .../list/{listId}/task/{taskId}."""
p = _norm(path)
m = re.match(r"^/team/([^/]+)/list/([^/]+)/task/([^/]+)$", p)
if not m:
return {"error": "Path must be /team/{teamId}/list/{listId}/task/{taskId} for upload"}
task_id = m.group(3)
return await self._svc.uploadTaskAttachment(task_id, data, fileName)
async def search(self, query: str, path: Optional[str] = None) -> List[ExternalEntry]:
base = _norm(path or "/")
team_id: Optional[str] = None
mt = re.match(r"^/team/([^/]+)", base)
if mt:
team_id = mt.group(1)
if not team_id:
teams = await self._svc.getAuthorizedTeams()
if not isinstance(teams, dict) or teams.get("error"):
return []
tl = teams.get("teams") or []
if not tl:
return []
team_id = str(tl[0].get("id", ""))
out: List[ExternalEntry] = []
page = 0
while True:
data = await self._svc.searchTeamTasks(team_id, query=query, page=page)
if isinstance(data, dict) and data.get("error"):
break
tasks = data.get("tasks", []) if isinstance(data, dict) else []
for task in tasks:
tid = str(task.get("id", ""))
name = task.get("name") or tid
list_obj = task.get("list") or {}
lid = str(list_obj.get("id", "")) if list_obj else ""
if not lid:
continue
out.append(
ExternalEntry(
name=name,
path=f"/team/{team_id}/list/{lid}/task/{tid}",
isFolder=False,
metadata={"cuType": _CU_TASK, "id": tid, "task": task},
)
)
if len(tasks) < 25:
break
page += 1
return out
class ClickupConnector(ProviderConnector):
"""One ClickUp connection → clickup virtual file service."""
def getAvailableServices(self) -> List[str]:
return ["clickup"]
def getServiceAdapter(self, service: str) -> ServiceAdapter:
if service != "clickup":
raise ValueError(f"ClickUp only supports 'clickup' service, got '{service}'")
return ClickupListsAdapter(self.accessToken)

View file

@ -22,6 +22,10 @@ class OperationTypeEnum(str, Enum):
IMAGE_ANALYSE = "imageAnalyse" IMAGE_ANALYSE = "imageAnalyse"
IMAGE_GENERATE = "imageGenerate" IMAGE_GENERATE = "imageGenerate"
# Neutralization (dedicated model selection; text vs vision backends)
NEUTRALIZATION_TEXT = "neutralizationText"
NEUTRALIZATION_IMAGE = "neutralizationImage"
# Web Operations # Web Operations
WEB_SEARCH_DATA = "webSearch" # Returns list of URLs only WEB_SEARCH_DATA = "webSearch" # Returns list of URLs only
WEB_CRAWL = "webCrawl" # Web crawl for a given URL WEB_CRAWL = "webCrawl" # Web crawl for a given URL
@ -168,6 +172,8 @@ class AiCallRequest(BaseModel):
contentParts: Optional[List['ContentPart']] = None # Content parts for model-aware chunking contentParts: Optional[List['ContentPart']] = None # Content parts for model-aware chunking
messages: Optional[List[Dict[str, Any]]] = Field(default=None, description="OpenAI-style messages for multi-turn agent conversations") messages: Optional[List[Dict[str, Any]]] = Field(default=None, description="OpenAI-style messages for multi-turn agent conversations")
tools: Optional[List[Dict[str, Any]]] = Field(default=None, description="Tool definitions for native function calling") tools: Optional[List[Dict[str, Any]]] = Field(default=None, description="Tool definitions for native function calling")
toolChoice: Optional[Any] = Field(default=None, description="Tool choice: 'auto', 'none', or specific tool (passed through to model call)")
requireNeutralization: Optional[bool] = Field(default=None, description="Per-request neutralization override: True=force, False=skip, None=use config")
class AiCallResponse(BaseModel): class AiCallResponse(BaseModel):

View file

@ -0,0 +1,68 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""Base Pydantic model with system-managed fields (DB + API + UI metadata)."""
from typing import Optional
from pydantic import BaseModel, Field
from modules.shared.attributeUtils import registerModelLabels
class PowerOnModel(BaseModel):
sysCreatedAt: Optional[float] = Field(
default=None,
description="Record creation timestamp (UTC, set by system)",
json_schema_extra={
"frontend_type": "timestamp",
"frontend_readonly": True,
"frontend_required": False,
"frontend_visible": False,
"system": True,
},
)
sysCreatedBy: Optional[str] = Field(
default=None,
description="User ID who created this record (set by system)",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"frontend_visible": False,
"system": True,
},
)
sysModifiedAt: Optional[float] = Field(
default=None,
description="Record last modification timestamp (UTC, set by system)",
json_schema_extra={
"frontend_type": "timestamp",
"frontend_readonly": True,
"frontend_required": False,
"frontend_visible": False,
"system": True,
},
)
sysModifiedBy: Optional[str] = Field(
default=None,
description="User ID who last modified this record (set by system)",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"frontend_visible": False,
"system": True,
},
)
registerModelLabels(
"PowerOnModel",
{"en": "Base Record", "de": "Basisdatensatz"},
{
"sysCreatedAt": {"en": "Created At", "de": "Erstellt am", "fr": "Cree le"},
"sysCreatedBy": {"en": "Created By", "de": "Erstellt von", "fr": "Cree par"},
"sysModifiedAt": {"en": "Modified At", "de": "Geaendert am", "fr": "Modifie le"},
"sysModifiedBy": {"en": "Modified By", "de": "Geaendert von", "fr": "Modifie par"},
},
)

View file

@ -6,24 +6,12 @@ from typing import List, Dict, Any, Optional
from enum import Enum from enum import Enum
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.attributeUtils import registerModelLabels
import uuid import uuid
# End-customer price for storage above plan-included volume (CHF per GB per month).
class BillingModelEnum(str, Enum): STORAGE_PRICE_PER_GB_CHF = 0.50
"""Billing model types (prepaid only; legacy UNLIMITED in DB maps to PREPAY_MANDATE)."""
PREPAY_MANDATE = "PREPAY_MANDATE" # Prepaid budget shared by all users in mandate
PREPAY_USER = "PREPAY_USER" # Prepaid budget per user within mandate
# Nur fuer initRootMandateBilling (Root-Mandant PREPAY_USER + Startguthaben in Settings).
DEFAULT_USER_CREDIT_CHF = 5.0
class AccountTypeEnum(str, Enum):
"""Account type for billing accounts."""
MANDATE = "MANDATE" # Account for entire mandate
USER = "USER" # Account for specific user within mandate
class TransactionTypeEnum(str, Enum): class TransactionTypeEnum(str, Enum):
@ -39,6 +27,8 @@ class ReferenceTypeEnum(str, Enum):
PAYMENT = "PAYMENT" # Payment/top-up PAYMENT = "PAYMENT" # Payment/top-up
ADMIN = "ADMIN" # Admin adjustment ADMIN = "ADMIN" # Admin adjustment
SYSTEM = "SYSTEM" # System credit (e.g., initial credit) SYSTEM = "SYSTEM" # System credit (e.g., initial credit)
STORAGE = "STORAGE" # Metered storage overage (prepay pool)
SUBSCRIPTION = "SUBSCRIPTION" # AI budget credit from subscription plan
class PeriodTypeEnum(str, Enum): class PeriodTypeEnum(str, Enum):
@ -48,14 +38,13 @@ class PeriodTypeEnum(str, Enum):
YEAR = "YEAR" YEAR = "YEAR"
class BillingAccount(BaseModel): class BillingAccount(PowerOnModel):
"""Billing account for mandate or user-mandate combination.""" """Billing account for mandate or user-mandate combination."""
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key" default_factory=lambda: str(uuid.uuid4()), description="Primary key"
) )
mandateId: str = Field(..., description="Foreign key to Mandate") mandateId: str = Field(..., description="Foreign key to Mandate")
userId: Optional[str] = Field(None, description="Foreign key to User (only for PREPAY_USER)") userId: Optional[str] = Field(None, description="Foreign key to User (None = mandate pool account, set = user audit account)")
accountType: AccountTypeEnum = Field(..., description="Account type: MANDATE or USER")
balance: float = Field(default=0.0, description="Current balance in CHF") balance: float = Field(default=0.0, description="Current balance in CHF")
warningThreshold: float = Field(default=0.0, description="Warning threshold in CHF") warningThreshold: float = Field(default=0.0, description="Warning threshold in CHF")
lastWarningAt: Optional[datetime] = Field(None, description="Last warning sent timestamp") lastWarningAt: Optional[datetime] = Field(None, description="Last warning sent timestamp")
@ -69,7 +58,6 @@ registerModelLabels(
"id": {"en": "ID", "de": "ID"}, "id": {"en": "ID", "de": "ID"},
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"}, "mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"},
"userId": {"en": "User ID", "de": "Benutzer-ID"}, "userId": {"en": "User ID", "de": "Benutzer-ID"},
"accountType": {"en": "Account Type", "de": "Kontotyp"},
"balance": {"en": "Balance (CHF)", "de": "Guthaben (CHF)"}, "balance": {"en": "Balance (CHF)", "de": "Guthaben (CHF)"},
"warningThreshold": {"en": "Warning Threshold (CHF)", "de": "Warnschwelle (CHF)"}, "warningThreshold": {"en": "Warning Threshold (CHF)", "de": "Warnschwelle (CHF)"},
"lastWarningAt": {"en": "Last Warning", "de": "Letzte Warnung"}, "lastWarningAt": {"en": "Last Warning", "de": "Letzte Warnung"},
@ -78,7 +66,7 @@ registerModelLabels(
) )
class BillingTransaction(BaseModel): class BillingTransaction(PowerOnModel):
"""Single billing transaction (credit, debit, adjustment).""" """Single billing transaction (credit, debit, adjustment)."""
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key" default_factory=lambda: str(uuid.uuid4()), description="Primary key"
@ -129,30 +117,43 @@ registerModelLabels(
class BillingSettings(BaseModel): class BillingSettings(BaseModel):
"""Billing settings per mandate.""" """Billing settings per mandate. Only PREPAY_MANDATE model."""
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key" default_factory=lambda: str(uuid.uuid4()), description="Primary key"
) )
mandateId: str = Field(..., description="Foreign key to Mandate (UNIQUE)") mandateId: str = Field(..., description="Foreign key to Mandate (UNIQUE)")
billingModel: BillingModelEnum = Field(..., description="Billing model")
# Configuration
defaultUserCredit: float = Field(
default=0.0,
description="Automatic initial credit (CHF) for PREPAY_USER only when a user is newly added to the root mandate; other mandates use 0 on join.",
)
warningThresholdPercent: float = Field(default=10.0, description="Warning threshold as percentage") warningThresholdPercent: float = Field(default=10.0, description="Warning threshold as percentage")
# Stripe # Stripe
stripeCustomerId: Optional[str] = Field(None, description="Stripe Customer ID (cus_xxx) — one per mandate") stripeCustomerId: Optional[str] = Field(None, description="Stripe Customer ID (cus_xxx) — one per mandate")
# Notifications (e.g. mandate owner / finance — also used when PREPAY_MANDATE pool is exhausted) # Auto-Recharge for AI budget
autoRechargeEnabled: bool = Field(default=False, description="Auto-buy AI budget when low")
rechargeAmountCHF: float = Field(default=10.0, description="Amount per auto-recharge (CHF, prepaid via Stripe)")
rechargeMaxPerMonth: int = Field(default=3, description="Max auto-recharges per month")
rechargesThisMonth: int = Field(default=0, description="Counter: auto-recharges used this month")
monthResetAt: Optional[datetime] = Field(None, description="When rechargesThisMonth was last reset")
# Notifications
notifyEmails: List[str] = Field( notifyEmails: List[str] = Field(
default_factory=list, default_factory=list,
description="Email addresses for billing alerts (mandate pool exhausted, warnings, etc.)", description="Email addresses for billing alerts (pool exhausted, warnings, etc.)",
) )
notifyOnWarning: bool = Field(default=True, description="Send email when warning threshold is reached") notifyOnWarning: bool = Field(default=True, description="Send email when warning threshold is reached")
# Storage overage (high-watermark within subscription period; resets on new period)
storageHighWatermarkMB: float = Field(
default=0.0, description="Peak indexed data volume MB this billing period"
)
storagePeriodStartAt: Optional[datetime] = Field(
None, description="Subscription billing period start used for storage reset"
)
storageBilledUpToMB: float = Field(
default=0.0,
description="Overage MB already debited this period (above plan-included volume)",
)
registerModelLabels( registerModelLabels(
"BillingSettings", "BillingSettings",
@ -160,18 +161,22 @@ registerModelLabels(
{ {
"id": {"en": "ID", "de": "ID"}, "id": {"en": "ID", "de": "ID"},
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"}, "mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"},
"billingModel": {"en": "Billing Model", "de": "Abrechnungsmodell"},
"defaultUserCredit": {
"en": "Root start credit (CHF)",
"de": "Startguthaben nur Root-Mandant (CHF)",
},
"warningThresholdPercent": {"en": "Warning Threshold (%)", "de": "Warnschwelle (%)"}, "warningThresholdPercent": {"en": "Warning Threshold (%)", "de": "Warnschwelle (%)"},
"stripeCustomerId": {"en": "Stripe Customer ID", "de": "Stripe-Kunden-ID"}, "stripeCustomerId": {"en": "Stripe Customer ID", "de": "Stripe-Kunden-ID"},
"autoRechargeEnabled": {"en": "Auto-Recharge", "de": "Auto-Nachladung"},
"rechargeAmountCHF": {"en": "Recharge Amount (CHF)", "de": "Nachladebetrag (CHF)"},
"rechargeMaxPerMonth": {"en": "Max Recharges/Month", "de": "Max. Nachladungen/Monat"},
"notifyEmails": { "notifyEmails": {
"en": "Billing notification emails (owner / admin)", "en": "Billing notification emails (owner / admin)",
"de": "E-Mails für Billing-Alerts (Inhaber/Admin)", "de": "E-Mails fuer Billing-Alerts (Inhaber/Admin)",
}, },
"notifyOnWarning": {"en": "Notify on Warning", "de": "Bei Warnung benachrichtigen"}, "notifyOnWarning": {"en": "Notify on Warning", "de": "Bei Warnung benachrichtigen"},
"storageHighWatermarkMB": {"en": "Storage peak (MB)", "de": "Speicher-Peak (MB)"},
"storagePeriodStartAt": {"en": "Storage period start", "de": "Speicher-Periodenbeginn"},
"storageBilledUpToMB": {
"en": "Storage billed overage (MB)",
"de": "Speicher abgerechneter Überhang (MB)",
},
}, },
) )
@ -238,7 +243,6 @@ class BillingBalanceResponse(BaseModel):
"""Response model for balance endpoint.""" """Response model for balance endpoint."""
mandateId: str mandateId: str
mandateName: str mandateName: str
billingModel: BillingModelEnum
balance: float balance: float
currency: str = "CHF" currency: str = "CHF"
warningThreshold: float warningThreshold: float
@ -269,20 +273,8 @@ class BillingCheckResult(BaseModel):
reason: Optional[str] = None reason: Optional[str] = None
currentBalance: Optional[float] = None currentBalance: Optional[float] = None
requiredAmount: Optional[float] = None requiredAmount: Optional[float] = None
billingModel: Optional[BillingModelEnum] = None
upgradeRequired: Optional[bool] = None upgradeRequired: Optional[bool] = None
subscriptionUiPath: Optional[str] = None subscriptionUiPath: Optional[str] = None
userAction: Optional[str] = None userAction: Optional[str] = None
def parseBillingModelFromStoredValue(raw: Optional[str]) -> BillingModelEnum:
"""Map DB string to enum. Legacy UNLIMITED / unknown values become PREPAY_MANDATE."""
if raw is None or (isinstance(raw, str) and raw.strip() == ""):
return BillingModelEnum.PREPAY_MANDATE
s = str(raw).strip().upper()
if s == "UNLIMITED":
return BillingModelEnum.PREPAY_MANDATE
try:
return BillingModelEnum(raw)
except ValueError:
return BillingModelEnum.PREPAY_MANDATE

View file

@ -5,12 +5,13 @@
from typing import List, Dict, Any, Optional 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.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.attributeUtils import registerModelLabels
from modules.shared.timeUtils import getUtcTimestamp from modules.shared.timeUtils import getUtcTimestamp
import uuid import uuid
class ChatLog(BaseModel): class ChatLog(PowerOnModel):
"""Log entries for chat workflows. User-owned, no mandate context.""" """Log entries for chat workflows. User-owned, no mandate context."""
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key" default_factory=lambda: str(uuid.uuid4()), description="Primary key"
@ -56,7 +57,7 @@ registerModelLabels(
) )
class ChatDocument(BaseModel): class ChatDocument(PowerOnModel):
"""Documents attached to chat messages. User-owned, no mandate context.""" """Documents attached to chat messages. User-owned, no mandate context."""
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key" default_factory=lambda: str(uuid.uuid4()), description="Primary key"
@ -163,7 +164,7 @@ registerModelLabels(
) )
class ChatMessage(BaseModel): class ChatMessage(PowerOnModel):
"""Messages in chat workflows. User-owned, no mandate context.""" """Messages in chat workflows. User-owned, no mandate context."""
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), description="Primary key" default_factory=lambda: str(uuid.uuid4()), description="Primary key"
@ -260,7 +261,7 @@ registerModelLabels(
) )
class ChatWorkflow(BaseModel): class ChatWorkflow(PowerOnModel):
"""Chat workflow container. User-owned, no mandate context.""" """Chat workflow container. User-owned, no mandate context."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
featureInstanceId: Optional[str] = Field(None, description="Feature instance ID for multi-tenancy isolation", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) featureInstanceId: Optional[str] = Field(None, description="Feature instance ID for multi-tenancy isolation", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})

View file

@ -8,16 +8,18 @@ Google Drive folder, FTP directory, etc.) for agent-accessible data containers.
from typing import Dict, Any, Optional from typing import Dict, Any, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.attributeUtils import registerModelLabels
from modules.shared.timeUtils import getUtcTimestamp
import uuid import uuid
class DataSource(BaseModel): class DataSource(PowerOnModel):
"""Configured external data source linked to a UserConnection.""" """Configured external data source linked to a UserConnection."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key") id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
connectionId: str = Field(description="FK to UserConnection") connectionId: str = Field(description="FK to UserConnection")
sourceType: str = Field(description="sharepointFolder, googleDriveFolder, outlookFolder, ftpFolder") sourceType: str = Field(
description="sharepointFolder, googleDriveFolder, outlookFolder, ftpFolder, clickupList (path under /team/...)"
)
path: str = Field(description="External path (e.g. '/sites/MySite/Documents/Reports')") path: str = Field(description="External path (e.g. '/sites/MySite/Documents/Reports')")
label: str = Field(description="User-visible label (often the last path segment)") label: str = Field(description="User-visible label (often the last path segment)")
displayPath: Optional[str] = Field( displayPath: Optional[str] = Field(
@ -29,7 +31,21 @@ class DataSource(BaseModel):
userId: str = Field(default="", description="Owner user ID") userId: str = Field(default="", description="Owner user ID")
autoSync: bool = Field(default=False, description="Automatically sync on schedule") autoSync: bool = Field(default=False, description="Automatically sync on schedule")
lastSynced: Optional[float] = Field(default=None, description="Last sync timestamp") lastSynced: Optional[float] = Field(default=None, description="Last sync timestamp")
createdAt: float = Field(default_factory=getUtcTimestamp, description="Creation timestamp") scope: str = Field(
default="personal",
description="Data visibility scope: personal, featureInstance, mandate, global",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}},
{"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}},
{"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}},
{"value": "global", "label": {"en": "Global", "de": "Global"}},
]}
)
neutralize: bool = Field(
default=False,
description="Whether this data source should be neutralized before AI processing",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
)
registerModelLabels( registerModelLabels(
@ -47,7 +63,8 @@ registerModelLabels(
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"}, "userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
"autoSync": {"en": "Auto Sync", "de": "Auto-Sync", "fr": "Synchro auto"}, "autoSync": {"en": "Auto Sync", "de": "Auto-Sync", "fr": "Synchro auto"},
"lastSynced": {"en": "Last Synced", "de": "Letzter Sync", "fr": "Dernier sync"}, "lastSynced": {"en": "Last Synced", "de": "Letzter Sync", "fr": "Dernier sync"},
"createdAt": {"en": "Created At", "de": "Erstellt am", "fr": "Créé le"}, "scope": {"en": "Scope", "de": "Sichtbarkeit"},
"neutralize": {"en": "Neutralize", "de": "Neutralisieren"},
}, },
) )

View file

@ -6,14 +6,14 @@ A FeatureDataSource links a FeatureInstance table (DATA_OBJECT) to a workspace
so the agent can query structured feature data (e.g. TrusteePosition rows). so the agent can query structured feature data (e.g. TrusteePosition rows).
""" """
from typing import Optional from typing import Dict, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.attributeUtils import registerModelLabels
from modules.shared.timeUtils import getUtcTimestamp
import uuid import uuid
class FeatureDataSource(BaseModel): class FeatureDataSource(PowerOnModel):
"""A feature-instance table attached as data source in the AI workspace.""" """A feature-instance table attached as data source in the AI workspace."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key") id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
featureInstanceId: str = Field(description="FK to FeatureInstance") featureInstanceId: str = Field(description="FK to FeatureInstance")
@ -24,7 +24,25 @@ class FeatureDataSource(BaseModel):
mandateId: str = Field(default="", description="Mandate scope") mandateId: str = Field(default="", description="Mandate scope")
userId: str = Field(default="", description="Owner user ID") userId: str = Field(default="", description="Owner user ID")
workspaceInstanceId: str = Field(description="Workspace instance where this source is used") workspaceInstanceId: str = Field(description="Workspace instance where this source is used")
createdAt: float = Field(default_factory=getUtcTimestamp, description="Creation timestamp") scope: str = Field(
default="personal",
description="Data visibility scope: personal, featureInstance, mandate, global",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}},
{"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}},
{"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}},
{"value": "global", "label": {"en": "Global", "de": "Global"}},
]}
)
neutralize: bool = Field(
default=False,
description="Whether this data source should be neutralized before AI processing",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
)
recordFilter: Optional[Dict[str, str]] = Field(
default=None,
description="Record-level filter applied when querying this table, e.g. {'sessionId': 'abc-123'}",
)
registerModelLabels( registerModelLabels(
@ -40,6 +58,5 @@ registerModelLabels(
"mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"}, "mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
"userId": {"en": "User", "de": "Benutzer", "fr": "Utilisateur"}, "userId": {"en": "User", "de": "Benutzer", "fr": "Utilisateur"},
"workspaceInstanceId": {"en": "Workspace", "de": "Workspace", "fr": "Espace de travail"}, "workspaceInstanceId": {"en": "Workspace", "de": "Workspace", "fr": "Espace de travail"},
"createdAt": {"en": "Created At", "de": "Erstellt am", "fr": "Créé le"},
}, },
) )

View file

@ -5,11 +5,12 @@
import uuid import uuid
from typing import Optional, Dict, Any from typing import Optional, Dict, Any
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.attributeUtils import registerModelLabels
from modules.datamodels.datamodelUtils import TextMultilingual from modules.datamodels.datamodelUtils import TextMultilingual
class Feature(BaseModel): class Feature(PowerOnModel):
""" """
Feature-Definition (global, z.B. 'trustee', 'chatbot'). Feature-Definition (global, z.B. 'trustee', 'chatbot').
Features sind die verfügbaren Funktionalitäten der Plattform. Features sind die verfügbaren Funktionalitäten der Plattform.
@ -40,7 +41,7 @@ registerModelLabels(
) )
class FeatureInstance(BaseModel): class FeatureInstance(PowerOnModel):
""" """
Instanz eines Features in einem Mandanten. Instanz eines Features in einem Mandanten.
Ein Mandant kann mehrere Instanzen desselben Features haben. Ein Mandant kann mehrere Instanzen desselben Features haben.

View file

@ -4,18 +4,17 @@
from typing import Optional from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.attributeUtils import registerModelLabels
from modules.shared.timeUtils import getUtcTimestamp
import uuid import uuid
class FileFolder(BaseModel): class FileFolder(PowerOnModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
name: str = Field(description="Folder name", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}) name: str = Field(description="Folder name", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True})
parentId: Optional[str] = Field(default=None, description="Parent folder ID (null = root)", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}) parentId: Optional[str] = Field(default=None, description="Parent folder ID (null = root)", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
mandateId: Optional[str] = Field(default=None, description="Mandate context", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) mandateId: Optional[str] = Field(default=None, description="Mandate context", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
featureInstanceId: Optional[str] = Field(default=None, description="Feature instance context", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) featureInstanceId: Optional[str] = Field(default=None, description="Feature instance context", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
createdAt: float = Field(default_factory=getUtcTimestamp, description="Creation timestamp", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
registerModelLabels( registerModelLabels(
@ -27,6 +26,5 @@ registerModelLabels(
"parentId": {"en": "Parent Folder", "fr": "Dossier parent"}, "parentId": {"en": "Parent Folder", "fr": "Dossier parent"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"}, "mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance"}, "featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance"},
"createdAt": {"en": "Created At", "fr": "Créé le"},
}, },
) )

View file

@ -3,15 +3,14 @@
"""File-related datamodels: FileItem, FilePreview, FileData.""" """File-related datamodels: FileItem, FilePreview, FileData."""
from typing import Dict, Any, List, Optional, Union from typing import Dict, Any, List, Optional, Union
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.attributeUtils import registerModelLabels
from modules.shared.timeUtils import getUtcTimestamp
import uuid import uuid
import base64 import base64
class FileItem(BaseModel): class FileItem(PowerOnModel):
model_config = ConfigDict(extra='allow') # Preserve system fields (_createdBy, _createdAt, etc.)
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
mandateId: Optional[str] = Field(default="", description="ID of the mandate this file belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) mandateId: Optional[str] = Field(default="", description="ID of the mandate this file belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
featureInstanceId: Optional[str] = Field(default="", description="ID of the feature instance this file belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "frontend_fk_source": "/api/features/instances", "frontend_fk_display_field": "label"}) featureInstanceId: Optional[str] = Field(default="", description="ID of the feature instance this file belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "frontend_fk_source": "/api/features/instances", "frontend_fk_display_field": "label"})
@ -19,11 +18,25 @@ class FileItem(BaseModel):
mimeType: str = Field(description="MIME type of the file", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) mimeType: str = Field(description="MIME type of the file", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
fileHash: str = Field(description="Hash of the file", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) fileHash: str = Field(description="Hash of the file", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
fileSize: int = Field(description="Size of the file in bytes", json_schema_extra={"frontend_type": "integer", "frontend_readonly": True, "frontend_required": False}) fileSize: int = Field(description="Size of the file in bytes", json_schema_extra={"frontend_type": "integer", "frontend_readonly": True, "frontend_required": False})
creationDate: float = Field(default_factory=getUtcTimestamp, description="Date when the file was created (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
tags: Optional[List[str]] = Field(default=None, description="Tags for categorization and search", json_schema_extra={"frontend_type": "tags", "frontend_readonly": False, "frontend_required": False}) tags: Optional[List[str]] = Field(default=None, description="Tags for categorization and search", json_schema_extra={"frontend_type": "tags", "frontend_readonly": False, "frontend_required": False})
folderId: Optional[str] = Field(default=None, description="ID of the parent folder", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}) folderId: Optional[str] = Field(default=None, description="ID of the parent folder", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
description: Optional[str] = Field(default=None, description="User-provided description of the file", json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False}) description: Optional[str] = Field(default=None, description="User-provided description of the file", json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False})
status: Optional[str] = Field(default=None, description="Processing status: pending, extracted, embedding, indexed, failed", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) status: Optional[str] = Field(default=None, description="Processing status: pending, extracted, embedding, indexed, failed", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
scope: str = Field(
default="personal",
description="Data visibility scope: personal, featureInstance, mandate, global",
json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}},
{"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}},
{"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}},
{"value": "global", "label": {"en": "Global", "de": "Global"}},
]}
)
neutralize: bool = Field(
default=False,
description="Whether this file should be neutralized before AI processing",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
)
registerModelLabels( registerModelLabels(
"FileItem", "FileItem",
@ -36,11 +49,12 @@ registerModelLabels(
"mimeType": {"en": "MIME Type", "fr": "Type MIME"}, "mimeType": {"en": "MIME Type", "fr": "Type MIME"},
"fileHash": {"en": "File Hash", "fr": "Hash du fichier"}, "fileHash": {"en": "File Hash", "fr": "Hash du fichier"},
"fileSize": {"en": "File Size", "fr": "Taille du fichier"}, "fileSize": {"en": "File Size", "fr": "Taille du fichier"},
"creationDate": {"en": "Creation Date", "fr": "Date de création"},
"tags": {"en": "Tags", "fr": "Tags"}, "tags": {"en": "Tags", "fr": "Tags"},
"folderId": {"en": "Folder ID", "fr": "ID du dossier"}, "folderId": {"en": "Folder ID", "fr": "ID du dossier"},
"description": {"en": "Description", "fr": "Description"}, "description": {"en": "Description", "fr": "Description"},
"status": {"en": "Status", "fr": "Statut"}, "status": {"en": "Status", "fr": "Statut"},
"scope": {"en": "Scope", "de": "Sichtbarkeit"},
"neutralize": {"en": "Neutralize", "de": "Neutralisieren"},
}, },
) )
@ -71,7 +85,7 @@ registerModelLabels(
}, },
) )
class FileData(BaseModel): class FileData(PowerOnModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key") id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
data: str = Field(description="File data content") data: str = Field(description="File data content")
base64Encoded: bool = Field(description="Whether the data is base64 encoded") base64Encoded: bool = Field(description="Whether the data is base64 encoded")

View file

@ -9,11 +9,11 @@ import uuid
import secrets import secrets
from typing import Optional, List from typing import Optional, List
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.attributeUtils import registerModelLabels
from modules.shared.timeUtils import getUtcTimestamp
class Invitation(BaseModel): class Invitation(PowerOnModel):
""" """
Einladungs-Token für neue User. Einladungs-Token für neue User.
Ermöglicht Self-Service Onboarding zu Mandanten und Feature-Instanzen. Ermöglicht Self-Service Onboarding zu Mandanten und Feature-Instanzen.
@ -56,15 +56,6 @@ class Invitation(BaseModel):
description="Email address to send invitation link (optional)", description="Email address to send invitation link (optional)",
json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": False} json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": False}
) )
createdBy: str = Field(
description="User ID of the person who created the invitation",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
)
createdAt: float = Field(
default_factory=getUtcTimestamp,
description="When the invitation was created (UTC timestamp)",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
)
expiresAt: float = Field( expiresAt: float = Field(
description="When the invitation expires (UTC timestamp)", description="When the invitation expires (UTC timestamp)",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": True} json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": True}
@ -121,8 +112,6 @@ registerModelLabels(
"roleIds": {"en": "Roles", "de": "Rollen", "fr": "Rôles"}, "roleIds": {"en": "Roles", "de": "Rollen", "fr": "Rôles"},
"targetUsername": {"en": "Target Username", "de": "Ziel-Benutzername", "fr": "Nom d'utilisateur cible"}, "targetUsername": {"en": "Target Username", "de": "Ziel-Benutzername", "fr": "Nom d'utilisateur cible"},
"email": {"en": "Email (optional)", "de": "E-Mail (optional)", "fr": "Email (optionnel)"}, "email": {"en": "Email (optional)", "de": "E-Mail (optional)", "fr": "Email (optionnel)"},
"createdBy": {"en": "Created By", "de": "Erstellt von", "fr": "Créé par"},
"createdAt": {"en": "Created At", "de": "Erstellt am", "fr": "Créé le"},
"expiresAt": {"en": "Expires At", "de": "Gültig bis", "fr": "Expire le"}, "expiresAt": {"en": "Expires At", "de": "Gültig bis", "fr": "Expire le"},
"usedBy": {"en": "Used By", "de": "Verwendet von", "fr": "Utilisé par"}, "usedBy": {"en": "Used By", "de": "Verwendet von", "fr": "Utilisé par"},
"usedAt": {"en": "Used At", "de": "Verwendet am", "fr": "Utilisé le"}, "usedAt": {"en": "Used At", "de": "Verwendet am", "fr": "Utilisé le"},

View file

@ -3,8 +3,10 @@
"""Knowledge Store data models: FileContentIndex, ContentChunk, WorkflowMemory. """Knowledge Store data models: FileContentIndex, ContentChunk, WorkflowMemory.
These models support the 3-tier RAG architecture: These models support the 3-tier RAG architecture:
- Shared Layer: mandateId-scoped, isShared=True - Personal Layer: scope=personal, userId-scoped
- Instance Layer: userId + featureInstanceId-scoped - Instance Layer: scope=featureInstance, featureInstanceId-scoped
- Mandate Layer: scope=mandate, mandateId-scoped (visible to all mandate users)
- Global Layer: scope=global (sysAdmin only)
- Workflow Layer: workflowId-scoped (WorkflowMemory) - Workflow Layer: workflowId-scoped (WorkflowMemory)
Vector fields use json_schema_extra={"db_type": "vector(1536)"} for pgvector. Vector fields use json_schema_extra={"db_type": "vector(1536)"} for pgvector.
@ -12,19 +14,19 @@ Vector fields use json_schema_extra={"db_type": "vector(1536)"} for pgvector.
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.attributeUtils import registerModelLabels
from modules.shared.timeUtils import getUtcTimestamp from modules.shared.timeUtils import getUtcTimestamp
import uuid import uuid
class FileContentIndex(BaseModel): class FileContentIndex(PowerOnModel):
"""Structural index of a file's content objects. Created without AI. """Structural index of a file's content objects. Created without AI.
Lives in the Instance Layer; optionally promoted to Shared Layer via isShared.""" Scope is mirrored from FileItem (poweron_management) at indexing time."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key (typically = fileId)") id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key (typically = fileId)")
userId: str = Field(description="Owner user ID") userId: str = Field(description="Owner user ID")
featureInstanceId: str = Field(default="", description="Feature instance scope") featureInstanceId: str = Field(default="", description="Feature instance scope")
mandateId: str = Field(default="", description="Mandate scope") mandateId: str = Field(default="", description="Mandate scope")
isShared: bool = Field(default=False, description="Visible in Shared Layer for all mandate users")
fileName: str = Field(description="Original file name") fileName: str = Field(description="Original file name")
mimeType: str = Field(description="MIME type of the file") mimeType: str = Field(description="MIME type of the file")
containerPath: Optional[str] = Field(default=None, description="Path within a container (e.g. 'archive.zip/folder/report.pdf')") containerPath: Optional[str] = Field(default=None, description="Path within a container (e.g. 'archive.zip/folder/report.pdf')")
@ -34,6 +36,18 @@ class FileContentIndex(BaseModel):
objectSummary: List[Dict[str, Any]] = Field(default_factory=list, description="Compact summary per content object") objectSummary: List[Dict[str, Any]] = Field(default_factory=list, description="Compact summary per content object")
extractedAt: float = Field(default_factory=getUtcTimestamp, description="Extraction timestamp") extractedAt: float = Field(default_factory=getUtcTimestamp, description="Extraction timestamp")
status: str = Field(default="pending", description="Processing status: pending, extracted, embedding, indexed, failed") status: str = Field(default="pending", description="Processing status: pending, extracted, embedding, indexed, failed")
scope: str = Field(
default="personal",
description="Data visibility scope: personal, featureInstance, mandate, global",
)
neutralizationStatus: Optional[str] = Field(
default=None,
description="Neutralization status: completed, failed, skipped, None = not required",
)
isNeutralized: bool = Field(
default=False,
description="True if content was neutralized before indexing",
)
registerModelLabels( registerModelLabels(
@ -44,7 +58,6 @@ registerModelLabels(
"userId": {"en": "User ID", "fr": "ID utilisateur"}, "userId": {"en": "User ID", "fr": "ID utilisateur"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance"}, "featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"}, "mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"isShared": {"en": "Shared", "fr": "Partagé"},
"fileName": {"en": "File Name", "fr": "Nom de fichier"}, "fileName": {"en": "File Name", "fr": "Nom de fichier"},
"mimeType": {"en": "MIME Type", "fr": "Type MIME"}, "mimeType": {"en": "MIME Type", "fr": "Type MIME"},
"containerPath": {"en": "Container Path", "fr": "Chemin du conteneur"}, "containerPath": {"en": "Container Path", "fr": "Chemin du conteneur"},
@ -54,11 +67,14 @@ registerModelLabels(
"objectSummary": {"en": "Object Summary", "fr": "Résumé des objets"}, "objectSummary": {"en": "Object Summary", "fr": "Résumé des objets"},
"extractedAt": {"en": "Extracted At", "fr": "Extrait le"}, "extractedAt": {"en": "Extracted At", "fr": "Extrait le"},
"status": {"en": "Status", "fr": "Statut"}, "status": {"en": "Status", "fr": "Statut"},
"scope": {"en": "Scope", "de": "Sichtbarkeit"},
"neutralizationStatus": {"en": "Neutralization Status", "de": "Neutralisierungsstatus"},
"isNeutralized": {"en": "Is Neutralized", "de": "Neutralisiert"},
}, },
) )
class ContentChunk(BaseModel): class ContentChunk(PowerOnModel):
"""Persisted content chunk with embedding vector. Reusable across workflows. """Persisted content chunk with embedding vector. Reusable across workflows.
Scalar content object (or chunk thereof) with pgvector embedding.""" Scalar content object (or chunk thereof) with pgvector embedding."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key") id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
@ -96,7 +112,7 @@ registerModelLabels(
) )
class RoundMemory(BaseModel): class RoundMemory(PowerOnModel):
"""Persistent per-round memory for agent tool results, file refs, and decisions. """Persistent per-round memory for agent tool results, file refs, and decisions.
Stored after each agent round so that RAG can retrieve relevant context Stored after each agent round so that RAG can retrieve relevant context
@ -120,7 +136,6 @@ class RoundMemory(BaseModel):
description="Embedding of summary for semantic retrieval", description="Embedding of summary for semantic retrieval",
json_schema_extra={"db_type": "vector(1536)"}, json_schema_extra={"db_type": "vector(1536)"},
) )
createdAt: float = Field(default_factory=getUtcTimestamp, description="Creation timestamp")
registerModelLabels( registerModelLabels(
@ -136,12 +151,11 @@ registerModelLabels(
"fullData": {"en": "Full Data", "fr": "Données complètes"}, "fullData": {"en": "Full Data", "fr": "Données complètes"},
"fileIds": {"en": "File IDs", "fr": "IDs de fichier"}, "fileIds": {"en": "File IDs", "fr": "IDs de fichier"},
"embedding": {"en": "Embedding", "fr": "Vecteur d'embedding"}, "embedding": {"en": "Embedding", "fr": "Vecteur d'embedding"},
"createdAt": {"en": "Created At", "fr": "Créé le"},
}, },
) )
class WorkflowMemory(BaseModel): class WorkflowMemory(PowerOnModel):
"""Workflow-scoped key-value cache for entities and facts. """Workflow-scoped key-value cache for entities and facts.
Extracted during agent rounds, persisted for cross-round and cross-workflow reuse.""" Extracted during agent rounds, persisted for cross-round and cross-workflow reuse."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key") id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
@ -151,7 +165,6 @@ class WorkflowMemory(BaseModel):
key: str = Field(description="Key identifier (e.g. 'entity:companyName')") key: str = Field(description="Key identifier (e.g. 'entity:companyName')")
value: str = Field(description="Extracted value") value: str = Field(description="Extracted value")
source: str = Field(default="extraction", description="Origin: extraction, tool, conversation, summary") source: str = Field(default="extraction", description="Origin: extraction, tool, conversation, summary")
createdAt: float = Field(default_factory=getUtcTimestamp, description="Creation timestamp")
embedding: Optional[List[float]] = Field( embedding: Optional[List[float]] = Field(
default=None, description="Optional embedding for semantic lookup", default=None, description="Optional embedding for semantic lookup",
json_schema_extra={"db_type": "vector(1536)"} json_schema_extra={"db_type": "vector(1536)"}
@ -169,7 +182,6 @@ registerModelLabels(
"key": {"en": "Key", "fr": "Clé"}, "key": {"en": "Key", "fr": "Clé"},
"value": {"en": "Value", "fr": "Valeur"}, "value": {"en": "Value", "fr": "Valeur"},
"source": {"en": "Source", "fr": "Source"}, "source": {"en": "Source", "fr": "Source"},
"createdAt": {"en": "Created At", "fr": "Créé le"},
"embedding": {"en": "Embedding", "fr": "Vecteur d'embedding"}, "embedding": {"en": "Embedding", "fr": "Vecteur d'embedding"},
}, },
) )

View file

@ -9,10 +9,11 @@ Rollen werden über Junction Tables verknüpft für saubere CASCADE DELETE.
import uuid import uuid
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.attributeUtils import registerModelLabels
class UserMandate(BaseModel): class UserMandate(PowerOnModel):
""" """
User-Mitgliedschaft in einem Mandanten. User-Mitgliedschaft in einem Mandanten.
Kein User gehört direkt zu einem Mandanten - Zugehörigkeit wird über dieses Model gesteuert. Kein User gehört direkt zu einem Mandanten - Zugehörigkeit wird über dieses Model gesteuert.
@ -50,7 +51,7 @@ registerModelLabels(
) )
class FeatureAccess(BaseModel): class FeatureAccess(PowerOnModel):
""" """
User-Zugriff auf eine Feature-Instanz. User-Zugriff auf eine Feature-Instanz.
Definiert welche User auf welche Feature-Instanzen zugreifen können. Definiert welche User auf welche Feature-Instanzen zugreifen können.
@ -88,7 +89,7 @@ registerModelLabels(
) )
class UserMandateRole(BaseModel): class UserMandateRole(PowerOnModel):
""" """
Junction Table: UserMandate zu Role. Junction Table: UserMandate zu Role.
Ermöglicht CASCADE DELETE auf Datenbankebene. Ermöglicht CASCADE DELETE auf Datenbankebene.
@ -119,7 +120,7 @@ registerModelLabels(
) )
class FeatureAccessRole(BaseModel): class FeatureAccessRole(PowerOnModel):
""" """
Junction Table: FeatureAccess zu Role. Junction Table: FeatureAccess zu Role.
Ermöglicht CASCADE DELETE auf Datenbankebene. Ermöglicht CASCADE DELETE auf Datenbankebene.

View file

@ -6,8 +6,8 @@ import uuid
from typing import Optional from typing import Optional
from enum import Enum from enum import Enum
from pydantic import BaseModel, Field, ConfigDict from pydantic import BaseModel, Field, ConfigDict
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.attributeUtils import registerModelLabels
from modules.shared.timeUtils import getUtcTimestamp
class MessagingChannel(str, Enum): class MessagingChannel(str, Enum):
@ -26,7 +26,7 @@ class DeliveryStatus(str, Enum):
FAILED = "failed" FAILED = "failed"
class MessagingSubscription(BaseModel): class MessagingSubscription(PowerOnModel):
"""Data model for messaging subscriptions""" """Data model for messaging subscriptions"""
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
@ -64,26 +64,6 @@ class MessagingSubscription(BaseModel):
description="Whether the subscription is enabled", description="Whether the subscription is enabled",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False} json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
) )
creationDate: float = Field(
default_factory=getUtcTimestamp,
description="When the subscription was created (UTC timestamp in seconds)",
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False}
)
lastModified: float = Field(
default_factory=getUtcTimestamp,
description="When the subscription was last modified (UTC timestamp in seconds)",
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False}
)
createdBy: Optional[str] = Field(
default=None,
description="User ID who created the subscription",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
modifiedBy: Optional[str] = Field(
default=None,
description="User ID who last modified the subscription",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
)
model_config = ConfigDict(use_enum_values=True) model_config = ConfigDict(use_enum_values=True)
@ -100,10 +80,6 @@ registerModelLabels(
"description": {"en": "Description", "fr": "Description"}, "description": {"en": "Description", "fr": "Description"},
"isSystemSubscription": {"en": "System Subscription", "fr": "Abonnement système"}, "isSystemSubscription": {"en": "System Subscription", "fr": "Abonnement système"},
"enabled": {"en": "Enabled", "fr": "Activé"}, "enabled": {"en": "Enabled", "fr": "Activé"},
"creationDate": {"en": "Creation Date", "fr": "Date de création"},
"lastModified": {"en": "Last Modified", "fr": "Dernière modification"},
"createdBy": {"en": "Created By", "fr": "Créé par"},
"modifiedBy": {"en": "Modified By", "fr": "Modifié par"},
}, },
) )
@ -155,16 +131,6 @@ class MessagingSubscriptionRegistration(BaseModel):
description="Whether this registration is enabled", description="Whether this registration is enabled",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False} json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}
) )
creationDate: float = Field(
default_factory=getUtcTimestamp,
description="When the registration was created (UTC timestamp in seconds)",
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False}
)
lastModified: float = Field(
default_factory=getUtcTimestamp,
description="When the registration was last modified (UTC timestamp in seconds)",
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False}
)
model_config = ConfigDict(use_enum_values=True) model_config = ConfigDict(use_enum_values=True)
@ -181,8 +147,6 @@ registerModelLabels(
"channel": {"en": "Channel", "fr": "Canal"}, "channel": {"en": "Channel", "fr": "Canal"},
"channelConfig": {"en": "Channel Config", "fr": "Configuration du canal"}, "channelConfig": {"en": "Channel Config", "fr": "Configuration du canal"},
"enabled": {"en": "Enabled", "fr": "Activé"}, "enabled": {"en": "Enabled", "fr": "Activé"},
"creationDate": {"en": "Creation Date", "fr": "Date de création"},
"lastModified": {"en": "Last Modified", "fr": "Dernière modification"},
}, },
) )
@ -248,11 +212,6 @@ class MessagingDelivery(BaseModel):
description="When the delivery was sent (UTC timestamp in seconds)", description="When the delivery was sent (UTC timestamp in seconds)",
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False} json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False}
) )
creationDate: float = Field(
default_factory=getUtcTimestamp,
description="When the delivery record was created (UTC timestamp in seconds)",
json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False}
)
model_config = ConfigDict(use_enum_values=True) model_config = ConfigDict(use_enum_values=True)
@ -270,7 +229,6 @@ registerModelLabels(
"status": {"en": "Status", "fr": "Statut"}, "status": {"en": "Status", "fr": "Statut"},
"errorMessage": {"en": "Error Message", "fr": "Message d'erreur"}, "errorMessage": {"en": "Error Message", "fr": "Message d'erreur"},
"sentAt": {"en": "Sent At", "fr": "Envoyé le"}, "sentAt": {"en": "Sent At", "fr": "Envoyé le"},
"creationDate": {"en": "Creation Date", "fr": "Date de création"},
}, },
) )
@ -349,4 +307,3 @@ class MessagingSubscriptionExecutionResult(BaseModel):
description="Error message if execution failed", description="Error message if execution failed",
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False} json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False}
) )
model_config = ConfigDict(extra="allow") # Allow additional fields for custom results

View file

@ -9,8 +9,8 @@ import uuid
from typing import Optional, List from typing import Optional, List
from enum import Enum from enum import Enum
from pydantic import BaseModel, Field, ConfigDict from pydantic import BaseModel, Field, ConfigDict
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.attributeUtils import registerModelLabels
from modules.shared.timeUtils import getUtcTimestamp
class NotificationType(str, Enum): class NotificationType(str, Enum):
@ -43,7 +43,7 @@ class NotificationAction(BaseModel):
) )
class UserNotification(BaseModel): class UserNotification(PowerOnModel):
""" """
In-app notification for a user. In-app notification for a user.
Supports actionable notifications with accept/decline buttons. Supports actionable notifications with accept/decline buttons.
@ -137,11 +137,6 @@ class UserNotification(BaseModel):
) )
# Timestamps # Timestamps
createdAt: float = Field(
default_factory=getUtcTimestamp,
description="When the notification was created (UTC timestamp)",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
)
readAt: Optional[float] = Field( readAt: Optional[float] = Field(
default=None, default=None,
description="When the notification was read (UTC timestamp)", description="When the notification was read (UTC timestamp)",
@ -177,7 +172,6 @@ registerModelLabels(
"actions": {"en": "Actions", "de": "Aktionen", "fr": "Actions"}, "actions": {"en": "Actions", "de": "Aktionen", "fr": "Actions"},
"actionTaken": {"en": "Action Taken", "de": "Durchgeführte Aktion", "fr": "Action effectuée"}, "actionTaken": {"en": "Action Taken", "de": "Durchgeführte Aktion", "fr": "Action effectuée"},
"actionResult": {"en": "Action Result", "de": "Aktions-Ergebnis", "fr": "Résultat de l'action"}, "actionResult": {"en": "Action Result", "de": "Aktions-Ergebnis", "fr": "Résultat de l'action"},
"createdAt": {"en": "Created At", "de": "Erstellt am", "fr": "Créé le"},
"readAt": {"en": "Read At", "de": "Gelesen am", "fr": "Lu le"}, "readAt": {"en": "Read At", "de": "Gelesen am", "fr": "Lu le"},
"actionedAt": {"en": "Actioned At", "de": "Bearbeitet am", "fr": "Traité le"}, "actionedAt": {"en": "Actioned At", "de": "Bearbeitet am", "fr": "Traité le"},
"expiresAt": {"en": "Expires At", "de": "Gültig bis", "fr": "Expire le"}, "expiresAt": {"en": "Expires At", "de": "Gültig bis", "fr": "Expire le"},

View file

@ -13,6 +13,7 @@ import uuid
from typing import Optional from typing import Optional
from enum import Enum from enum import Enum
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.attributeUtils import registerModelLabels
from modules.datamodels.datamodelUtils import TextMultilingual from modules.datamodels.datamodelUtils import TextMultilingual
from modules.datamodels.datamodelUam import AccessLevel from modules.datamodels.datamodelUam import AccessLevel
@ -25,7 +26,7 @@ class AccessRuleContext(str, Enum):
RESOURCE = "RESOURCE" # System resources (AI models, actions, etc.) RESOURCE = "RESOURCE" # System resources (AI models, actions, etc.)
class Role(BaseModel): class Role(PowerOnModel):
""" """
Data model for RBAC roles. Data model for RBAC roles.
@ -90,7 +91,7 @@ registerModelLabels(
) )
class AccessRule(BaseModel): class AccessRule(PowerOnModel):
""" """
Data model for access control rules. Data model for access control rules.

View file

@ -11,6 +11,7 @@ Multi-Tenant Design:
from typing import Optional, Any from typing import Optional, Any
from pydantic import BaseModel, Field, ConfigDict, model_validator from pydantic import BaseModel, Field, ConfigDict, model_validator
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.attributeUtils import registerModelLabels
from modules.shared.timeUtils import getUtcTimestamp from modules.shared.timeUtils import getUtcTimestamp
from .datamodelUam import AuthAuthority from .datamodelUam import AuthAuthority
@ -30,7 +31,7 @@ class TokenPurpose(str, Enum):
DATA_CONNECTION = "dataConnection" DATA_CONNECTION = "dataConnection"
class Token(BaseModel): class Token(PowerOnModel):
""" """
Authentication Token model. Authentication Token model.
@ -55,9 +56,6 @@ class Token(BaseModel):
description="When the token expires (UTC timestamp in seconds)" description="When the token expires (UTC timestamp in seconds)"
) )
tokenRefresh: Optional[str] = None tokenRefresh: Optional[str] = None
createdAt: Optional[float] = Field(
None, description="When the token was created (UTC timestamp in seconds)"
)
status: TokenStatus = Field( status: TokenStatus = Field(
default=TokenStatus.ACTIVE, description="Token status: active/revoked" default=TokenStatus.ACTIVE, description="Token status: active/revoked"
) )
@ -106,7 +104,6 @@ registerModelLabels(
"tokenType": {"en": "Token Type", "de": "Token-Typ", "fr": "Type de jeton"}, "tokenType": {"en": "Token Type", "de": "Token-Typ", "fr": "Type de jeton"},
"expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"}, "expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"},
"tokenRefresh": {"en": "Refresh Token", "de": "Refresh-Token", "fr": "Jeton de rafraîchissement"}, "tokenRefresh": {"en": "Refresh Token", "de": "Refresh-Token", "fr": "Jeton de rafraîchissement"},
"createdAt": {"en": "Created At", "de": "Erstellt am", "fr": "Créé le"},
"status": {"en": "Status", "de": "Status", "fr": "Statut"}, "status": {"en": "Status", "de": "Status", "fr": "Statut"},
"revokedAt": {"en": "Revoked At", "de": "Widerrufen am", "fr": "Révoqué le"}, "revokedAt": {"en": "Revoked At", "de": "Widerrufen am", "fr": "Révoqué le"},
"revokedBy": {"en": "Revoked By", "de": "Widerrufen von", "fr": "Révoqué par"}, "revokedBy": {"en": "Revoked By", "de": "Widerrufen von", "fr": "Révoqué par"},
@ -116,7 +113,7 @@ registerModelLabels(
) )
class AuthEvent(BaseModel): class AuthEvent(PowerOnModel):
"""Authentication event for audit logging.""" """Authentication event for audit logging."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the auth event", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the auth event", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
userId: str = Field(description="ID of the user this event belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) userId: str = Field(description="ID of the user this event belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})

View file

@ -10,6 +10,7 @@ from typing import 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
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.attributeUtils import registerModelLabels
import uuid import uuid
@ -30,6 +31,7 @@ OPERATIVE_STATUSES = {SubscriptionStatusEnum.ACTIVE, SubscriptionStatusEnum.TRIA
ALLOWED_TRANSITIONS = { ALLOWED_TRANSITIONS = {
(SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.ACTIVE), (SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.ACTIVE),
(SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.TRIALING),
(SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.SCHEDULED), (SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.SCHEDULED),
(SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.EXPIRED), (SubscriptionStatusEnum.PENDING, SubscriptionStatusEnum.EXPIRED),
(SubscriptionStatusEnum.SCHEDULED, SubscriptionStatusEnum.ACTIVE), (SubscriptionStatusEnum.SCHEDULED, SubscriptionStatusEnum.ACTIVE),
@ -70,6 +72,8 @@ class SubscriptionPlan(BaseModel):
maxUsers: Optional[int] = Field(None, description="Hard cap on active users (None = unlimited)") maxUsers: Optional[int] = Field(None, description="Hard cap on active users (None = unlimited)")
maxFeatureInstances: Optional[int] = Field(None, description="Hard cap on active feature instances (None = unlimited)") maxFeatureInstances: Optional[int] = Field(None, description="Hard cap on active feature instances (None = unlimited)")
trialDays: Optional[int] = Field(None, description="Trial duration in days (only for trial plans)") trialDays: Optional[int] = Field(None, description="Trial duration in days (only for trial plans)")
maxDataVolumeMB: Optional[int] = Field(None, description="Soft-limit for data volume in MB per mandate (None = unlimited)")
budgetAiCHF: float = Field(default=0.0, description="AI budget (CHF) included in subscription price per billing period")
successorPlanKey: Optional[str] = Field(None, description="Plan to transition to when trial ends") successorPlanKey: Optional[str] = Field(None, description="Plan to transition to when trial ends")
@ -84,6 +88,8 @@ registerModelLabels(
"pricePerFeatureInstanceCHF": {"en": "Price per Instance (CHF)", "de": "Preis pro Instanz (CHF)"}, "pricePerFeatureInstanceCHF": {"en": "Price per Instance (CHF)", "de": "Preis pro Instanz (CHF)"},
"maxUsers": {"en": "Max Users", "de": "Max. Benutzer", "fr": "Max. utilisateurs"}, "maxUsers": {"en": "Max Users", "de": "Max. Benutzer", "fr": "Max. utilisateurs"},
"maxFeatureInstances": {"en": "Max Instances", "de": "Max. Instanzen", "fr": "Max. instances"}, "maxFeatureInstances": {"en": "Max Instances", "de": "Max. Instanzen", "fr": "Max. instances"},
"maxDataVolumeMB": {"en": "Data Volume (MB)", "de": "Datenvolumen (MB)"},
"budgetAiCHF": {"en": "AI Budget (CHF)", "de": "AI-Budget (CHF)"},
}, },
) )
@ -122,7 +128,7 @@ registerModelLabels(
# Instance: MandateSubscription # Instance: MandateSubscription
# ============================================================================ # ============================================================================
class MandateSubscription(BaseModel): class MandateSubscription(PowerOnModel):
"""A subscription instance bound to a specific mandate. """A subscription instance bound to a specific mandate.
See wiki/concepts/Subscription-State-Machine.md for state transitions.""" See wiki/concepts/Subscription-State-Machine.md for state transitions."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key") id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
@ -182,20 +188,24 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
autoRenew=False, autoRenew=False,
maxUsers=None, maxUsers=None,
maxFeatureInstances=None, maxFeatureInstances=None,
maxDataVolumeMB=None,
budgetAiCHF=0.0,
), ),
"TRIAL_7D": SubscriptionPlan( "TRIAL_7D": SubscriptionPlan(
planKey="TRIAL_7D", planKey="TRIAL_7D",
selectableByUser=False, selectableByUser=False,
title={"en": "Free Trial (7 days)", "de": "Gratis-Testphase (7 Tage)", "fr": "Essai gratuit (7 jours)"}, title={"en": "Free Trial (7 days)", "de": "Gratis-Testphase (7 Tage)", "fr": "Essai gratuit (7 jours)"},
description={ description={
"en": "Try the platform for 7 days — 1 user, up to 3 feature instances.", "en": "Try the platform for 7 days — 1 user, up to 3 feature instances, 5 CHF AI budget included.",
"de": "Plattform 7 Tage testen — 1 User, bis zu 3 Feature-Instanzen.", "de": "Plattform 7 Tage testen — 1 User, bis zu 3 Feature-Instanzen, 5 CHF AI-Budget inklusive.",
}, },
billingPeriod=BillingPeriodEnum.NONE, billingPeriod=BillingPeriodEnum.NONE,
autoRenew=False, autoRenew=False,
maxUsers=1, maxUsers=1,
maxFeatureInstances=3, maxFeatureInstances=3,
trialDays=7, trialDays=7,
maxDataVolumeMB=500,
budgetAiCHF=5.0,
successorPlanKey="STANDARD_MONTHLY", successorPlanKey="STANDARD_MONTHLY",
), ),
"STANDARD_MONTHLY": SubscriptionPlan( "STANDARD_MONTHLY": SubscriptionPlan(
@ -203,24 +213,28 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
selectableByUser=True, selectableByUser=True,
title={"en": "Standard (Monthly)", "de": "Standard (Monatlich)", "fr": "Standard (Mensuel)"}, title={"en": "Standard (Monthly)", "de": "Standard (Monatlich)", "fr": "Standard (Mensuel)"},
description={ description={
"en": "Usage-based billing per active user and feature instance, billed monthly.", "en": "Usage-based billing per active user and feature instance, billed monthly. Includes 10 CHF AI budget.",
"de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, monatlich.", "de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, monatlich. Inkl. 10 CHF AI-Budget.",
}, },
billingPeriod=BillingPeriodEnum.MONTHLY, billingPeriod=BillingPeriodEnum.MONTHLY,
pricePerUserCHF=90.0, pricePerUserCHF=79.0,
pricePerFeatureInstanceCHF=150.0, pricePerFeatureInstanceCHF=119.0,
maxDataVolumeMB=1024,
budgetAiCHF=10.0,
), ),
"STANDARD_YEARLY": SubscriptionPlan( "STANDARD_YEARLY": SubscriptionPlan(
planKey="STANDARD_YEARLY", planKey="STANDARD_YEARLY",
selectableByUser=True, selectableByUser=True,
title={"en": "Standard (Yearly)", "de": "Standard (Jährlich)", "fr": "Standard (Annuel)"}, title={"en": "Standard (Yearly)", "de": "Standard (Jährlich)", "fr": "Standard (Annuel)"},
description={ description={
"en": "Usage-based billing per active user and feature instance, billed yearly.", "en": "Usage-based billing per active user and feature instance, billed yearly. Includes 120 CHF AI budget.",
"de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, jährlich.", "de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, jährlich. Inkl. 120 CHF AI-Budget.",
}, },
billingPeriod=BillingPeriodEnum.YEARLY, billingPeriod=BillingPeriodEnum.YEARLY,
pricePerUserCHF=1080.0, pricePerUserCHF=948.0,
pricePerFeatureInstanceCHF=1800.0, pricePerFeatureInstanceCHF=1428.0,
maxDataVolumeMB=1024,
budgetAiCHF=120.0,
), ),
} }

View file

@ -10,9 +10,10 @@ Multi-Tenant Design:
""" """
import uuid import uuid
from typing import Optional, List from typing import Optional, List, Dict, Any
from enum import Enum from enum import Enum
from pydantic import BaseModel, Field, EmailStr, field_validator, computed_field from pydantic import BaseModel, Field, EmailStr, field_validator, computed_field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.attributeUtils import registerModelLabels
from modules.shared.timeUtils import getUtcTimestamp from modules.shared.timeUtils import getUtcTimestamp
@ -21,6 +22,7 @@ class AuthAuthority(str, Enum):
LOCAL = "local" LOCAL = "local"
GOOGLE = "google" GOOGLE = "google"
MSFT = "msft" MSFT = "msft"
CLICKUP = "clickup"
class ConnectionStatus(str, Enum): class ConnectionStatus(str, Enum):
ACTIVE = "active" ACTIVE = "active"
@ -59,7 +61,7 @@ class UserPermissions(BaseModel):
) )
class Mandate(BaseModel): class Mandate(PowerOnModel):
""" """
Mandate (Mandant/Tenant) model. Mandate (Mandant/Tenant) model.
Ein Mandant ist ein isolierter Bereich für Daten und Berechtigungen. Ein Mandant ist ein isolierter Bereich für Daten und Berechtigungen.
@ -88,6 +90,11 @@ class Mandate(BaseModel):
description="Whether this is a system mandate (e.g. root mandate). Cannot be deleted.", description="Whether this is a system mandate (e.g. root mandate). Cannot be deleted.",
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False} json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False}
) )
deletedAt: Optional[float] = Field(
default=None,
description="Timestamp when the mandate was soft-deleted. After 30 days, hard-delete is triggered.",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}
)
@field_validator('isSystem', mode='before') @field_validator('isSystem', mode='before')
@classmethod @classmethod
@ -97,7 +104,6 @@ class Mandate(BaseModel):
return False return False
return v return v
registerModelLabels( registerModelLabels(
"Mandate", "Mandate",
{"en": "Mandate", "de": "Mandant", "fr": "Mandat"}, {"en": "Mandate", "de": "Mandant", "fr": "Mandat"},
@ -107,11 +113,12 @@ registerModelLabels(
"label": {"en": "Label", "de": "Label", "fr": "Libellé"}, "label": {"en": "Label", "de": "Label", "fr": "Libellé"},
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"}, "enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
"isSystem": {"en": "System Mandate", "de": "System-Mandant", "fr": "Mandat système"}, "isSystem": {"en": "System Mandate", "de": "System-Mandant", "fr": "Mandat système"},
"deletedAt": {"en": "Deleted at", "de": "Gelöscht am", "fr": "Supprimé le"},
}, },
) )
class UserConnection(BaseModel): class UserConnection(PowerOnModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the connection", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the connection", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
userId: str = Field(description="ID of the user this connection belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) userId: str = Field(description="ID of the user this connection belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
authority: AuthAuthority = Field(description="Authentication authority", json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": "/api/connections/authorities/options"}) authority: AuthAuthority = Field(description="Authentication authority", json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": "/api/connections/authorities/options"})
@ -141,7 +148,12 @@ class UserConnection(BaseModel):
@property @property
def displayLabel(self) -> str: def displayLabel(self) -> str:
"""Human-readable label for display in dropdowns""" """Human-readable label for display in dropdowns"""
authorityLabels = {"msft": "Microsoft", "google": "Google", "local": "Local"} authorityLabels = {
"msft": "Microsoft",
"google": "Google",
"local": "Local",
"clickup": "ClickUp",
}
return f"{authorityLabels.get(self.authority.value, self.authority.value)}: {self.externalUsername}" return f"{authorityLabels.get(self.authority.value, self.authority.value)}: {self.externalUsername}"
@ -168,7 +180,7 @@ registerModelLabels(
) )
class User(BaseModel): class User(PowerOnModel):
""" """
User model. User model.
@ -255,6 +267,11 @@ class User(BaseModel):
description="Primary authentication authority", description="Primary authentication authority",
json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": "/api/connections/authorities/options"} json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": "/api/connections/authorities/options"}
) )
roleLabels: List[str] = Field(
default_factory=list,
description="Role labels (from DB or enriched when loading users)",
json_schema_extra={"frontend_type": "multiselect", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False},
)
registerModelLabels( registerModelLabels(
@ -269,6 +286,7 @@ registerModelLabels(
"enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"}, "enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"},
"isSysAdmin": {"en": "System Admin", "de": "System-Admin", "fr": "Admin système"}, "isSysAdmin": {"en": "System Admin", "de": "System-Admin", "fr": "Admin système"},
"authenticationAuthority": {"en": "Auth Authority", "de": "Authentifizierung", "fr": "Autorité d'authentification"}, "authenticationAuthority": {"en": "Auth Authority", "de": "Authentifizierung", "fr": "Autorité d'authentification"},
"roleLabels": {"en": "Role Labels", "de": "Rollen-Labels", "fr": "Libellés de rôles"},
}, },
) )
@ -289,3 +307,65 @@ registerModelLabels(
"resetTokenExpires": {"en": "Reset Token Expires", "de": "Token läuft ab", "fr": "Expiration du jeton"}, "resetTokenExpires": {"en": "Reset Token Expires", "de": "Token läuft ab", "fr": "Expiration du jeton"},
}, },
) )
def _normalizeTtsVoiceMap(value: Any) -> Optional[Dict[str, str]]:
"""
Coerce ttsVoiceMap payloads to Dict[str, str].
UI/clients may send per-locale objects like {"voiceName": "de-DE-Chirp3-HD-Achird"};
storage and model field type are locale -> voice id string.
"""
if value is None:
return None
if not isinstance(value, dict):
return None
out: Dict[str, str] = {}
for rawKey, rawVal in value.items():
key = str(rawKey)
if rawVal is None:
continue
if isinstance(rawVal, str):
out[key] = rawVal
elif isinstance(rawVal, dict):
vn = rawVal.get("voiceName")
if vn is not None and str(vn).strip() != "":
out[key] = str(vn).strip()
else:
out[key] = str(rawVal)
return out if out else None
class UserVoicePreferences(PowerOnModel):
"""User-level voice/language preferences, shared across all features."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
userId: str = Field(description="User ID")
mandateId: Optional[str] = Field(default=None, description="Mandate scope (None = global for user)")
sttLanguage: str = Field(default="de-DE", description="Speech-to-text language code")
ttsLanguage: str = Field(default="de-DE", description="Text-to-speech language code")
ttsVoice: Optional[str] = Field(default=None, description="Preferred TTS voice identifier")
ttsVoiceMap: Optional[Dict[str, str]] = Field(default=None, description="Language-to-voice mapping")
translationSourceLanguage: Optional[str] = Field(default=None, description="Source language for translations")
translationTargetLanguage: Optional[str] = Field(default=None, description="Target language for translations")
@field_validator("ttsVoiceMap", mode="before")
@classmethod
def _validateTtsVoiceMap(cls, value: Any) -> Optional[Dict[str, str]]:
return _normalizeTtsVoiceMap(value)
registerModelLabels(
"UserVoicePreferences",
{"en": "Voice Preferences", "de": "Spracheinstellungen", "fr": "Préférences vocales"},
{
"id": {"en": "ID", "de": "ID", "fr": "ID"},
"userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"},
"mandateId": {"en": "Mandate ID", "de": "Mandanten-ID", "fr": "ID du mandat"},
"sttLanguage": {"en": "STT Language", "de": "STT-Sprache", "fr": "Langue STT"},
"ttsLanguage": {"en": "TTS Language", "de": "TTS-Sprache", "fr": "Langue TTS"},
"ttsVoice": {"en": "TTS Voice", "de": "TTS-Stimme", "fr": "Voix TTS"},
"ttsVoiceMap": {"en": "Voice Map", "de": "Stimmen-Zuordnung", "fr": "Carte des voix"},
"translationSourceLanguage": {"en": "Translation Source", "de": "Übersetzung Quelle", "fr": "Langue source"},
"translationTargetLanguage": {"en": "Translation Target", "de": "Übersetzung Ziel", "fr": "Langue cible"},
},
)

View file

@ -3,13 +3,13 @@
"""Utility datamodels: Prompt, TextMultilingual.""" """Utility datamodels: Prompt, TextMultilingual."""
from typing import Dict, Optional from typing import Dict, Optional
from pydantic import BaseModel, ConfigDict, Field, field_validator from pydantic import BaseModel, Field, field_validator
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.attributeUtils import registerModelLabels
import uuid import uuid
class Prompt(BaseModel): class Prompt(PowerOnModel):
model_config = ConfigDict(extra='allow') # Preserve system fields (_createdBy, _createdAt, etc.)
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
mandateId: str = Field(default="", description="ID of the mandate this prompt belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) mandateId: str = Field(default="", description="ID of the mandate this prompt belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
isSystem: bool = Field(default=False, description="System prompt visible to all users (read-only for non-SysAdmin)", json_schema_extra={"frontend_type": "boolean", "frontend_readonly": True, "frontend_required": False}) isSystem: bool = Field(default=False, description="System prompt visible to all users (read-only for non-SysAdmin)", json_schema_extra={"frontend_type": "boolean", "frontend_readonly": True, "frontend_required": False})

View file

@ -1,7 +1,7 @@
# Copyright (c) 2025 Patrick Motsch # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""Voice settings datamodel — re-exported from workspace feature for backward compatibility.""" """Voice settings datamodel — re-exported from UAM for central voice preferences."""
from modules.features.workspace.datamodelFeatureWorkspace import VoiceSettings from modules.datamodels.datamodelUam import UserVoicePreferences
__all__ = ["VoiceSettings"] __all__ = ["UserVoicePreferences"]

View file

@ -4,6 +4,7 @@
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.attributeUtils import registerModelLabels
from modules.datamodels.datamodelUtils import TextMultilingual from modules.datamodels.datamodelUtils import TextMultilingual
import uuid import uuid
@ -48,7 +49,7 @@ registerModelLabels(
) )
class AutomationTemplate(BaseModel): class AutomationTemplate(PowerOnModel):
"""Automation-Vorlage ohne scharfe Placeholder-Werte (DB-persistiert). """Automation-Vorlage ohne scharfe Placeholder-Werte (DB-persistiert).
System-Templates (isSystem=True): Nur durch SysAdmin aenderbar. Alle User koennen lesen. System-Templates (isSystem=True): Nur durch SysAdmin aenderbar. Alle User koennen lesen.
@ -82,9 +83,6 @@ class AutomationTemplate(BaseModel):
description="Feature instance ID (null for system templates, set for instance-scoped templates)", description="Feature instance ID (null for system templates, set for instance-scoped templates)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
) )
# System fields (_createdAt, _createdBy, etc.) werden automatisch vom DB-Connector gesetzt
registerModelLabels( registerModelLabels(
"AutomationTemplate", "AutomationTemplate",
{"en": "Automation Template", "ge": "Automation-Vorlage", "fr": "Modèle d'automatisation"}, {"en": "Automation Template", "ge": "Automation-Vorlage", "fr": "Modèle d'automatisation"},

View file

@ -22,6 +22,13 @@ from modules.shared.configuration import APP_CONFIG
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _automationDefinitionPayload(data: Dict[str, Any]) -> Dict[str, Any]:
"""Strip connector/enrichment keys; only fields defined on AutomationDefinition."""
allowed = AutomationDefinition.model_fields.keys()
return {k: v for k, v in (data or {}).items() if k in allowed}
# Singleton factory for Automation instances # Singleton factory for Automation instances
_automationInterfaces = {} _automationInterfaces = {}
@ -100,7 +107,7 @@ class AutomationObjects:
if recordId: if recordId:
record = self.db.getRecordset(model, recordFilter={"id": recordId}) record = self.db.getRecordset(model, recordFilter={"id": recordId})
if record: if record:
return record[0].get("_createdBy") == self.userId return record[0].get("sysCreatedBy") == self.userId
else: else:
return False # Record not found = no access return False # Record not found = no access
return True # No recordId needed (e.g., for CREATE) return True # No recordId needed (e.g., for CREATE)
@ -130,7 +137,7 @@ class AutomationObjects:
featureInstanceIds = set() featureInstanceIds = set()
for automation in automations: for automation in automations:
createdBy = automation.get("_createdBy") createdBy = automation.get("sysCreatedBy")
if createdBy: if createdBy:
userIds.add(createdBy) userIds.add(createdBy)
@ -186,8 +193,8 @@ class AutomationObjects:
# Enrich each automation with the fetched data # Enrich each automation with the fetched data
# SECURITY: Never show a fallback name — if lookup fails, show empty string # SECURITY: Never show a fallback name — if lookup fails, show empty string
for automation in automations: for automation in automations:
createdBy = automation.get("_createdBy") createdBy = automation.get("sysCreatedBy")
automation["_createdByUserName"] = usersMap.get(createdBy, "") if createdBy else "" automation["sysCreatedByUserName"] = usersMap.get(createdBy, "") if createdBy else ""
mandateId = automation.get("mandateId") mandateId = automation.get("mandateId")
automation["mandateName"] = mandatesMap.get(mandateId, "") if mandateId else "" automation["mandateName"] = mandatesMap.get(mandateId, "") if mandateId else ""
@ -295,7 +302,7 @@ class AutomationObjects:
Args: Args:
automationId: ID of the automation to get automationId: ID of the automation to get
includeSystemFields: If True, returns raw dict with system fields (_createdBy, etc). includeSystemFields: If True, returns raw dict with system fields (sysCreatedBy, etc).
If False (default), returns Pydantic model without system fields. If False (default), returns Pydantic model without system fields.
""" """
try: try:
@ -330,7 +337,7 @@ class AutomationObjects:
return AutomationWithSystemFields(automation) return AutomationWithSystemFields(automation)
# Clean metadata fields and return Pydantic model # Clean metadata fields and return Pydantic model
cleanedRecord = {k: v for k, v in automation.items() if not k.startswith("_")} cleanedRecord = _automationDefinitionPayload(automation)
return AutomationDefinition(**cleanedRecord) return AutomationDefinition(**cleanedRecord)
except Exception as e: except Exception as e:
logger.error(f"Error getting automation definition: {str(e)}") logger.error(f"Error getting automation definition: {str(e)}")
@ -365,7 +372,7 @@ class AutomationObjects:
# Ensure database connector has correct userId context # Ensure database connector has correct userId context
if not self.userId: if not self.userId:
logger.error(f"createAutomationDefinition: userId is not set! Cannot set _createdBy. currentUser={self.currentUser}") logger.error(f"createAutomationDefinition: userId is not set! Cannot set sysCreatedBy. currentUser={self.currentUser}")
elif hasattr(self.db, 'updateContext'): elif hasattr(self.db, 'updateContext'):
try: try:
self.db.updateContext(self.userId) self.db.updateContext(self.userId)
@ -386,7 +393,7 @@ class AutomationObjects:
self._notifyAutomationChanged() self._notifyAutomationChanged()
# Clean metadata fields and return Pydantic model # Clean metadata fields and return Pydantic model
cleanedRecord = {k: v for k, v in createdAutomation.items() if not k.startswith("_")} cleanedRecord = _automationDefinitionPayload(createdAutomation)
return AutomationDefinition(**cleanedRecord) return AutomationDefinition(**cleanedRecord)
except Exception as e: except Exception as e:
logger.error(f"Error creating automation definition: {str(e)}") logger.error(f"Error creating automation definition: {str(e)}")
@ -446,7 +453,7 @@ class AutomationObjects:
self._notifyAutomationChanged() self._notifyAutomationChanged()
# Clean metadata fields and return Pydantic model # Clean metadata fields and return Pydantic model
cleanedRecord = {k: v for k, v in updatedAutomation.items() if not k.startswith("_")} cleanedRecord = _automationDefinitionPayload(updatedAutomation)
return AutomationDefinition(**cleanedRecord) return AutomationDefinition(**cleanedRecord)
except Exception as e: except Exception as e:
logger.error(f"Error updating automation definition: {str(e)}") logger.error(f"Error updating automation definition: {str(e)}")
@ -561,7 +568,7 @@ class AutomationObjects:
# Collect unique user IDs # Collect unique user IDs
userIds = set() userIds = set()
for template in templates: for template in templates:
createdBy = template.get("_createdBy") createdBy = template.get("sysCreatedBy")
if createdBy: if createdBy:
userIds.add(createdBy) userIds.add(createdBy)
@ -585,8 +592,8 @@ class AutomationObjects:
# Apply to templates — SECURITY: no fallback, empty if not found # Apply to templates — SECURITY: no fallback, empty if not found
for template in templates: for template in templates:
createdBy = template.get("_createdBy") createdBy = template.get("sysCreatedBy")
template["_createdByUserName"] = userNameMap.get(createdBy, "") if createdBy else "" template["sysCreatedByUserName"] = userNameMap.get(createdBy, "") if createdBy else ""
except Exception as e: except Exception as e:
logger.warning(f"Could not enrich templates with user names: {e}") logger.warning(f"Could not enrich templates with user names: {e}")

View file

@ -227,7 +227,7 @@ def getFeatureDefinition() -> Dict[str, Any]:
"code": FEATURE_CODE, "code": FEATURE_CODE,
"label": FEATURE_LABEL, "label": FEATURE_LABEL,
"icon": FEATURE_ICON, "icon": FEATURE_ICON,
"autoCreateInstance": True, # Automatically create instance in root mandate during bootstrap "autoCreateInstance": False,
} }

View file

@ -77,8 +77,8 @@ def get_automations(
# If pagination was requested, result is PaginatedResult # If pagination was requested, result is PaginatedResult
# If no pagination, result is List[Dict] # If no pagination, result is List[Dict]
# Note: Using JSONResponse to bypass Pydantic validation which would filter out _createdBy # Note: Using JSONResponse to bypass Pydantic validation which would filter out sysCreatedBy
# The enriched fields (_createdByUserName, mandateName) are not in the Pydantic model # The enriched fields (sysCreatedByUserName, mandateName) are not in the Pydantic model
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
if paginationParams: if paginationParams:

View file

@ -4,6 +4,7 @@
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.attributeUtils import registerModelLabels
import uuid import uuid
@ -36,6 +37,11 @@ class Automation2Workflow(BaseModel):
description="Whether workflow is active", description="Whether workflow is active",
json_schema_extra={"frontend_type": "checkbox", "frontend_required": False}, json_schema_extra={"frontend_type": "checkbox", "frontend_required": False},
) )
invocations: List[Dict[str, Any]] = Field(
default_factory=list,
description="Entry points / starts (manual, form, schedule, webhook, …) configured outside the canvas",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False},
)
registerModelLabels( registerModelLabels(
@ -48,11 +54,12 @@ registerModelLabels(
"label": {"en": "Label", "de": "Bezeichnung", "fr": "Libellé"}, "label": {"en": "Label", "de": "Bezeichnung", "fr": "Libellé"},
"graph": {"en": "Graph", "de": "Graph", "fr": "Graphe"}, "graph": {"en": "Graph", "de": "Graph", "fr": "Graphe"},
"active": {"en": "Active", "de": "Aktiv", "fr": "Actif"}, "active": {"en": "Active", "de": "Aktiv", "fr": "Actif"},
"invocations": {"en": "Starts / Entry points", "de": "Starts / Einstiegspunkte", "fr": "Points d'entrée"},
}, },
) )
class Automation2WorkflowRun(BaseModel): class Automation2WorkflowRun(PowerOnModel):
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Primary key", description="Primary key",
@ -98,7 +105,7 @@ registerModelLabels(
) )
class Automation2HumanTask(BaseModel): class Automation2HumanTask(PowerOnModel):
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
description="Primary key", description="Primary key",

View file

@ -0,0 +1,96 @@
# Copyright (c) 2025 Patrick Motsch
"""
Workflow entry points (Starts) configuration outside the flow editor.
Kinds align with run envelope trigger.type where applicable.
"""
import uuid
from typing import Any, Dict, List, Optional
# On-demand (gear: Manueller Trigger, Formular)
KINDS_ON_DEMAND = frozenset({"manual", "form", "api"})
# Always-on (gear: Zeitplan, Immer aktiv, plus legacy listener kinds)
KINDS_ALWAYS_ON = frozenset({"schedule", "always_on", "email", "webhook", "event"})
ALL_KINDS = KINDS_ON_DEMAND | KINDS_ALWAYS_ON
def category_for_kind(kind: str) -> str:
if kind in KINDS_ALWAYS_ON:
return "always_on"
return "on_demand"
def default_manual_entry_point() -> Dict[str, Any]:
"""Single default manual start when a workflow has no invocations yet."""
return {
"id": str(uuid.uuid4()),
"kind": "manual",
"category": "on_demand",
"enabled": True,
"title": {
"de": "Jetzt ausführen",
"en": "Run now",
"fr": "Exécuter",
},
"description": {},
"config": {},
}
def _normalize_title(title: Any) -> Dict[str, str]:
if isinstance(title, dict):
return {k: str(v) for k, v in title.items() if v is not None}
if isinstance(title, str) and title.strip():
return {"de": title, "en": title, "fr": title}
return {"de": "Start", "en": "Start", "fr": "Départ"}
def normalize_invocation_entry(raw: Dict[str, Any]) -> Dict[str, Any]:
"""Validate and normalize a single entry point dict."""
kind = (raw.get("kind") or "manual").strip()
if kind not in ALL_KINDS:
kind = "manual"
cat = raw.get("category")
if cat not in ("on_demand", "always_on"):
cat = category_for_kind(kind)
eid = raw.get("id") or str(uuid.uuid4())
enabled = raw.get("enabled", True)
if not isinstance(enabled, bool):
enabled = bool(enabled)
config = raw.get("config") if isinstance(raw.get("config"), dict) else {}
desc = raw.get("description") if isinstance(raw.get("description"), dict) else {}
return {
"id": str(eid),
"kind": kind,
"category": cat,
"enabled": enabled,
"title": _normalize_title(raw.get("title")),
"description": desc,
"config": config,
}
def normalize_invocations_list(items: Optional[List[Any]]) -> List[Dict[str, Any]]:
if not items:
return [default_manual_entry_point()]
out: List[Dict[str, Any]] = []
for raw in items:
if isinstance(raw, dict):
out.append(normalize_invocation_entry(raw))
if not out:
return [default_manual_entry_point()]
return out
# Schedule / cron: wire an external job runner (APScheduler, Celery, system cron) to call
# POST .../execute with entryPointId set to a schedule entry — no separate in-process scheduler here yet.
def find_invocation(workflow: Dict[str, Any], entry_point_id: str) -> Optional[Dict[str, Any]]:
for inv in workflow.get("invocations") or []:
if isinstance(inv, dict) and inv.get("id") == entry_point_id:
return inv
return None

View file

@ -30,6 +30,7 @@ from modules.features.automation2.datamodelFeatureAutomation2 import (
Automation2WorkflowRun, Automation2WorkflowRun,
Automation2HumanTask, Automation2HumanTask,
) )
from modules.features.automation2.entryPoints import normalize_invocations_list
from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
@ -49,6 +50,83 @@ def getAutomation2Interface(
) )
def getAllWorkflowsForScheduling() -> List[Dict[str, Any]]:
"""
Get all active Automation2 workflows that have a schedule entry point (primary invocation).
Used by the scheduler to register cron jobs. Does not filter by mandate/instance.
"""
dbHost = APP_CONFIG.get("DB_HOST", "localhost")
dbDatabase = "poweron_automation2"
dbUser = APP_CONFIG.get("DB_USER")
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD")
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
connector = DatabaseConnector(
dbHost=dbHost,
dbDatabase=dbDatabase,
dbUser=dbUser,
dbPassword=dbPassword,
dbPort=dbPort,
userId=None,
)
if not connector._ensureTableExists(Automation2Workflow):
logger.warning("Automation2 schedule: table Automation2Workflow does not exist")
return []
# Don't filter by active in SQL: existing workflows may have active=NULL.
# Treat NULL as active; skip only when active is explicitly False.
records = connector.getRecordset(
Automation2Workflow,
recordFilter=None,
)
raw_count = len(records) if records else 0
result = []
for r in records or []:
if r.get("active") is False:
continue
wf = dict(r)
wf["invocations"] = normalize_invocations_list(wf.get("invocations"))
invocations = wf.get("invocations") or []
primary = invocations[0] if invocations else {}
if not isinstance(primary, dict):
primary = {}
# Cron comes from graph start node params (trigger.schedule)
graph = wf.get("graph") or {}
nodes = graph.get("nodes") or []
cron = None
for n in nodes:
if n.get("type") == "trigger.schedule":
params = n.get("parameters") or {}
cron = params.get("cron")
if cron:
break
if not cron or not isinstance(cron, str) or not cron.strip():
continue
# Prefer invocations; if graph has trigger.schedule but invocations say manual, still schedule
if primary.get("kind") == "schedule" and primary.get("enabled", True):
entry_point_id = primary.get("id")
elif invocations and isinstance(invocations[0], dict) and invocations[0].get("id"):
entry_point_id = invocations[0].get("id")
else:
entry_point_id = str(uuid.uuid4())
result.append({
"workflowId": wf.get("id"),
"mandateId": wf.get("mandateId"),
"featureInstanceId": wf.get("featureInstanceId"),
"entryPointId": entry_point_id,
"cron": cron.strip(),
"workflow": wf,
})
logger.info(
"Automation2 schedule: DB has %d workflow(s), %d active with trigger.schedule+cron",
raw_count,
len(result),
)
return result
class Automation2Objects: class Automation2Objects:
"""Interface for Automation2 database operations.""" """Interface for Automation2 database operations."""
@ -87,18 +165,26 @@ class Automation2Objects:
# Workflow CRUD # Workflow CRUD
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
def getWorkflows(self) -> List[Dict[str, Any]]: def getWorkflows(self, active: Optional[bool] = None) -> List[Dict[str, Any]]:
"""Get all workflows for this mandate and feature instance.""" """Get all workflows for this mandate and feature instance.
Optional active filter: True=only active, False=only inactive, None=all.
"""
if not self.db._ensureTableExists(Automation2Workflow): if not self.db._ensureTableExists(Automation2Workflow):
return [] return []
rf: Dict[str, Any] = {
"mandateId": self.mandateId,
"featureInstanceId": self.featureInstanceId,
}
if active is not None:
rf["active"] = active
records = self.db.getRecordset( records = self.db.getRecordset(
Automation2Workflow, Automation2Workflow,
recordFilter={ recordFilter=rf,
"mandateId": self.mandateId,
"featureInstanceId": self.featureInstanceId,
},
) )
return [dict(r) for r in records] if records else [] rows = [dict(r) for r in records] if records else []
for wf in rows:
wf["invocations"] = normalize_invocations_list(wf.get("invocations"))
return rows
def getWorkflow(self, workflowId: str) -> Optional[Dict[str, Any]]: def getWorkflow(self, workflowId: str) -> Optional[Dict[str, Any]]:
"""Get a single workflow by ID.""" """Get a single workflow by ID."""
@ -114,7 +200,9 @@ class Automation2Objects:
) )
if not records: if not records:
return None return None
return dict(records[0]) wf = dict(records[0])
wf["invocations"] = normalize_invocations_list(wf.get("invocations"))
return wf
def createWorkflow(self, data: Dict[str, Any]) -> Dict[str, Any]: def createWorkflow(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Create a new workflow.""" """Create a new workflow."""
@ -122,8 +210,18 @@ class Automation2Objects:
data["id"] = str(uuid.uuid4()) data["id"] = str(uuid.uuid4())
data["mandateId"] = self.mandateId data["mandateId"] = self.mandateId
data["featureInstanceId"] = self.featureInstanceId data["featureInstanceId"] = self.featureInstanceId
if "active" not in data or data.get("active") is None:
data["active"] = True
data["invocations"] = normalize_invocations_list(data.get("invocations"))
created = self.db.recordCreate(Automation2Workflow, data) created = self.db.recordCreate(Automation2Workflow, data)
return dict(created) out = dict(created)
out["invocations"] = normalize_invocations_list(out.get("invocations"))
try:
from modules.shared.callbackRegistry import callbackRegistry
callbackRegistry.trigger("automation2.workflow.changed")
except Exception:
pass
return out
def updateWorkflow(self, workflowId: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: def updateWorkflow(self, workflowId: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Update an existing workflow.""" """Update an existing workflow."""
@ -133,8 +231,17 @@ class Automation2Objects:
# Don't overwrite mandateId/featureInstanceId # Don't overwrite mandateId/featureInstanceId
data.pop("mandateId", None) data.pop("mandateId", None)
data.pop("featureInstanceId", None) data.pop("featureInstanceId", None)
if "invocations" in data:
data["invocations"] = normalize_invocations_list(data.get("invocations"))
updated = self.db.recordModify(Automation2Workflow, workflowId, data) updated = self.db.recordModify(Automation2Workflow, workflowId, data)
return dict(updated) out = dict(updated)
out["invocations"] = normalize_invocations_list(out.get("invocations"))
try:
from modules.shared.callbackRegistry import callbackRegistry
callbackRegistry.trigger("automation2.workflow.changed")
except Exception:
pass
return out
def deleteWorkflow(self, workflowId: str) -> bool: def deleteWorkflow(self, workflowId: str) -> bool:
"""Delete a workflow.""" """Delete a workflow."""
@ -142,6 +249,11 @@ class Automation2Objects:
if not existing: if not existing:
return False return False
self.db.recordDelete(Automation2Workflow, workflowId) self.db.recordDelete(Automation2Workflow, workflowId)
try:
from modules.shared.callbackRegistry import callbackRegistry
callbackRegistry.trigger("automation2.workflow.changed")
except Exception:
pass
return True return True
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@ -209,6 +321,28 @@ class Automation2Objects:
) )
return [dict(r) for r in records] if records else [] return [dict(r) for r in records] if records else []
def getRecentCompletedRuns(self, limit: int = 20) -> List[Dict[str, Any]]:
"""Get recently completed runs for workflows in this instance (for output display)."""
if not self.db._ensureTableExists(Automation2WorkflowRun):
return []
workflows = self.getWorkflows()
wf_ids = [w["id"] for w in workflows if w.get("id")]
if not wf_ids:
return []
records = self.db.getRecordset(
Automation2WorkflowRun,
recordFilter={"status": "completed"},
)
if not records:
return []
runs = [dict(r) for r in records if r.get("workflowId") in wf_ids]
wf_by_id = {w["id"]: w for w in workflows}
for r in runs:
wf = wf_by_id.get(r.get("workflowId"), {})
r["workflowLabel"] = wf.get("label") or r.get("workflowId", "")
runs.sort(key=lambda x: (x.get("_modifiedAt") or x.get("_createdAt") or 0), reverse=True)
return runs[:limit]
def getRunsWaitingForEmail(self) -> List[Dict[str, Any]]: def getRunsWaitingForEmail(self) -> List[Dict[str, Any]]:
"""Get all paused runs waiting for a new email (for background poller).""" """Get all paused runs waiting for a new email (for background poller)."""
if not self.db._ensureTableExists(Automation2WorkflowRun): if not self.db._ensureTableExists(Automation2WorkflowRun):
@ -289,23 +423,38 @@ class Automation2Objects:
status: str = None, status: str = None,
assigneeId: str = None, assigneeId: str = None,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""Get tasks with optional filters. AssigneeId filters to that user; None returns all.""" """Get tasks with optional filters.
When assigneeId is set: returns tasks assigned to that user OR unassigned (so schedule tasks show up).
When assigneeId is None: returns all tasks.
"""
if not self.db._ensureTableExists(Automation2HumanTask): if not self.db._ensureTableExists(Automation2HumanTask):
return [] return []
rf = {} base_rf: Dict[str, Any] = {}
if workflowId: if workflowId:
rf["workflowId"] = workflowId base_rf["workflowId"] = workflowId
if runId: if runId:
rf["runId"] = runId base_rf["runId"] = runId
if status: if status:
rf["status"] = status base_rf["status"] = status
if assigneeId: if assigneeId:
rf["assigneeId"] = assigneeId rf_assigned = {**base_rf, "assigneeId": assigneeId}
records = self.db.getRecordset( rf_unassigned = {**base_rf, "assigneeId": None}
Automation2HumanTask, records1 = self.db.getRecordset(Automation2HumanTask, recordFilter=rf_assigned)
recordFilter=rf if rf else None, records2 = self.db.getRecordset(Automation2HumanTask, recordFilter=rf_unassigned)
) seen = set()
items = [dict(r) for r in records] if records else [] items = []
for r in (records1 or []) + (records2 or []):
rec = dict(r)
tid = rec.get("id")
if tid and tid not in seen:
seen.add(tid)
items.append(rec)
else:
records = self.db.getRecordset(
Automation2HumanTask,
recordFilter=base_rf if base_rf else None,
)
items = [dict(r) for r in records] if records else []
workflows = {w["id"]: w for w in self.getWorkflows()} workflows = {w["id"]: w for w in self.getWorkflows()}
filtered = [t for t in items if t.get("workflowId") in workflows] filtered = [t for t in items if t.get("workflowId") in workflows]
return filtered return filtered

View file

@ -19,6 +19,8 @@ REQUIRED_SERVICES = [
{"serviceKey": "ai", "meta": {"usage": "AI nodes"}}, {"serviceKey": "ai", "meta": {"usage": "AI nodes"}},
{"serviceKey": "extraction", "meta": {"usage": "Workflow method actions"}}, {"serviceKey": "extraction", "meta": {"usage": "Workflow method actions"}},
{"serviceKey": "sharepoint", "meta": {"usage": "SharePoint actions"}}, {"serviceKey": "sharepoint", "meta": {"usage": "SharePoint actions"}},
{"serviceKey": "clickup", "meta": {"usage": "ClickUp actions"}},
{"serviceKey": "generation", "meta": {"usage": "file.create document rendering"}},
] ]
FEATURE_LABEL = {"en": "Automation 2", "de": "Automatisierung 2", "fr": "Automatisation 2"} FEATURE_LABEL = {"en": "Automation 2", "de": "Automatisierung 2", "fr": "Automatisation 2"}
FEATURE_ICON = "mdi-sitemap" FEATURE_ICON = "mdi-sitemap"
@ -60,12 +62,25 @@ RESOURCE_OBJECTS = [
] ]
TEMPLATE_ROLES = [ TEMPLATE_ROLES = [
{
"roleLabel": "automation2-viewer",
"description": {
"en": "Automation2 Viewer - View workflows (read-only)",
"de": "Automation2 Betrachter - Workflows ansehen (nur lesen)",
"fr": "Visualiseur Automation2 - Consulter les workflows (lecture seule)",
},
"accessRules": [
{"context": "UI", "item": "ui.feature.automation2.workflows", "view": True},
{"context": "UI", "item": "ui.feature.automation2.workflows-tasks", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
],
},
{ {
"roleLabel": "automation2-user", "roleLabel": "automation2-user",
"description": { "description": {
"en": "Automation2 User - Use automation2 flow builder", "en": "Automation2 User - Use automation2 flow builder",
"de": "Automation2 Benutzer - Flow-Builder nutzen", "de": "Automation2 Benutzer - Flow-Builder nutzen",
"fr": "Utilisateur Automation2 - Utiliser le flow builder" "fr": "Utilisateur Automation2 - Utiliser le flow builder",
}, },
"accessRules": [ "accessRules": [
{"context": "UI", "item": "ui.feature.automation2.editor", "view": True}, {"context": "UI", "item": "ui.feature.automation2.editor", "view": True},
@ -75,7 +90,20 @@ TEMPLATE_ROLES = [
{"context": "RESOURCE", "item": "resource.feature.automation2.node-types", "view": True}, {"context": "RESOURCE", "item": "resource.feature.automation2.node-types", "view": True},
{"context": "RESOURCE", "item": "resource.feature.automation2.execute", "view": True}, {"context": "RESOURCE", "item": "resource.feature.automation2.execute", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"}, {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
] ],
},
{
"roleLabel": "automation2-admin",
"description": {
"en": "Automation2 Admin - Full UI and API for the instance; data remains user-scoped (MY)",
"de": "Automation2 Admin - Volle UI und API für die Instanz; Daten weiterhin benutzerspezifisch (MY)",
"fr": "Administrateur Automation2 - UI et API complets pour l'instance; donnees limitees a l'utilisateur (MY)",
},
"accessRules": [
{"context": "UI", "item": None, "view": True},
{"context": "RESOURCE", "item": None, "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
],
}, },
] ]
@ -157,6 +185,8 @@ class _Automation2ServiceHub:
utils = None utils = None
extraction = None extraction = None
sharepoint = None sharepoint = None
clickup = None
generation = None
async def onStart(eventUser) -> None: async def onStart(eventUser) -> None:
@ -175,7 +205,7 @@ def getFeatureDefinition() -> Dict[str, Any]:
"code": FEATURE_CODE, "code": FEATURE_CODE,
"label": FEATURE_LABEL, "label": FEATURE_LABEL,
"icon": FEATURE_ICON, "icon": FEATURE_ICON,
"autoCreateInstance": True, "autoCreateInstance": False,
} }

View file

@ -3,18 +3,20 @@
from .triggers import TRIGGER_NODES from .triggers import TRIGGER_NODES
from .flow import FLOW_NODES from .flow import FLOW_NODES
from .data import DATA_NODES
from .input import INPUT_NODES from .input import INPUT_NODES
from .ai import AI_NODES from .ai import AI_NODES
from .email import EMAIL_NODES from .email import EMAIL_NODES
from .sharepoint import SHAREPOINT_NODES from .sharepoint import SHAREPOINT_NODES
from .clickup import CLICKUP_NODES
from .file import FILE_NODES
STATIC_NODE_TYPES = ( STATIC_NODE_TYPES = (
TRIGGER_NODES TRIGGER_NODES
+ FLOW_NODES + FLOW_NODES
+ DATA_NODES
+ INPUT_NODES + INPUT_NODES
+ AI_NODES + AI_NODES
+ EMAIL_NODES + EMAIL_NODES
+ SHAREPOINT_NODES + SHAREPOINT_NODES
+ CLICKUP_NODES
+ FILE_NODES
) )

View file

@ -9,7 +9,6 @@ AI_NODES = [
"description": {"en": "Enter a prompt and AI does something", "de": "Prompt eingeben und KI führt aus", "fr": "Entrer une invite et l'IA exécute"}, "description": {"en": "Enter a prompt and AI does something", "de": "Prompt eingeben und KI führt aus", "fr": "Entrer une invite et l'IA exécute"},
"parameters": [ "parameters": [
{"name": "prompt", "type": "string", "required": True, "description": {"en": "AI prompt", "de": "KI-Prompt", "fr": "Invite IA"}}, {"name": "prompt", "type": "string", "required": True, "description": {"en": "AI prompt", "de": "KI-Prompt", "fr": "Invite IA"}},
{"name": "resultType", "type": "string", "required": False, "description": {"en": "Output format (txt, json, md, etc.)", "de": "Ausgabeformat", "fr": "Format de sortie"}, "default": "txt"},
], ],
"inputs": 1, "inputs": 1,
"outputs": 1, "outputs": 1,
@ -85,7 +84,6 @@ AI_NODES = [
"description": {"en": "Generate document from prompt", "de": "Dokument aus Prompt generieren", "fr": "Générer un document"}, "description": {"en": "Generate document from prompt", "de": "Dokument aus Prompt generieren", "fr": "Générer un document"},
"parameters": [ "parameters": [
{"name": "prompt", "type": "string", "required": True, "description": {"en": "Generation prompt", "de": "Generierungs-Prompt", "fr": "Invite de génération"}}, {"name": "prompt", "type": "string", "required": True, "description": {"en": "Generation prompt", "de": "Generierungs-Prompt", "fr": "Invite de génération"}},
{"name": "format", "type": "string", "required": False, "description": {"en": "Output format", "de": "Ausgabeformat", "fr": "Format de sortie"}, "default": "docx"},
], ],
"inputs": 1, "inputs": 1,
"outputs": 1, "outputs": 1,

View file

@ -0,0 +1,227 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""ClickUp nodes — map to MethodClickup actions."""
CLICKUP_NODES = [
{
"id": "clickup.searchTasks",
"category": "clickup",
"label": {"en": "Search tasks", "de": "Aufgaben suchen", "fr": "Rechercher tâches"},
"description": {
"en": "Search tasks in a workspace (team)",
"de": "Aufgaben in einem Workspace suchen",
"fr": "Rechercher des tâches dans un espace",
},
"parameters": [
{"name": "connectionId", "type": "string", "required": True, "description": {"en": "ClickUp connection", "de": "ClickUp-Verbindung", "fr": "Connexion ClickUp"}},
{"name": "teamId", "type": "string", "required": True, "description": {"en": "Workspace (team) ID", "de": "Team-/Workspace-ID", "fr": "ID équipe"}},
{"name": "query", "type": "string", "required": True, "description": {"en": "Search query", "de": "Suchbegriff", "fr": "Requête"}},
{"name": "page", "type": "number", "required": False, "description": {"en": "Page", "de": "Seite", "fr": "Page"}, "default": 0},
{
"name": "listId",
"type": "string",
"required": False,
"description": {
"en": "If set, search this list via list API (not team search).",
"de": "Wenn gesetzt: Suche in dieser Liste (Listen-API, nicht Team-Suche).",
"fr": "Si défini : recherche dans cette liste (API liste).",
},
},
{
"name": "includeClosed",
"type": "boolean",
"required": False,
"default": False,
"description": {
"en": "With listId: include closed tasks.",
"de": "Mit Liste: erledigte Aufgaben einbeziehen.",
"fr": "Avec liste : inclure les tâches terminées.",
},
},
{
"name": "fullTaskData",
"type": "boolean",
"required": False,
"default": False,
"description": {
"en": "Return full ClickUp API JSON per task (very large). Default: slim fields only.",
"de": "Vollständige ClickUp-Rohdaten pro Task (sehr groß). Standard: nur schlanke Felder.",
"fr": "Réponse brute complète (très volumineuse). Par défaut : champs réduits.",
},
},
{
"name": "matchNameOnly",
"type": "boolean",
"required": False,
"default": True,
"description": {
"en": "Keep only tasks whose title contains the search query (default: on).",
"de": "Nur Aufgaben, deren Titel den Suchbegriff enthält (Standard: an).",
"fr": "Ne garder que les tâches dont le titre contient la requête (défaut : oui).",
},
},
],
"inputs": 1,
"outputs": 1,
"meta": {"icon": "mdi-magnify", "color": "#7B68EE"},
"_method": "clickup",
"_action": "searchTasks",
"_paramMap": {
"connectionId": "connectionReference",
"teamId": "teamId",
"query": "query",
"page": "page",
"listId": "listId",
"fullTaskData": "fullTaskData",
"matchNameOnly": "matchNameOnly",
"includeClosed": "includeClosed",
},
},
{
"id": "clickup.listTasks",
"category": "clickup",
"label": {"en": "List tasks", "de": "Aufgaben auflisten", "fr": "Lister les tâches"},
"description": {
"en": "List tasks in a list (pick list path from browse)",
"de": "Aufgaben einer Liste auflisten (Pfad aus Browse)",
"fr": "Lister les tâches d'une liste",
},
"parameters": [
{"name": "connectionId", "type": "string", "required": True, "description": {"en": "ClickUp connection", "de": "ClickUp-Verbindung", "fr": "Connexion ClickUp"}},
{"name": "path", "type": "string", "required": True, "description": {"en": "Virtual path to list /team/.../list/...", "de": "Pfad zur Liste", "fr": "Chemin vers la liste"}},
{"name": "page", "type": "number", "required": False, "description": {"en": "Page", "de": "Seite", "fr": "Page"}, "default": 0},
{"name": "includeClosed", "type": "boolean", "required": False, "description": {"en": "Include closed", "de": "Erledigte einbeziehen", "fr": "Inclure terminées"}, "default": False},
],
"inputs": 1,
"outputs": 1,
"meta": {"icon": "mdi-format-list-bulleted", "color": "#7B68EE"},
"_method": "clickup",
"_action": "listTasks",
"_paramMap": {
"connectionId": "connectionReference",
"path": "pathQuery",
"page": "page",
"includeClosed": "includeClosed",
},
},
{
"id": "clickup.getTask",
"category": "clickup",
"label": {"en": "Get task", "de": "Aufgabe abrufen", "fr": "Obtenir la tâche"},
"description": {"en": "Get one task by ID or path", "de": "Eine Aufgabe abrufen", "fr": "Obtenir une tâche"},
"parameters": [
{"name": "connectionId", "type": "string", "required": True, "description": {"en": "ClickUp connection", "de": "ClickUp-Verbindung", "fr": "Connexion ClickUp"}},
{"name": "taskId", "type": "string", "required": False, "description": {"en": "Task ID", "de": "Task-ID", "fr": "ID tâche"}},
{"name": "path", "type": "string", "required": False, "description": {"en": "Or path .../task/{id}", "de": "Oder Pfad .../task/{id}", "fr": "Ou chemin .../task/{id}"}},
],
"inputs": 1,
"outputs": 1,
"meta": {"icon": "mdi-file-document-outline", "color": "#7B68EE"},
"_method": "clickup",
"_action": "getTask",
"_paramMap": {"connectionId": "connectionReference", "taskId": "taskId", "path": "pathQuery"},
},
{
"id": "clickup.createTask",
"category": "clickup",
"label": {"en": "Create task", "de": "Aufgabe erstellen", "fr": "Créer une tâche"},
"description": {"en": "Create a task in a list", "de": "Aufgabe in einer Liste erstellen", "fr": "Créer une tâche dans une liste"},
"parameters": [
{"name": "connectionId", "type": "string", "required": True, "description": {"en": "ClickUp connection", "de": "ClickUp-Verbindung", "fr": "Connexion ClickUp"}},
{"name": "teamId", "type": "string", "required": False, "description": {"en": "Workspace (team) for list picker", "de": "Workspace für Listen-Auswahl", "fr": "Équipe"}},
{"name": "path", "type": "string", "required": False, "description": {"en": "Optional path /team/.../list/...", "de": "Optional: Pfad zur Liste", "fr": "Chemin optionnel"}},
{"name": "listId", "type": "string", "required": False, "description": {"en": "List ID", "de": "Listen-ID", "fr": "ID liste"}},
{"name": "name", "type": "string", "required": True, "description": {"en": "Task name", "de": "Name", "fr": "Nom"}},
{"name": "description", "type": "string", "required": False, "description": {"en": "Description", "de": "Beschreibung", "fr": "Description"}},
{"name": "taskStatus", "type": "string", "required": False, "description": {"en": "Status (list status name)", "de": "Status (wie in der Liste)", "fr": "Statut"}},
{"name": "taskPriority", "type": "string", "required": False, "description": {"en": "14 or empty", "de": "14 oder leer", "fr": "14"}},
{"name": "taskDueDateMs", "type": "string", "required": False, "description": {"en": "Due date (Unix ms)", "de": "Fälligkeit (ms)", "fr": "Échéance (ms)"}},
{"name": "taskAssigneeIds", "type": "object", "required": False, "description": {"en": "Assignee user ids", "de": "Zugewiesene (User-IDs)", "fr": "Assignés"}},
{"name": "taskTimeEstimateMs", "type": "string", "required": False, "description": {"en": "Time estimate (ms)", "de": "Zeitschätzung (ms)", "fr": "Estimation (ms)"}},
{"name": "taskTimeEstimateHours", "type": "string", "required": False, "description": {"en": "Time estimate (hours)", "de": "Zeitschätzung (Stunden)", "fr": "Heures"}},
{"name": "customFieldValues", "type": "object", "required": False, "description": {"en": "Custom field id → value", "de": "Benutzerdefinierte Felder", "fr": "Champs personnalisés"}},
{"name": "taskFields", "type": "string", "required": False, "description": {"en": "Extra JSON (advanced)", "de": "Zusätzliches JSON (fortgeschritten)", "fr": "JSON avancé"}},
],
"inputs": 1,
"outputs": 1,
"meta": {"icon": "mdi-plus-circle-outline", "color": "#7B68EE"},
"_method": "clickup",
"_action": "createTask",
"_paramMap": {
"connectionId": "connectionReference",
"teamId": "teamId",
"path": "pathQuery",
"listId": "listId",
"name": "name",
"description": "description",
"taskStatus": "taskStatus",
"taskPriority": "taskPriority",
"taskDueDateMs": "taskDueDateMs",
"taskAssigneeIds": "taskAssigneeIds",
"taskTimeEstimateMs": "taskTimeEstimateMs",
"taskTimeEstimateHours": "taskTimeEstimateHours",
"customFieldValues": "customFieldValues",
"taskFields": "taskFields",
},
},
{
"id": "clickup.updateTask",
"category": "clickup",
"label": {"en": "Update task", "de": "Aufgabe aktualisieren", "fr": "Mettre à jour la tâche"},
"description": {
"en": "Update task fields (rows or JSON)",
"de": "Felder der Aufgabe ändern (Zeilen oder JSON)",
"fr": "Mettre à jour les champs (lignes ou JSON)",
},
"parameters": [
{"name": "connectionId", "type": "string", "required": True, "description": {"en": "ClickUp connection", "de": "ClickUp-Verbindung", "fr": "Connexion ClickUp"}},
{"name": "taskId", "type": "string", "required": False, "description": {"en": "Task ID", "de": "Task-ID", "fr": "ID tâche"}},
{"name": "path", "type": "string", "required": False, "description": {"en": "Or path to task", "de": "Oder Pfad", "fr": "Ou chemin"}},
{
"name": "taskUpdateEntries",
"type": "object",
"required": False,
"description": {
"en": "List of {fieldKey, value, customFieldId?}",
"de": "Liste der zu ändernden Felder (fieldKey, value, optional customFieldId)",
"fr": "Liste de champs à mettre à jour",
},
},
{"name": "taskUpdate", "type": "string", "required": False, "description": {"en": "JSON body for API (optional if rows set)", "de": "JSON für API (optional wenn Zeilen gesetzt)", "fr": "Corps JSON"}},
],
"inputs": 1,
"outputs": 1,
"meta": {"icon": "mdi-pencil-outline", "color": "#7B68EE"},
"_method": "clickup",
"_action": "updateTask",
"_paramMap": {
"connectionId": "connectionReference",
"taskId": "taskId",
"path": "path",
"taskUpdate": "taskUpdate",
},
},
{
"id": "clickup.uploadAttachment",
"category": "clickup",
"label": {"en": "Upload attachment", "de": "Anhang hochladen", "fr": "Téléverser pièce jointe"},
"description": {"en": "Upload file to a task (upstream file)", "de": "Datei an Task anhängen", "fr": "Joindre un fichier à la tâche"},
"parameters": [
{"name": "connectionId", "type": "string", "required": True, "description": {"en": "ClickUp connection", "de": "ClickUp-Verbindung", "fr": "Connexion ClickUp"}},
{"name": "taskId", "type": "string", "required": False, "description": {"en": "Task ID", "de": "Task-ID", "fr": "ID tâche"}},
{"name": "path", "type": "string", "required": False, "description": {"en": "Or path to task", "de": "Oder Pfad", "fr": "Ou chemin"}},
{"name": "fileName", "type": "string", "required": False, "description": {"en": "File name", "de": "Dateiname", "fr": "Nom du fichier"}},
],
"inputs": 1,
"outputs": 1,
"meta": {"icon": "mdi-attachment", "color": "#7B68EE"},
"_method": "clickup",
"_action": "uploadAttachment",
"_paramMap": {
"connectionId": "connectionReference",
"taskId": "taskId",
"path": "path",
"fileName": "fileName",
},
},
]

View file

@ -1,58 +0,0 @@
# Copyright (c) 2025 Patrick Motsch
# Data transformation node definitions.
DATA_NODES = [
{
"id": "data.setFields",
"category": "data",
"label": {"en": "Set Fields", "de": "Felder setzen", "fr": "Définir champs"},
"description": {"en": "Set or override fields on payload", "de": "Felder setzen oder überschreiben", "fr": "Définir ou écraser des champs"},
"parameters": [
{"name": "fields", "type": "object", "required": True, "description": {"en": "Key-value pairs", "de": "Schlüssel-Wert-Paare", "fr": "Paires clé-valeur"}},
],
"inputs": 1,
"outputs": 1,
"executor": "data",
"meta": {"icon": "mdi-pencil", "color": "#673AB7"},
},
{
"id": "data.filter",
"category": "data",
"label": {"en": "Filter", "de": "Filtern", "fr": "Filtrer"},
"description": {"en": "Filter array by condition", "de": "Array nach Bedingung filtern", "fr": "Filtrer tableau par condition"},
"parameters": [
{"name": "condition", "type": "string", "required": True, "description": {"en": "Expression (e.g. item.active == true)", "de": "Bedingung", "fr": "Condition"}},
{"name": "itemsPath", "type": "string", "required": False, "description": {"en": "Path to array", "de": "Pfad zum Array", "fr": "Chemin vers le tableau"}},
],
"inputs": 1,
"outputs": 1,
"executor": "data",
"meta": {"icon": "mdi-filter", "color": "#673AB7"},
},
{
"id": "data.parseJson",
"category": "data",
"label": {"en": "Parse JSON", "de": "JSON parsen", "fr": "Parser JSON"},
"description": {"en": "Parse JSON string to object", "de": "JSON-String in Objekt parsen", "fr": "Parser chaîne JSON en objet"},
"parameters": [
{"name": "jsonPath", "type": "string", "required": False, "description": {"en": "Path to JSON string (default: input)", "de": "Pfad zum JSON", "fr": "Chemin vers JSON"}},
],
"inputs": 1,
"outputs": 1,
"executor": "data",
"meta": {"icon": "mdi-code-json", "color": "#673AB7"},
},
{
"id": "data.template",
"category": "data",
"label": {"en": "Template / Interpolation", "de": "Vorlage / Interpolation", "fr": "Modèle / Interpolation"},
"description": {"en": "Text with {{placeholder}} substitution", "de": "Text mit {{platzhalter}}-Ersetzung", "fr": "Texte avec substitution {{placeholder}}"},
"parameters": [
{"name": "template", "type": "string", "required": True, "description": {"en": "Template (use {{path}} for values)", "de": "Vorlage", "fr": "Modèle"}},
],
"inputs": 1,
"outputs": 1,
"executor": "data",
"meta": {"icon": "mdi-format-text", "color": "#673AB7"},
},
]

View file

@ -0,0 +1,60 @@
# Copyright (c) 2025 Patrick Motsch
# File node definitions - create files from context (e.g. from AI nodes).
FILE_NODES = [
{
"id": "file.create",
"category": "file",
"label": {"en": "Create File", "de": "Datei erstellen", "fr": "Créer fichier"},
"description": {
"en": "Create a file from context (text/markdown from AI). Configurable format and style.",
"de": "Erstellt eine Datei aus Kontext (Text/Markdown von KI). Format und Stil konfigurierbar.",
"fr": "Crée un fichier à partir du contexte. Format et style configurables.",
},
"parameters": [
{
"name": "contentSources",
"type": "json",
"required": False,
"description": {
"en": "Array of context refs (e.g. AI, form). Concatenated in order. Empty = from connected node.",
"de": "Liste von Kontext-Quellen (z.B. KI, Formular). Werden nacheinander zusammengefügt. Leer = vom verbundenen Node.",
"fr": "Liste de sources de contexte. Concaténées dans l'ordre. Vide = du noeud connecté.",
},
"default": [],
},
{
"name": "outputFormat",
"type": "string",
"required": True,
"description": {"en": "Output format", "de": "Ausgabeformat", "fr": "Format de sortie"},
"default": "docx",
},
{
"name": "title",
"type": "string",
"required": False,
"description": {"en": "Document title", "de": "Dokumenttitel", "fr": "Titre du document"},
},
{
"name": "templateName",
"type": "string",
"required": False,
"description": {"en": "Style preset: default, corporate, minimal", "de": "Stil-Vorlage", "fr": "Prését style"},
},
{
"name": "language",
"type": "string",
"required": False,
"description": {"en": "Language code (de, en, fr)", "de": "Sprachcode", "fr": "Code langue"},
"default": "de",
},
],
"inputs": 1,
"outputs": 1,
"meta": {"icon": "mdi-file-plus-outline", "color": "#2196F3"},
"_method": "file",
"_action": "create",
"_paramMap": {},
},
]

View file

@ -12,6 +12,7 @@ FLOW_NODES = [
], ],
"inputs": 1, "inputs": 1,
"outputs": 2, "outputs": 2,
"outputLabels": {"en": ["Yes", "No"], "de": ["Ja", "Nein"], "fr": ["Oui", "Non"]},
"executor": "flow", "executor": "flow",
"meta": {"icon": "mdi-source-branch", "color": "#FF9800"}, "meta": {"icon": "mdi-source-branch", "color": "#FF9800"},
}, },
@ -29,19 +30,6 @@ FLOW_NODES = [
"executor": "flow", "executor": "flow",
"meta": {"icon": "mdi-swap-horizontal", "color": "#FF9800"}, "meta": {"icon": "mdi-swap-horizontal", "color": "#FF9800"},
}, },
{
"id": "flow.merge",
"category": "flow",
"label": {"en": "Merge", "de": "Zusammenführen", "fr": "Fusionner"},
"description": {"en": "Merge multiple inputs", "de": "Mehrere Eingaben zusammenführen", "fr": "Fusionner plusieurs entrées"},
"parameters": [
{"name": "mode", "type": "string", "required": False, "description": {"en": "append | combine", "de": "Modus", "fr": "Mode"}},
],
"inputs": 2,
"outputs": 1,
"executor": "flow",
"meta": {"icon": "mdi-merge", "color": "#FF9800"},
},
{ {
"id": "flow.loop", "id": "flow.loop",
"category": "flow", "category": "flow",
@ -55,28 +43,4 @@ FLOW_NODES = [
"executor": "flow", "executor": "flow",
"meta": {"icon": "mdi-repeat", "color": "#FF9800"}, "meta": {"icon": "mdi-repeat", "color": "#FF9800"},
}, },
{
"id": "flow.wait",
"category": "flow",
"label": {"en": "Wait / Delay", "de": "Warten / Verzögerung", "fr": "Attendre / Délai"},
"description": {"en": "Pause for duration", "de": "Pause für Dauer", "fr": "Pause pour durée"},
"parameters": [
{"name": "seconds", "type": "number", "required": True, "description": {"en": "Seconds to wait", "de": "Sekunden", "fr": "Secondes"}},
],
"inputs": 1,
"outputs": 1,
"executor": "flow",
"meta": {"icon": "mdi-timer", "color": "#FF9800"},
},
{
"id": "flow.stop",
"category": "flow",
"label": {"en": "Stop / Terminate", "de": "Stopp / Beenden", "fr": "Arrêter / Terminer"},
"description": {"en": "Stop workflow execution", "de": "Workflow-Ausführung beenden", "fr": "Arrêter l'exécution"},
"parameters": [],
"inputs": 1,
"outputs": 0,
"executor": "flow",
"meta": {"icon": "mdi-stop", "color": "#F44336"},
},
] ]

View file

@ -12,7 +12,11 @@ INPUT_NODES = [
"name": "fields", "name": "fields",
"type": "json", "type": "json",
"required": True, "required": True,
"description": {"en": "Form fields: [{name, type, label, required, options?}]", "de": "Formularfelder", "fr": "Champs du formulaire"}, "description": {
"en": "Form fields: [{name, type, label, required, options?}]. type may include clickup_tasks with clickupConnectionId + clickupListId for a ClickUp task dropdown (value {add, rem}).",
"de": "Formularfelder. type: u. a. clickup_tasks mit clickupConnectionId und clickupListId für ClickUp-Aufgaben-Dropdown (Wert wie Relationship-Feld).",
"fr": "Champs du formulaire",
},
"default": [], "default": [],
}, },
], ],
@ -42,7 +46,8 @@ INPUT_NODES = [
"label": {"en": "Upload", "de": "Upload", "fr": "Téléversement"}, "label": {"en": "Upload", "de": "Upload", "fr": "Téléversement"},
"description": {"en": "User uploads file(s)", "de": "Benutzer lädt Datei(en) hoch", "fr": "L'utilisateur téléverse des fichiers"}, "description": {"en": "User uploads file(s)", "de": "Benutzer lädt Datei(en) hoch", "fr": "L'utilisateur téléverse des fichiers"},
"parameters": [ "parameters": [
{"name": "accept", "type": "string", "required": False, "description": {"en": "MIME types (e.g. .pdf,image/*)", "de": "MIME-Typen", "fr": "Types MIME"}, "default": ""}, {"name": "accept", "type": "string", "required": False, "description": {"en": "Accept string for file input (e.g. .pdf,image/*)", "de": "Accept-String für Dateiauswahl", "fr": "Chaîne accept"}, "default": ""},
{"name": "allowedTypes", "type": "json", "required": False, "description": {"en": "Selected file types (from UI multi-select)", "de": "Ausgewählte Dateitypen", "fr": "Types sélectionnés"}, "default": []},
{"name": "maxSize", "type": "number", "required": False, "description": {"en": "Max file size in MB", "de": "Max. Dateigröße in MB", "fr": "Taille max en Mo"}, "default": 10}, {"name": "maxSize", "type": "number", "required": False, "description": {"en": "Max file size in MB", "de": "Max. Dateigröße in MB", "fr": "Taille max en Mo"}, "default": 10},
{"name": "multiple", "type": "boolean", "required": False, "description": {"en": "Allow multiple files", "de": "Mehrere Dateien erlauben", "fr": "Autoriser plusieurs fichiers"}, "default": False}, {"name": "multiple", "type": "boolean", "required": False, "description": {"en": "Allow multiple files", "de": "Mehrere Dateien erlauben", "fr": "Autoriser plusieurs fichiers"}, "default": False},
], ],

View file

@ -1,12 +1,16 @@
# Copyright (c) 2025 Patrick Motsch # Copyright (c) 2025 Patrick Motsch
# Trigger node definitions - workflow entry points. # Canvas start nodes — variant reflects workflow configuration (gear in editor).
TRIGGER_NODES = [ TRIGGER_NODES = [
{ {
"id": "trigger.manual", "id": "trigger.manual",
"category": "trigger", "category": "trigger",
"label": {"en": "Manual Trigger", "de": "Manueller Trigger", "fr": "Déclencheur manuel"}, "label": {"en": "Start", "de": "Start", "fr": "Départ"},
"description": {"en": "Start workflow on button press", "de": "Startet den Workflow bei Knopfdruck", "fr": "Démarre le workflow sur clic"}, "description": {
"en": "Manual, API, or background triggers (webhook, email, …).",
"de": "Manuell, API oder Hintergrund-Starts (Webhook, E-Mail, …).",
"fr": "Manuel, API ou déclencheurs en arrière-plan.",
},
"parameters": [], "parameters": [],
"inputs": 0, "inputs": 0,
"outputs": 1, "outputs": 1,
@ -14,29 +18,47 @@ TRIGGER_NODES = [
"meta": {"icon": "mdi-play", "color": "#4CAF50"}, "meta": {"icon": "mdi-play", "color": "#4CAF50"},
}, },
{ {
"id": "trigger.schedule", "id": "trigger.form",
"category": "trigger", "category": "trigger",
"label": {"en": "Schedule", "de": "Zeitplan", "fr": "Planification"}, "label": {"en": "Start (form)", "de": "Start (Formular)", "fr": "Départ (formulaire)"},
"description": {"en": "Run on a cron schedule", "de": "Läuft nach Cron-Zeitplan", "fr": "S'exécute selon un cron"}, "description": {
"en": "Form fields are filled at run time; configure fields on this node.",
"de": "Felder werden beim Start befüllt; konfigurieren Sie die Felder auf dieser Node.",
"fr": "Les champs sont remplis au démarrage.",
},
"parameters": [ "parameters": [
{"name": "cron", "type": "string", "required": True, "description": {"en": "Cron expression (e.g. 0 9 * * * for daily at 9)", "de": "Cron-Ausdruck", "fr": "Expression cron"}}, {
], "name": "formFields",
"inputs": 0, "type": "json",
"outputs": 1, "required": False,
"executor": "trigger", "description": {"en": "Field definitions", "de": "Felddefinitionen", "fr": "Définitions"},
"meta": {"icon": "mdi-clock", "color": "#2196F3"}, },
},
{
"id": "trigger.formSubmit",
"category": "trigger",
"label": {"en": "Form Submit", "de": "Formular-Absendung", "fr": "Soumission formulaire"},
"description": {"en": "Start when form is submitted", "de": "Startet bei Formular-Absendung", "fr": "Démarre à la soumission du formulaire"},
"parameters": [
{"name": "formId", "type": "string", "required": True, "description": {"en": "Form identifier", "de": "Formular-ID", "fr": "Identifiant du formulaire"}},
], ],
"inputs": 0, "inputs": 0,
"outputs": 1, "outputs": 1,
"executor": "trigger", "executor": "trigger",
"meta": {"icon": "mdi-form-select", "color": "#9C27B0"}, "meta": {"icon": "mdi-form-select", "color": "#9C27B0"},
}, },
{
"id": "trigger.schedule",
"category": "trigger",
"label": {"en": "Start (schedule)", "de": "Start (Zeitplan)", "fr": "Départ (planification)"},
"description": {
"en": "Cron expression for scheduled runs (configure on this node).",
"de": "Cron-Ausdruck für geplante Läufe.",
"fr": "Expression cron pour les exécutions planifiées.",
},
"parameters": [
{
"name": "cron",
"type": "string",
"required": False,
"description": {"en": "Cron expression", "de": "Cron-Ausdruck", "fr": "Expression cron"},
},
],
"inputs": 0,
"outputs": 1,
"executor": "trigger",
"meta": {"icon": "mdi-clock", "color": "#2196F3"},
},
] ]

View file

@ -36,6 +36,11 @@ def _localizeNode(node: Dict[str, Any], language: str) -> Dict[str, Any]:
out["label"] = node["label"].get(lang, node["label"].get("en", str(node["label"]))) out["label"] = node["label"].get(lang, node["label"].get("en", str(node["label"])))
if isinstance(node.get("description"), dict): if isinstance(node.get("description"), dict):
out["description"] = node["description"].get(lang, node["description"].get("en", str(node["description"]))) out["description"] = node["description"].get(lang, node["description"].get("en", str(node["description"])))
ol = node.get("outputLabels")
if isinstance(ol, dict) and ol:
first = next(iter(ol.values()), None)
if isinstance(first, (list, tuple)):
out["outputLabels"] = ol.get(lang, ol.get("en", list(first)))
params = [] params = []
for p in node.get("parameters", []): for p in node.get("parameters", []):
pc = dict(p) pc = dict(p)
@ -61,8 +66,10 @@ def getNodeTypesForApi(
{"id": "flow", "label": {"en": "Flow", "de": "Ablauf", "fr": "Flux"}}, {"id": "flow", "label": {"en": "Flow", "de": "Ablauf", "fr": "Flux"}},
{"id": "data", "label": {"en": "Data", "de": "Daten", "fr": "Données"}}, {"id": "data", "label": {"en": "Data", "de": "Daten", "fr": "Données"}},
{"id": "ai", "label": {"en": "AI", "de": "KI", "fr": "IA"}}, {"id": "ai", "label": {"en": "AI", "de": "KI", "fr": "IA"}},
{"id": "file", "label": {"en": "File", "de": "Datei", "fr": "Fichier"}},
{"id": "email", "label": {"en": "Email", "de": "E-Mail", "fr": "Email"}}, {"id": "email", "label": {"en": "Email", "de": "E-Mail", "fr": "Email"}},
{"id": "sharepoint", "label": {"en": "SharePoint", "de": "SharePoint", "fr": "SharePoint"}}, {"id": "sharepoint", "label": {"en": "SharePoint", "de": "SharePoint", "fr": "SharePoint"}},
{"id": "clickup", "label": {"en": "ClickUp", "de": "ClickUp", "fr": "ClickUp"}},
] ]
return {"nodeTypes": localized, "categories": categories} return {"nodeTypes": localized, "categories": categories}

View file

@ -5,6 +5,8 @@ Automation2 routes - node-types, execute, workflows, runs, tasks, connections, b
""" """
import logging import logging
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, Path, Query, Body, Request, HTTPException from fastapi import APIRouter, Depends, Path, Query, Body, Request, HTTPException
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from modules.auth import limiter, getRequestContext, RequestContext from modules.auth import limiter, getRequestContext, RequestContext
@ -13,9 +15,75 @@ from modules.features.automation2.mainAutomation2 import getAutomation2Services
from modules.features.automation2.nodeRegistry import getNodeTypesForApi from modules.features.automation2.nodeRegistry import getNodeTypesForApi
from modules.features.automation2.interfaceFeatureAutomation2 import getAutomation2Interface from modules.features.automation2.interfaceFeatureAutomation2 import getAutomation2Interface
from modules.workflows.automation2.executionEngine import executeGraph from modules.workflows.automation2.executionEngine import executeGraph
from modules.workflows.automation2.runEnvelope import (
default_run_envelope,
merge_run_envelope,
normalize_run_envelope,
)
from modules.features.automation2.entryPoints import find_invocation
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _build_execute_run_envelope(
body: Dict[str, Any],
workflow: Optional[Dict[str, Any]],
user_id: Optional[str],
) -> Dict[str, Any]:
"""Build normalized run envelope from POST /execute body."""
if isinstance(body.get("runEnvelope"), dict):
env = normalize_run_envelope(body["runEnvelope"], user_id=user_id)
pl = body.get("payload")
if isinstance(pl, dict):
env = merge_run_envelope(env, {"payload": pl})
return env
entry_point_id = body.get("entryPointId")
if entry_point_id:
if not workflow:
raise HTTPException(
status_code=400,
detail="entryPointId requires a saved workflow (workflowId must refer to a stored workflow)",
)
inv = find_invocation(workflow, entry_point_id)
if not inv:
raise HTTPException(status_code=400, detail="entryPointId not found on workflow")
if not inv.get("enabled", True):
raise HTTPException(status_code=400, detail="entry point is disabled")
kind = inv.get("kind", "manual")
trig_map = {
"manual": "manual",
"form": "form",
"schedule": "schedule",
"always_on": "event",
"email": "email",
"webhook": "webhook",
"api": "api",
"event": "event",
}
trig = trig_map.get(kind, "manual")
title = inv.get("title") or {}
label = ""
if isinstance(title, dict):
label = title.get("en") or title.get("de") or ""
elif isinstance(title, str):
label = title
base = default_run_envelope(
trig,
entry_point_id=inv.get("id"),
entry_point_label=label or None,
)
pl = body.get("payload")
if isinstance(pl, dict):
base = merge_run_envelope(base, {"payload": pl})
return normalize_run_envelope(base, user_id=user_id)
env = normalize_run_envelope(None, user_id=user_id)
pl = body.get("payload")
if isinstance(pl, dict):
env = merge_run_envelope(env, {"payload": pl})
return env
router = APIRouter( router = APIRouter(
prefix="/api/automation2", prefix="/api/automation2",
tags=["Automation2"], tags=["Automation2"],
@ -55,6 +123,26 @@ def get_automation2_info(
} }
@router.post("/{instanceId}/schedule-sync")
@limiter.limit("10/minute")
def post_schedule_sync(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Manually trigger schedule sync (re-register cron jobs for all schedule workflows)."""
_validateInstanceAccess(instanceId, context)
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.workflows.automation2.subAutomation2Schedule import sync_automation2_schedule_events
root = getRootInterface()
event_user = root.getUserByUsername("event")
if not event_user:
return {"success": False, "error": "Event user not available", "synced": 0}
result = sync_automation2_schedule_events(event_user)
return {"success": True, **result}
@router.get("/{instanceId}/node-types") @router.get("/{instanceId}/node-types")
@limiter.limit("60/minute") @limiter.limit("60/minute")
def get_node_types( def get_node_types(
@ -109,6 +197,10 @@ async def post_execute(
graph = body.get("graph") or body graph = body.get("graph") or body
workflowId = body.get("workflowId") workflowId = body.get("workflowId")
req_nodes = graph.get("nodes") or [] req_nodes = graph.get("nodes") or []
workflow_for_envelope: Optional[Dict[str, Any]] = None
if workflowId and not str(workflowId).startswith("transient-"):
a2_pre = getAutomation2Interface(context.user, mandateId, instanceId)
workflow_for_envelope = a2_pre.getWorkflow(workflowId)
# When workflowId is set: prefer graph from request (current editor state) if it has nodes. # When workflowId is set: prefer graph from request (current editor state) if it has nodes.
# Only fall back to stored workflow graph when request graph is empty (e.g. resume from email). # Only fall back to stored workflow graph when request graph is empty (e.g. resume from email).
if workflowId and len(req_nodes) == 0: if workflowId and len(req_nodes) == 0:
@ -117,6 +209,7 @@ async def post_execute(
if wf and wf.get("graph"): if wf and wf.get("graph"):
graph = wf["graph"] graph = wf["graph"]
logger.info("automation2 execute: loaded graph from workflow %s", workflowId) logger.info("automation2 execute: loaded graph from workflow %s", workflowId)
workflow_for_envelope = wf
# Use transient workflowId when none provided (e.g. execute from editor without save) # Use transient workflowId when none provided (e.g. execute from editor without save)
# Required for email.checkEmail pause/resume - run must be created # Required for email.checkEmail pause/resume - run must be created
if not workflowId: if not workflowId:
@ -132,6 +225,8 @@ async def post_execute(
workflowId, workflowId,
mandateId, mandateId,
) )
run_env = _build_execute_run_envelope(body, workflow_for_envelope, userId)
a2_interface = getAutomation2Interface(context.user, mandateId, instanceId) a2_interface = getAutomation2Interface(context.user, mandateId, instanceId)
result = await executeGraph( result = await executeGraph(
graph=graph, graph=graph,
@ -141,6 +236,7 @@ async def post_execute(
userId=userId, userId=userId,
mandateId=mandateId, mandateId=mandateId,
automation2_interface=a2_interface, automation2_interface=a2_interface,
run_envelope=run_env,
) )
logger.info( logger.info(
"automation2 execute result: success=%s error=%s nodeOutputs_keys=%s failedNode=%s paused=%s", "automation2 execute result: success=%s error=%s nodeOutputs_keys=%s failedNode=%s paused=%s",
@ -239,6 +335,7 @@ async def list_connection_services(
services = provider.getAvailableServices() services = provider.getAvailableServices()
_serviceLabels = { _serviceLabels = {
"sharepoint": "SharePoint", "sharepoint": "SharePoint",
"clickup": "ClickUp",
"outlook": "Outlook", "outlook": "Outlook",
"teams": "Teams", "teams": "Teams",
"onedrive": "OneDrive", "onedrive": "OneDrive",
@ -248,6 +345,7 @@ async def list_connection_services(
} }
_serviceIcons = { _serviceIcons = {
"sharepoint": "sharepoint", "sharepoint": "sharepoint",
"clickup": "folder",
"outlook": "mail", "outlook": "mail",
"teams": "chat", "teams": "chat",
"onedrive": "cloud", "onedrive": "cloud",
@ -342,15 +440,17 @@ def _get_node_label_from_graph(graph: dict, nodeId: str) -> str:
def get_workflows( def get_workflows(
request: Request, request: Request,
instanceId: str = Path(..., description="Feature instance ID"), instanceId: str = Path(..., description="Feature instance ID"),
active: Optional[bool] = Query(None, description="Filter by active: true|false"),
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
) -> dict: ) -> dict:
"""List all workflows for this feature instance. """List all workflows for this feature instance.
Enriches each workflow with runCount, isRunning, stuckAtNodeId, stuckAtNodeLabel, Enriches each workflow with runCount, isRunning, stuckAtNodeId, stuckAtNodeLabel,
createdAt, lastStartedAt. createdAt, lastStartedAt.
Query param active: filter by active status (true|false).
""" """
mandateId = _validateInstanceAccess(instanceId, context) mandateId = _validateInstanceAccess(instanceId, context)
a2 = getAutomation2Interface(context.user, mandateId, instanceId) a2 = getAutomation2Interface(context.user, mandateId, instanceId)
items = a2.getWorkflows() items = a2.getWorkflows(active=active)
enriched = [] enriched = []
for wf in items: for wf in items:
wf_id = wf.get("id") wf_id = wf.get("id")
@ -359,7 +459,7 @@ def get_workflows(
active_run = None active_run = None
last_started_at = None last_started_at = None
for r in runs: for r in runs:
ts = r.get("_createdAt") ts = r.get("sysCreatedAt")
if ts and (last_started_at is None or ts > last_started_at): if ts and (last_started_at is None or ts > last_started_at):
last_started_at = ts last_started_at = ts
if r.get("status") in ("running", "paused"): if r.get("status") in ("running", "paused"):
@ -375,7 +475,7 @@ def get_workflows(
"runStatus": active_run.get("status") if active_run else None, "runStatus": active_run.get("status") if active_run else None,
"stuckAtNodeId": stuck_at_node_id, "stuckAtNodeId": stuck_at_node_id,
"stuckAtNodeLabel": stuck_at_node_label or stuck_at_node_id or "", "stuckAtNodeLabel": stuck_at_node_label or stuck_at_node_id or "",
"createdAt": wf.get("_createdAt"), "createdAt": wf.get("sysCreatedAt"),
"lastStartedAt": last_started_at, "lastStartedAt": last_started_at,
}) })
return {"workflows": enriched} return {"workflows": enriched}
@ -447,11 +547,163 @@ def delete_workflow(
return {"success": True} return {"success": True}
@router.post("/{instanceId}/workflows/{workflowId}/webhooks/{entryPointId}")
@limiter.limit("60/minute")
async def post_workflow_webhook(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
workflowId: str = Path(..., description="Workflow ID"),
entryPointId: str = Path(..., description="Entry point ID (kind must be webhook)"),
body: dict = Body(default_factory=dict),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""
Invoke a workflow via a webhook entry point. Optional shared secret in
X-Automation2-Webhook-Secret or X-Webhook-Secret when config.webhookSecret is set.
"""
mandateId = _validateInstanceAccess(instanceId, context)
userId = str(context.user.id) if context.user else None
a2 = getAutomation2Interface(context.user, mandateId, instanceId)
wf = a2.getWorkflow(workflowId)
if not wf or not wf.get("graph"):
raise HTTPException(status_code=404, detail="Workflow not found")
inv = find_invocation(wf, entryPointId)
if not inv:
raise HTTPException(status_code=404, detail="Entry point not found")
if inv.get("kind") != "webhook":
raise HTTPException(status_code=400, detail="Entry point is not a webhook")
if not inv.get("enabled", True):
raise HTTPException(status_code=400, detail="Entry point is disabled")
cfg = inv.get("config") or {}
secret = cfg.get("webhookSecret")
if secret:
hdr = request.headers.get("X-Automation2-Webhook-Secret") or request.headers.get(
"X-Webhook-Secret"
)
if hdr != str(secret):
raise HTTPException(status_code=403, detail="Invalid webhook secret")
services = getAutomation2Services(
context.user,
mandateId=mandateId,
featureInstanceId=instanceId,
)
from modules.workflows.processing.shared.methodDiscovery import discoverMethods
discoverMethods(services)
title = inv.get("title") or {}
label = ""
if isinstance(title, dict):
label = title.get("en") or title.get("de") or ""
elif isinstance(title, str):
label = title
pl = body if isinstance(body, dict) else {}
base = default_run_envelope(
"webhook",
entry_point_id=inv.get("id"),
entry_point_label=label or None,
payload=pl,
raw={"httpBody": body},
)
run_env = normalize_run_envelope(base, user_id=userId)
result = await executeGraph(
graph=wf["graph"],
services=services,
workflowId=workflowId,
instanceId=instanceId,
userId=userId,
mandateId=mandateId,
automation2_interface=a2,
run_envelope=run_env,
)
return result
@router.post("/{instanceId}/workflows/{workflowId}/forms/{entryPointId}/submit")
@limiter.limit("60/minute")
async def post_workflow_form_submit(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
workflowId: str = Path(..., description="Workflow ID"),
entryPointId: str = Path(..., description="Entry point ID (kind must be form)"),
body: dict = Body(default_factory=dict),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Form-style submit: same as execute with trigger.type form and payload from body."""
mandateId = _validateInstanceAccess(instanceId, context)
userId = str(context.user.id) if context.user else None
a2 = getAutomation2Interface(context.user, mandateId, instanceId)
wf = a2.getWorkflow(workflowId)
if not wf or not wf.get("graph"):
raise HTTPException(status_code=404, detail="Workflow not found")
inv = find_invocation(wf, entryPointId)
if not inv:
raise HTTPException(status_code=404, detail="Entry point not found")
if inv.get("kind") != "form":
raise HTTPException(status_code=400, detail="Entry point is not a form")
if not inv.get("enabled", True):
raise HTTPException(status_code=400, detail="Entry point is disabled")
services = getAutomation2Services(
context.user,
mandateId=mandateId,
featureInstanceId=instanceId,
)
from modules.workflows.processing.shared.methodDiscovery import discoverMethods
discoverMethods(services)
title = inv.get("title") or {}
label = ""
if isinstance(title, dict):
label = title.get("en") or title.get("de") or ""
elif isinstance(title, str):
label = title
pl = body if isinstance(body, dict) else {}
base = default_run_envelope(
"form",
entry_point_id=inv.get("id"),
entry_point_label=label or None,
payload=pl,
raw={"formBody": body},
)
run_env = normalize_run_envelope(base, user_id=userId)
result = await executeGraph(
graph=wf["graph"],
services=services,
workflowId=workflowId,
instanceId=instanceId,
userId=userId,
mandateId=mandateId,
automation2_interface=a2,
run_envelope=run_env,
)
return result
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Runs and Resume # Runs and Resume
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@router.get("/{instanceId}/runs/completed")
@limiter.limit("60/minute")
def get_completed_runs(
request: Request,
instanceId: str = Path(..., description="Feature instance ID"),
limit: int = Query(20, ge=1, le=50),
context: RequestContext = Depends(getRequestContext),
) -> dict:
"""Get recently completed runs with output (for Tasks page output section)."""
mandateId = _validateInstanceAccess(instanceId, context)
a2 = getAutomation2Interface(context.user, mandateId, instanceId)
runs = a2.getRecentCompletedRuns(limit=limit)
return {"runs": runs}
@router.get("/{instanceId}/workflows/{workflowId}/runs") @router.get("/{instanceId}/workflows/{workflowId}/runs")
@limiter.limit("60/minute") @limiter.limit("60/minute")
def get_workflow_runs( def get_workflow_runs(
@ -536,7 +788,7 @@ def get_tasks(
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
) -> dict: ) -> dict:
"""Get tasks - by default those assigned to current user, or all if no assignee filter. """Get tasks - by default those assigned to current user, or all if no assignee filter.
Enriches each task with workflowLabel and createdAt (_createdAt). Enriches each task with workflowLabel and createdAt (from sysCreatedAt).
""" """
mandateId = _validateInstanceAccess(instanceId, context) mandateId = _validateInstanceAccess(instanceId, context)
a2 = getAutomation2Interface(context.user, mandateId, instanceId) a2 = getAutomation2Interface(context.user, mandateId, instanceId)
@ -549,7 +801,7 @@ def get_tasks(
enriched.append({ enriched.append({
**t, **t,
"workflowLabel": wf.get("label", t.get("workflowId", "")) if wf else t.get("workflowId", ""), "workflowLabel": wf.get("label", t.get("workflowId", "")) if wf else t.get("workflowId", ""),
"createdAt": t.get("_createdAt"), "createdAt": t.get("sysCreatedAt"),
}) })
return {"tasks": enriched} return {"tasks": enriched}

View file

@ -20,6 +20,7 @@ from modules.datamodels.datamodelRbac import AccessRuleContext
from modules.datamodels.datamodelUam import AccessLevel from modules.datamodels.datamodelUam import AccessLevel
from modules.datamodels.datamodelChat import UserInputRequest from modules.datamodels.datamodelChat import UserInputRequest
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp
# ============================================================================= # =============================================================================
@ -27,7 +28,7 @@ from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp
# ============================================================================= # =============================================================================
class ChatbotDocument(BaseModel): class ChatbotDocument(PowerOnModel):
"""Documents attached to chatbot messages.""" """Documents attached to chatbot messages."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key") id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
messageId: str = Field(description="Foreign key to message") messageId: str = Field(description="Foreign key to message")
@ -41,7 +42,7 @@ class ChatbotDocument(BaseModel):
actionId: Optional[str] = Field(None, description="ID of the action that created this document") actionId: Optional[str] = Field(None, description="ID of the action that created this document")
class ChatbotMessage(BaseModel): class ChatbotMessage(PowerOnModel):
"""Messages in chatbot conversations. Must match bridge format in memory.py.""" """Messages in chatbot conversations. Must match bridge format in memory.py."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key") id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
conversationId: str = Field(description="Foreign key to conversation") conversationId: str = Field(description="Foreign key to conversation")
@ -64,7 +65,7 @@ class ChatbotMessage(BaseModel):
actionProgress: Optional[str] = Field(None, description="Action progress status") actionProgress: Optional[str] = Field(None, description="Action progress status")
class ChatbotLog(BaseModel): class ChatbotLog(PowerOnModel):
"""Log entries for chatbot conversations.""" """Log entries for chatbot conversations."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key") id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
conversationId: str = Field(description="Foreign key to conversation") conversationId: str = Field(description="Foreign key to conversation")
@ -85,7 +86,7 @@ class ChatbotWorkflowModeEnum(str, Enum):
WORKFLOW_CHATBOT = "Chatbot" WORKFLOW_CHATBOT = "Chatbot"
class ChatbotConversation(BaseModel): class ChatbotConversation(PowerOnModel):
"""Chatbot conversation container. Per feature-instance isolation via featureInstanceId.""" """Chatbot conversation container. Per feature-instance isolation via featureInstanceId."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key") id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key")
featureInstanceId: str = Field(description="Feature instance ID for per-instance isolation") featureInstanceId: str = Field(description="Feature instance ID for per-instance isolation")
@ -328,9 +329,8 @@ class ChatObjects:
objectFields[fieldName] = value objectFields[fieldName] = value
else: else:
# Field not in model - treat as scalar if simple, otherwise filter out # Field not in model - treat as scalar if simple, otherwise filter out
# BUT: always include metadata fields (_createdBy, _createdAt, etc.) as they're handled by connector # Underscore-prefixed keys (e.g. UI meta) pass through; sys* live on PowerOnModel subclasses
if fieldName.startswith("_"): if fieldName.startswith("_"):
# Metadata fields should be passed through to connector
simpleFields[fieldName] = value simpleFields[fieldName] = value
elif isinstance(value, (str, int, float, bool, type(None))): elif isinstance(value, (str, int, float, bool, type(None))):
simpleFields[fieldName] = value simpleFields[fieldName] = value

View file

@ -1222,23 +1222,21 @@ def _preflight_billing_check(services, mandateId: str, featureInstanceId: Option
balanceCheck = billingService.checkBalance(0.01) balanceCheck = billingService.checkBalance(0.01)
if not balanceCheck.allowed: if not balanceCheck.allowed:
mid = str(getattr(services, "mandateId", None) or mandateId or "") mid = str(getattr(services, "mandateId", None) or mandateId or "")
from modules.datamodels.datamodelBilling import BillingModelEnum
from modules.serviceCenter.services.serviceBilling.billingExhaustedNotify import ( from modules.serviceCenter.services.serviceBilling.billingExhaustedNotify import (
maybeEmailMandatePoolExhausted, maybeEmailMandatePoolExhausted,
) )
if balanceCheck.billingModel == BillingModelEnum.PREPAY_MANDATE: u = getattr(services, "user", None)
u = getattr(services, "user", None) ulabel = (
ulabel = ( (getattr(u, "email", None) or getattr(u, "username", None) or str(getattr(u, "id", "")))
(getattr(u, "email", None) or getattr(u, "username", None) or str(getattr(u, "id", ""))) if u is not None else ""
if u is not None else "" )
) maybeEmailMandatePoolExhausted(
maybeEmailMandatePoolExhausted( mid,
mid, str(getattr(u, "id", "") if u is not None else ""),
str(getattr(u, "id", "") if u is not None else ""), ulabel,
ulabel, float(balanceCheck.currentBalance or 0.0),
float(balanceCheck.currentBalance or 0.0), 0.01,
0.01, )
)
raise BillingService.InsufficientBalanceException.fromBalanceCheck( raise BillingService.InsufficientBalanceException.fromBalanceCheck(
balanceCheck, balanceCheck,
mid, mid,

View file

@ -7,6 +7,8 @@ Pydantic models for coaching contexts, sessions, messages, tasks, scores, and us
from typing import Optional, List, Dict, Any from typing import Optional, List, Dict, Any
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from enum import Enum from enum import Enum
from modules.datamodels.datamodelBase import PowerOnModel
import uuid import uuid
@ -73,7 +75,7 @@ class CoachingScoreTrend(str, Enum):
# Database Models # Database Models
# ============================================================================ # ============================================================================
class CoachingContext(BaseModel): class CoachingContext(PowerOnModel):
"""A coaching context/dossier representing a topic the user is working on.""" """A coaching context/dossier 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)")
@ -91,11 +93,9 @@ class CoachingContext(BaseModel):
lastSessionAt: Optional[str] = Field(default=None) lastSessionAt: Optional[str] = Field(default=None)
rollingOverview: Optional[str] = Field(default=None, description="AI summary of older sessions for long context history") rollingOverview: Optional[str] = Field(default=None, description="AI summary of older sessions for long context history")
rollingOverviewUpToSessionCount: Optional[int] = Field(default=None, description="Session count covered by rollingOverview") rollingOverviewUpToSessionCount: Optional[int] = Field(default=None, description="Session count covered by rollingOverview")
createdAt: Optional[str] = Field(default=None)
updatedAt: Optional[str] = Field(default=None)
class CoachingSession(BaseModel): class CoachingSession(PowerOnModel):
"""A single coaching conversation session within a context.""" """A single coaching conversation session within a context."""
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") contextId: str = Field(description="FK to CoachingContext")
@ -115,11 +115,9 @@ class CoachingSession(BaseModel):
emailSent: bool = Field(default=False) emailSent: bool = Field(default=False)
startedAt: Optional[str] = Field(default=None) startedAt: Optional[str] = Field(default=None)
endedAt: Optional[str] = Field(default=None) endedAt: Optional[str] = Field(default=None)
createdAt: Optional[str] = Field(default=None)
updatedAt: Optional[str] = Field(default=None)
class CoachingMessage(BaseModel): 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")
@ -130,10 +128,9 @@ class CoachingMessage(BaseModel):
contentType: CoachingMessageContentType = Field(default=CoachingMessageContentType.TEXT) contentType: CoachingMessageContentType = Field(default=CoachingMessageContentType.TEXT)
audioRef: Optional[str] = Field(default=None, description="Reference to audio file") audioRef: Optional[str] = Field(default=None, description="Reference to audio file")
metadata: Optional[str] = Field(default=None, description="JSON: token count, voice info, etc.") metadata: Optional[str] = Field(default=None, description="JSON: token count, voice info, etc.")
createdAt: Optional[str] = Field(default=None)
class CoachingTask(BaseModel): class CoachingTask(PowerOnModel):
"""A task/checklist item assigned within a coaching context.""" """A task/checklist item assigned within a coaching context."""
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") contextId: str = Field(description="FK to CoachingContext")
@ -146,11 +143,9 @@ class CoachingTask(BaseModel):
priority: CoachingTaskPriority = Field(default=CoachingTaskPriority.MEDIUM) priority: CoachingTaskPriority = Field(default=CoachingTaskPriority.MEDIUM)
dueDate: Optional[str] = Field(default=None) dueDate: Optional[str] = Field(default=None)
completedAt: Optional[str] = Field(default=None) completedAt: Optional[str] = Field(default=None)
createdAt: Optional[str] = Field(default=None)
updatedAt: Optional[str] = Field(default=None)
class CoachingScore(BaseModel): 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") contextId: str = Field(description="FK to CoachingContext")
@ -161,17 +156,14 @@ class CoachingScore(BaseModel):
score: float = Field(ge=0.0, le=100.0) score: float = Field(ge=0.0, le=100.0)
trend: CoachingScoreTrend = Field(default=CoachingScoreTrend.STABLE) trend: CoachingScoreTrend = Field(default=CoachingScoreTrend.STABLE)
evidence: Optional[str] = Field(default=None, description="AI reasoning for the score") evidence: Optional[str] = Field(default=None, description="AI reasoning for the score")
createdAt: Optional[str] = Field(default=None)
class CoachingUserProfile(BaseModel): class CoachingUserProfile(PowerOnModel):
"""Per-user coaching profile and preferences.""" """Per-user coaching profile and preferences."""
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") 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")
preferredLanguage: str = Field(default="de-DE")
preferredVoice: Optional[str] = Field(default=None, description="Google TTS voice name")
dailyReminderTime: Optional[str] = Field(default=None, description="HH:MM format") dailyReminderTime: Optional[str] = Field(default=None, description="HH:MM format")
dailyReminderEnabled: bool = Field(default=False) dailyReminderEnabled: bool = Field(default=False)
emailSummaryEnabled: bool = Field(default=True) emailSummaryEnabled: bool = Field(default=True)
@ -180,15 +172,13 @@ class CoachingUserProfile(BaseModel):
totalSessions: int = Field(default=0) totalSessions: int = Field(default=0)
totalMinutes: int = Field(default=0) totalMinutes: int = Field(default=0)
lastSessionAt: Optional[str] = Field(default=None) lastSessionAt: Optional[str] = Field(default=None)
createdAt: Optional[str] = Field(default=None)
updatedAt: Optional[str] = Field(default=None)
# ============================================================================ # ============================================================================
# Iteration 2: Personas # Iteration 2: Personas
# ============================================================================ # ============================================================================
class CoachingPersona(BaseModel): class CoachingPersona(PowerOnModel):
"""A roleplay persona for coaching sessions.""" """A roleplay persona for coaching sessions."""
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 ('system' for builtins)") userId: str = Field(description="Owner user ID ('system' for builtins)")
@ -201,35 +191,13 @@ class CoachingPersona(BaseModel):
gender: Optional[str] = Field(default=None, description="m or f") gender: Optional[str] = Field(default=None, description="m or f")
category: str = Field(default="builtin", description="'builtin' or 'custom'") category: str = Field(default="builtin", description="'builtin' or 'custom'")
isActive: bool = Field(default=True) isActive: bool = Field(default=True)
createdAt: Optional[str] = Field(default=None)
updatedAt: Optional[str] = Field(default=None)
# ============================================================================
# Iteration 2: Documents
# ============================================================================
class CoachingDocument(BaseModel):
"""A document attached to a coaching context."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
contextId: str = Field(description="FK to CoachingContext")
userId: str = Field(description="Owner user ID")
mandateId: str = Field(description="Mandate ID")
instanceId: Optional[str] = Field(default=None)
fileName: str = Field(description="Original file name")
mimeType: str = Field(default="application/octet-stream")
fileSize: int = Field(default=0)
extractedText: Optional[str] = Field(default=None, description="Text content extracted from file")
summary: Optional[str] = Field(default=None, description="AI-generated summary")
fileRef: Optional[str] = Field(default=None, description="Reference to file in storage")
createdAt: Optional[str] = Field(default=None)
# ============================================================================ # ============================================================================
# Iteration 2: Badges / Gamification # Iteration 2: Badges / Gamification
# ============================================================================ # ============================================================================
class CoachingBadge(BaseModel): class CoachingBadge(PowerOnModel):
"""An achievement badge awarded to a user.""" """An achievement badge awarded to a user."""
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") userId: str = Field(description="Owner user ID")
@ -237,7 +205,6 @@ class CoachingBadge(BaseModel):
instanceId: str = Field(description="Feature instance ID") instanceId: str = Field(description="Feature instance ID")
badgeKey: str = Field(description="Badge identifier, e.g. 'streak_7'") badgeKey: str = Field(description="Badge identifier, e.g. 'streak_7'")
awardedAt: Optional[str] = Field(default=None) awardedAt: Optional[str] = Field(default=None)
createdAt: Optional[str] = Field(default=None)
# ============================================================================ # ============================================================================
@ -261,6 +228,10 @@ class UpdateContextRequest(BaseModel):
class SendMessageRequest(BaseModel): class SendMessageRequest(BaseModel):
content: str = Field(description="User message text") content: str = Field(description="User message text")
contentType: Optional[CoachingMessageContentType] = CoachingMessageContentType.TEXT contentType: Optional[CoachingMessageContentType] = CoachingMessageContentType.TEXT
fileIds: Optional[List[str]] = Field(default=None, description="Attached file IDs for agent context")
dataSourceIds: Optional[List[str]] = Field(default=None, description="Personal data source IDs")
featureDataSourceIds: Optional[List[str]] = Field(default=None, description="Feature data source IDs")
allowedProviders: Optional[List[str]] = Field(default=None, description="Allowed AI providers")
class CreateTaskRequest(BaseModel): class CreateTaskRequest(BaseModel):
@ -282,8 +253,6 @@ class UpdateTaskStatusRequest(BaseModel):
class UpdateProfileRequest(BaseModel): class UpdateProfileRequest(BaseModel):
preferredLanguage: Optional[str] = None
preferredVoice: Optional[str] = None
dailyReminderTime: Optional[str] = None dailyReminderTime: Optional[str] = None
dailyReminderEnabled: Optional[bool] = None dailyReminderEnabled: Optional[bool] = None
emailSummaryEnabled: Optional[bool] = None emailSummaryEnabled: Optional[bool] = None

View file

@ -269,34 +269,6 @@ class CommcoachObjects:
from .datamodelCommcoach import CoachingPersona from .datamodelCommcoach import CoachingPersona
return self.db.recordDelete(CoachingPersona, personaId) return self.db.recordDelete(CoachingPersona, personaId)
# =========================================================================
# Documents
# =========================================================================
def getDocuments(self, contextId: str, userId: str) -> List[Dict[str, Any]]:
from .datamodelCommcoach import CoachingDocument
records = self.db.getRecordset(CoachingDocument, recordFilter={"contextId": contextId, "userId": userId})
records.sort(key=lambda r: r.get("createdAt") or "", reverse=True)
return records
def getDocument(self, documentId: str) -> Optional[Dict[str, Any]]:
from .datamodelCommcoach import CoachingDocument
records = self.db.getRecordset(CoachingDocument, recordFilter={"id": documentId})
return records[0] if records else None
def createDocument(self, data: Dict[str, Any]) -> Dict[str, Any]:
from .datamodelCommcoach import CoachingDocument
data["createdAt"] = getIsoTimestamp()
return self.db.recordCreate(CoachingDocument, data)
def updateDocument(self, documentId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
from .datamodelCommcoach import CoachingDocument
return self.db.recordModify(CoachingDocument, documentId, updates)
def deleteDocument(self, documentId: str) -> bool:
from .datamodelCommcoach import CoachingDocument
return self.db.recordDelete(CoachingDocument, documentId)
# ========================================================================= # =========================================================================
# Badges # Badges
# ========================================================================= # =========================================================================

View file

@ -36,12 +36,22 @@ DATA_OBJECTS = [
{ {
"objectKey": "data.feature.commcoach.CoachingContext", "objectKey": "data.feature.commcoach.CoachingContext",
"label": {"en": "Coaching Context", "de": "Coaching-Kontext", "fr": "Contexte coaching"}, "label": {"en": "Coaching Context", "de": "Coaching-Kontext", "fr": "Contexte coaching"},
"meta": {"table": "CoachingContext", "fields": ["id", "title", "category", "status"]} "meta": {
"table": "CoachingContext",
"fields": ["id", "title", "category", "status"],
"isParent": True,
"displayFields": ["title", "category", "status"],
}
}, },
{ {
"objectKey": "data.feature.commcoach.CoachingSession", "objectKey": "data.feature.commcoach.CoachingSession",
"label": {"en": "Coaching Session", "de": "Coaching-Session", "fr": "Session coaching"}, "label": {"en": "Coaching Session", "de": "Coaching-Session", "fr": "Session coaching"},
"meta": {"table": "CoachingSession", "fields": ["id", "contextId", "status", "summary"]} "meta": {
"table": "CoachingSession",
"fields": ["id", "contextId", "status", "summary"],
"parentTable": "CoachingContext",
"parentKey": "contextId",
}
}, },
{ {
"objectKey": "data.feature.commcoach.CoachingMessage", "objectKey": "data.feature.commcoach.CoachingMessage",
@ -51,7 +61,12 @@ DATA_OBJECTS = [
{ {
"objectKey": "data.feature.commcoach.CoachingTask", "objectKey": "data.feature.commcoach.CoachingTask",
"label": {"en": "Coaching Task", "de": "Coaching-Aufgabe", "fr": "Tache coaching"}, "label": {"en": "Coaching Task", "de": "Coaching-Aufgabe", "fr": "Tache coaching"},
"meta": {"table": "CoachingTask", "fields": ["id", "contextId", "title", "status"]} "meta": {
"table": "CoachingTask",
"fields": ["id", "contextId", "title", "status"],
"parentTable": "CoachingContext",
"parentKey": "contextId",
}
}, },
{ {
"objectKey": "data.feature.commcoach.CoachingScore", "objectKey": "data.feature.commcoach.CoachingScore",
@ -61,18 +76,13 @@ DATA_OBJECTS = [
{ {
"objectKey": "data.feature.commcoach.CoachingUserProfile", "objectKey": "data.feature.commcoach.CoachingUserProfile",
"label": {"en": "User Profile", "de": "Benutzerprofil", "fr": "Profil utilisateur"}, "label": {"en": "User Profile", "de": "Benutzerprofil", "fr": "Profil utilisateur"},
"meta": {"table": "CoachingUserProfile", "fields": ["id", "userId", "preferredLanguage"]} "meta": {"table": "CoachingUserProfile", "fields": ["id", "userId", "dailyReminderEnabled"]}
}, },
{ {
"objectKey": "data.feature.commcoach.CoachingPersona", "objectKey": "data.feature.commcoach.CoachingPersona",
"label": {"en": "Coaching Persona", "de": "Coaching-Persona", "fr": "Persona coaching"}, "label": {"en": "Coaching Persona", "de": "Coaching-Persona", "fr": "Persona coaching"},
"meta": {"table": "CoachingPersona", "fields": ["id", "key", "label", "gender"]} "meta": {"table": "CoachingPersona", "fields": ["id", "key", "label", "gender"]}
}, },
{
"objectKey": "data.feature.commcoach.CoachingDocument",
"label": {"en": "Coaching Document", "de": "Coaching-Dokument", "fr": "Document coaching"},
"meta": {"table": "CoachingDocument", "fields": ["id", "contextId", "fileName"]}
},
{ {
"objectKey": "data.feature.commcoach.CoachingBadge", "objectKey": "data.feature.commcoach.CoachingBadge",
"label": {"en": "Coaching Badge", "de": "Coaching-Auszeichnung", "fr": "Badge coaching"}, "label": {"en": "Coaching Badge", "de": "Coaching-Auszeichnung", "fr": "Badge coaching"},
@ -114,12 +124,27 @@ RESOURCE_OBJECTS = [
] ]
TEMPLATE_ROLES = [ TEMPLATE_ROLES = [
{
"roleLabel": "commcoach-viewer",
"description": {
"en": "Communication Coach Viewer - View coaching data (read-only)",
"de": "Kommunikations-Coach Betrachter - Coaching-Daten ansehen (nur lesen)",
"fr": "Visualiseur Coach Communication - Consulter les donnees coaching (lecture seule)",
},
"accessRules": [
{"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.coaching", "view": True},
{"context": "UI", "item": "ui.feature.commcoach.dossier", "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"},
],
},
{ {
"roleLabel": "commcoach-user", "roleLabel": "commcoach-user",
"description": { "description": {
"en": "Communication Coach User - Can manage own coaching contexts and sessions", "en": "Communication Coach User - Can manage own coaching contexts and sessions",
"de": "Kommunikations-Coach Benutzer - Kann eigene Coaching-Kontexte und Sessions verwalten", "de": "Kommunikations-Coach Benutzer - Kann eigene Coaching-Kontexte und Sessions verwalten",
"fr": "Utilisateur Coach Communication - Peut gerer ses propres contextes et sessions" "fr": "Utilisateur Coach Communication - Peut gerer ses propres contextes et sessions",
}, },
"accessRules": [ "accessRules": [
{"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True},
@ -137,7 +162,20 @@ TEMPLATE_ROLES = [
{"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},
] ],
},
{
"roleLabel": "commcoach-admin",
"description": {
"en": "Communication Coach Admin - All UI and API actions; data scoped to own records",
"de": "Kommunikations-Coach Admin - Alle UI- und API-Aktionen; Daten nur eigene Datensaetze",
"fr": "Administrateur Coach Communication - Toute l'UI et les API; donnees propres",
},
"accessRules": [
{"context": "UI", "item": None, "view": True},
{"context": "RESOURCE", "item": None, "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
],
}, },
] ]
@ -147,7 +185,7 @@ def getFeatureDefinition() -> Dict[str, Any]:
"code": FEATURE_CODE, "code": FEATURE_CODE,
"label": FEATURE_LABEL, "label": FEATURE_LABEL,
"icon": FEATURE_ICON, "icon": FEATURE_ICON,
"autoCreateInstance": True, "autoCreateInstance": False,
} }

View file

@ -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, dashboard, and voice endpoints. Implements coaching context management, session streaming, tasks, and dashboard.
""" """
import logging import logging
@ -26,7 +26,7 @@ from .datamodelCommcoach import (
CoachingContext, CoachingContextStatus, CoachingSession, CoachingSessionStatus, CoachingContext, CoachingContextStatus, CoachingSession, CoachingSessionStatus,
CoachingMessage, CoachingMessageRole, CoachingMessageContentType, CoachingMessage, CoachingMessageRole, CoachingMessageContentType,
CoachingTask, CoachingTaskStatus, CoachingTask, CoachingTaskStatus,
CoachingPersona, CoachingDocument, CoachingBadge, CoachingPersona, CoachingBadge,
CreateContextRequest, UpdateContextRequest, CreateContextRequest, UpdateContextRequest,
SendMessageRequest, CreateTaskRequest, UpdateTaskRequest, UpdateTaskStatusRequest, SendMessageRequest, CreateTaskRequest, UpdateTaskRequest, UpdateTaskStatusRequest,
UpdateProfileRequest, UpdateProfileRequest,
@ -334,10 +334,8 @@ async def startSession(
try: try:
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
voiceInterface = getVoiceInterface(context.user, mandateId) voiceInterface = getVoiceInterface(context.user, mandateId)
profile = interface.getProfile(userId, instanceId) from .serviceCommcoach import _getUserVoicePrefs, _stripMarkdownForTts, _buildTtsConfigErrorMessage
language = profile.get("preferredLanguage", "de-DE") if profile else "de-DE" language, voiceName = _getUserVoicePrefs(userId, mandateId)
voiceName = profile.get("preferredVoice") if profile else None
from .serviceCommcoach import _stripMarkdownForTts
ttsResult = await voiceInterface.textToSpeech( ttsResult = await voiceInterface.textToSpeech(
text=_stripMarkdownForTts(greetingText), text=_stripMarkdownForTts(greetingText),
languageCode=language, languageCode=language,
@ -350,8 +348,12 @@ async def startSession(
audioBytes if isinstance(audioBytes, bytes) else audioBytes.encode() audioBytes if isinstance(audioBytes, bytes) else audioBytes.encode()
).decode() ).decode()
yield f"data: {json.dumps({'type': 'ttsAudio', 'data': {'audio': audioB64, 'format': 'mp3'}})}\n\n" yield f"data: {json.dumps({'type': 'ttsAudio', 'data': {'audio': audioB64, 'format': 'mp3'}})}\n\n"
else:
errorDetail = ttsResult.get("error", "Text-to-Speech failed")
yield f"data: {json.dumps({'type': 'error', 'data': {'message': _buildTtsConfigErrorMessage(language, voiceName, errorDetail), 'detail': errorDetail, 'ttsLanguage': language, 'ttsVoice': voiceName}})}\n\n"
except Exception as e: except Exception as e:
logger.warning(f"TTS failed for resumed session: {e}") logger.warning(f"TTS failed for resumed session: {e}")
yield f"data: {json.dumps({'type': 'error', 'data': {'message': 'Die konfigurierte Stimme für diese Sprache ist ungültig oder nicht verfügbar. Bitte passe sie unter Einstellungen > Stimme & Sprache an.', 'detail': str(e)}})}\n\n"
yield f"data: {json.dumps({'type': 'complete', 'data': {}, 'timestamp': getIsoTimestamp()})}\n\n" yield f"data: {json.dumps({'type': 'complete', 'data': {}, 'timestamp': getIsoTimestamp()})}\n\n"
return StreamingResponse( return StreamingResponse(
@ -512,7 +514,13 @@ async def sendMessageStream(
_activeProcessTasks.pop(sessionId, None) _activeProcessTasks.pop(sessionId, None)
task = asyncio.create_task( task = asyncio.create_task(
service.processMessage(sessionId, contextId, body.content, interface) service.processMessage(
sessionId, contextId, body.content, interface,
fileIds=body.fileIds,
dataSourceIds=body.dataSourceIds,
featureDataSourceIds=body.featureDataSourceIds,
allowedProviders=body.allowedProviders,
)
) )
task.add_done_callback(_onTaskDone) task.add_done_callback(_onTaskDone)
_activeProcessTasks[sessionId] = task _activeProcessTasks[sessionId] = task
@ -574,8 +582,8 @@ async def sendAudioStream(
if not audioBody: if not audioBody:
raise HTTPException(status_code=400, detail="No audio data received") raise HTTPException(status_code=400, detail="No audio data received")
profile = interface.getProfile(str(context.user.id), instanceId) from .serviceCommcoach import _getUserVoicePrefs
language = profile.get("preferredLanguage", "de-DE") if profile else "de-DE" language, _ = _getUserVoicePrefs(str(context.user.id), mandateId)
contextId = session.get("contextId") contextId = session.get("contextId")
service = CommcoachService(context.user, mandateId, instanceId) service = CommcoachService(context.user, mandateId, instanceId)
@ -839,73 +847,6 @@ async def updateProfile(
return {"profile": updated} return {"profile": updated}
# =========================================================================
# Voice Endpoints
# =========================================================================
@router.get("/{instanceId}/voice/languages")
@limiter.limit("30/minute")
async def getVoiceLanguages(
request: Request,
instanceId: str,
context: RequestContext = Depends(getRequestContext),
):
mandateId = _validateInstanceAccess(instanceId, context)
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
voiceInterface = getVoiceInterface(context.user, mandateId)
languagesResult = await voiceInterface.getAvailableLanguages()
languageList = languagesResult.get("languages", []) if isinstance(languagesResult, dict) else languagesResult
return {"languages": languageList}
@router.get("/{instanceId}/voice/voices")
@limiter.limit("30/minute")
async def getVoiceVoices(
request: Request,
instanceId: str,
language: str = "de-DE",
context: RequestContext = Depends(getRequestContext),
):
mandateId = _validateInstanceAccess(instanceId, context)
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
voiceInterface = getVoiceInterface(context.user, mandateId)
voicesResult = await voiceInterface.getAvailableVoices(language)
voiceList = voicesResult.get("voices", []) if isinstance(voicesResult, dict) else voicesResult
return {"voices": voiceList}
@router.post("/{instanceId}/voice/tts")
@limiter.limit("10/minute")
async def testVoice(
request: Request,
instanceId: str,
context: RequestContext = Depends(getRequestContext),
):
"""TTS preview / voice test."""
mandateId = _validateInstanceAccess(instanceId, context)
body = await request.json()
text = body.get("text", "Hallo, ich bin dein Coaching-Assistent.")
language = body.get("language", "de-DE")
voiceId = body.get("voiceId")
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
voiceInterface = getVoiceInterface(context.user, mandateId)
try:
result = await voiceInterface.textToSpeech(text=text, languageCode=language, voiceName=voiceId)
if result and isinstance(result, dict):
audioContent = result.get("audioContent")
if audioContent:
audioB64 = base64.b64encode(
audioContent if isinstance(audioContent, bytes) else audioContent.encode()
).decode()
return {"success": True, "audio": audioB64, "format": "mp3", "text": text}
return {"success": False, "error": "TTS returned no audio"}
except Exception as e:
logger.error(f"Voice test failed: {e}")
raise HTTPException(status_code=500, detail=f"TTS test failed: {str(e)}")
# ========================================================================= # =========================================================================
# Export Endpoints (Iteration 2) # Export Endpoints (Iteration 2)
# ========================================================================= # =========================================================================
@ -1074,202 +1015,6 @@ async def deletePersonaRoute(
return {"deleted": True} return {"deleted": True}
# =========================================================================
# Document Endpoints (Iteration 2)
# =========================================================================
@router.get("/{instanceId}/contexts/{contextId}/documents")
@limiter.limit("60/minute")
async def listDocuments(
request: Request,
instanceId: str,
contextId: str,
context: RequestContext = Depends(getRequestContext),
):
_validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
userId = str(context.user.id)
docs = interface.getDocuments(contextId, userId)
return {"documents": docs}
@router.post("/{instanceId}/contexts/{contextId}/documents")
@limiter.limit("10/minute")
async def uploadDocument(
request: Request,
instanceId: str,
contextId: str,
context: RequestContext = Depends(getRequestContext),
):
"""Upload a document and bind it to a context. Stores file in Management DB."""
mandateId = _validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
userId = str(context.user.id)
ctx = interface.getContext(contextId)
if not ctx:
raise HTTPException(status_code=404, detail="Context not found")
_validateOwnership(ctx, context)
form = await request.form()
file = form.get("file")
if not file or not hasattr(file, "read"):
raise HTTPException(status_code=400, detail="No file uploaded")
content = await file.read()
fileName = getattr(file, "filename", "document")
mimeType = getattr(file, "content_type", "application/octet-stream")
fileSize = len(content)
if not content:
raise HTTPException(status_code=400, detail="Leere Datei hochgeladen")
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
mgmtInterface = interfaceDbManagement.getInterface(currentUser=context.user)
fileItem, _dupType = mgmtInterface.saveUploadedFile(content, fileName)
fileRef = fileItem.id
extractedText = _extractText(content, mimeType, fileName)
summary = None
if extractedText and len(extractedText.strip()) > 50:
try:
from .serviceCommcoach import CommcoachService
service = CommcoachService(context.user, mandateId, instanceId)
aiResp = await service._callAi(
"Du fasst Dokumente in 2-3 Saetzen zusammen.",
f"Fasse folgendes Dokument zusammen:\n\n{extractedText[:3000]}"
)
if aiResp and aiResp.errorCount == 0 and aiResp.content:
summary = aiResp.content.strip()
except Exception as e:
logger.warning(f"Document summary failed: {e}")
docData = CoachingDocument(
contextId=contextId,
userId=userId,
mandateId=mandateId,
instanceId=instanceId,
fileName=fileName,
mimeType=mimeType,
fileSize=fileSize,
extractedText=extractedText[:10000] if extractedText else None,
summary=summary,
fileRef=fileRef,
).model_dump()
created = interface.createDocument(docData)
return {"document": created}
@router.delete("/{instanceId}/documents/{documentId}")
@limiter.limit("10/minute")
async def deleteDocumentRoute(
request: Request,
instanceId: str,
documentId: str,
context: RequestContext = Depends(getRequestContext),
):
mandateId = _validateInstanceAccess(instanceId, context)
interface = _getInterface(context, instanceId)
doc = interface.getDocument(documentId)
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
_validateOwnership(doc, context)
fileRef = doc.get("fileRef")
if fileRef:
try:
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
mgmtInterface = interfaceDbManagement.getInterface(
currentUser=context.user, mandateId=mandateId, featureInstanceId=instanceId
)
mgmtInterface.deleteFile(fileRef)
except Exception as e:
logger.warning(f"Failed to delete file {fileRef}: {e}")
interface.deleteDocument(documentId)
return {"deleted": True}
def _extractText(content: bytes, mimeType: str, fileName: str) -> Optional[str]:
"""Extract text from uploaded file content (TXT, MD, HTML, PDF, DOCX, XLSX, PPTX)."""
import io
lowerName = fileName.lower()
try:
if mimeType in ("text/plain",) or lowerName.endswith(".txt"):
return content.decode("utf-8", errors="replace")
if mimeType in ("text/markdown",) or lowerName.endswith(".md"):
return content.decode("utf-8", errors="replace")
if mimeType in ("text/html",) or lowerName.endswith((".html", ".htm")):
from html.parser import HTMLParser
class _Strip(HTMLParser):
def __init__(self):
super().__init__()
self._parts: list[str] = []
def handle_data(self, d):
self._parts.append(d)
def result(self):
return " ".join(self._parts)
parser = _Strip()
parser.feed(content.decode("utf-8", errors="replace"))
return parser.result()
if "pdf" in mimeType or lowerName.endswith(".pdf"):
try:
from PyPDF2 import PdfReader
reader = PdfReader(io.BytesIO(content))
return "".join(page.extract_text() or "" for page in reader.pages)
except ImportError:
logger.warning("PyPDF2 not installed, cannot extract PDF text")
return None
if "wordprocessingml" in mimeType or lowerName.endswith(".docx"):
try:
from docx import Document
doc = Document(io.BytesIO(content))
return "\n".join(p.text for p in doc.paragraphs if p.text)
except ImportError:
logger.warning("python-docx not installed, cannot extract DOCX text")
return None
if "spreadsheetml" in mimeType or lowerName.endswith(".xlsx"):
try:
from openpyxl import load_workbook
wb = load_workbook(io.BytesIO(content), read_only=True, data_only=True)
parts: list[str] = []
for ws in wb.worksheets:
for row in ws.iter_rows(values_only=True):
cells = [str(c) for c in row if c is not None]
if cells:
parts.append("\t".join(cells))
return "\n".join(parts)
except ImportError:
logger.warning("openpyxl not installed, cannot extract XLSX text")
return None
if "presentationml" in mimeType or lowerName.endswith(".pptx"):
try:
from pptx import Presentation
prs = Presentation(io.BytesIO(content))
parts = []
for slide in prs.slides:
for shape in slide.shapes:
if shape.has_text_frame:
parts.append(shape.text_frame.text)
return "\n".join(parts)
except ImportError:
logger.warning("python-pptx not installed, cannot extract PPTX text")
return None
logger.info(f"No text extractor for {fileName} (mime={mimeType})")
except Exception as e:
logger.warning(f"Text extraction failed for {fileName}: {e}")
return None
# ========================================================================= # =========================================================================
# Badge + Score History Endpoints (Iteration 2) # Badge + Score History Endpoints (Iteration 2)
# ========================================================================= # =========================================================================

File diff suppressed because it is too large Load diff

View file

@ -168,29 +168,18 @@ Handlungsprinzip:
- Wenn der Benutzer dich bittet, etwas zu erstellen (Dokument, Präsentation, Checkliste, Plan), dann TU ES SOFORT. Frage NICHT nochmals nach Bestätigung. - Wenn der Benutzer dich bittet, etwas zu erstellen (Dokument, Präsentation, Checkliste, Plan), dann TU ES SOFORT. Frage NICHT nochmals nach Bestätigung.
- Verwende alle verfügbaren Informationen aus dem Chat-Verlauf, den Dokumenten und dem Kontext. - Verwende alle verfügbaren Informationen aus dem Chat-Verlauf, den Dokumenten und dem Kontext.
- Wenn der Benutzer sagt "erstelle", "mach", "schreib", dann liefere das fertige Ergebnis keine Aufzählung von Punkten, die du "gleich umsetzen wirst". - Wenn der Benutzer sagt "erstelle", "mach", "schreib", dann liefere das fertige Ergebnis keine Aufzählung von Punkten, die du "gleich umsetzen wirst".
- Dir wird automatisch relevanter Kontext aus früheren Sessions bereitgestellt (Relevant Knowledge). Nutze diesen für Kontinuität und Bezugnahme auf frühere Gespräche.
Antwortformat: Antwortformat:
Du antwortest IMMER als reines JSON-Objekt mit exakt diesen Feldern: - Antworte direkt als Freitext (KEIN JSON). Markdown-Formatierung ist erlaubt.
{"text": "...", "speech": "...", "documents": []} - Halte Antworten gesprächig und kurz (2-6 Sätze im Normalfall), wie in einem echten Coaching-Gespräch.
- Bei komplexen Themen oder wenn der Benutzer Details anfragt, darf die Antwort ausführlicher sein.
- Dein Text wird sowohl angezeigt als auch vorgelesen schreibe daher natürlich und gut sprechbar.
"text": Dein schriftlicher Chat-Text. Details, Struktur, Übungen, Beispiele. Markdown-Formatierung erlaubt. Tool-Nutzung:
"speech": Dein gesprochener Kommentar. Natürlich, wie ein Gespräch. Fasse zusammen, kommentiere, motiviere, stelle Fragen. Lies NICHT den Text vor, ergänze ihn mündlich. 2-4 Sätze, reiner Redetext ohne Formatierung. - Du hast Zugriff auf Tools (Dateien lesen, Web-Suche, Datenquellen abfragen) wenn der Benutzer Dateien/Quellen angehängt hat oder Recherche benötigt.
"documents": Dokumente die der Benutzer aufbewahren kann. Erstelle ein Dokument wenn: der Benutzer explizit darum bittet, du strukturierte Inhalte lieferst, oder Material zum Aufbewahren sinnvoll ist. Wenn keine: leeres Array []. - Nutze Tools NUR wenn nötig. Für normales Coaching-Gespräch: antworte direkt ohne Tools.
- Wenn du ein Tool nutzt, erkläre kurz was du tust."""
Dokument-Format:
{"title": "Dateiname_mit_Extension.html", "content": "...vollstaendiger Inhalt..."}
- Der Title IST der Dateiname inkl. Extension (.html, .md, .txt etc.)
- Fuer HTML-Dokumente: Erstelle VOLLSTAENDIGES, professionell gestyltes HTML mit inline CSS. Kein Markdown, sondern fertiges HTML mit Farben, Layout, Typografie.
- Fuer andere Dokumente: Verwende Markdown.
- WICHTIG: Der Content muss VOLLSTAENDIG und AUSFUEHRLICH sein. Keine Platzhalter, keine "hier kommt..."-Abschnitte. Schreibe echte, detaillierte Inhalte basierend auf allen verfuegbaren Informationen aus dem Chat und den Dokumenten.
- Laengenbeschraenkung fuer Dokumente: KEINE. Schreibe so viel wie noetig fuer ein vollstaendiges Ergebnis.
Kanalverteilung:
- Fakten, Listen, Übungen -> text
- Empathie, Einordnung, Nachfragen -> speech
- Erstellte Dateien, Materialien zum Aufbewahren -> documents
WICHTIG: Antworte NUR mit dem JSON-Objekt. Kein Text vor oder nach dem JSON."""
if contextDescription: if contextDescription:
prompt += f"\n\nKontext-Beschreibung: {contextDescription}" prompt += f"\n\nKontext-Beschreibung: {contextDescription}"
@ -229,12 +218,18 @@ WICHTIG: Antworte NUR mit dem JSON-Objekt. Kein Text vor oder nach dem JSON."""
prompt += f"\n{retrievedSession.get('summary', '')[:500]}" prompt += f"\n{retrievedSession.get('summary', '')[:500]}"
if retrievedByTopic: if retrievedByTopic:
prompt += "\n\nRelevante Sessions zum angefragten Thema:" prompt += "\n\nRelevante Sessions und Mandantenwissen zum angefragten Thema:"
for s in retrievedByTopic[:3]: for s in retrievedByTopic[:5]:
summary = s.get("summary", "") summary = s.get("summary", s.get("content", ""))
if not summary:
continue
dateStr = s.get("date", "") dateStr = s.get("date", "")
if summary: if s.get("source") == "rag":
prompt += f"\n- [{dateStr}] {summary[:300]}" label = s.get("ragSourceLabel") or "Mandantenwissen"
prompt += f"\n- [Wissen: {label}] {summary[:320]}"
else:
prefix = f"[{dateStr}] " if dateStr else ""
prompt += f"\n- {prefix}{summary[:300]}"
if openTasks: if openTasks:
prompt += "\n\nOffene Aufgaben:" prompt += "\n\nOffene Aufgaben:"
@ -273,7 +268,7 @@ Fuer ein NEUES Dokument: {"title": "...", "content": "...Inhalt..."}"""
def buildSummaryPrompt(messages: List[Dict[str, Any]], contextTitle: str) -> str: def buildSummaryPrompt(messages: List[Dict[str, Any]], contextTitle: str) -> str:
"""Build a prompt to generate a session summary as JSON with plain text and styled HTML email.""" """Build a prompt to generate a session summary plus structured email content."""
conversation = "" conversation = ""
for msg in messages: for msg in messages:
role = "Benutzer" if msg.get("role") == "user" else "Coach" role = "Benutzer" if msg.get("role") == "user" else "Coach"
@ -281,27 +276,33 @@ def buildSummaryPrompt(messages: List[Dict[str, Any]], contextTitle: str) -> str
return f"""Erstelle eine Zusammenfassung dieser Coaching-Session zum Thema "{contextTitle}". return f"""Erstelle eine Zusammenfassung dieser Coaching-Session zum Thema "{contextTitle}".
Antworte AUSSCHLIESSLICH als JSON mit zwei Feldern: Antworte AUSSCHLIESSLICH als JSON im folgenden Format:
{{ {{
"summary": "Kompakte Zusammenfassung als Plaintext (fuer Anzeige in der App). Struktur: 1. Kernthema, 2. Erkenntnisse, 3. Naechste Schritte, 4. Fortschritt.", "summary": "Kompakte Plaintext-Zusammenfassung fuer die App. Struktur: Kernthema, Erkenntnisse, Naechste Schritte, Fortschritt.",
"emailHtml": "<div>...</div>" "email": {{
"headline": "Kurze, professionelle Titelzeile fuer die E-Mail",
"intro": "1-2 Saetze, die den Kern der Session auf den Punkt bringen",
"coreTopic": "Das zentrale Thema in einem praezisen Satz",
"insights": ["Erkenntnis 1", "Erkenntnis 2"],
"nextSteps": ["Naechster Schritt 1", "Naechster Schritt 2"],
"progress": ["Fortschritt 1", "Fortschritt 2"]
}}
}} }}
Fuer "emailHtml": Erstelle ein professionell formatiertes HTML-Fragment (KEIN vollstaendiges HTML-Dokument, nur der Inhalt-Block). Regeln:
Verwende inline CSS fuer schoene Darstellung in E-Mail-Clients: - KEIN HTML erzeugen.
- Verwende <h3> fuer Abschnitte (color: #1e40af; margin: 20px 0 8px; font-size: 16px) - "summary" ist reiner Plaintext ohne Markdown.
- Verwende <ul>/<li> fuer Stichpunkte (margin: 4px 0; line-height: 1.6) - "headline" kurz und professionell.
- Verwende <strong> fuer Hervorhebungen - "intro" in natuerlichem Business-Deutsch.
- Verwende <p> fuer Fliesstext (color: #374151; line-height: 1.65; font-size: 15px) - "insights", "nextSteps" und "progress" jeweils als kurze Stichpunkte.
- Verwende <hr style="border:none;border-top:1px solid #e5e7eb;margin:20px 0"> als Trenner - Maximal 4 Eintraege pro Liste.
- Wenn eine Liste leer ist, gib [] zurueck.
Fuer "summary": Kompakter Plaintext ohne HTML/Markdown. Abschnitte mit Zeilenumbruechen trennen.
Gespräch: Gespräch:
{conversation} {conversation}
Antworte auf Deutsch, sachlich und kompakt. NUR JSON, keine Erklaerungen.""" Antworte auf Deutsch, sachlich, klar und kompakt. NUR JSON, keine Erklaerungen."""
def buildScoringPrompt(messages: List[Dict[str, Any]], contextCategory: str) -> str: def buildScoringPrompt(messages: List[Dict[str, Any]], contextCategory: str) -> str:

View file

@ -172,20 +172,48 @@ def searchSessionsByTopic(
def searchSessionsByTopicRag( def searchSessionsByTopicRag(
sessions: List[Dict[str, Any]],
query: str, query: str,
maxResults: int = TOPIC_SEARCH_MAX_RESULTS, userId: str,
embeddingProvider: Optional[Any] = None, instanceId: str,
mandateId: str = None,
queryVector: List[float] = None,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""Search using platform RAG (semantic search across mandate-wide knowledge data).
Requires a pre-computed queryVector (embedding). The caller is responsible
for generating the embedding via AiService.callEmbedding before invoking this.
""" """
Phase 7 RAG: Semantic search via embeddings. if not queryVector:
When embeddingProvider is None, falls back to keyword search. logger.warning("searchSessionsByTopicRag called without queryVector, skipping RAG search")
Future: Pass embeddingProvider that has embed(text) -> vector and similarity search. return []
""" try:
if embeddingProvider is None: from modules.interfaces.interfaceDbKnowledge import getInterface as _getKnowledgeInterface
return searchSessionsByTopic(sessions, query, maxResults)
# TODO: When embedding API exists: embed query, embed session summaries, cosine similarity knowledgeDb = _getKnowledgeInterface()
return searchSessionsByTopic(sessions, query, maxResults)
results = knowledgeDb.semanticSearch(
queryVector=queryVector,
userId=userId,
featureInstanceId=instanceId,
mandateId=mandateId,
isSysAdmin=False,
limit=TOPIC_SEARCH_MAX_RESULTS,
)
formatted = []
for r in (results or []):
rData = r if isinstance(r, dict) else r.model_dump() if hasattr(r, "model_dump") else {}
contextRef = rData.get("contextRef") or {}
formatted.append({
"source": "rag",
"content": rData.get("data") or rData.get("summary") or "",
"fileName": contextRef.get("containerPath") or "RAG-Ergebnis",
"score": rData.get("_score") or 0,
})
return formatted
except Exception as e:
logger.warning(f"RAG search failed for query '{query[:50]}': {e}")
return []
def buildSessionSummariesForPrompt( def buildSessionSummariesForPrompt(

View file

@ -0,0 +1,223 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
CommCoach Session Indexer.
Indexes coaching session data into the knowledge store (pgvector) for RAG-based long-term memory.
Called after session completion to ensure semantic searchability across 20+ sessions.
"""
import logging
import uuid
import json
from typing import List, Dict, Any, Optional
logger = logging.getLogger(__name__)
_COACHING_FILE_PREFIX = "coaching-session:"
async def indexSessionData(
sessionId: str,
contextId: str,
userId: str,
featureInstanceId: str,
mandateId: str,
messages: List[Dict[str, Any]],
summary: Optional[str],
keyTopics: Optional[str],
goals: Optional[List[Any]],
insights: Optional[List[Any]],
tasks: Optional[List[Dict[str, Any]]],
contextTitle: str = "",
knowledgeService=None,
):
"""Index a completed coaching session into the knowledge store.
Creates ContentChunks with embeddings for:
- Each User+Assistant message pair (maximum detail depth)
- Session summary
- Key topics (individually, for precise retrieval)
- Current goals
- New insights
- Tasks (open + done)
"""
if not knowledgeService:
logger.warning("No knowledge service available for coaching indexer")
return
syntheticFileId = f"{_COACHING_FILE_PREFIX}{sessionId}"
chunks = []
# 1. Message pairs (User + Assistant) as individual chunks
messagePairs = _extractMessagePairs(messages)
for idx, pair in enumerate(messagePairs):
chunks.append({
"contentObjectId": f"{sessionId}:msg-pair:{idx}",
"data": pair["text"],
"contextRef": {
"containerPath": f"session:{sessionId}",
"location": f"message-pair-{idx}",
"type": "coaching-message-pair",
"contextId": contextId,
"sessionId": sessionId,
"contextTitle": contextTitle,
},
})
# 2. Session summary
if summary:
chunks.append({
"contentObjectId": f"{sessionId}:summary",
"data": f"Session-Zusammenfassung ({contextTitle}): {summary}",
"contextRef": {
"containerPath": f"session:{sessionId}",
"location": "summary",
"type": "coaching-session-summary",
"contextId": contextId,
"sessionId": sessionId,
"contextTitle": contextTitle,
},
})
# 3. Key topics (each as separate chunk for precise retrieval)
parsedTopics = _parseJsonSafe(keyTopics, [])
for tidx, topic in enumerate(parsedTopics):
topicStr = str(topic).strip()
if topicStr:
chunks.append({
"contentObjectId": f"{sessionId}:topic:{tidx}",
"data": f"Coaching-Thema ({contextTitle}): {topicStr}",
"contextRef": {
"containerPath": f"session:{sessionId}",
"location": f"topic-{tidx}",
"type": "coaching-key-topic",
"contextId": contextId,
"sessionId": sessionId,
"contextTitle": contextTitle,
},
})
# 4. Goals
if goals:
goalTexts = [g.get("text", g) if isinstance(g, dict) else str(g) for g in goals if g]
if goalTexts:
goalsStr = "\n".join(f"- {g}" for g in goalTexts)
chunks.append({
"contentObjectId": f"{sessionId}:goals",
"data": f"Coaching-Ziele ({contextTitle}):\n{goalsStr}",
"contextRef": {
"containerPath": f"session:{sessionId}",
"location": "goals",
"type": "coaching-goals",
"contextId": contextId,
"sessionId": sessionId,
"contextTitle": contextTitle,
},
})
# 5. Insights
if insights:
insightTexts = [i.get("text", i) if isinstance(i, dict) else str(i) for i in insights if i]
if insightTexts:
insightsStr = "\n".join(f"- {t}" for t in insightTexts)
chunks.append({
"contentObjectId": f"{sessionId}:insights",
"data": f"Coaching-Erkenntnisse ({contextTitle}):\n{insightsStr}",
"contextRef": {
"containerPath": f"session:{sessionId}",
"location": "insights",
"type": "coaching-insights",
"contextId": contextId,
"sessionId": sessionId,
"contextTitle": contextTitle,
},
})
# 6. Tasks
if tasks:
taskLines = []
for t in tasks:
status = t.get("status", "open")
title = t.get("title", "")
if title:
taskLines.append(f"- [{status}] {title}")
if taskLines:
tasksStr = "\n".join(taskLines)
chunks.append({
"contentObjectId": f"{sessionId}:tasks",
"data": f"Coaching-Aufgaben ({contextTitle}):\n{tasksStr}",
"contextRef": {
"containerPath": f"session:{sessionId}",
"location": "tasks",
"type": "coaching-tasks",
"contextId": contextId,
"sessionId": sessionId,
"contextTitle": contextTitle,
},
})
if not chunks:
logger.info(f"No chunks to index for session {sessionId}")
return
logger.info(f"Indexing {len(chunks)} chunks for coaching session {sessionId}")
try:
contentObjects = [
{
"contentObjectId": c["contentObjectId"],
"contentType": "text",
"data": c["data"],
"contextRef": c["contextRef"],
}
for c in chunks
]
await knowledgeService.indexFile(
fileId=syntheticFileId,
fileName=f"coaching-session-{sessionId[:8]}",
mimeType="application/x-coaching-session",
userId=userId,
featureInstanceId=featureInstanceId,
mandateId=mandateId,
contentObjects=contentObjects,
)
logger.info(f"Successfully indexed coaching session {sessionId} ({len(chunks)} chunks)")
except Exception as e:
logger.error(f"Failed to index coaching session {sessionId}: {e}", exc_info=True)
def _extractMessagePairs(messages: List[Dict[str, Any]]) -> List[Dict[str, str]]:
"""Extract User+Assistant pairs from message list."""
pairs = []
i = 0
while i < len(messages):
msg = messages[i]
if msg.get("role") == "user":
userText = (msg.get("content") or "").strip()
assistantText = ""
if i + 1 < len(messages) and messages[i + 1].get("role") == "assistant":
assistantText = (messages[i + 1].get("content") or "").strip()
i += 2
else:
i += 1
if userText:
text = f"Benutzer: {userText}"
if assistantText:
text += f"\nCoach: {assistantText}"
pairs.append({"text": text})
else:
i += 1
return pairs
def _parseJsonSafe(value, fallback):
if not value:
return fallback
if isinstance(value, (list, dict)):
return value
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError):
return fallback

View file

@ -6,11 +6,44 @@ Handles daily reminders and scheduled email summaries.
""" """
import logging import logging
import html
from typing import Dict, Any, List from typing import Dict, Any, List
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _buildReminderHtmlBlock(contextTitles: List[str], streakDays: int) -> str:
rows = "".join(
'<tr>'
'<td valign="top" style="padding:0 10px 8px 0;font-size:15px;line-height:1.6;color:#2563eb;">•</td>'
f'<td style="padding:0 0 8px 0;font-size:15px;line-height:1.6;color:#374151;">{html.escape(title)}</td>'
'</tr>'
for title in contextTitles[:3]
)
topicsBlock = (
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
'style="border-collapse:separate;border-spacing:0;border:1px solid #e5e7eb;border-radius:12px;'
'background-color:#ffffff;margin:0 0 16px 0;">'
'<tr><td style="padding:18px 20px;">'
'<div style="font-size:12px;font-weight:700;letter-spacing:0.06em;text-transform:uppercase;'
'color:#1d4ed8;margin:0 0 8px 0;">Aktive Coaching-Themen</div>'
f'<table role="presentation" cellpadding="0" cellspacing="0" style="border-collapse:collapse;">{rows}</table>'
'</td></tr></table>'
)
streakBlock = (
'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" '
'style="border-collapse:separate;border-spacing:0;border:1px solid #dbeafe;border-radius:12px;'
'background:linear-gradient(135deg,#eff6ff,#f8fbff);">'
'<tr><td style="padding:18px 20px;">'
'<div style="font-size:12px;font-weight:700;letter-spacing:0.06em;text-transform:uppercase;'
'color:#1d4ed8;margin:0 0 8px 0;">Dein Rhythmus</div>'
f'<div style="font-size:15px;line-height:1.7;color:#374151;">Aktueller Streak: '
f'<strong>{int(streakDays or 0)} Tage</strong></div>'
'</td></tr></table>'
)
return topicsBlock + streakBlock
def registerScheduledJobs(eventManagement): def registerScheduledJobs(eventManagement):
"""Register CommCoach scheduled jobs with the event management system.""" """Register CommCoach scheduled jobs with the event management system."""
try: try:
@ -31,6 +64,7 @@ async def _runDailyReminders():
from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.connectors.connectorDbPostgre import DatabaseConnector
from .datamodelCommcoach import CoachingUserProfile, CoachingContextStatus from .datamodelCommcoach import CoachingUserProfile, CoachingContextStatus
from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface
from modules.shared.notifyMandateAdmins import _renderHtmlEmail, _resolveMandateName
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data") dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
db = DatabaseConnector( db = DatabaseConnector(
@ -71,15 +105,21 @@ async def _runDailyReminders():
contextTitles = [c.get("title", "Unbenannt") for c in contexts[:3]] contextTitles = [c.get("title", "Unbenannt") for c in contexts[:3]]
contextList = ", ".join(contextTitles) contextList = ", ".join(contextTitles)
subject = "Dein taegliches Coaching wartet" subject = "Dein tägliches Coaching wartet"
message = f""" mandateName = _resolveMandateName(profile.get("mandateId"))
<h2>Zeit fuer dein Coaching</h2> htmlMessage = _renderHtmlEmail(
<p>Du hast aktive Coaching-Themen: <strong>{contextList}</strong></p> "Zeit für dein tägliches Coaching",
<p>Nimm dir 10 Minuten fuer eine kurze Session. Konsistenz ist der Schluessel zu Fortschritt.</p> [
<p>Dein aktueller Streak: <strong>{profile.get('streakDays', 0)} Tage</strong></p> f"Du hast aktuell {len(contexts)} aktive Coaching-Themen.",
""" "Schon 10 Minuten reichen oft, um einen Gedanken zu klären, eine nächste Aktion festzulegen oder ein Gespräch vorzubereiten.",
f"Im Fokus: {contextList}",
],
mandateName,
footerNote="Diese Erinnerung wurde automatisch auf Basis deiner CommCoach-Einstellungen versendet.",
rawHtmlBlock=_buildReminderHtmlBlock(contextTitles, int(profile.get("streakDays", 0) or 0)),
)
messaging.send("email", user.email, subject, message) messaging.send("email", user.email, subject, htmlMessage)
sentCount += 1 sentCount += 1
except Exception as e: except Exception as e:
logger.warning(f"Failed to send reminder to user {profile.get('userId')}: {e}") logger.warning(f"Failed to send reminder to user {profile.get('userId')}: {e}")

View file

@ -136,7 +136,6 @@ class TestCoachingUserProfile:
profile = CoachingUserProfile( profile = CoachingUserProfile(
userId="u1", mandateId="m1", instanceId="i1", userId="u1", mandateId="m1", instanceId="i1",
) )
assert profile.preferredLanguage == "de-DE"
assert profile.dailyReminderEnabled is False assert profile.dailyReminderEnabled is False
assert profile.emailSummaryEnabled is True assert profile.emailSummaryEnabled is True
assert profile.streakDays == 0 assert profile.streakDays == 0

View file

@ -31,7 +31,7 @@ class TestFeatureDefinition:
assert defn["code"] == "commcoach" assert defn["code"] == "commcoach"
assert "label" in defn assert "label" in defn
assert "icon" in defn assert "icon" in defn
assert defn["autoCreateInstance"] is True assert defn["autoCreateInstance"] is False
class TestRbacObjects: class TestRbacObjects:

View file

@ -3,17 +3,33 @@
"""Neutralizer models: DataNeutraliserConfig and DataNeutralizerAttributes.""" """Neutralizer models: DataNeutraliserConfig and DataNeutralizerAttributes."""
import uuid import uuid
from enum import Enum
from typing import Optional from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.attributeUtils import registerModelLabels
class DataNeutraliserConfig(BaseModel): class DataScope(str, Enum):
PERSONAL = "personal"
FEATURE_INSTANCE = "featureInstance"
MANDATE = "mandate"
GLOBAL = "global"
class DataNeutraliserConfig(PowerOnModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the configuration", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the configuration", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
mandateId: str = Field(description="ID of the mandate this configuration belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) mandateId: str = Field(description="ID of the mandate this configuration belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
featureInstanceId: str = Field(description="ID of the feature instance this configuration belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) featureInstanceId: str = Field(description="ID of the feature instance this configuration belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
userId: str = Field(description="ID of the user who created this configuration", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) userId: str = Field(description="ID of the user who created this configuration", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
enabled: bool = Field(default=True, description="Whether data neutralization is enabled", json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}) enabled: bool = Field(default=True, description="Whether data neutralization is enabled", json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False})
scope: str = Field(default="personal", description="Data visibility scope: personal, featureInstance, mandate, global", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
{"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}},
{"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}},
{"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}},
{"value": "global", "label": {"en": "Global", "de": "Global"}},
]})
neutralizationStatus: str = Field(default="not_required", description="Status of neutralization: pending, completed, failed, not_required", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
namesToParse: str = Field(default="", description="Multiline list of names to parse for neutralization", json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False}) namesToParse: str = Field(default="", description="Multiline list of names to parse for neutralization", json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False})
sharepointSourcePath: str = Field(default="", description="SharePoint path to read files for neutralization", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}) sharepointSourcePath: str = Field(default="", description="SharePoint path to read files for neutralization", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
sharepointTargetPath: str = Field(default="", description="SharePoint path to store neutralized files", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}) sharepointTargetPath: str = Field(default="", description="SharePoint path to store neutralized files", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False})
@ -26,6 +42,8 @@ registerModelLabels(
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"}, "featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"userId": {"en": "User ID", "fr": "ID utilisateur"}, "userId": {"en": "User ID", "fr": "ID utilisateur"},
"enabled": {"en": "Enabled", "fr": "Activé"}, "enabled": {"en": "Enabled", "fr": "Activé"},
"scope": {"en": "Scope", "fr": "Portée"},
"neutralizationStatus": {"en": "Neutralization Status", "fr": "Statut de neutralisation"},
"namesToParse": {"en": "Names to Parse", "fr": "Noms à analyser"}, "namesToParse": {"en": "Names to Parse", "fr": "Noms à analyser"},
"sharepointSourcePath": {"en": "Source Path", "fr": "Chemin source"}, "sharepointSourcePath": {"en": "Source Path", "fr": "Chemin source"},
"sharepointTargetPath": {"en": "Target Path", "fr": "Chemin cible"}, "sharepointTargetPath": {"en": "Target Path", "fr": "Chemin cible"},
@ -40,6 +58,17 @@ class DataNeutralizerAttributes(BaseModel):
originalText: str = Field(description="Original text that was neutralized", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) originalText: str = Field(description="Original text that was neutralized", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
fileId: Optional[str] = Field(default=None, description="ID of the file this attribute belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) fileId: Optional[str] = Field(default=None, description="ID of the file this attribute belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
patternType: str = Field(description="Type of pattern that matched (email, phone, name, etc.)", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) patternType: str = Field(description="Type of pattern that matched (email, phone, name, etc.)", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
class DataNeutralizationSnapshot(BaseModel):
"""Stores the full neutralized text (with embedded placeholders) per source."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
mandateId: str = Field(description="Mandate scope")
featureInstanceId: str = Field(default="", description="Feature instance scope")
userId: str = Field(description="User who triggered neutralization")
sourceLabel: str = Field(description="Human label, e.g. 'Prompt', 'Kontext', 'Nachricht 3'")
neutralizedText: str = Field(description="Full text with [type.uuid] placeholders embedded")
placeholderCount: int = Field(default=0, description="Number of placeholders in the text")
registerModelLabels( registerModelLabels(
"DataNeutralizerAttributes", "DataNeutralizerAttributes",
{"en": "Neutralized Data Attribute", "fr": "Attribut de données neutralisées"}, {"en": "Neutralized Data Attribute", "fr": "Attribut de données neutralisées"},
@ -53,5 +82,18 @@ registerModelLabels(
"patternType": {"en": "Pattern Type", "fr": "Type de modèle"}, "patternType": {"en": "Pattern Type", "fr": "Type de modèle"},
}, },
) )
registerModelLabels(
"DataNeutralizationSnapshot",
{"en": "Neutralization Snapshot", "de": "Neutralisierungs-Snapshot"},
{
"id": {"en": "ID"},
"mandateId": {"en": "Mandate ID"},
"featureInstanceId": {"en": "Feature Instance ID"},
"userId": {"en": "User ID"},
"sourceLabel": {"en": "Source", "de": "Quelle"},
"neutralizedText": {"en": "Neutralized Text", "de": "Neutralisierter Text"},
"placeholderCount": {"en": "Placeholders", "de": "Platzhalter"},
},
)

View file

@ -11,6 +11,7 @@ from typing import Dict, List, Any, Optional
from modules.features.neutralization.datamodelFeatureNeutralizer import ( from modules.features.neutralization.datamodelFeatureNeutralizer import (
DataNeutraliserConfig, DataNeutraliserConfig,
DataNeutralizerAttributes, DataNeutralizerAttributes,
DataNeutralizationSnapshot,
) )
from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
@ -212,6 +213,89 @@ class InterfaceFeatureNeutralizer:
logger.error(f"Error getting attribute by ID: {str(e)}") logger.error(f"Error getting attribute by ID: {str(e)}")
return None return None
def deleteAttributeById(self, attributeId: str) -> bool:
"""Delete a single neutralization attribute by its ID"""
try:
attribute = self.getAttributeById(attributeId)
if not attribute:
logger.warning(f"Attribute {attributeId} not found for deletion")
return False
self.db.recordDelete(DataNeutralizerAttributes, attributeId)
logger.info(f"Deleted neutralization attribute {attributeId}")
return True
except Exception as e:
logger.error(f"Error deleting attribute by ID: {str(e)}")
return False
# ------------------------------------------------------------------
# Snapshot CRUD
# ------------------------------------------------------------------
def getSnapshots(self) -> List[DataNeutralizationSnapshot]:
"""Return all neutralization snapshots for the current mandate + feature instance."""
try:
_filter: Dict[str, Any] = {"mandateId": self.mandateId}
if self.featureInstanceId:
_filter["featureInstanceId"] = self.featureInstanceId
rows = getRecordsetWithRBAC(
self.db,
DataNeutralizationSnapshot,
self.currentUser,
recordFilter=_filter,
mandateId=self.mandateId,
)
return [
DataNeutralizationSnapshot(**{k: v for k, v in r.items() if not k.startswith("_")})
for r in rows
]
except Exception as e:
logger.error(f"Error getting snapshots: {e}")
return []
def clearSnapshots(self) -> int:
"""Delete all snapshots for the current feature-instance scope. Returns count deleted."""
try:
_filter: Dict[str, Any] = {"mandateId": self.mandateId}
if self.featureInstanceId:
_filter["featureInstanceId"] = self.featureInstanceId
existing = self.db.getRecordset(DataNeutralizationSnapshot, recordFilter=_filter)
for row in existing:
self.db.recordDelete(DataNeutralizationSnapshot, row["id"])
return len(existing)
except Exception as e:
logger.error(f"Error clearing snapshots: {e}")
return 0
def createSnapshot(
self,
sourceLabel: str,
neutralizedText: str,
placeholderCount: int = 0,
) -> Optional[DataNeutralizationSnapshot]:
"""Persist one neutralization snapshot."""
try:
if not self.userId:
logger.warning("Cannot create snapshot: missing userId")
return None
snap = DataNeutralizationSnapshot(
mandateId=self.mandateId or "",
featureInstanceId=self.featureInstanceId or "",
userId=self.userId,
sourceLabel=sourceLabel,
neutralizedText=neutralizedText,
placeholderCount=placeholderCount,
)
created = self.db.recordCreate(DataNeutralizationSnapshot, snap.model_dump())
return DataNeutralizationSnapshot(**{k: v for k, v in created.items() if not k.startswith("_")})
except Exception as e:
logger.error(f"Error creating snapshot: {e}")
return None
# ------------------------------------------------------------------
# Attribute CRUD
# ------------------------------------------------------------------
def createAttribute( def createAttribute(
self, self,
attributeId: str, attributeId: str,

View file

@ -45,34 +45,55 @@ RESOURCE_OBJECTS = [
# Template roles for this feature # Template roles for this feature
TEMPLATE_ROLES = [ TEMPLATE_ROLES = [
{
"roleLabel": "neutralization-viewer",
"description": {
"en": "Neutralization Viewer - View neutralization data (read-only)",
"de": "Neutralisierungs-Betrachter - Neutralisierungsdaten einsehen (nur lesen)",
"fr": "Visualiseur neutralisation - Consulter les données de neutralisation (lecture seule)",
},
"accessRules": [
{"context": "UI", "item": "ui.feature.neutralization.playground", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
],
},
{
"roleLabel": "neutralization-user",
"description": {
"en": "Neutralization User - Use neutralization tools and manage own data",
"de": "Neutralisierungs-Benutzer - Neutralisierungstools nutzen und eigene Daten verwalten",
"fr": "Utilisateur neutralisation - Utiliser les outils et gérer ses propres données",
},
"accessRules": [
{"context": "UI", "item": "ui.feature.neutralization.playground", "view": True},
{"context": "UI", "item": "ui.feature.neutralization.attributes", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
],
},
{ {
"roleLabel": "neutralization-admin", "roleLabel": "neutralization-admin",
"description": { "description": {
"en": "Neutralization Administrator - Full access to neutralization settings and data", "en": "Neutralization Administrator - Full access to neutralization settings and data",
"de": "Neutralisierungs-Administrator - Vollzugriff auf Neutralisierungs-Einstellungen und Daten", "de": "Neutralisierungs-Administrator - Vollzugriff auf Neutralisierungs-Einstellungen und Daten",
"fr": "Administrateur neutralisation - Accès complet aux paramètres et données" "fr": "Administrateur neutralisation - Accès complet aux paramètres et données",
}, },
"accessRules": [ "accessRules": [
# Full UI access (all views including admin views)
{"context": "UI", "item": None, "view": True}, {"context": "UI", "item": None, "view": True},
# Full DATA access
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"}, {"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
] ],
}, },
{ {
"roleLabel": "neutralization-analyst", "roleLabel": "neutralization-analyst",
"description": { "description": {
"en": "Neutralization Analyst - Analyze and process neutralization data", "en": "Neutralization Analyst - Analyze and process neutralization data",
"de": "Neutralisierungs-Analyst - Neutralisierungsdaten analysieren und verarbeiten", "de": "Neutralisierungs-Analyst - Neutralisierungsdaten analysieren und verarbeiten",
"fr": "Analyste neutralisation - Analyser et traiter les données de neutralisation" "fr": "Analyste neutralisation - Analyser et traiter les données de neutralisation",
}, },
"accessRules": [ "accessRules": [
# UI access to specific views - vollqualifizierte ObjectKeys
{"context": "UI", "item": "ui.feature.neutralization.playground", "view": True}, {"context": "UI", "item": "ui.feature.neutralization.playground", "view": True},
{"context": "UI", "item": "ui.feature.neutralization.attributes", "view": True}, {"context": "UI", "item": "ui.feature.neutralization.attributes", "view": True},
# Group-level DATA access (read-only for sensitive config)
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "n", "update": "n", "delete": "n"}, {"context": "DATA", "item": None, "view": True, "read": "g", "create": "n", "update": "n", "delete": "n"},
] ],
}, },
] ]

View file

@ -6,7 +6,8 @@ from typing import Any, Dict, List, Optional
from urllib.parse import urlparse, unquote from urllib.parse import urlparse, unquote
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
from .datamodelFeatureNeutralizer import DataNeutralizerAttributes, DataNeutraliserConfig from .datamodelFeatureNeutralizer import DataNeutralizerAttributes, DataNeutraliserConfig, DataNeutralizationSnapshot
from .interfaceFeatureNeutralizer import getInterface as _getNeutralizerInterface
from modules.serviceHub import getInterface as getServices from modules.serviceHub import getInterface as getServices
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -85,7 +86,7 @@ class NeutralizationPlayground:
'neutralized_file_id': None, 'neutralized_file_id': None,
'processed_info': {'type': 'error', 'error': 'File could not be decoded as text. Supported: UTF-8, Latin-1. For PDF/Word/Excel, use supported binary formats.'} 'processed_info': {'type': 'error', 'error': 'File could not be decoded as text. Supported: UTF-8, Latin-1. For PDF/Word/Excel, use supported binary formats.'}
} }
result = self.services.neutralization.processText(text_content) result = await self.services.neutralization.processTextAsync(text_content)
result['neutralized_file_name'] = f'neutralized_{filename}' result['neutralized_file_name'] = f'neutralized_{filename}'
# Save neutralized text as file to user files # Save neutralized text as file to user files
if self.services.interfaceDbComponent and result.get('neutralized_text') is not None: if self.services.interfaceDbComponent and result.get('neutralized_text') is not None:
@ -129,6 +130,11 @@ class NeutralizationPlayground:
} }
# Delete a single attribute by ID
def deleteAttribute(self, attributeId: str) -> bool:
interface = _getNeutralizerInterface(self.currentUser, self.mandateId, self.featureInstanceId)
return interface.deleteAttributeById(attributeId)
# Cleanup attributes # Cleanup attributes
def cleanAttributes(self, fileId: str) -> bool: def cleanAttributes(self, fileId: str) -> bool:
return self.services.neutralization.deleteNeutralizationAttributes(fileId) return self.services.neutralization.deleteNeutralizationAttributes(fileId)
@ -192,12 +198,28 @@ class NeutralizationPlayground:
"""Resolve UIDs in neutralized text back to original text""" """Resolve UIDs in neutralized text back to original text"""
return self.services.neutralization.resolveText(text) return self.services.neutralization.resolveText(text)
def getSnapshots(self) -> List[DataNeutralizationSnapshot]:
"""Return stored neutralization text snapshots."""
try:
return self.services.neutralization.getSnapshots()
except Exception as e:
logger.error(f"Error getting snapshots: {e}")
return []
def getAttributes(self, fileId: str = None) -> List[DataNeutralizerAttributes]: def getAttributes(self, fileId: str = None) -> List[DataNeutralizerAttributes]:
"""Get neutralization attributes, optionally filtered by file ID""" """Get neutralization attributes, optionally filtered by file ID"""
try: try:
allAttributes = self.services.neutralization.getAttributes() allAttributes = self.services.neutralization.getAttributes()
if fileId: if fileId:
return [attr for attr in allAttributes if attr.fileId == fileId] want = str(fileId).strip()
def _matches(a: DataNeutralizerAttributes) -> bool:
af = a.fileId
if af is None or (isinstance(af, str) and not str(af).strip()):
return False
return str(af).strip() == want
return [attr for attr in allAttributes if _matches(attr)]
return allAttributes return allAttributes
except Exception as e: except Exception as e:
logger.error(f"Error getting attributes: {str(e)}") logger.error(f"Error getting attributes: {str(e)}")
@ -390,7 +412,7 @@ class SharepointProcessor:
textContent = fileContent.decode('utf-8') textContent = fileContent.decode('utf-8')
except UnicodeDecodeError: except UnicodeDecodeError:
textContent = fileContent.decode('latin-1') textContent = fileContent.decode('latin-1')
result = self.services.neutralization.processText(textContent) result = await self.services.neutralization.processTextAsync(textContent)
content_to_upload = (result.get('neutralized_text') or '').encode('utf-8') content_to_upload = (result.get('neutralized_text') or '').encode('utf-8')
neutralizedFilename = f"neutralized_{fileInfo['name']}" neutralizedFilename = f"neutralized_{fileInfo['name']}"

View file

@ -8,12 +8,33 @@ import logging
from modules.auth import limiter, getRequestContext, RequestContext from modules.auth import limiter, getRequestContext, RequestContext
# Import interfaces # Import interfaces
from .datamodelFeatureNeutralizer import DataNeutraliserConfig, DataNeutralizerAttributes from .datamodelFeatureNeutralizer import DataNeutraliserConfig, DataNeutralizerAttributes, DataNeutralizationSnapshot
from .neutralizePlayground import NeutralizationPlayground from .neutralizePlayground import NeutralizationPlayground
# Configure logger # Configure logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _assertFeatureInstancePathMatchesContext(featureInstanceIdFromPath: str, context: RequestContext) -> None:
"""Reject path/instance mismatch when request context already carries an instance id."""
ctxId = str(context.featureInstanceId).strip() if getattr(context, "featureInstanceId", None) else ""
pathId = (featureInstanceIdFromPath or "").strip()
if ctxId and pathId and pathId != ctxId:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Feature instance id in URL does not match request context (X-Instance-Id)",
)
def _fetchNeutralizationAttributes(context: RequestContext, fileId: Optional[str]) -> List[DataNeutralizerAttributes]:
service = NeutralizationPlayground(
context.user,
str(context.mandateId) if context.mandateId else "",
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None,
)
return service.getAttributes(fileId)
# Create router for neutralization endpoints # Create router for neutralization endpoints
router = APIRouter( router = APIRouter(
prefix="/api/neutralization", prefix="/api/neutralization",
@ -208,15 +229,9 @@ def get_neutralization_attributes(
) -> List[DataNeutralizerAttributes]: ) -> List[DataNeutralizerAttributes]:
"""Get neutralization attributes, optionally filtered by file ID""" """Get neutralization attributes, optionally filtered by file ID"""
try: try:
service = NeutralizationPlayground( return _fetchNeutralizationAttributes(context, fileId)
context.user, except HTTPException:
str(context.mandateId) if context.mandateId else "", raise
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None
)
attributes = service.getAttributes(fileId)
return attributes
except Exception as e: except Exception as e:
logger.error(f"Error getting neutralization attributes: {str(e)}") logger.error(f"Error getting neutralization attributes: {str(e)}")
raise HTTPException( raise HTTPException(
@ -224,6 +239,72 @@ def get_neutralization_attributes(
detail=f"Error getting neutralization attributes: {str(e)}" detail=f"Error getting neutralization attributes: {str(e)}"
) )
@router.get("/{feature_instance_id}/attributes", response_model=List[DataNeutralizerAttributes])
@limiter.limit("30/minute")
def get_neutralization_attributes_scoped(
request: Request,
feature_instance_id: str = Path(..., description="Workspace / feature instance id (must match X-Instance-Id when set)"),
fileId: Optional[str] = Query(None, description="Filter by file ID"),
context: RequestContext = Depends(getRequestContext),
) -> List[DataNeutralizerAttributes]:
"""Same as GET /attributes; path includes instance id for workspace UI compatibility."""
_assertFeatureInstancePathMatchesContext(feature_instance_id, context)
try:
return _fetchNeutralizationAttributes(context, fileId)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting neutralization attributes: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Error getting neutralization attributes: {str(e)}"
)
@router.get("/snapshots", response_model=List[DataNeutralizationSnapshot])
@limiter.limit("30/minute")
def get_neutralization_snapshots(
request: Request,
context: RequestContext = Depends(getRequestContext),
) -> List[DataNeutralizationSnapshot]:
"""Return neutralized-text snapshots (full text with placeholders) for the current feature instance."""
try:
service = NeutralizationPlayground(
context.user,
str(context.mandateId) if context.mandateId else "",
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None,
)
return service.getSnapshots()
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting neutralization snapshots: {e}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
@router.get("/{feature_instance_id}/snapshots", response_model=List[DataNeutralizationSnapshot])
@limiter.limit("30/minute")
def get_neutralization_snapshots_scoped(
request: Request,
feature_instance_id: str = Path(..., description="Workspace instance id (must match X-Instance-Id when set)"),
context: RequestContext = Depends(getRequestContext),
) -> List[DataNeutralizationSnapshot]:
"""Same as GET /snapshots; path includes instance id for workspace UI (explicit scope)."""
_assertFeatureInstancePathMatchesContext(feature_instance_id, context)
try:
service = NeutralizationPlayground(
context.user,
str(context.mandateId) if context.mandateId else "",
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None,
)
return service.getSnapshots()
except HTTPException:
raise
except Exception as e:
logger.error(f"Error getting neutralization snapshots (scoped): {e}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=str(e))
@router.post("/process-sharepoint", response_model=Dict[str, Any]) @router.post("/process-sharepoint", response_model=Dict[str, Any])
@limiter.limit("5/minute") @limiter.limit("5/minute")
async def process_sharepoint_files( async def process_sharepoint_files(
@ -317,6 +398,108 @@ def get_neutralization_stats(
detail=f"Error getting neutralization stats: {str(e)}" detail=f"Error getting neutralization stats: {str(e)}"
) )
def _deleteSingleNeutralizationAttribute(context: RequestContext, attributeId: str) -> Dict[str, str]:
service = NeutralizationPlayground(
context.user,
str(context.mandateId) if context.mandateId else "",
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None,
)
success = service.deleteAttribute(attributeId)
if success:
return {"message": f"Attribute {attributeId} deleted"}
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Attribute {attributeId} not found",
)
@router.delete("/attributes/single/{attributeId}", response_model=Dict[str, str])
@limiter.limit("30/minute")
def deleteAttribute(
request: Request,
attributeId: str = Path(..., description="Attribute ID to delete"),
context: RequestContext = Depends(getRequestContext),
) -> Dict[str, str]:
"""Delete a single neutralization attribute by ID."""
try:
return _deleteSingleNeutralizationAttribute(context, attributeId)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting attribute: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/{feature_instance_id}/attributes/single/{attributeId}", response_model=Dict[str, str])
@limiter.limit("30/minute")
def deleteAttributeScoped(
request: Request,
feature_instance_id: str = Path(..., description="Workspace / feature instance id"),
attributeId: str = Path(..., description="Attribute ID to delete"),
context: RequestContext = Depends(getRequestContext),
) -> Dict[str, str]:
"""Same as DELETE /attributes/single/{attributeId}; path includes instance id for workspace UI."""
_assertFeatureInstancePathMatchesContext(feature_instance_id, context)
try:
return _deleteSingleNeutralizationAttribute(context, attributeId)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting attribute: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
def _retriggerNeutralizationBody(context: RequestContext, fileId: str) -> Dict[str, str]:
if not fileId:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="fileId is required",
)
service = NeutralizationPlayground(
context.user,
str(context.mandateId) if context.mandateId else "",
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None,
)
service.cleanupFileAttributes(fileId)
return {"message": f"Neutralization re-triggered for file {fileId}", "fileId": fileId}
@router.post("/retrigger", response_model=Dict[str, str])
@limiter.limit("10/minute")
def retriggerNeutralization(
request: Request,
retriggerData: Dict[str, str] = Body(...),
context: RequestContext = Depends(getRequestContext),
) -> Dict[str, str]:
"""Re-trigger neutralization for a specific file."""
try:
return _retriggerNeutralizationBody(context, retriggerData.get("fileId", ""))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error re-triggering neutralization: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/{feature_instance_id}/retrigger", response_model=Dict[str, str])
@limiter.limit("10/minute")
def retriggerNeutralizationScoped(
request: Request,
feature_instance_id: str = Path(..., description="Workspace / feature instance id"),
retriggerData: Dict[str, str] = Body(...),
context: RequestContext = Depends(getRequestContext),
) -> Dict[str, str]:
"""Same as POST /retrigger; path includes instance id for workspace UI compatibility."""
_assertFeatureInstancePathMatchesContext(feature_instance_id, context)
try:
return _retriggerNeutralizationBody(context, retriggerData.get("fileId", ""))
except HTTPException:
raise
except Exception as e:
logger.error(f"Error re-triggering neutralization: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/attributes/{fileId}", response_model=Dict[str, str]) @router.delete("/attributes/{fileId}", response_model=Dict[str, str])
@limiter.limit("10/minute") @limiter.limit("10/minute")
def cleanup_file_attributes( def cleanup_file_attributes(

View file

@ -60,6 +60,12 @@ class NeutralizationService:
mandateId=serviceCenter.mandateId or dbApp.mandateId, mandateId=serviceCenter.mandateId or dbApp.mandateId,
featureInstanceId=getattr(serviceCenter, 'featureInstanceId', None) or getattr(dbApp, 'featureInstanceId', None) featureInstanceId=getattr(serviceCenter, 'featureInstanceId', None) or getattr(dbApp, 'featureInstanceId', None)
) )
elif serviceCenter and getattr(serviceCenter, "user", None):
self.interfaceNeutralizer = getNeutralizerInterface(
currentUser=serviceCenter.user,
mandateId=getattr(serviceCenter, 'mandateId', None) or getattr(serviceCenter, 'mandate_id', None),
featureInstanceId=getattr(serviceCenter, 'featureInstanceId', None) or getattr(serviceCenter, 'feature_instance_id', None),
)
namesList = NamesToParse if isinstance(NamesToParse, list) else [] namesList = NamesToParse if isinstance(NamesToParse, list) else []
self.NamesToParse = namesList self.NamesToParse = namesList
@ -82,11 +88,213 @@ class NeutralizationService:
# Public API: process text or file # Public API: process text or file
def processText(self, text: str) -> Dict[str, Any]: _NEUT_INSTRUCTION = (
"""Neutralize a raw text string and return a standard result dict.""" "Analyze the following text and identify ALL sensitive content that must be neutralized:\n"
result = self._neutralizeText(text, 'text') "1. Personal data (PII): names of persons, email addresses, phone numbers, "
self._persistAttributes(result.get('mapping', {}), None) "physical addresses, ID numbers, dates of birth, financial data (IBAN, account numbers), "
return result "social security numbers\n"
"2. Protected business logic: proprietary algorithms, trade secrets, confidential "
"processes, internal procedures, code snippets that reveal implementation details\n"
"3. Named entities: company names, product names, project names, brand names\n\n"
"Return ONLY a JSON array (no markdown, no explanation):\n"
'[{"text":"exact substring","type":"name|email|phone|address|id|financial|logic|company|product|location|other"}]\n\n'
"Rules:\n"
"- Every entry's 'text' must be an exact, verbatim substring of the input.\n"
"- Do NOT include generic words, common language constructs or non-sensitive terms.\n"
"- If nothing is sensitive, return [].\n\n"
)
_BYTES_PER_TOKEN = 3
_SELECTOR_MAX_RATIO = 0.8
_CHUNK_SAFETY_MARGIN = 0.9
def _resolveNeutModel(self):
"""Query the model registry for the best NEUTRALIZATION_TEXT model.
Returns the model object (with contextLength etc.) or None."""
try:
from modules.aicore.aicoreModelRegistry import modelRegistry
from modules.aicore.aicoreModelSelector import modelSelector as _modSel
from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum
_models = modelRegistry.getAvailableModels()
_opts = AiCallOptions(operationType=OperationTypeEnum.NEUTRALIZATION_TEXT)
_failover = _modSel.getFailoverModelList("x", "", _opts, _models)
return _failover[0] if _failover else None
except Exception as _e:
logger.warning(f"_resolveNeutModel failed: {_e}")
return None
def _calcMaxChunkChars(self, model) -> int:
"""Derive the maximum text-chunk size (in characters) from the selected
model's contextLength, mirroring the rules in aicoreModelSelector:
promptTokens = promptBytes / 3 must be <= contextLength * 0.8
Subtract the instruction overhead and apply a safety margin."""
if not model or getattr(model, 'contextLength', 0) <= 0:
return 5000
_instructionBytes = len(self._NEUT_INSTRUCTION.encode('utf-8')) + 30
_maxPromptBytes = int(model.contextLength * self._SELECTOR_MAX_RATIO * self._BYTES_PER_TOKEN)
_maxChunkChars = int((_maxPromptBytes - _instructionBytes) * self._CHUNK_SAFETY_MARGIN)
return max(_maxChunkChars, 500)
@staticmethod
def _splitTextIntoChunks(text: str, maxChars: int) -> List[str]:
"""Split *text* into chunks of at most *maxChars*, preferring paragraph
then sentence boundaries so that the LLM sees coherent blocks."""
if len(text) <= maxChars:
return [text]
chunks: List[str] = []
remaining = text
while remaining:
if len(remaining) <= maxChars:
chunks.append(remaining)
break
_cut = maxChars
_para = remaining.rfind("\n\n", 0, _cut)
if _para > maxChars // 3:
_cut = _para + 2
else:
_nl = remaining.rfind("\n", 0, _cut)
if _nl > maxChars // 3:
_cut = _nl + 1
else:
_dot = remaining.rfind(". ", 0, _cut)
if _dot > maxChars // 3:
_cut = _dot + 2
else:
_sp = remaining.rfind(" ", 0, _cut)
if _sp > maxChars // 3:
_cut = _sp + 1
chunks.append(remaining[:_cut])
remaining = remaining[_cut:]
return chunks
async def _analyseChunk(self, aiService, chunkText: str) -> List[dict]:
"""Send one chunk to the NEUTRALIZATION_TEXT model, return raw findings list."""
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum
_prompt = self._NEUT_INSTRUCTION + "Text to analyze:\n---\n" + chunkText + "\n---"
_request = AiCallRequest(
prompt=_prompt,
options=AiCallOptions(operationType=OperationTypeEnum.NEUTRALIZATION_TEXT),
)
_response = await aiService.callAi(_request)
if not _response or not getattr(_response, 'content', None):
raise RuntimeError(
"Neutralization AI call returned no response "
"(no model available for NEUTRALIZATION_TEXT?)"
)
if getattr(_response, 'errorCount', 0) > 0 or getattr(_response, 'modelName', '') == 'error':
raise RuntimeError(
f"Neutralization AI call failed: {_response.content}"
)
_content = _response.content.strip()
if _content.startswith("```"):
_content = _content.split("\n", 1)[-1].rsplit("```", 1)[0].strip()
try:
return json.loads(_content)
except json.JSONDecodeError:
_bracket = _content.find("[")
if _bracket >= 0:
try:
return json.loads(_content[_bracket:])
except json.JSONDecodeError:
pass
return []
async def processTextAsync(self, text: str, fileId: Optional[str] = None) -> Dict[str, Any]:
"""AI-powered text neutralization with automatic chunking.
If *text* exceeds the safe token budget for the neutralization model
it is split into smaller chunks, each analysed separately. Findings
are merged and de-duplicated before placeholder replacement.
Regex patterns run as a supplementary pass to catch anything the
model missed.
"""
import uuid as _uuid
aiService = None
if self._getService:
try:
aiService = self._getService("ai")
except Exception:
pass
aiMapping: Dict[str, str] = {}
if not aiService or not hasattr(aiService, 'callAi'):
raise RuntimeError("Neutralization requires an AI service but none is available")
if text.strip():
_neutModel = self._resolveNeutModel()
_maxChunkChars = self._calcMaxChunkChars(_neutModel)
logger.info(
f"processTextAsync: model={getattr(_neutModel, 'name', '?')}, "
f"contextLength={getattr(_neutModel, 'contextLength', '?')} tokens, "
f"maxChunkChars={_maxChunkChars}"
)
_chunks = self._splitTextIntoChunks(text, _maxChunkChars)
if len(_chunks) > 1:
logger.info(
f"processTextAsync: text ({len(text)} chars) "
f"split into {len(_chunks)} chunk(s) of max {_maxChunkChars} chars"
)
for _chunkIdx, _chunkText in enumerate(_chunks):
_findings = await self._analyseChunk(aiService, _chunkText)
if not isinstance(_findings, list):
continue
for _f in _findings:
if not isinstance(_f, dict):
continue
_origText = _f.get("text", "")
_patType = _f.get("type", "other").lower()
if not _origText or _origText not in text:
continue
if _origText in aiMapping:
continue
_uid = str(_uuid.uuid4())
_placeholder = f"[{_patType}.{_uid}]"
aiMapping[_origText] = _placeholder
logger.info(f"AI neutralization found {len(aiMapping)} item(s)"
+ (f" across {len(_chunks)} chunk(s)" if len(_chunks) > 1 else ""))
neutralizedText = text
for _orig, _ph in sorted(aiMapping.items(), key=lambda x: -len(x[0])):
neutralizedText = neutralizedText.replace(_orig, _ph)
regexMapping: Dict[str, str] = {}
finalText = neutralizedText
allMapping = {**aiMapping, **regexMapping}
if allMapping:
_loop = asyncio.get_event_loop()
await _loop.run_in_executor(
None, self._persistAttributes, allMapping, fileId
)
logger.debug(f"processTextAsync: {len(allMapping)} attribute(s) persisted")
return {
'neutralized_text': finalText,
'mapping': allMapping,
'attributes': [
NeutralizationAttribute(original=k, placeholder=v)
for k, v in allMapping.items()
],
'processed_info': {'type': 'text', 'ai_findings': len(aiMapping), 'regex_findings': len(regexMapping)},
}
def processText(self, text: str, fileId: Optional[str] = None) -> Dict[str, Any]:
"""Sync wrapper around processTextAsync. Propagates errors."""
try:
return asyncio.run(self.processTextAsync(text, fileId))
except RuntimeError as _re:
if "cannot be called from a running event loop" in str(_re):
loop = asyncio.get_event_loop()
return loop.run_until_complete(self.processTextAsync(text, fileId))
raise
def processFile(self, fileId: str) -> Dict[str, Any]: def processFile(self, fileId: str) -> Dict[str, Any]:
"""Neutralize a file referenced by its fileId using component interface. """Neutralize a file referenced by its fileId using component interface.
@ -153,8 +361,7 @@ class NeutralizationService:
raise ValueError("Unable to decode file content as text.") raise ValueError("Unable to decode file content as text.")
textContent = decoded textContent = decoded
result = self._neutralizeText(textContent, textType) result = self.processText(textContent, fileId)
self._persistAttributes(result.get('mapping', {}), fileId)
if fileName: if fileName:
result['neutralized_file_name'] = f"neutralized_{fileName}" result['neutralized_file_name'] = f"neutralized_{fileName}"
result['file_id'] = fileId result['file_id'] = fileId
@ -203,6 +410,89 @@ class NeutralizationService:
'processed_info': {'type': 'binary', 'status': 'error', 'error': str(e)} 'processed_info': {'type': 'binary', 'status': 'error', 'error': str(e)}
} }
async def processImageAsync(self, imageBytes: bytes, fileName: str, mimeType: str = "image/png") -> Dict[str, Any]:
"""Analyze image via internal vision model to check for sensitive content.
Returns dict with:
- 'status': 'ok' | 'blocked' | 'error'
- 'hasSensitiveContent': bool
- 'analysis': str (model's analysis text, if available)
- 'processed_info': dict with details
Uses NEUTRALIZATION_IMAGE operation type only internal Private-LLM models.
If no internal model available returns 'blocked'.
"""
import base64
try:
aiService = None
if self._getService:
try:
aiService = self._getService("ai")
except Exception:
pass
if not aiService or not hasattr(aiService, 'callAi'):
logger.warning(f"processImage: AI service not available — blocking image '{fileName}'")
return {
'status': 'blocked',
'hasSensitiveContent': True,
'analysis': '',
'processed_info': {'type': 'image', 'status': 'blocked', 'reason': 'AI service unavailable'}
}
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum
_b64Data = base64.b64encode(imageBytes).decode('utf-8')
_dataUrl = f"data:{mimeType};base64,{_b64Data}"
_prompt = (
"Analyze this image for personally identifiable information (PII). "
"Check for: names, addresses, phone numbers, email addresses, ID numbers, "
"faces, signatures, handwritten text, license plates, financial data. "
"Respond with JSON: {\"hasPII\": true/false, \"findings\": [\"...\"]}"
)
_request = AiCallRequest(
prompt=_prompt,
options=AiCallOptions(operationType=OperationTypeEnum.NEUTRALIZATION_IMAGE),
messages=[{"role": "user", "content": [
{"type": "text", "text": _prompt},
{"type": "image_url", "image_url": {"url": _dataUrl}},
]}],
)
_response = await aiService.callAi(_request)
_hasPII = False
_analysis = _response.content if _response and hasattr(_response, 'content') else ''
if _analysis:
_lowerAnalysis = _analysis.lower()
if '"haspii": true' in _lowerAnalysis or '"haspii":true' in _lowerAnalysis:
_hasPII = True
return {
'status': 'blocked' if _hasPII else 'ok',
'hasSensitiveContent': _hasPII,
'analysis': _analysis,
'processed_info': {'type': 'image', 'status': 'blocked' if _hasPII else 'ok', 'fileName': fileName}
}
except Exception as e:
logger.error(f"processImage failed for '{fileName}': {e}")
return {
'status': 'blocked',
'hasSensitiveContent': True,
'analysis': '',
'processed_info': {'type': 'image', 'status': 'error', 'error': str(e)}
}
def processImage(self, imageBytes: bytes, fileName: str, mimeType: str = "image/png") -> Dict[str, Any]:
"""Sync wrapper for processImageAsync. Uses asyncio.run when no event loop is running."""
import asyncio
try:
return asyncio.run(self.processImageAsync(imageBytes, fileName, mimeType))
except RuntimeError:
loop = asyncio.get_event_loop()
return loop.run_until_complete(self.processImageAsync(imageBytes, fileName, mimeType))
def resolveText(self, text: str) -> str: def resolveText(self, text: str) -> str:
if not self.interfaceNeutralizer: if not self.interfaceNeutralizer:
return text return text
@ -236,6 +526,22 @@ class NeutralizationService:
return False return False
return self.interfaceNeutralizer.deleteNeutralizationAttributes(fileId) return self.interfaceNeutralizer.deleteNeutralizationAttributes(fileId)
def getSnapshots(self):
if not self.interfaceNeutralizer:
return []
return self.interfaceNeutralizer.getSnapshots()
def clearSnapshots(self) -> int:
if not self.interfaceNeutralizer:
return 0
return self.interfaceNeutralizer.clearSnapshots()
def saveSnapshot(self, sourceLabel: str, neutralizedText: str, placeholderCount: int = 0):
if not self.interfaceNeutralizer:
logger.warning("saveSnapshot: interfaceNeutralizer is None — snapshot not stored")
return None
return self.interfaceNeutralizer.createSnapshot(sourceLabel, neutralizedText, placeholderCount)
def _persistAttributes(self, mapping: Dict[str, str], fileId: Optional[str]) -> None: def _persistAttributes(self, mapping: Dict[str, str], fileId: Optional[str]) -> None:
"""Persist mapping to DB for resolve to work. mapping: originalText -> placeholder e.g. '[email.uuid]'""" """Persist mapping to DB for resolve to work. mapping: originalText -> placeholder e.g. '[email.uuid]'"""
if not self.interfaceNeutralizer or not mapping: if not self.interfaceNeutralizer or not mapping:
@ -295,10 +601,22 @@ class NeutralizationService:
p = part if isinstance(part, dict) else part.model_dump() if hasattr(part, 'model_dump') else part p = part if isinstance(part, dict) else part.model_dump() if hasattr(part, 'model_dump') else part
type_group = p.get('typeGroup', '') type_group = p.get('typeGroup', '')
data = p.get('data', '') data = p.get('data', '')
if type_group in ('binary', 'image') or not (data and str(data).strip()): if type_group == 'binary' or not (data and str(data).strip()):
neutralized_parts.append(part) neutralized_parts.append(part)
continue continue
nr = self._neutralizeText(str(data), 'text' if type_group != 'table' else 'csv') if type_group == 'image':
import base64 as _b64img
try:
_imgBytes = _b64img.b64decode(str(data))
_imgResult = await self.processImageAsync(_imgBytes, fileName)
if _imgResult.get("status") == "ok":
neutralized_parts.append(part)
else:
logger.warning(f"Image part blocked in binary file '{fileName}' (PII detected), removing")
except Exception as _imgErr:
logger.warning(f"Image check failed in binary file '{fileName}': {_imgErr}, removing (fail-safe)")
continue
nr = await self.processTextAsync(str(data), fileId)
proc = nr.get('processed_info', {}) or {} proc = nr.get('processed_info', {}) or {}
if isinstance(proc, dict) and proc.get('type') == 'error': if isinstance(proc, dict) and proc.get('type') == 'error':
neutralization_error = proc.get('error', 'Neutralization failed') neutralization_error = proc.get('error', 'Neutralization failed')
@ -307,7 +625,6 @@ class NeutralizationService:
all_mapping.update(mapping) all_mapping.update(mapping)
new_part = {**p, 'data': neu_text} new_part = {**p, 'data': neu_text}
neutralized_parts.append(new_part) neutralized_parts.append(new_part)
self._persistAttributes(all_mapping, fileId)
# 3. PDF: Use in-place only; no fallback to render # 3. PDF: Use in-place only; no fallback to render
if mimeType == "application/pdf": if mimeType == "application/pdf":
@ -451,10 +768,31 @@ class NeutralizationService:
# Helper functions # Helper functions
def _neutralizeTextLight(self, text: str) -> Dict[str, Any]:
"""Regex-only supplementary pass using already-initialised processors.
Unlike ``_neutralizeText`` this does **no** DB I/O
(``_reloadNamesFromConfig`` is skipped) so it is safe to call from
an async context without blocking the event-loop or risking a
DB-connection-pool deadlock during parallel document processing.
"""
try:
data, mapping, replaced_fields, processed_info = self.textProcessor.processTextContent(text)
neutralized_text = str(data)
attributes = [NeutralizationAttribute(original=k, placeholder=v) for k, v in mapping.items()]
return NeutralizationResult(
neutralized_text=neutralized_text,
mapping=mapping,
attributes=attributes,
processed_info=processed_info,
).model_dump()
except Exception as e:
logger.warning(f"_neutralizeTextLight error: {e}")
return {'neutralized_text': text, 'mapping': {}, 'attributes': [], 'processed_info': {'type': 'error', 'error': str(e)}}
def _neutralizeText(self, text: str, textType: str = None) -> Dict[str, Any]: def _neutralizeText(self, text: str, textType: str = None) -> Dict[str, Any]:
"""Process text and return unified dict for API consumption.""" """Process text and return unified dict for API consumption."""
try: try:
# Reload names from config before processing to ensure we have the latest names
self._reloadNamesFromConfig() self._reloadNamesFromConfig()
# Auto-detect content type if not provided # Auto-detect content type if not provided

View file

@ -7,6 +7,7 @@ Implements a general Swiss architecture planning data model.
from typing import List, Dict, Any, Optional, ForwardRef from typing import List, Dict, Any, Optional, ForwardRef
from enum import Enum from enum import Enum
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.attributeUtils import registerModelLabels
from modules.shared.timeUtils import getUtcTimestamp from modules.shared.timeUtils import getUtcTimestamp
import uuid import uuid
@ -178,7 +179,7 @@ class Dokument(BaseModel):
) )
class Kontext(BaseModel): class Kontext(PowerOnModel):
"""Supporting data object for flexible additional information.""" """Supporting data object for flexible additional information."""
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
@ -248,7 +249,7 @@ class Land(BaseModel):
) )
class Kanton(BaseModel): class Kanton(PowerOnModel):
"""Cantonal level administrative entity.""" """Cantonal level administrative entity."""
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
@ -368,7 +369,7 @@ class Gemeinde(BaseModel):
ParzelleRef = ForwardRef('Parzelle') ParzelleRef = ForwardRef('Parzelle')
class Parzelle(BaseModel): class Parzelle(PowerOnModel):
"""Represents a plot with all building law properties.""" """Represents a plot with all building law properties."""
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
@ -594,7 +595,7 @@ class Parzelle(BaseModel):
) )
class Projekt(BaseModel): class Projekt(PowerOnModel):
"""Core object representing a construction project.""" """Core object representing a construction project."""
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),

View file

@ -39,52 +39,57 @@ RESOURCE_OBJECTS = [
# Template roles for this feature with AccessRules # Template roles for this feature with AccessRules
# IMPORTANT: item uses vollqualifizierte ObjectKeys (gemäss Navigation-API-Konzept) # IMPORTANT: item uses vollqualifizierte ObjectKeys (gemäss Navigation-API-Konzept)
TEMPLATE_ROLES = [ TEMPLATE_ROLES = [
{
"roleLabel": "realestate-viewer",
"description": {
"en": "Real Estate Viewer - View property information (read-only)",
"de": "Immobilien-Betrachter - Immobilien-Informationen einsehen (nur lesen)",
"fr": "Visualiseur immobilier - Consulter les informations immobilières (lecture seule)",
},
"accessRules": [
{"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
],
},
{
"roleLabel": "realestate-user",
"description": {
"en": "Real Estate User - Create and manage own property records",
"de": "Immobilien-Benutzer - Eigene Immobilien-Daten erstellen und verwalten",
"fr": "Utilisateur immobilier - Créer et gérer ses propres données immobilières",
},
"accessRules": [
{"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
{"context": "RESOURCE", "item": "resource.feature.realestate.project.create", "view": True},
],
},
{ {
"roleLabel": "realestate-admin", "roleLabel": "realestate-admin",
"description": { "description": {
"en": "Real Estate Administrator - Full access to all property data and settings", "en": "Real Estate Administrator - Full access to all property data and settings",
"de": "Immobilien-Administrator - Vollzugriff auf alle Immobiliendaten und Einstellungen", "de": "Immobilien-Administrator - Vollzugriff auf alle Immobiliendaten und Einstellungen",
"fr": "Administrateur immobilier - Accès complet aux données et paramètres" "fr": "Administrateur immobilier - Accès complet aux données et paramètres",
}, },
"accessRules": [ "accessRules": [
# Full UI access (all views including admin views)
{"context": "UI", "item": None, "view": True}, {"context": "UI", "item": None, "view": True},
# Full DATA access
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"}, {"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
# Admin resources
{"context": "RESOURCE", "item": "resource.feature.realestate.project.create", "view": True}, {"context": "RESOURCE", "item": "resource.feature.realestate.project.create", "view": True},
{"context": "RESOURCE", "item": "resource.feature.realestate.project.delete", "view": True}, {"context": "RESOURCE", "item": "resource.feature.realestate.project.delete", "view": True},
] ],
}, },
{ {
"roleLabel": "realestate-manager", "roleLabel": "realestate-manager",
"description": { "description": {
"en": "Real Estate Manager - Manage properties and tenants", "en": "Real Estate Manager - Manage properties and tenants",
"de": "Immobilien-Verwalter - Immobilien und Mieter verwalten", "de": "Immobilien-Verwalter - Immobilien und Mieter verwalten",
"fr": "Gestionnaire immobilier - Gérer les propriétés et locataires" "fr": "Gestionnaire immobilier - Gérer les propriétés et locataires",
}, },
"accessRules": [ "accessRules": [
# UI access to map view
{"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
# Group-level DATA access
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"}, {"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"},
# Resource: create projects
{"context": "RESOURCE", "item": "resource.feature.realestate.project.create", "view": True}, {"context": "RESOURCE", "item": "resource.feature.realestate.project.create", "view": True},
] ],
},
{
"roleLabel": "realestate-viewer",
"description": {
"en": "Real Estate Viewer - View property information",
"de": "Immobilien-Betrachter - Immobilien-Informationen einsehen",
"fr": "Visualiseur immobilier - Consulter les informations immobilières"
},
"accessRules": [
# UI access to map view (read-only)
{"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True},
# Read-only DATA access (my records)
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
]
}, },
] ]

View file

@ -9,6 +9,8 @@ from pydantic import BaseModel, Field
from enum import Enum from enum import Enum
import uuid import uuid
from modules.datamodels.datamodelBase import PowerOnModel
# ============================================================================ # ============================================================================
# Enums # Enums
@ -72,7 +74,7 @@ class TeamsbotTransferMode(str, Enum):
# Database Models (stored in PostgreSQL) # Database Models (stored in PostgreSQL)
# ============================================================================ # ============================================================================
class TeamsbotSession(BaseModel): 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)")
@ -90,11 +92,9 @@ class TeamsbotSession(BaseModel):
errorMessage: Optional[str] = Field(default=None, description="Error message if status is ERROR") errorMessage: Optional[str] = Field(default=None, description="Error message if status is ERROR")
transcriptSegmentCount: int = Field(default=0, description="Number of transcript segments in this session") transcriptSegmentCount: int = Field(default=0, description="Number of transcript segments in this session")
botResponseCount: int = Field(default=0, description="Number of bot responses in this session") botResponseCount: int = Field(default=0, description="Number of bot responses in this session")
creationDate: Optional[str] = Field(default=None, description="ISO timestamp of record creation")
lastModified: Optional[str] = Field(default=None, description="ISO timestamp of last modification")
class TeamsbotTranscript(BaseModel): class TeamsbotTranscript(PowerOnModel):
"""A single transcript segment from the meeting.""" """A single transcript segment from the meeting."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Transcript segment ID") id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Transcript segment ID")
sessionId: str = Field(description="Session ID (FK)") sessionId: str = Field(description="Session ID (FK)")
@ -105,10 +105,9 @@ class TeamsbotTranscript(BaseModel):
language: Optional[str] = Field(default=None, description="Detected language code (e.g., de-DE)") language: Optional[str] = Field(default=None, description="Detected language code (e.g., de-DE)")
isFinal: bool = Field(default=True, description="Whether this is a final or interim result") isFinal: bool = Field(default=True, description="Whether this is a final or interim result")
source: Optional[str] = Field(default=None, description="Source: caption, audioCapture, chat, chatHistory, speakerHint") source: Optional[str] = Field(default=None, description="Source: caption, audioCapture, chat, chatHistory, speakerHint")
creationDate: Optional[str] = Field(default=None, description="ISO timestamp of record creation")
class TeamsbotBotResponse(BaseModel): class TeamsbotBotResponse(PowerOnModel):
"""A bot response generated during a meeting session.""" """A bot response generated during a meeting session."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Response ID") id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Response ID")
sessionId: str = Field(description="Session ID (FK)") sessionId: str = Field(description="Session ID (FK)")
@ -121,14 +120,13 @@ class TeamsbotBotResponse(BaseModel):
processingTime: float = Field(default=0.0, description="Processing time in seconds") processingTime: float = Field(default=0.0, description="Processing time in seconds")
priceCHF: float = Field(default=0.0, description="Cost of this AI call in CHF") priceCHF: float = Field(default=0.0, description="Cost of this AI call in CHF")
timestamp: Optional[str] = Field(default=None, description="ISO timestamp of the response") timestamp: Optional[str] = Field(default=None, description="ISO timestamp of the response")
creationDate: Optional[str] = Field(default=None, description="ISO timestamp of record creation")
# ============================================================================ # ============================================================================
# System Bot Accounts (stored in PostgreSQL, credentials encrypted) # System Bot Accounts (stored in PostgreSQL, credentials encrypted)
# ============================================================================ # ============================================================================
class TeamsbotSystemBot(BaseModel): class TeamsbotSystemBot(PowerOnModel):
"""A system bot account for authenticated meeting joins. """A system bot account for authenticated meeting joins.
Credentials are stored encrypted in the database, NOT in the UI-visible config. Credentials are stored encrypted in the database, NOT in the UI-visible config.
Only mandate admins can manage system bots.""" Only mandate admins can manage system bots."""
@ -138,15 +136,13 @@ class TeamsbotSystemBot(BaseModel):
email: str = Field(description="Microsoft account email") email: str = Field(description="Microsoft account email")
encryptedPassword: str = Field(description="Encrypted Microsoft account password") encryptedPassword: str = Field(description="Encrypted Microsoft account password")
isActive: bool = Field(default=True, description="Whether this bot account is active") isActive: bool = Field(default=True, description="Whether this bot account is active")
creationDate: Optional[str] = Field(default=None, description="ISO timestamp of creation")
lastModified: Optional[str] = Field(default=None, description="ISO timestamp of last modification")
# ============================================================================ # ============================================================================
# User Account Credentials (stored in PostgreSQL, credentials encrypted) # User Account Credentials (stored in PostgreSQL, credentials encrypted)
# ============================================================================ # ============================================================================
class TeamsbotUserAccount(BaseModel): class TeamsbotUserAccount(PowerOnModel):
"""Saved Microsoft credentials for 'Mein Account' joins. """Saved Microsoft credentials for 'Mein Account' joins.
Each user can store their own MS credentials per mandate. Each user can store their own MS credentials per mandate.
Password is encrypted; on login only MFA confirmation is needed.""" Password is encrypted; on login only MFA confirmation is needed."""
@ -156,15 +152,13 @@ class TeamsbotUserAccount(BaseModel):
email: str = Field(description="Microsoft account email") email: str = Field(description="Microsoft account email")
encryptedPassword: str = Field(description="Encrypted Microsoft account password") encryptedPassword: str = Field(description="Encrypted Microsoft account password")
displayName: Optional[str] = Field(default=None, description="Display name derived from MS account") displayName: Optional[str] = Field(default=None, description="Display name derived from MS account")
creationDate: Optional[str] = Field(default=None, description="ISO timestamp of creation")
lastModified: Optional[str] = Field(default=None, description="ISO timestamp of last modification")
# ============================================================================ # ============================================================================
# Per-User Settings (stored in PostgreSQL, per user per instance) # Per-User Settings (stored in PostgreSQL, per user per instance)
# ============================================================================ # ============================================================================
class TeamsbotUserSettings(BaseModel): class TeamsbotUserSettings(PowerOnModel):
"""Per-user settings for the Teams Bot feature. """Per-user settings for the Teams Bot feature.
Each user has their own settings per feature instance. Each user has their own settings per feature instance.
These override the instance-level defaults (TeamsbotConfig).""" These override the instance-level defaults (TeamsbotConfig)."""
@ -182,8 +176,6 @@ class TeamsbotUserSettings(BaseModel):
triggerCooldownSeconds: Optional[int] = Field(default=None, description="Trigger cooldown override") triggerCooldownSeconds: Optional[int] = Field(default=None, description="Trigger cooldown override")
contextWindowSegments: Optional[int] = Field(default=None, description="Context window override") contextWindowSegments: Optional[int] = Field(default=None, description="Context window override")
debugMode: Optional[bool] = Field(default=None, description="Debug mode override") debugMode: Optional[bool] = Field(default=None, description="Debug mode override")
creationDate: Optional[str] = Field(default=None, description="ISO timestamp of creation")
lastModified: Optional[str] = Field(default=None, description="ISO timestamp of last modification")
# ============================================================================ # ============================================================================

View file

@ -10,7 +10,6 @@ from typing import Dict, Any, List, Optional
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.timeUtils import getIsoTimestamp
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from .datamodelTeamsbot import ( from .datamodelTeamsbot import (
@ -104,13 +103,10 @@ class TeamsbotObjects:
def createSession(self, sessionData: Dict[str, Any]) -> Dict[str, Any]: def createSession(self, sessionData: Dict[str, Any]) -> Dict[str, Any]:
"""Create a new session.""" """Create a new session."""
sessionData["creationDate"] = getIsoTimestamp()
sessionData["lastModified"] = getIsoTimestamp()
return self.db.recordCreate(TeamsbotSession, sessionData) return self.db.recordCreate(TeamsbotSession, sessionData)
def updateSession(self, sessionId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: def updateSession(self, sessionId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Update session fields.""" """Update session fields."""
updates["lastModified"] = getIsoTimestamp()
return self.db.recordModify(TeamsbotSession, sessionId, updates) return self.db.recordModify(TeamsbotSession, sessionId, updates)
def deleteSession(self, sessionId: str) -> bool: def deleteSession(self, sessionId: str) -> bool:
@ -149,7 +145,6 @@ class TeamsbotObjects:
def createTranscript(self, transcriptData: Dict[str, Any]) -> Dict[str, Any]: def createTranscript(self, transcriptData: Dict[str, Any]) -> Dict[str, Any]:
"""Create a new transcript segment.""" """Create a new transcript segment."""
transcriptData["creationDate"] = getIsoTimestamp()
return self.db.recordCreate(TeamsbotTranscript, transcriptData) return self.db.recordCreate(TeamsbotTranscript, transcriptData)
def updateTranscript(self, transcriptId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: def updateTranscript(self, transcriptId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
@ -180,7 +175,6 @@ class TeamsbotObjects:
def createBotResponse(self, responseData: Dict[str, Any]) -> Dict[str, Any]: def createBotResponse(self, responseData: Dict[str, Any]) -> Dict[str, Any]:
"""Create a new bot response record.""" """Create a new bot response record."""
responseData["creationDate"] = getIsoTimestamp()
return self.db.recordCreate(TeamsbotBotResponse, responseData) return self.db.recordCreate(TeamsbotBotResponse, responseData)
def _deleteResponsesBySession(self, sessionId: str) -> int: def _deleteResponsesBySession(self, sessionId: str) -> int:
@ -216,13 +210,10 @@ class TeamsbotObjects:
def createSystemBot(self, botData: Dict[str, Any]) -> Dict[str, Any]: def createSystemBot(self, botData: Dict[str, Any]) -> Dict[str, Any]:
"""Create a new system bot account.""" """Create a new system bot account."""
botData["creationDate"] = getIsoTimestamp()
botData["lastModified"] = getIsoTimestamp()
return self.db.recordCreate(TeamsbotSystemBot, botData) return self.db.recordCreate(TeamsbotSystemBot, botData)
def updateSystemBot(self, botId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: def updateSystemBot(self, botId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Update a system bot account.""" """Update a system bot account."""
updates["lastModified"] = getIsoTimestamp()
return self.db.recordModify(TeamsbotSystemBot, botId, updates) return self.db.recordModify(TeamsbotSystemBot, botId, updates)
def deleteSystemBot(self, botId: str) -> bool: def deleteSystemBot(self, botId: str) -> bool:
@ -243,13 +234,10 @@ class TeamsbotObjects:
def createUserSettings(self, settingsData: Dict[str, Any]) -> Dict[str, Any]: def createUserSettings(self, settingsData: Dict[str, Any]) -> Dict[str, Any]:
"""Create user settings.""" """Create user settings."""
settingsData["creationDate"] = getIsoTimestamp()
settingsData["lastModified"] = getIsoTimestamp()
return self.db.recordCreate(TeamsbotUserSettings, settingsData) return self.db.recordCreate(TeamsbotUserSettings, settingsData)
def updateUserSettings(self, settingsId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: def updateUserSettings(self, settingsId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Update user settings.""" """Update user settings."""
updates["lastModified"] = getIsoTimestamp()
return self.db.recordModify(TeamsbotUserSettings, settingsId, updates) return self.db.recordModify(TeamsbotUserSettings, settingsId, updates)
def deleteUserSettings(self, settingsId: str) -> bool: def deleteUserSettings(self, settingsId: str) -> bool:
@ -270,13 +258,10 @@ class TeamsbotObjects:
def createUserAccount(self, data: Dict[str, Any]) -> Dict[str, Any]: def createUserAccount(self, data: Dict[str, Any]) -> Dict[str, Any]:
"""Create saved MS credentials.""" """Create saved MS credentials."""
data["creationDate"] = getIsoTimestamp()
data["lastModified"] = getIsoTimestamp()
return self.db.recordCreate(TeamsbotUserAccount, data) return self.db.recordCreate(TeamsbotUserAccount, data)
def updateUserAccount(self, accountId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]: def updateUserAccount(self, accountId: str, updates: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Update saved MS credentials.""" """Update saved MS credentials."""
updates["lastModified"] = getIsoTimestamp()
return self.db.recordModify(TeamsbotUserAccount, accountId, updates) return self.db.recordModify(TeamsbotUserAccount, accountId, updates)
def deleteUserAccount(self, accountId: str) -> bool: def deleteUserAccount(self, accountId: str) -> bool:

View file

@ -39,17 +39,32 @@ DATA_OBJECTS = [
{ {
"objectKey": "data.feature.teamsbot.TeamsbotSession", "objectKey": "data.feature.teamsbot.TeamsbotSession",
"label": {"en": "Session", "de": "Sitzung", "fr": "Session"}, "label": {"en": "Session", "de": "Sitzung", "fr": "Session"},
"meta": {"table": "TeamsbotSession", "fields": ["id", "meetingLink", "botName", "status", "startedAt", "endedAt"]} "meta": {
"table": "TeamsbotSession",
"fields": ["id", "meetingLink", "botName", "status", "startedAt", "endedAt"],
"isParent": True,
"displayFields": ["botName", "status", "startedAt"],
}
}, },
{ {
"objectKey": "data.feature.teamsbot.TeamsbotTranscript", "objectKey": "data.feature.teamsbot.TeamsbotTranscript",
"label": {"en": "Transcript", "de": "Transkript", "fr": "Transcription"}, "label": {"en": "Transcript", "de": "Transkript", "fr": "Transcription"},
"meta": {"table": "TeamsbotTranscript", "fields": ["id", "sessionId", "speaker", "text", "timestamp"]} "meta": {
"table": "TeamsbotTranscript",
"fields": ["id", "sessionId", "speaker", "text", "timestamp"],
"parentTable": "TeamsbotSession",
"parentKey": "sessionId",
}
}, },
{ {
"objectKey": "data.feature.teamsbot.TeamsbotBotResponse", "objectKey": "data.feature.teamsbot.TeamsbotBotResponse",
"label": {"en": "Bot Response", "de": "Bot-Antwort", "fr": "Réponse du bot"}, "label": {"en": "Bot Response", "de": "Bot-Antwort", "fr": "Réponse du bot"},
"meta": {"table": "TeamsbotBotResponse", "fields": ["id", "sessionId", "responseText", "detectedIntent"]} "meta": {
"table": "TeamsbotBotResponse",
"fields": ["id", "sessionId", "responseText", "detectedIntent"],
"parentTable": "TeamsbotSession",
"parentKey": "sessionId",
}
}, },
{ {
"objectKey": "data.feature.teamsbot.*", "objectKey": "data.feature.teamsbot.*",
@ -103,25 +118,35 @@ TEMPLATE_ROLES = [
{"context": "RESOURCE", "item": "resource.feature.teamsbot.config.edit", "view": True}, {"context": "RESOURCE", "item": "resource.feature.teamsbot.config.edit", "view": True},
] ]
}, },
{
"roleLabel": "teamsbot-viewer",
"description": {
"en": "Teams Bot Viewer - View sessions and transcripts (read-only)",
"de": "Teams Bot Betrachter - Sitzungen und Transkripte ansehen (nur lesen)",
"fr": "Visualiseur Teams Bot - Consulter les sessions et transcriptions (lecture seule)",
},
"accessRules": [
{"context": "UI", "item": "ui.feature.teamsbot.dashboard", "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"},
],
},
{ {
"roleLabel": "teamsbot-user", "roleLabel": "teamsbot-user",
"description": { "description": {
"en": "Teams Bot User - Can start/stop sessions and view transcripts", "en": "Teams Bot User - Can start/stop sessions and view transcripts",
"de": "Teams Bot Benutzer - Kann Sitzungen starten/stoppen und Transkripte einsehen", "de": "Teams Bot Benutzer - Kann Sitzungen starten/stoppen und Transkripte einsehen",
"fr": "Utilisateur Teams Bot - Peut démarrer/arrêter des sessions et voir les transcriptions" "fr": "Utilisateur Teams Bot - Peut démarrer/arrêter des sessions et voir les transcriptions",
}, },
"accessRules": [ "accessRules": [
# UI access to dashboard and sessions (not settings)
{"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.teamsbot.sessions", "view": True}, {"context": "UI", "item": "ui.feature.teamsbot.sessions", "view": True},
# Own records only
{"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"},
# Start and stop sessions
{"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},
] ],
}, },
] ]
@ -132,7 +157,7 @@ def getFeatureDefinition() -> Dict[str, Any]:
"code": FEATURE_CODE, "code": FEATURE_CODE,
"label": FEATURE_LABEL, "label": FEATURE_LABEL,
"icon": FEATURE_ICON, "icon": FEATURE_ICON,
"autoCreateInstance": True, "autoCreateInstance": False,
} }

View file

@ -17,6 +17,8 @@ from fastapi import WebSocket
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, PriorityEnum from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, PriorityEnum
from modules.shared.timeUtils import getUtcTimestamp, getIsoTimestamp from modules.shared.timeUtils import getUtcTimestamp, getIsoTimestamp
from modules.serviceCenter import getService as _getServiceCenterService
from modules.serviceCenter.context import ServiceCenterContext
from .datamodelTeamsbot import ( from .datamodelTeamsbot import (
TeamsbotSessionStatus, TeamsbotSessionStatus,
@ -35,18 +37,18 @@ logger = logging.getLogger(__name__)
# ========================================================================= # =========================================================================
# Minimal Service Context (for AI billing in bridge callbacks) # AI Service Factory (for billing-aware AI calls)
# ========================================================================= # =========================================================================
class _ServiceContext: def _createAiService(user, mandateId, featureInstanceId=None):
"""Minimal context providing user/mandate info for AiService billing. """Create a properly wired AiService via the service center."""
Used by bridge callbacks where a full Services instance is not available.""" ctx = ServiceCenterContext(
user=user,
def __init__(self, user, mandateId, featureInstanceId=None): mandate_id=mandateId,
self.user = user feature_instance_id=featureInstanceId,
self.mandateId = mandateId feature_code="teamsbot",
self.featureInstanceId = featureInstanceId )
self.featureCode = "teamsbot" return _getServiceCenterService("ai", ctx)
# ========================================================================= # =========================================================================
@ -1062,11 +1064,7 @@ class TeamsbotService:
# Call SPEECH_TEAMS # Call SPEECH_TEAMS
try: try:
from modules.serviceCenter.services.serviceAi.mainServiceAi import AiService aiService = _createAiService(self.currentUser, self.mandateId, self.instanceId)
# Create minimal service context for AI billing
serviceContext = _ServiceContext(self.currentUser, self.mandateId, self.instanceId)
aiService = AiService(serviceCenter=serviceContext)
await aiService.ensureAiObjectsInitialized() await aiService.ensureAiObjectsInitialized()
request = AiCallRequest( request = AiCallRequest(
@ -1684,11 +1682,7 @@ class TeamsbotService:
"""Summarize a long user-provided session context to its essential points. """Summarize a long user-provided session context to its essential points.
This reduces token usage in every subsequent AI call.""" This reduces token usage in every subsequent AI call."""
try: try:
from modules.serviceCenter.services.serviceAi.mainServiceAi import AiService aiService = _createAiService(self.currentUser, self.mandateId, self.instanceId)
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, PriorityEnum
serviceContext = _ServiceContext(self.currentUser, self.mandateId, self.instanceId)
aiService = AiService(serviceCenter=serviceContext)
await aiService.ensureAiObjectsInitialized() await aiService.ensureAiObjectsInitialized()
request = AiCallRequest( request = AiCallRequest(
@ -1738,11 +1732,7 @@ class TeamsbotService:
lines.append(f"[{speaker}]: {text}") lines.append(f"[{speaker}]: {text}")
textToSummarize = "\n".join(lines) textToSummarize = "\n".join(lines)
from modules.serviceCenter.services.serviceAi.mainServiceAi import AiService aiService = _createAiService(self.currentUser, self.mandateId, self.instanceId)
from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, PriorityEnum
serviceContext = _ServiceContext(self.currentUser, self.mandateId, self.instanceId)
aiService = AiService(serviceCenter=serviceContext)
await aiService.ensureAiObjectsInitialized() await aiService.ensureAiObjectsInitialized()
request = AiCallRequest( request = AiCallRequest(
@ -1783,10 +1773,7 @@ class TeamsbotService:
for t in transcripts for t in transcripts
) )
from modules.serviceCenter.services.serviceAi.mainServiceAi import AiService aiService = _createAiService(self.currentUser, self.mandateId, self.instanceId)
serviceContext = _ServiceContext(self.currentUser, self.mandateId, self.instanceId)
aiService = AiService(serviceCenter=serviceContext)
await aiService.ensureAiObjectsInitialized() await aiService.ensureAiObjectsInitialized()
request = AiCallRequest( request = AiCallRequest(

View file

@ -5,11 +5,13 @@
from enum import Enum from enum import Enum
from typing import Optional from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.attributeUtils import registerModelLabels
import uuid import uuid
class TrusteeOrganisation(BaseModel): class TrusteeOrganisation(PowerOnModel):
"""Represents trustee organisations (companies) within the Trustee feature.""" """Represents trustee organisations (companies) within the Trustee feature."""
id: str = Field( # Unique string label (PK), not UUID id: str = Field( # Unique string label (PK), not UUID
description="Unique organisation identifier (label)", description="Unique organisation identifier (label)",
@ -55,7 +57,7 @@ class TrusteeOrganisation(BaseModel):
} }
) )
# System attributes are automatically set by DatabaseConnector: # System attributes are automatically set by DatabaseConnector:
# _createdAt, _modifiedAt, _createdBy, _modifiedBy # sysCreatedAt, sysModifiedAt, sysCreatedBy, sysModifiedBy (PowerOnModel)
registerModelLabels( registerModelLabels(
@ -71,7 +73,7 @@ registerModelLabels(
) )
class TrusteeRole(BaseModel): class TrusteeRole(PowerOnModel):
"""Defines roles within the Trustee feature.""" """Defines roles within the Trustee feature."""
id: str = Field( # Unique string label (PK), not UUID id: str = Field( # Unique string label (PK), not UUID
description="Unique role identifier (label)", description="Unique role identifier (label)",
@ -122,7 +124,7 @@ registerModelLabels(
) )
class TrusteeAccess(BaseModel): class TrusteeAccess(PowerOnModel):
"""Defines user access to organisations with specific roles.""" """Defines user access to organisations with specific roles."""
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
@ -207,7 +209,7 @@ registerModelLabels(
) )
class TrusteeContract(BaseModel): class TrusteeContract(PowerOnModel):
"""Defines customer contracts within organisations.""" """Defines customer contracts within organisations."""
id: str = Field( id: str = Field(
default_factory=lambda: str(uuid.uuid4()), default_factory=lambda: str(uuid.uuid4()),
@ -289,7 +291,7 @@ class TrusteeDocumentTypeEnum(str, Enum):
AUTO = "auto" AUTO = "auto"
class TrusteeDocument(BaseModel): class TrusteeDocument(PowerOnModel):
"""Contains document references for bookings. """Contains document references for bookings.
Documents reference files in the central Files table via fileId. Documents reference files in the central Files table via fileId.
@ -413,7 +415,7 @@ registerModelLabels(
) )
class TrusteePosition(BaseModel): class TrusteePosition(PowerOnModel):
"""Contains booking positions (expense entries). """Contains booking positions (expense entries).
A position can have up to two document references: documentId (Beleg) and bankDocumentId (Bank-Referenz). A position can have up to two document references: documentId (Beleg) and bankDocumentId (Bank-Referenz).
@ -696,10 +698,6 @@ class TrusteePosition(BaseModel):
} }
) )
# Allow extra fields like _createdAt from database
model_config = {"extra": "allow"}
registerModelLabels( registerModelLabels(
"TrusteePosition", "TrusteePosition",
{"en": "Position", "fr": "Position", "de": "Position"}, {"en": "Position", "fr": "Position", "de": "Position"},
@ -739,7 +737,7 @@ registerModelLabels(
# ── TrusteeData* tables (synced from external accounting apps for analysis) ── # ── TrusteeData* tables (synced from external accounting apps for analysis) ──
class TrusteeDataAccount(BaseModel): class TrusteeDataAccount(PowerOnModel):
"""Chart of accounts synced from external accounting system.""" """Chart of accounts synced from external accounting system."""
id: str = Field(default_factory=lambda: str(uuid.uuid4())) id: str = Field(default_factory=lambda: str(uuid.uuid4()))
accountNumber: str = Field(description="Account number (e.g. '1020')") accountNumber: str = Field(description="Account number (e.g. '1020')")
@ -769,7 +767,7 @@ registerModelLabels(
) )
class TrusteeDataJournalEntry(BaseModel): class TrusteeDataJournalEntry(PowerOnModel):
"""Journal entry header synced from external accounting system.""" """Journal entry header synced from external accounting system."""
id: str = Field(default_factory=lambda: str(uuid.uuid4())) id: str = Field(default_factory=lambda: str(uuid.uuid4()))
externalId: Optional[str] = Field(default=None, description="ID in the source system") externalId: Optional[str] = Field(default=None, description="ID in the source system")
@ -799,7 +797,7 @@ registerModelLabels(
) )
class TrusteeDataJournalLine(BaseModel): class TrusteeDataJournalLine(PowerOnModel):
"""Journal entry line (debit/credit) synced from external accounting system.""" """Journal entry line (debit/credit) synced from external accounting system."""
id: str = Field(default_factory=lambda: str(uuid.uuid4())) id: str = Field(default_factory=lambda: str(uuid.uuid4()))
journalEntryId: str = Field(description="FK → TrusteeDataJournalEntry.id") journalEntryId: str = Field(description="FK → TrusteeDataJournalEntry.id")
@ -833,7 +831,7 @@ registerModelLabels(
) )
class TrusteeDataContact(BaseModel): class TrusteeDataContact(PowerOnModel):
"""Customer or vendor synced from external accounting system.""" """Customer or vendor synced from external accounting system."""
id: str = Field(default_factory=lambda: str(uuid.uuid4())) id: str = Field(default_factory=lambda: str(uuid.uuid4()))
externalId: Optional[str] = Field(default=None, description="ID in the source system") externalId: Optional[str] = Field(default=None, description="ID in the source system")
@ -873,7 +871,7 @@ registerModelLabels(
) )
class TrusteeDataAccountBalance(BaseModel): class TrusteeDataAccountBalance(PowerOnModel):
"""Account balance per period, derived from journal lines or directly from accounting system.""" """Account balance per period, derived from journal lines or directly from accounting system."""
id: str = Field(default_factory=lambda: str(uuid.uuid4())) id: str = Field(default_factory=lambda: str(uuid.uuid4()))
accountNumber: str = Field(description="Account number") accountNumber: str = Field(description="Account number")
@ -907,7 +905,7 @@ registerModelLabels(
) )
class TrusteeAccountingConfig(BaseModel): class TrusteeAccountingConfig(PowerOnModel):
"""Per-instance accounting system configuration with encrypted credentials. """Per-instance accounting system configuration with encrypted credentials.
Each feature instance can connect to exactly one accounting system. Each feature instance can connect to exactly one accounting system.
@ -946,7 +944,7 @@ registerModelLabels(
) )
class TrusteeAccountingSync(BaseModel): class TrusteeAccountingSync(PowerOnModel):
"""Tracks which position was synced to which external system and when. """Tracks which position was synced to which external system and when.
Used for duplicate prevention, audit trail, and retry logic. Used for duplicate prevention, audit trail, and retry logic.

View file

@ -1152,7 +1152,7 @@ class TrusteeObjects:
logger.warning(f"Document {documentId} not found") logger.warning(f"Document {documentId} not found")
return None return None
createdBy = existing.get("_createdBy") createdBy = existing.get("sysCreatedBy")
# Check system RBAC permission (userreport can only edit their own records) # Check system RBAC permission (userreport can only edit their own records)
if not self.checkCombinedPermission(TrusteeDocument, "update", recordCreatedBy=createdBy): if not self.checkCombinedPermission(TrusteeDocument, "update", recordCreatedBy=createdBy):
@ -1178,7 +1178,7 @@ class TrusteeObjects:
logger.warning(f"Document {documentId} not found") logger.warning(f"Document {documentId} not found")
return False return False
createdBy = existing.get("_createdBy") createdBy = existing.get("sysCreatedBy")
if not self.checkCombinedPermission(TrusteeDocument, "delete", recordCreatedBy=createdBy): if not self.checkCombinedPermission(TrusteeDocument, "delete", recordCreatedBy=createdBy):
logger.warning(f"User {self.userId} lacks permission to delete document") logger.warning(f"User {self.userId} lacks permission to delete document")
@ -1198,7 +1198,7 @@ class TrusteeObjects:
def _toTrusteePositionOrDelete(self, rawRecord: Dict[str, Any], deleteCorrupt: bool = True) -> Optional[TrusteePosition]: def _toTrusteePositionOrDelete(self, rawRecord: Dict[str, Any], deleteCorrupt: bool = True) -> Optional[TrusteePosition]:
"""Build TrusteePosition safely; optionally delete irreparably corrupt records.""" """Build TrusteePosition safely; optionally delete irreparably corrupt records."""
cleanRecord = {k: v for k, v in (rawRecord or {}).items() if not k.startswith("_") or k == "_createdAt"} cleanRecord = {k: v for k, v in (rawRecord or {}).items() if not k.startswith("_") or k == "sysCreatedAt"}
if not cleanRecord: if not cleanRecord:
return None return None
@ -1271,7 +1271,7 @@ class TrusteeObjects:
"""Get all positions with RBAC filtering and optional DB-level pagination. """Get all positions with RBAC filtering and optional DB-level pagination.
Filtering, sorting, and pagination are handled at the SQL level. Filtering, sorting, and pagination are handled at the SQL level.
Post-processing cleans internal fields (keeps _createdAt) and validates Post-processing cleans internal fields (keeps sysCreatedAt) and validates
each record via _toTrusteePositionOrDelete (corrupt rows are deleted). each record via _toTrusteePositionOrDelete (corrupt rows are deleted).
NOTE(post-process): totalItems may slightly overcount when corrupt legacy NOTE(post-process): totalItems may slightly overcount when corrupt legacy
@ -1288,7 +1288,7 @@ class TrusteeObjects:
featureCode=self.FEATURE_CODE featureCode=self.FEATURE_CODE
) )
keepFields = {'_createdAt'} keepFields = {'sysCreatedAt'}
def _cleanAndValidate(records): def _cleanAndValidate(records):
items = [] items = []
@ -1369,7 +1369,7 @@ class TrusteeObjects:
logger.warning(f"Position {positionId} not found") logger.warning(f"Position {positionId} not found")
return None return None
createdBy = existing.get("_createdBy") createdBy = existing.get("sysCreatedBy")
# Check system RBAC permission (userreport can only edit their own records) # Check system RBAC permission (userreport can only edit their own records)
if not self.checkCombinedPermission(TrusteePosition, "update", recordCreatedBy=createdBy): if not self.checkCombinedPermission(TrusteePosition, "update", recordCreatedBy=createdBy):
@ -1391,7 +1391,7 @@ class TrusteeObjects:
logger.warning(f"Position {positionId} not found") logger.warning(f"Position {positionId} not found")
return False return False
createdBy = existing.get("_createdBy") createdBy = existing.get("sysCreatedBy")
if not self.checkCombinedPermission(TrusteePosition, "delete", recordCreatedBy=createdBy): if not self.checkCombinedPermission(TrusteePosition, "delete", recordCreatedBy=createdBy):
logger.warning(f"User {self.userId} lacks permission to delete position") logger.warning(f"User {self.userId} lacks permission to delete position")

View file

@ -58,10 +58,25 @@ UI_OBJECTS = [
# DATA Objects for RBAC catalog (tables/entities) # DATA Objects for RBAC catalog (tables/entities)
# Used for AccessRules on data-level permissions # Used for AccessRules on data-level permissions
DATA_OBJECTS = [ DATA_OBJECTS = [
{
"objectKey": "data.feature.trustee.TrusteeOrganisation",
"label": {"en": "Organisation", "de": "Organisation", "fr": "Organisation"},
"meta": {
"table": "TrusteeOrganisation",
"fields": ["id", "label", "enabled"],
"isParent": True,
"displayFields": ["label"],
}
},
{ {
"objectKey": "data.feature.trustee.TrusteePosition", "objectKey": "data.feature.trustee.TrusteePosition",
"label": {"en": "Position", "de": "Position", "fr": "Position"}, "label": {"en": "Position", "de": "Position", "fr": "Position"},
"meta": {"table": "TrusteePosition", "fields": ["id", "label", "description", "organisationId"]} "meta": {
"table": "TrusteePosition",
"fields": ["id", "label", "description", "organisationId"],
"parentTable": "TrusteeOrganisation",
"parentKey": "organisationId",
}
}, },
{ {
"objectKey": "data.feature.trustee.TrusteeDocument", "objectKey": "data.feature.trustee.TrusteeDocument",
@ -71,7 +86,12 @@ DATA_OBJECTS = [
{ {
"objectKey": "data.feature.trustee.TrusteeAccountingConfig", "objectKey": "data.feature.trustee.TrusteeAccountingConfig",
"label": {"en": "Accounting Config", "de": "Buchhaltungs-Konfiguration", "fr": "Config. comptable"}, "label": {"en": "Accounting Config", "de": "Buchhaltungs-Konfiguration", "fr": "Config. comptable"},
"meta": {"table": "TrusteeAccountingConfig", "fields": ["id", "connectorType", "displayLabel", "encryptedConfig", "isActive"]} "meta": {
"table": "TrusteeAccountingConfig",
"fields": ["id", "connectorType", "displayLabel", "encryptedConfig", "isActive"],
"parentTable": "TrusteeOrganisation",
"parentKey": "organisationId",
}
}, },
{ {
"objectKey": "data.feature.trustee.TrusteeAccountingSync", "objectKey": "data.feature.trustee.TrusteeAccountingSync",
@ -170,60 +190,81 @@ RESOURCE_OBJECTS = [
# Note: UI item=None means ALL views, specific items restrict to named views # Note: UI item=None means ALL views, specific items restrict to named views
# IMPORTANT: item uses vollqualifizierte ObjectKeys (gemäss Navigation-API-Konzept) # IMPORTANT: item uses vollqualifizierte ObjectKeys (gemäss Navigation-API-Konzept)
TEMPLATE_ROLES = [ TEMPLATE_ROLES = [
{
"roleLabel": "trustee-viewer",
"description": {
"en": "Trustee Viewer - View trustee data (read-only)",
"de": "Treuhand-Betrachter - Treuhand-Daten einsehen (nur lesen)",
"fr": "Visualiseur fiduciaire - Consulter les données fiduciaires (lecture seule)",
},
"accessRules": [
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
{"context": "UI", "item": "ui.feature.trustee.documents", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"},
],
},
{
"roleLabel": "trustee-user",
"description": {
"en": "Trustee User - Create and manage own trustee records",
"de": "Treuhand-Benutzer - Eigene Treuhand-Daten erstellen und verwalten",
"fr": "Utilisateur fiduciaire - Créer et gérer ses propres données fiduciaires",
},
"accessRules": [
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
{"context": "UI", "item": "ui.feature.trustee.documents", "view": True},
{"context": "UI", "item": "ui.feature.trustee.expense-import", "view": True},
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
],
},
{ {
"roleLabel": "trustee-admin", "roleLabel": "trustee-admin",
"description": { "description": {
"en": "Trustee Administrator - Full access to all trustee data and settings", "en": "Trustee Administrator - Full access to all trustee data and settings",
"de": "Treuhand-Administrator - Vollzugriff auf alle Treuhand-Daten und Einstellungen", "de": "Treuhand-Administrator - Vollzugriff auf alle Treuhand-Daten und Einstellungen",
"fr": "Administrateur fiduciaire - Accès complet aux données et paramètres fiduciaires" "fr": "Administrateur fiduciaire - Accès complet aux données et paramètres fiduciaires",
}, },
"accessRules": [ "accessRules": [
# Full UI access (all views including admin views)
{"context": "UI", "item": None, "view": True}, {"context": "UI", "item": None, "view": True},
# Full DATA access
{"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"}, {"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"},
# Admin resource: manage instance roles
{"context": "RESOURCE", "item": "resource.feature.trustee.instance-roles.manage", "view": True}, {"context": "RESOURCE", "item": "resource.feature.trustee.instance-roles.manage", "view": True},
] ],
}, },
{ {
"roleLabel": "trustee-accountant", "roleLabel": "trustee-accountant",
"description": { "description": {
"en": "Trustee Accountant - Manage accounting and financial data", "en": "Trustee Accountant - Manage accounting and financial data",
"de": "Treuhand-Buchhalter - Buchhaltungs- und Finanzdaten verwalten", "de": "Treuhand-Buchhalter - Buchhaltungs- und Finanzdaten verwalten",
"fr": "Comptable fiduciaire - Gérer les données comptables et financières" "fr": "Comptable fiduciaire - Gérer les données comptables et financières",
}, },
"accessRules": [ "accessRules": [
# UI access to main views (not admin views, not expense-import) - vollqualifizierte ObjectKeys
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.trustee.positions", "view": True}, {"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
{"context": "UI", "item": "ui.feature.trustee.documents", "view": True}, {"context": "UI", "item": "ui.feature.trustee.documents", "view": True},
{"context": "UI", "item": "ui.feature.trustee.settings", "view": True}, {"context": "UI", "item": "ui.feature.trustee.settings", "view": True},
# Group-level DATA access
{"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"}, {"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"},
# Accounting sync permission
{"context": "RESOURCE", "item": "resource.feature.trustee.accounting.sync", "view": True}, {"context": "RESOURCE", "item": "resource.feature.trustee.accounting.sync", "view": True},
{"context": "RESOURCE", "item": "resource.feature.trustee.accounting.view", "view": True}, {"context": "RESOURCE", "item": "resource.feature.trustee.accounting.view", "view": True},
] ],
}, },
{ {
"roleLabel": "trustee-client", "roleLabel": "trustee-client",
"description": { "description": {
"en": "Trustee Client - View own accounting data and documents", "en": "Trustee Client - View own accounting data and documents",
"de": "Treuhand-Kunde - Eigene Buchhaltungsdaten und Dokumente einsehen", "de": "Treuhand-Kunde - Eigene Buchhaltungsdaten und Dokumente einsehen",
"fr": "Client fiduciaire - Consulter ses propres données comptables et documents" "fr": "Client fiduciaire - Consulter ses propres données comptables et documents",
}, },
"accessRules": [ "accessRules": [
# UI access to main views + expense-import - vollqualifizierte ObjectKeys
{"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True},
{"context": "UI", "item": "ui.feature.trustee.positions", "view": True}, {"context": "UI", "item": "ui.feature.trustee.positions", "view": True},
{"context": "UI", "item": "ui.feature.trustee.documents", "view": True}, {"context": "UI", "item": "ui.feature.trustee.documents", "view": True},
{"context": "UI", "item": "ui.feature.trustee.expense-import", "view": True}, {"context": "UI", "item": "ui.feature.trustee.expense-import", "view": True},
{"context": "UI", "item": "ui.feature.trustee.scan-upload", "view": True}, {"context": "UI", "item": "ui.feature.trustee.scan-upload", "view": True},
# Own records only (MY level)
{"context": "DATA", "item": "data.feature.trustee.TrusteePosition", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"}, {"context": "DATA", "item": "data.feature.trustee.TrusteePosition", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
{"context": "DATA", "item": "data.feature.trustee.TrusteeDocument", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"}, {"context": "DATA", "item": "data.feature.trustee.TrusteeDocument", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"},
] ],
}, },
] ]

View file

@ -1,30 +1,15 @@
# Copyright (c) 2025 Patrick Motsch # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""Workspace feature data models — VoiceSettings and WorkspaceUserSettings.""" """Workspace feature data models — WorkspaceUserSettings."""
from typing import Dict, Any, Optional from typing import Optional
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.attributeUtils import registerModelLabels from modules.shared.attributeUtils import registerModelLabels
from modules.shared.timeUtils import getUtcTimestamp
import uuid import uuid
class VoiceSettings(BaseModel): class WorkspaceUserSettings(PowerOnModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
userId: str = Field(description="ID of the user these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
mandateId: str = Field(description="ID of the mandate these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
featureInstanceId: str = Field(description="ID of the feature instance these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
sttLanguage: str = Field(default="de-DE", description="Speech-to-Text language", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True})
ttsLanguage: str = Field(default="de-DE", description="Text-to-Speech language", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True})
ttsVoice: str = Field(default="de-DE-KatjaNeural", description="Text-to-Speech voice", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True})
ttsVoiceMap: Dict[str, Any] = Field(default_factory=dict, description="Per-language voice mapping, e.g. {'de-DE': {'voiceName': 'de-DE-Wavenet-A'}, 'en-US': {'voiceName': 'en-US-Wavenet-C'}}", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False})
translationEnabled: bool = Field(default=True, description="Whether translation is enabled", json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False})
targetLanguage: str = Field(default="en-US", description="Target language for translation", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False})
creationDate: float = Field(default_factory=getUtcTimestamp, description="Date when the settings were created (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
lastModified: float = Field(default_factory=getUtcTimestamp, description="Date when the settings were last modified (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False})
class WorkspaceUserSettings(BaseModel):
"""Per-user workspace settings. None values mean 'use instance default'.""" """Per-user workspace settings. None values mean 'use instance default'."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
userId: str = Field(description="User ID", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) userId: str = Field(description="User ID", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True})
@ -33,25 +18,6 @@ class WorkspaceUserSettings(BaseModel):
maxAgentRounds: Optional[int] = Field(default=None, description="Max agent rounds override (None = instance default)", json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": False}) maxAgentRounds: Optional[int] = Field(default=None, description="Max agent rounds override (None = instance default)", json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": False})
registerModelLabels(
"VoiceSettings",
{"en": "Voice Settings", "fr": "Paramètres vocaux"},
{
"id": {"en": "ID", "fr": "ID"},
"userId": {"en": "User ID", "fr": "ID utilisateur"},
"mandateId": {"en": "Mandate ID", "fr": "ID du mandat"},
"featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"},
"sttLanguage": {"en": "STT Language", "fr": "Langue STT"},
"ttsLanguage": {"en": "TTS Language", "fr": "Langue TTS"},
"ttsVoice": {"en": "TTS Voice", "fr": "Voix TTS"},
"ttsVoiceMap": {"en": "TTS Voice Map", "fr": "Carte des voix TTS"},
"translationEnabled": {"en": "Translation Enabled", "fr": "Traduction activée"},
"targetLanguage": {"en": "Target Language", "fr": "Langue cible"},
"creationDate": {"en": "Creation Date", "fr": "Date de création"},
"lastModified": {"en": "Last Modified", "fr": "Dernière modification"},
},
)
registerModelLabels( registerModelLabels(
"WorkspaceUserSettings", "WorkspaceUserSettings",
{"en": "Workspace User Settings", "de": "Workspace Benutzereinstellungen"}, {"en": "Workspace User Settings", "de": "Workspace Benutzereinstellungen"},

View file

@ -1,7 +1,7 @@
# Copyright (c) 2025 Patrick Motsch # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
Interface for Workspace feature manages VoiceSettings and WorkspaceUserSettings. Interface for Workspace feature manages WorkspaceUserSettings.
Uses a dedicated poweron_workspace database. Uses a dedicated poweron_workspace database.
""" """
@ -10,11 +10,10 @@ from typing import Dict, Any, Optional
from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
from modules.features.workspace.datamodelFeatureWorkspace import VoiceSettings, WorkspaceUserSettings from modules.features.workspace.datamodelFeatureWorkspace import WorkspaceUserSettings
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
from modules.security.rbac import RbacClass from modules.security.rbac import RbacClass
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.shared.timeUtils import getUtcTimestamp
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -62,122 +61,6 @@ class WorkspaceObjects:
self.featureInstanceId = featureInstanceId self.featureInstanceId = featureInstanceId
self.db.updateContext(self.userId) self.db.updateContext(self.userId)
# =========================================================================
# VoiceSettings CRUD
# =========================================================================
def getVoiceSettings(self, userId: Optional[str] = None) -> Optional[VoiceSettings]:
try:
targetUserId = userId or self.userId
if not targetUserId:
logger.error("No user ID provided for voice settings")
return None
recordFilter: Dict[str, Any] = {"userId": targetUserId}
if self.featureInstanceId:
recordFilter["featureInstanceId"] = self.featureInstanceId
filteredSettings = getRecordsetWithRBAC(
self.db, VoiceSettings, self.currentUser,
recordFilter=recordFilter, mandateId=self.mandateId,
)
if not filteredSettings:
return None
settingsData = filteredSettings[0]
if not settingsData.get("creationDate"):
settingsData["creationDate"] = getUtcTimestamp()
if not settingsData.get("lastModified"):
settingsData["lastModified"] = getUtcTimestamp()
return VoiceSettings(**settingsData)
except Exception as e:
logger.error(f"Error getting voice settings: {e}")
return None
def createVoiceSettings(self, settingsData: Dict[str, Any]) -> Dict[str, Any]:
try:
if "userId" not in settingsData:
settingsData["userId"] = self.userId
if "mandateId" not in settingsData:
settingsData["mandateId"] = self.mandateId
if "featureInstanceId" not in settingsData:
settingsData["featureInstanceId"] = self.featureInstanceId
existing = self.getVoiceSettings(settingsData["userId"])
if existing:
raise ValueError(f"Voice settings already exist for user {settingsData['userId']}")
createdRecord = self.db.recordCreate(VoiceSettings, settingsData)
if not createdRecord or not createdRecord.get("id"):
raise ValueError("Failed to create voice settings record")
logger.info(f"Created voice settings for user {settingsData['userId']}")
return createdRecord
except Exception as e:
logger.error(f"Error creating voice settings: {e}")
raise
def updateVoiceSettings(self, userId: str, updateData: Dict[str, Any]) -> Dict[str, Any]:
try:
existing = self.getVoiceSettings(userId)
if not existing:
raise ValueError(f"Voice settings not found for user {userId}")
updateData["lastModified"] = getUtcTimestamp()
success = self.db.recordModify(VoiceSettings, existing.id, updateData)
if not success:
raise ValueError("Failed to update voice settings record")
updated = self.getVoiceSettings(userId)
if not updated:
raise ValueError("Failed to retrieve updated voice settings")
logger.info(f"Updated voice settings for user {userId}")
return updated.model_dump()
except Exception as e:
logger.error(f"Error updating voice settings: {e}")
raise
def deleteVoiceSettings(self, userId: str) -> bool:
try:
existing = self.getVoiceSettings(userId)
if not existing:
return False
success = self.db.recordDelete(VoiceSettings, existing.id)
if success:
logger.info(f"Deleted voice settings for user {userId}")
return success
except Exception as e:
logger.error(f"Error deleting voice settings: {e}")
return False
def getOrCreateVoiceSettings(self, userId: Optional[str] = None) -> VoiceSettings:
targetUserId = userId or self.userId
if not targetUserId:
raise ValueError("No user ID provided for voice settings")
existing = self.getVoiceSettings(targetUserId)
if existing:
return existing
defaultSettings = {
"userId": targetUserId,
"mandateId": self.mandateId,
"featureInstanceId": self.featureInstanceId,
"sttLanguage": "de-DE",
"ttsLanguage": "de-DE",
"ttsVoice": "de-DE-KatjaNeural",
"translationEnabled": True,
"targetLanguage": "en-US",
}
createdRecord = self.createVoiceSettings(defaultSettings)
return VoiceSettings(**createdRecord)
# ========================================================================= # =========================================================================
# WorkspaceUserSettings CRUD # WorkspaceUserSettings CRUD
# ========================================================================= # =========================================================================

View file

@ -128,7 +128,7 @@ TEMPLATE_ROLES = [
"accessRules": [ "accessRules": [
{"context": "UI", "item": None, "view": True}, {"context": "UI", "item": None, "view": True},
{"context": "RESOURCE", "item": None, "view": True}, {"context": "RESOURCE", "item": None, "view": True},
# DATA: never ALL in shared instances — every role (including admin) sees only _createdBy = self # DATA: never ALL in shared instances — every role (including admin) sees only sysCreatedBy = self
{"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"}, {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"},
] ]
}, },

View file

@ -76,6 +76,27 @@ class _PendingEditsStore:
_pendingEditsStore = _PendingEditsStore() _pendingEditsStore = _PendingEditsStore()
def _workspaceBillingFeatureCode(user, mandateId: Optional[str], instanceId: str) -> Optional[str]:
"""Resolve FeatureInstance.featureCode for billing/UI when workflow is not on ServiceCenterContext."""
if not instanceId or not str(instanceId).strip():
return None
try:
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
appIf = getAppInterface(user, mandateId=mandateId or None)
inst = appIf.getFeatureInstance(str(instanceId).strip())
if not inst:
return None
if isinstance(inst, dict):
code = inst.get("featureCode")
else:
code = getattr(inst, "featureCode", None)
return str(code).strip() if code else None
except Exception as e:
logger.debug("Workspace: feature code lookup failed for instance %s: %s", instanceId, e)
return None
class WorkspaceInputRequest(BaseModel): class WorkspaceInputRequest(BaseModel):
"""Prompt input for the unified workspace.""" """Prompt input for the unified workspace."""
prompt: str = Field(description="User prompt text") prompt: str = Field(description="User prompt text")
@ -87,6 +108,7 @@ class WorkspaceInputRequest(BaseModel):
workflowId: Optional[str] = Field(default=None, description="Continue existing workflow") workflowId: Optional[str] = Field(default=None, description="Continue existing workflow")
userLanguage: str = Field(default="en", description="User language code") userLanguage: str = Field(default="en", description="User language code")
allowedProviders: List[str] = Field(default_factory=list, description="Restrict AI to these providers") allowedProviders: List[str] = Field(default_factory=list, description="Restrict AI to these providers")
requireNeutralization: Optional[bool] = Field(default=None, description="Per-request neutralization override")
async def _getAiObjects() -> AiObjects: async def _getAiObjects() -> AiObjects:
@ -162,6 +184,7 @@ _SOURCE_TYPE_TO_SERVICE = {
"googleDriveFolder": "drive", "googleDriveFolder": "drive",
"gmailFolder": "gmail", "gmailFolder": "gmail",
"ftpFolder": "files", "ftpFolder": "files",
"clickupList": "clickup",
} }
@ -247,12 +270,19 @@ def _buildFeatureDataSourceContext(featureDataSourceIds: List[str]) -> str:
tableFields = obj.get("meta", {}).get("fields", []) tableFields = obj.get("meta", {}).get("fields", [])
break break
recordFilter = fds.get("recordFilter")
filterLine = ""
if recordFilter and isinstance(recordFilter, dict):
filterParts = [f"{k} = {v}" for k, v in recordFilter.items()]
filterLine = f"\n recordFilter: {', '.join(filterParts)} (data is scoped to this record)"
parts.append( parts.append(
f"- featureInstanceId: {fiId}\n" f"- featureInstanceId: {fiId}\n"
f" feature: {featureCode}\n" f" feature: {featureCode}\n"
f" instance: \"{instanceLabel}\"\n" f" instance: \"{instanceLabel}\"\n"
f" table: {tableName} ({label})\n" f" table: {tableName} ({label})\n"
f" fields: {', '.join(tableFields) if tableFields else 'all'}" f" fields: {', '.join(tableFields) if tableFields else 'all'}"
f"{filterLine}"
) )
except Exception as e: except Exception as e:
logger.warning(f"Error loading FeatureDataSource {fdsId}: {e}") logger.warning(f"Error loading FeatureDataSource {fdsId}: {e}")
@ -545,11 +575,13 @@ async def streamWorkspaceStart(
from modules.serviceCenter import getService from modules.serviceCenter import getService
from modules.serviceCenter.context import ServiceCenterContext from modules.serviceCenter.context import ServiceCenterContext
wsBillingFeatureCode = _workspaceBillingFeatureCode(context.user, mandateId or "", instanceId)
svcCtx = ServiceCenterContext( svcCtx = ServiceCenterContext(
user=context.user, user=context.user,
mandate_id=mandateId or "", mandate_id=mandateId or "",
feature_instance_id=instanceId, feature_instance_id=instanceId,
workflow_id=workflowId, workflow_id=workflowId,
feature_code=wsBillingFeatureCode,
) )
chatSvc = getService("chat", svcCtx) chatSvc = getService("chat", svcCtx)
attachmentLabel = _buildWorkspaceAttachmentLabel( attachmentLabel = _buildWorkspaceAttachmentLabel(
@ -588,6 +620,8 @@ async def streamWorkspaceStart(
userLanguage=userInput.userLanguage, userLanguage=userInput.userLanguage,
instanceConfig=instanceConfig, instanceConfig=instanceConfig,
allowedProviders=userInput.allowedProviders, allowedProviders=userInput.allowedProviders,
requireNeutralization=userInput.requireNeutralization,
billingFeatureCode=wsBillingFeatureCode,
) )
) )
eventManager.register_agent_task(queueId, agentTask) eventManager.register_agent_task(queueId, agentTask)
@ -643,6 +677,8 @@ async def _runWorkspaceAgent(
userLanguage: str = "en", userLanguage: str = "en",
instanceConfig: Dict[str, Any] = None, instanceConfig: Dict[str, Any] = None,
allowedProviders: List[str] = None, allowedProviders: List[str] = None,
requireNeutralization: Optional[bool] = None,
billingFeatureCode: Optional[str] = None,
): ):
"""Run the serviceAgent loop and forward events to the SSE queue.""" """Run the serviceAgent loop and forward events to the SSE queue."""
try: try:
@ -653,6 +689,7 @@ async def _runWorkspaceAgent(
mandate_id=mandateId, mandate_id=mandateId,
feature_instance_id=instanceId, feature_instance_id=instanceId,
workflow_id=workflowId, workflow_id=workflowId,
feature_code=billingFeatureCode,
) )
agentService = getService("agent", ctx) agentService = getService("agent", ctx)
chatService = getService("chat", ctx) chatService = getService("chat", ctx)
@ -660,6 +697,11 @@ async def _runWorkspaceAgent(
if allowedProviders: if allowedProviders:
aiService.services.allowedProviders = allowedProviders aiService.services.allowedProviders = allowedProviders
logger.info(f"Workspace agent: allowedProviders={allowedProviders}")
else:
logger.debug("Workspace agent: no allowedProviders in request")
if requireNeutralization is not None:
ctx.requireNeutralization = requireNeutralization
wfRecord = chatInterface.getWorkflow(workflowId) if workflowId else None wfRecord = chatInterface.getWorkflow(workflowId) if workflowId else None
wfName = "" wfName = ""
@ -887,12 +929,30 @@ async def listWorkspaceWorkflows(
request: Request, request: Request,
instanceId: str = Path(...), instanceId: str = Path(...),
includeArchived: bool = Query(default=False, description="Include archived workflows"), includeArchived: bool = Query(default=False, description="Include archived workflows"),
search: str = Query(default="", description="Fulltext search in workflow titles and message content"),
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
"""List workspace workflows/conversations for this instance.""" """List workspace workflows/conversations for this instance."""
_validateInstanceAccess(instanceId, context) _validateInstanceAccess(instanceId, context)
chatInterface = _getChatInterface(context, featureInstanceId=instanceId) chatInterface = _getChatInterface(context, featureInstanceId=instanceId)
workflows = chatInterface.getWorkflows() or [] workflows = chatInterface.getWorkflows() or []
from modules.interfaces.interfaceDbApp import getRootInterface
rootIf = getRootInterface()
_fiCache: Dict[str, Dict[str, str]] = {}
def _resolveFeatureLabels(fiId: str) -> Dict[str, str]:
if fiId not in _fiCache:
fi = rootIf.getFeatureInstance(fiId)
if fi:
_fiCache[fiId] = {
"featureLabel": getattr(fi, "label", "") or getattr(fi, "featureCode", fiId),
"featureCode": getattr(fi, "featureCode", ""),
}
else:
_fiCache[fiId] = {"featureLabel": fiId[:8], "featureCode": ""}
return _fiCache[fiId]
items = [] items = []
for wf in workflows: for wf in workflows:
if isinstance(wf, dict): if isinstance(wf, dict):
@ -904,13 +964,63 @@ async def listWorkspaceWorkflows(
"status": getattr(wf, "status", ""), "status": getattr(wf, "status", ""),
"startedAt": getattr(wf, "startedAt", None), "startedAt": getattr(wf, "startedAt", None),
"lastActivity": getattr(wf, "lastActivity", None), "lastActivity": getattr(wf, "lastActivity", None),
"featureInstanceId": getattr(wf, "featureInstanceId", instanceId),
} }
if not includeArchived and item.get("status") == "archived": if not includeArchived and item.get("status") == "archived":
continue continue
fiId = item.get("featureInstanceId") or instanceId
labels = _resolveFeatureLabels(fiId)
item.setdefault("featureLabel", labels["featureLabel"])
item.setdefault("featureCode", labels["featureCode"])
item.setdefault("featureInstanceId", fiId)
lastMsg = chatInterface.getLastMessageTimestamp(item.get("id"))
if lastMsg:
item["lastMessageAt"] = lastMsg
items.append(item) items.append(item)
if search and search.strip():
searchLower = search.strip().lower()
matchedIds = set()
for item in items:
if searchLower in (item.get("name") or "").lower() or searchLower in (item.get("label") or "").lower():
matchedIds.add(item["id"])
contentHits = chatInterface.searchWorkflowsByContent(searchLower, limit=50)
matchedIds.update(contentHits)
items = [i for i in items if i["id"] in matchedIds]
return JSONResponse({"workflows": items}) return JSONResponse({"workflows": items})
class ResolveRagRequest(BaseModel):
"""Request body for resolving a chat via RAG."""
chatId: str = Field(..., description="Workflow/chat ID to resolve")
@router.post("/{instanceId}/resolve-rag")
@limiter.limit("60/minute")
async def resolveRag(
request: Request,
instanceId: str = Path(...),
body: ResolveRagRequest = Body(...),
context: RequestContext = Depends(getRequestContext),
):
"""Build a RAG summary for a chat (workflow) to inject into the input area."""
_validateInstanceAccess(instanceId, context)
chatInterface = _getChatInterface(context, featureInstanceId=instanceId)
messages = chatInterface.getMessages(body.chatId) or []
texts = []
for msg in messages[:30]:
content = msg.get("message") if isinstance(msg, dict) else getattr(msg, "message", "")
if content:
texts.append(content[:500])
summary = "\n---\n".join(texts[:10]) if texts else ""
return JSONResponse({"summary": summary, "chatId": body.chatId, "messageCount": len(texts)})
class UpdateWorkflowRequest(BaseModel): class UpdateWorkflowRequest(BaseModel):
"""Request body for updating a workflow (PATCH).""" """Request body for updating a workflow (PATCH)."""
name: Optional[str] = Field(default=None, description="New workflow name") name: Optional[str] = Field(default=None, description="New workflow name")
@ -1233,8 +1343,8 @@ async def listFeatureConnections(
instanceId: str = Path(...), instanceId: str = Path(...),
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
"""List feature instances the user has access to across ALL mandates.""" """List feature instances the user has access to, scoped to the workspace mandate."""
_validateInstanceAccess(instanceId, context) wsMandateId, _ = _validateInstanceAccess(instanceId, context)
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.security.rbacCatalog import getCatalogService from modules.security.rbacCatalog import getCatalogService
from modules.datamodels.datamodelUam import Mandate from modules.datamodels.datamodelUam import Mandate
@ -1249,8 +1359,14 @@ async def listFeatureConnections(
if not userMandates: if not userMandates:
return JSONResponse({"featureConnectionsByMandate": []}) return JSONResponse({"featureConnectionsByMandate": []})
allowedMandateIds = {um.mandateId for um in userMandates}
if wsMandateId and wsMandateId in allowedMandateIds:
allowedMandateIds = {wsMandateId}
mandateLabels: dict = {} mandateLabels: dict = {}
for um in userMandates: for um in userMandates:
if um.mandateId not in allowedMandateIds:
continue
try: try:
rows = rootIf.db.getRecordset(Mandate, recordFilter={"id": um.mandateId}) rows = rootIf.db.getRecordset(Mandate, recordFilter={"id": um.mandateId})
if rows: if rows:
@ -1262,6 +1378,8 @@ async def listFeatureConnections(
byMandate: dict = {} byMandate: dict = {}
seenIds: set = set() seenIds: set = set()
for um in userMandates: for um in userMandates:
if um.mandateId not in allowedMandateIds:
continue
allInstances = rootIf.getFeatureInstancesByMandate(um.mandateId) allInstances = rootIf.getFeatureInstancesByMandate(um.mandateId)
for inst in allInstances: for inst in allInstances:
if inst.id in seenIds: if inst.id in seenIds:
@ -1315,7 +1433,7 @@ async def listFeatureConnectionTables(
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
"""List data tables (DATA_OBJECTS) for a feature instance, filtered by RBAC.""" """List data tables (DATA_OBJECTS) for a feature instance, filtered by RBAC."""
_validateInstanceAccess(instanceId, context) wsMandateId, _ = _validateInstanceAccess(instanceId, context)
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.security.rbacCatalog import getCatalogService from modules.security.rbacCatalog import getCatalogService
@ -1325,6 +1443,8 @@ async def listFeatureConnectionTables(
raise HTTPException(status_code=404, detail="Feature instance not found") raise HTTPException(status_code=404, detail="Feature instance not found")
mandateId = str(inst.mandateId) if inst.mandateId else None mandateId = str(inst.mandateId) if inst.mandateId else None
if wsMandateId and mandateId and mandateId != wsMandateId:
raise HTTPException(status_code=403, detail="Feature instance does not belong to workspace mandate")
catalog = getCatalogService() catalog = getCatalogService()
try: try:
@ -1345,16 +1465,132 @@ async def listFeatureConnectionTables(
tables = [] tables = []
for obj in accessible: for obj in accessible:
meta = obj.get("meta", {}) meta = obj.get("meta", {})
tables.append({ node = {
"objectKey": obj.get("objectKey", ""), "objectKey": obj.get("objectKey", ""),
"tableName": meta.get("table", ""), "tableName": meta.get("table", ""),
"label": obj.get("label", {}), "label": obj.get("label", {}),
"fields": meta.get("fields", []), "fields": meta.get("fields", []),
}) }
if meta.get("isParent"):
node["isParent"] = True
node["displayFields"] = meta.get("displayFields", [])
if meta.get("parentTable"):
node["parentTable"] = meta["parentTable"]
node["parentKey"] = meta.get("parentKey", "")
tables.append(node)
return JSONResponse({"tables": tables}) return JSONResponse({"tables": tables})
@router.get("/{instanceId}/feature-connections/{fiId}/parent-objects/{tableName}")
@limiter.limit("120/minute")
async def listParentObjects(
request: Request,
instanceId: str = Path(...),
fiId: str = Path(..., description="Feature instance ID"),
tableName: str = Path(..., description="Parent table name from DATA_OBJECTS"),
context: RequestContext = Depends(getRequestContext),
):
"""List records from a parent table so the user can pick a specific record to scope data."""
wsMandateId, _ = _validateInstanceAccess(instanceId, context)
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.security.rbacCatalog import getCatalogService
rootIf = getRootInterface()
inst = rootIf.getFeatureInstance(fiId)
if not inst:
raise HTTPException(status_code=404, detail="Feature instance not found")
featureCode = inst.featureCode
mandateId = str(inst.mandateId) if inst.mandateId else ""
if wsMandateId and mandateId and mandateId != wsMandateId:
raise HTTPException(status_code=403, detail="Feature instance does not belong to workspace mandate")
catalog = getCatalogService()
parentObj = None
for obj in catalog.getDataObjects(featureCode):
meta = obj.get("meta", {})
if meta.get("table") == tableName and meta.get("isParent"):
parentObj = obj
break
if not parentObj:
raise HTTPException(status_code=400, detail=f"Table '{tableName}' is not a registered parent table")
displayFields = parentObj["meta"].get("displayFields", [])
selectCols = ', '.join(f'"{f}"' for f in (["id"] + displayFields)) if displayFields else "*"
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG
featureDbName = f"poweron_{featureCode.lower()}"
featureDbConn = None
try:
featureDbConn = DatabaseConnector(
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
dbDatabase=featureDbName,
dbUser=APP_CONFIG.get("DB_USER"),
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"),
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
userId=str(context.user.id),
)
conn = featureDbConn.connection
with conn.cursor() as cur:
cur.execute(
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = 'public' AND LOWER(table_name) = LOWER(%s) "
"AND column_name IN ('featureInstanceId', 'instanceId')",
[tableName],
)
instanceCols = [row["column_name"] for row in cur.fetchall()]
instanceCol = "featureInstanceId" if "featureInstanceId" in instanceCols else "instanceId"
cur.execute(
"SELECT column_name FROM information_schema.columns "
"WHERE table_schema = 'public' AND LOWER(table_name) = LOWER(%s) "
"AND column_name = 'userId'",
[tableName],
)
hasUserId = cur.rowcount > 0
sql = (
f'SELECT {selectCols} FROM "{tableName}" '
f'WHERE "{instanceCol}" = %s'
)
params = [fiId]
if mandateId:
sql += ' AND "mandateId" = %s'
params.append(mandateId)
if hasUserId:
sql += ' AND "userId" = %s'
params.append(str(context.user.id))
sql += ' ORDER BY "id" DESC LIMIT 100'
cur.execute(sql, params)
rows = []
for row in cur.fetchall():
r = dict(row)
for k, v in r.items():
if hasattr(v, "isoformat"):
r[k] = v.isoformat()
elif isinstance(v, (bytes, bytearray)):
r[k] = f"<binary {len(v)} bytes>"
displayParts = [str(r.get(f, "")) for f in displayFields if r.get(f) is not None]
rows.append({
"id": r.get("id", ""),
"displayLabel": " | ".join(displayParts) if displayParts else r.get("id", ""),
"fields": {f: r.get(f) for f in displayFields},
})
except Exception as e:
logger.error(f"listParentObjects({tableName}) failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=f"Failed to list parent objects: {e}")
finally:
if featureDbConn:
try:
featureDbConn.close()
except Exception:
pass
return JSONResponse({"parentObjects": rows})
class CreateFeatureDataSourceRequest(BaseModel): class CreateFeatureDataSourceRequest(BaseModel):
"""Request body for adding a feature table as data source.""" """Request body for adding a feature table as data source."""
featureInstanceId: str = Field(description="Feature instance ID") featureInstanceId: str = Field(description="Feature instance ID")
@ -1362,6 +1598,7 @@ class CreateFeatureDataSourceRequest(BaseModel):
tableName: str = Field(description="Table name from DATA_OBJECTS") tableName: str = Field(description="Table name from DATA_OBJECTS")
objectKey: str = Field(description="RBAC object key") objectKey: str = Field(description="RBAC object key")
label: str = Field(description="User-visible label") label: str = Field(description="User-visible label")
recordFilter: Optional[dict] = Field(default=None, description="Record-level filter for scoping")
@router.post("/{instanceId}/feature-datasources") @router.post("/{instanceId}/feature-datasources")
@ -1373,13 +1610,15 @@ async def createFeatureDataSource(
context: RequestContext = Depends(getRequestContext), context: RequestContext = Depends(getRequestContext),
): ):
"""Create a FeatureDataSource for this workspace instance.""" """Create a FeatureDataSource for this workspace instance."""
_validateInstanceAccess(instanceId, context) wsMandateId, _ = _validateInstanceAccess(instanceId, context)
from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
rootIf = getRootInterface() rootIf = getRootInterface()
inst = rootIf.getFeatureInstance(body.featureInstanceId) inst = rootIf.getFeatureInstance(body.featureInstanceId)
mandateId = str(inst.mandateId) if inst else (str(context.mandateId) if context.mandateId else "") mandateId = str(inst.mandateId) if inst else (str(context.mandateId) if context.mandateId else "")
if wsMandateId and mandateId and mandateId != wsMandateId:
raise HTTPException(status_code=403, detail="Feature instance does not belong to workspace mandate")
fds = FeatureDataSource( fds = FeatureDataSource(
featureInstanceId=body.featureInstanceId, featureInstanceId=body.featureInstanceId,
@ -1390,6 +1629,7 @@ async def createFeatureDataSource(
mandateId=mandateId, mandateId=mandateId,
userId=str(context.user.id), userId=str(context.user.id),
workspaceInstanceId=instanceId, workspaceInstanceId=instanceId,
recordFilter=body.recordFilter,
) )
created = rootIf.db.recordCreate(FeatureDataSource, fds.model_dump()) created = rootIf.db.recordCreate(FeatureDataSource, fds.model_dump())
return JSONResponse(created if isinstance(created, dict) else fds.model_dump()) return JSONResponse(created if isinstance(created, dict) else fds.model_dump())
@ -1582,137 +1822,6 @@ async def synthesizeVoice(
return JSONResponse({"audio": None, "note": "TTS via browser Speech Synthesis API recommended"}) return JSONResponse({"audio": None, "note": "TTS via browser Speech Synthesis API recommended"})
# =========================================================================
# Voice Settings Endpoints
# =========================================================================
@router.get("/{instanceId}/settings/voice")
@limiter.limit("120/minute")
async def getVoiceSettings(
request: Request,
instanceId: str = Path(...),
context: RequestContext = Depends(getRequestContext),
):
"""Load voice settings for the current user and instance."""
_validateInstanceAccess(instanceId, context)
wsInterface = _getWorkspaceInterface(context, instanceId)
userId = str(context.user.id)
try:
vs = wsInterface.getVoiceSettings(userId)
if not vs:
logger.info(f"GET voice settings: not found for user={userId}, creating defaults")
vs = wsInterface.getOrCreateVoiceSettings(userId)
result = vs.model_dump() if vs else {}
mapKeys = list(result.get("ttsVoiceMap", {}).keys()) if result else []
logger.info(f"GET voice settings for user={userId}: ttsVoiceMap languages={mapKeys}")
return JSONResponse(result)
except Exception as e:
logger.error(f"Failed to load voice settings for user={userId}: {e}", exc_info=True)
return JSONResponse({"ttsVoiceMap": {}}, status_code=200)
@router.put("/{instanceId}/settings/voice")
@limiter.limit("120/minute")
async def updateVoiceSettings(
request: Request,
instanceId: str = Path(...),
body: dict = Body(...),
context: RequestContext = Depends(getRequestContext),
):
"""Update voice settings for the current user and instance."""
_validateInstanceAccess(instanceId, context)
wsInterface = _getWorkspaceInterface(context, instanceId)
userId = str(context.user.id)
try:
logger.info(f"PUT voice settings for user={userId}, instance={instanceId}, body keys={list(body.keys())}")
vs = wsInterface.getVoiceSettings(userId)
if not vs:
logger.info(f"No existing voice settings, creating new for user={userId}")
createData = {
"userId": userId,
"mandateId": str(context.mandateId) if context.mandateId else "",
"featureInstanceId": instanceId,
}
createData.update(body)
created = wsInterface.createVoiceSettings(createData)
logger.info(f"Created voice settings for user={userId}, ttsVoiceMap keys={list((created or {}).get('ttsVoiceMap', {}).keys())}")
return JSONResponse(created)
updateData = {k: v for k, v in body.items() if k not in ("id", "userId", "mandateId", "featureInstanceId", "creationDate")}
logger.info(f"Updating voice settings for user={userId}, update keys={list(updateData.keys())}")
updated = wsInterface.updateVoiceSettings(userId, updateData)
logger.info(f"Updated voice settings for user={userId}, ttsVoiceMap keys={list((updated or {}).get('ttsVoiceMap', {}).keys())}")
return JSONResponse(updated)
except Exception as e:
logger.error(f"Failed to update voice settings for user={userId}: {e}", exc_info=True)
return JSONResponse({"error": str(e)}, status_code=500)
@router.get("/{instanceId}/voice/languages")
@limiter.limit("120/minute")
async def getVoiceLanguages(
request: Request,
instanceId: str = Path(...),
context: RequestContext = Depends(getRequestContext),
):
"""Return available TTS languages."""
mandateId, _ = _validateInstanceAccess(instanceId, context)
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
voiceInterface = getVoiceInterface(context.user, mandateId)
languagesResult = await voiceInterface.getAvailableLanguages()
languageList = languagesResult.get("languages", []) if isinstance(languagesResult, dict) else languagesResult
return JSONResponse({"languages": languageList})
@router.get("/{instanceId}/voice/voices")
@limiter.limit("120/minute")
async def getVoiceVoices(
request: Request,
instanceId: str = Path(...),
language: str = Query("de-DE"),
context: RequestContext = Depends(getRequestContext),
):
"""Return available TTS voices for a given language."""
mandateId, _ = _validateInstanceAccess(instanceId, context)
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
voiceInterface = getVoiceInterface(context.user, mandateId)
voicesResult = await voiceInterface.getAvailableVoices(language)
voiceList = voicesResult.get("voices", []) if isinstance(voicesResult, dict) else voicesResult
return JSONResponse({"voices": voiceList})
@router.post("/{instanceId}/voice/test")
@limiter.limit("30/minute")
async def testVoice(
request: Request,
instanceId: str = Path(...),
body: dict = Body(...),
context: RequestContext = Depends(getRequestContext),
):
"""Test a specific voice with a sample text."""
import base64
mandateId, _ = _validateInstanceAccess(instanceId, context)
text = body.get("text", "Hallo, das ist ein Stimmtest.")
language = body.get("language", "de-DE")
voiceId = body.get("voiceId")
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
voiceInterface = getVoiceInterface(context.user, mandateId)
try:
result = await voiceInterface.textToSpeech(text=text, languageCode=language, voiceName=voiceId)
if result and isinstance(result, dict):
audioContent = result.get("audioContent")
if audioContent:
audioB64 = base64.b64encode(
audioContent if isinstance(audioContent, bytes) else audioContent.encode()
).decode()
return JSONResponse({"success": True, "audio": audioB64, "format": "mp3", "text": text})
return JSONResponse({"success": False, "error": "TTS returned no audio"})
except Exception as e:
logger.error(f"Voice test failed: {e}")
raise HTTPException(status_code=500, detail=f"TTS test failed: {str(e)}")
# ============================================================================= # =============================================================================

View file

@ -134,7 +134,7 @@ class AiObjects:
logger.info(f"Attempting AI call with model: {model.name} (attempt {attempt + 1}/{len(failoverModelList)})") logger.info(f"Attempting AI call with model: {model.name} (attempt {attempt + 1}/{len(failoverModelList)})")
if request.messages: if request.messages:
response = await self._callWithMessages(model, request.messages, options, request.tools) response = await self._callWithMessages(model, request.messages, options, request.tools, toolChoice=request.toolChoice)
else: else:
response = await self._callWithModel(model, prompt, context, options) response = await self._callWithModel(model, prompt, context, options)
@ -149,7 +149,7 @@ class AiObjects:
await asyncio.sleep(retryAfter + 0.5) await asyncio.sleep(retryAfter + 0.5)
try: try:
if request.messages: if request.messages:
response = await self._callWithMessages(model, request.messages, options, request.tools) response = await self._callWithMessages(model, request.messages, options, request.tools, toolChoice=request.toolChoice)
else: else:
response = await self._callWithModel(model, prompt, context, options) response = await self._callWithModel(model, prompt, context, options)
logger.info(f"AI call successful with {model.name} after rate-limit retry") logger.info(f"AI call successful with {model.name} after rate-limit retry")
@ -288,7 +288,8 @@ class AiObjects:
async def _callWithMessages(self, model: AiModel, messages: List[Dict[str, Any]], async def _callWithMessages(self, model: AiModel, messages: List[Dict[str, Any]],
options: AiCallOptions = None, options: AiCallOptions = None,
tools: List[Dict[str, Any]] = None) -> AiCallResponse: tools: List[Dict[str, Any]] = None,
toolChoice: Any = None) -> AiCallResponse:
"""Call a model with pre-built messages (agent mode). Supports tools for native function calling.""" """Call a model with pre-built messages (agent mode). Supports tools for native function calling."""
import json as _json import json as _json
@ -302,7 +303,8 @@ class AiObjects:
messages=messages, messages=messages,
model=model, model=model,
options=options or {}, options=options or {},
tools=tools tools=tools,
toolChoice=toolChoice,
) )
modelResponse = await model.functionCall(modelCall) modelResponse = await model.functionCall(modelCall)
@ -379,7 +381,7 @@ class AiObjects:
for attempt, model in enumerate(failoverModelList): for attempt, model in enumerate(failoverModelList):
try: try:
logger.info(f"Streaming AI call with model: {model.name} (attempt {attempt + 1})") logger.info(f"Streaming AI call with model: {model.name} (attempt {attempt + 1})")
async for chunk in self._callWithMessagesStream(model, request.messages, options, request.tools): async for chunk in self._callWithMessagesStream(model, request.messages, options, request.tools, toolChoice=request.toolChoice):
yield chunk yield chunk
return return
@ -390,7 +392,7 @@ class AiObjects:
logger.info(f"Rate limit on {model.name}, waiting {retryAfter:.1f}s before retry") logger.info(f"Rate limit on {model.name}, waiting {retryAfter:.1f}s before retry")
await asyncio.sleep(retryAfter + 0.5) await asyncio.sleep(retryAfter + 0.5)
try: try:
async for chunk in self._callWithMessagesStream(model, request.messages, options, request.tools): async for chunk in self._callWithMessagesStream(model, request.messages, options, request.tools, toolChoice=request.toolChoice):
yield chunk yield chunk
return return
except Exception as retryErr: except Exception as retryErr:
@ -421,6 +423,7 @@ class AiObjects:
async def _callWithMessagesStream( async def _callWithMessagesStream(
self, model: AiModel, messages: List[Dict[str, Any]], self, model: AiModel, messages: List[Dict[str, Any]],
options: AiCallOptions = None, tools: List[Dict[str, Any]] = None, options: AiCallOptions = None, tools: List[Dict[str, Any]] = None,
toolChoice: Any = None,
) -> AsyncGenerator[Union[str, AiCallResponse], None]: ) -> AsyncGenerator[Union[str, AiCallResponse], None]:
"""Stream a model call. Yields str deltas, then final AiCallResponse with billing.""" """Stream a model call. Yields str deltas, then final AiCallResponse with billing."""
from modules.datamodels.datamodelAi import AiModelCall, AiModelResponse from modules.datamodels.datamodelAi import AiModelCall, AiModelResponse
@ -429,7 +432,7 @@ class AiObjects:
startTime = time.time() startTime = time.time()
if not model.functionCallStream: if not model.functionCallStream:
response = await self._callWithMessages(model, messages, options, tools) response = await self._callWithMessages(model, messages, options, tools, toolChoice=toolChoice)
if response.content: if response.content:
yield response.content yield response.content
yield response yield response
@ -438,6 +441,7 @@ class AiObjects:
modelCall = AiModelCall( modelCall = AiModelCall(
messages=messages, model=model, messages=messages, model=model,
options=options or {}, tools=tools, options=options or {}, tools=tools,
toolChoice=toolChoice,
) )
finalModelResponse = None finalModelResponse = None

View file

@ -11,9 +11,9 @@ Multi-Tenant Design:
""" """
import logging import logging
from typing import Optional, Dict from typing import Optional, Dict, Tuple
from passlib.context import CryptContext from passlib.context import CryptContext
from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.connectors.connectorDbPostgre import DatabaseConnector, _get_cached_connector
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.datamodels.datamodelUam import ( from modules.datamodels.datamodelUam import (
Mandate, Mandate,
@ -38,6 +38,89 @@ pwdContext = CryptContext(schemes=["argon2"], deprecated="auto")
# Cache für Role-IDs (roleLabel -> roleId) # Cache für Role-IDs (roleLabel -> roleId)
_roleIdCache: Dict[str, str] = {} _roleIdCache: Dict[str, str] = {}
# PowerOn logical databases to scan (same set as gateway/scripts/script_db_export_migration.py ALL_DATABASES).
_POWERON_DATABASE_NAMES: Tuple[str, ...] = (
"poweron_app",
"poweron_automation",
"poweron_automation2",
"poweron_billing",
"poweron_chat",
"poweron_chatbot",
"poweron_commcoach",
"poweron_knowledge",
"poweron_management",
"poweron_neutralization",
"poweron_realestate",
"poweron_teamsbot",
"poweron_test",
"poweron_trustee",
"poweron_workspace",
)
def _configPrefixForPoweronDatabase(dbName: str) -> str:
return {
"poweron_app": "DB_APP",
"poweron_chat": "DB_CHAT",
"poweron_chatbot": "DB_CHATBOT",
"poweron_management": "DB_MANAGEMENT",
"poweron_realestate": "DB_REALESTATE",
"poweron_trustee": "DB_TRUSTEE",
# Same as initAutomationTemplates: default DB_* (not a separate DB_AUTOMATION_* prefix).
"poweron_automation": "DB",
"poweron_billing": "DB",
}.get(dbName, "DB")
def _openConnectorForPoweronDatabase(dbName: str) -> Optional[DatabaseConnector]:
"""Connect to a named PowerOn database using DB_* / DB_APP_* style config (shared with export script)."""
prefix = _configPrefixForPoweronDatabase(dbName)
host = APP_CONFIG.get(f"{prefix}_HOST") or APP_CONFIG.get("DB_HOST", "localhost")
user = APP_CONFIG.get(f"{prefix}_USER") or APP_CONFIG.get("DB_USER")
password = APP_CONFIG.get(f"{prefix}_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD_SECRET")
portRaw = APP_CONFIG.get(f"{prefix}_PORT") or APP_CONFIG.get("DB_PORT", 5432)
try:
port = int(portRaw)
except (TypeError, ValueError):
port = 5432
if not user or not password:
logger.debug(
f"bootstrap: skip legacy _* -> sys* migration for {dbName} (missing credentials for {prefix})"
)
return None
try:
return _get_cached_connector(
dbHost=host,
dbDatabase=dbName,
dbUser=user,
dbPassword=password,
dbPort=port,
userId=None,
)
except Exception as e:
logger.warning(f"bootstrap: cannot open {dbName} for legacy _* -> sys* migration: {e}")
return None
def migrateLegacyUnderscoreSysColumnsAllPoweronDatabases() -> None:
"""
Run DatabaseConnector.migrateLegacyUnderscoreSysColumns on every configured PowerOn database.
Actual table scan and SQL live in the connector module.
"""
grandTotal = 0
for dbName in _POWERON_DATABASE_NAMES:
conn = _openConnectorForPoweronDatabase(dbName)
if not conn:
continue
try:
grandTotal += conn.migrateLegacyUnderscoreSysColumns()
except Exception as e:
logger.warning(f"bootstrap: migrateLegacyUnderscoreSysColumns failed for {dbName}: {e}")
if grandTotal:
logger.info(
f"bootstrap: legacy _* -> sys* migration total {grandTotal} cell(s) across PowerOn databases"
)
def initBootstrap(db: DatabaseConnector) -> None: def initBootstrap(db: DatabaseConnector) -> None:
""" """
@ -51,6 +134,9 @@ def initBootstrap(db: DatabaseConnector) -> None:
# Initialize root mandate # Initialize root mandate
mandateId = initRootMandate(db) mandateId = initRootMandate(db)
# Copy legacy _createdAt/_createdBy/_modifiedAt/_modifiedBy into sys* on all PowerOn DBs (connector routine)
migrateLegacyUnderscoreSysColumnsAllPoweronDatabases()
# Migrate existing mandate records: description -> label # Migrate existing mandate records: description -> label
_migrateMandateDescriptionToLabel(db) _migrateMandateDescriptionToLabel(db)
@ -92,8 +178,38 @@ def initBootstrap(db: DatabaseConnector) -> None:
# Seed automation templates (after admin user exists) # Seed automation templates (after admin user exists)
initAutomationTemplates(db, adminUserId) initAutomationTemplates(db, adminUserId)
# Initialize feature instances for root mandate # Run root-user migration (one-time, sets completion flag)
if mandateId: migrationDone = False
try:
from modules.migration.migrateRootUsers import migrateRootUsers, _isMigrationCompleted
migrationDone = _isMigrationCompleted(db)
if not migrationDone:
# Create root instances first (needed for migration), then migrate
if mandateId:
initRootMandateFeatures(db, mandateId)
result = migrateRootUsers(db)
migrationDone = result.get("status") != "error"
else:
migrationDone = True
except Exception as e:
logger.error(f"Root user migration failed: {e}")
# Run voice & documents migration (one-time, sets completion flag)
try:
from modules.migration.migrateVoiceAndDocuments import migrateVoiceAndDocuments
migrateVoiceAndDocuments(db)
except Exception as e:
logger.error(f"Voice & documents migration failed: {e}")
# Backfill FileContentIndex scope fields from FileItem (one-time)
try:
from modules.migration.migrateRagScopeFields import runMigration as migrateRagScope
migrateRagScope(appDb=db)
except Exception as e:
logger.error(f"RAG scope fields migration failed: {e}")
# After migration: root mandate is purely technical — no feature instances
if not migrationDone and mandateId:
initRootMandateFeatures(db, mandateId) initRootMandateFeatures(db, mandateId)
# Remove feature instances for features that no longer exist in the codebase # Remove feature instances for features that no longer exist in the codebase
@ -110,18 +226,26 @@ def initBootstrap(db: DatabaseConnector) -> None:
# Auto-provision Stripe Products/Prices for paid plans (idempotent) # Auto-provision Stripe Products/Prices for paid plans (idempotent)
_bootstrapStripePrices() _bootstrapStripePrices()
# Purge soft-deleted mandates past 30-day retention
try:
from modules.interfaces.interfaceDbApp import getRootInterface
rootIf = getRootInterface()
rootIf.purgeExpiredMandates(retentionDays=30)
except Exception as e:
logger.warning(f"Mandate retention purge failed: {e}")
def initAutomationTemplates(dbApp: DatabaseConnector, adminUserId: Optional[str] = None) -> None: def initAutomationTemplates(dbApp: DatabaseConnector, adminUserId: Optional[str] = None) -> None:
""" """
Seed initial automation templates from subAutomationTemplates.py. Seed initial automation templates from subAutomationTemplates.py.
Only runs if no templates exist yet (bootstrap). Only runs if no templates exist yet (bootstrap).
Creates templates with _createdBy = admin user (SysAdmin privilege). Creates templates with sysCreatedBy = admin user (SysAdmin privilege).
NOTE: AutomationTemplate lives in poweron_automation database, not poweron_app! NOTE: AutomationTemplate lives in poweron_automation database, not poweron_app!
Args: Args:
dbApp: Database connector for poweron_app (used to get admin user if needed) dbApp: Database connector for poweron_app (used to get admin user if needed)
adminUserId: Admin user ID for _createdBy field adminUserId: Admin user ID for sysCreatedBy field
""" """
import json import json
from modules.features.automation.subAutomationTemplates import AUTOMATION_TEMPLATES from modules.features.automation.subAutomationTemplates import AUTOMATION_TEMPLATES
@ -2004,71 +2128,43 @@ def _createStoreResourceRules(db: DatabaseConnector) -> None:
def initRootMandateBilling(mandateId: str) -> None: def initRootMandateBilling(mandateId: str) -> None:
""" """
Initialize billing settings for root mandate. Initialize billing settings for root mandate (PREPAY_MANDATE).
Root mandate uses PREPAY_USER model with default initial credit per user in settings (DEFAULT_USER_CREDIT_CHF at bootstrap only). Creates mandate pool account and user audit accounts.
Creates billing accounts for ALL users regardless of billing model (for audit trail).
Args:
mandateId: Root mandate ID
""" """
try: try:
from modules.interfaces.interfaceDbBilling import _getRootInterface from modules.interfaces.interfaceDbBilling import _getRootInterface
from modules.interfaces.interfaceDbApp import getRootInterface as getAppRootInterface from modules.interfaces.interfaceDbApp import getRootInterface as getAppRootInterface
from modules.datamodels.datamodelBilling import ( from modules.datamodels.datamodelBilling import BillingSettings
BillingSettings,
BillingModelEnum,
DEFAULT_USER_CREDIT_CHF,
parseBillingModelFromStoredValue,
)
billingInterface = _getRootInterface() billingInterface = _getRootInterface()
appInterface = getAppRootInterface() appInterface = getAppRootInterface()
# Check if settings already exist
existingSettings = billingInterface.getSettings(mandateId) existingSettings = billingInterface.getSettings(mandateId)
if existingSettings: if existingSettings:
logger.info("Billing settings for root mandate already exist") logger.info("Billing settings for root mandate already exist")
else: else:
settings = BillingSettings( settings = BillingSettings(
mandateId=mandateId, mandateId=mandateId,
billingModel=BillingModelEnum.PREPAY_USER,
defaultUserCredit=DEFAULT_USER_CREDIT_CHF,
warningThresholdPercent=10.0, warningThresholdPercent=10.0,
notifyOnWarning=True notifyOnWarning=True
) )
billingInterface.createSettings(settings) billingInterface.createSettings(settings)
logger.info( logger.info("Created billing settings for root mandate: PREPAY_MANDATE")
f"Created billing settings for root mandate: PREPAY_USER with {DEFAULT_USER_CREDIT_CHF} CHF default credit"
)
existingSettings = billingInterface.getSettings(mandateId) existingSettings = billingInterface.getSettings(mandateId)
# Always create user accounts for all users (audit trail)
if existingSettings: if existingSettings:
billingModel = parseBillingModelFromStoredValue( billingInterface.getOrCreateMandateAccount(mandateId, initialBalance=0.0)
existingSettings.get("billingModel")
).value
# Initial balance depends on billing model
if billingModel == BillingModelEnum.PREPAY_USER.value:
initialBalance = float(existingSettings.get("defaultUserCredit", 0.0))
else:
initialBalance = 0.0 # PREPAY_MANDATE: budget on pool account
userMandates = appInterface.getUserMandatesByMandate(mandateId) userMandates = appInterface.getUserMandatesByMandate(mandateId)
accountsCreated = 0 accountsCreated = 0
for um in userMandates: for um in userMandates:
userId = um.get("userId") if isinstance(um, dict) else getattr(um, "userId", None) userId = um.get("userId") if isinstance(um, dict) else getattr(um, "userId", None)
if userId: if userId:
existingAccount = billingInterface.getUserAccount(mandateId, userId) existingAccount = billingInterface.getUserAccount(mandateId, userId)
if not existingAccount: if not existingAccount:
billingInterface.getOrCreateUserAccount(mandateId, userId, initialBalance=initialBalance) billingInterface.getOrCreateUserAccount(mandateId, userId, initialBalance=0.0)
accountsCreated += 1 accountsCreated += 1
logger.debug(f"Created billing account for user {userId}")
if accountsCreated > 0: if accountsCreated > 0:
logger.info(f"Created {accountsCreated} billing accounts for root mandate users with {initialBalance} CHF each") logger.info(f"Created {accountsCreated} billing audit accounts for root mandate users")
except Exception as e: except Exception as e:
logger.warning(f"Failed to initialize root mandate billing (non-critical): {e}") logger.warning(f"Failed to initialize root mandate billing (non-critical): {e}")

File diff suppressed because it is too large Load diff

View file

@ -9,7 +9,7 @@ All billing data is stored in the poweron_billing database.
import logging import logging
from typing import Dict, Any, List, Optional, Union from typing import Dict, Any, List, Optional, Union
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta, timezone
import uuid import uuid
from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.connectors.connectorDbPostgre import DatabaseConnector
@ -24,19 +24,49 @@ from modules.datamodels.datamodelBilling import (
BillingSettings, BillingSettings,
StripeWebhookEvent, StripeWebhookEvent,
UsageStatistics, UsageStatistics,
BillingModelEnum,
AccountTypeEnum,
TransactionTypeEnum, TransactionTypeEnum,
ReferenceTypeEnum, ReferenceTypeEnum,
PeriodTypeEnum, PeriodTypeEnum,
BillingBalanceResponse, BillingBalanceResponse,
BillingCheckResult, BillingCheckResult,
parseBillingModelFromStoredValue, STORAGE_PRICE_PER_GB_CHF,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _logBillingTransactionsMissingSysCreatedAt(rows: List[Dict[str, Any]], context: str) -> None:
"""Log ERROR when sysCreatedAt is missing; does not raise."""
missingIds = [r.get("id") for r in rows if r.get("sysCreatedAt") is None]
if not missingIds:
return
cap = 40
sample = missingIds[:cap]
suffix = f"; ... (+{len(missingIds) - cap} more)" if len(missingIds) > cap else ""
logger.error(
"BillingTransaction missing sysCreatedAt (%s): count=%s; transactionIds=%s%s",
context,
len(missingIds),
sample,
suffix,
)
def _numericSysCreatedAtForSort(row: Dict[str, Any]) -> float:
v = row["sysCreatedAt"]
if isinstance(v, datetime):
return v.timestamp()
return float(v)
def _sortBillingTransactionsBySysCreatedAtDesc(rows: List[Dict[str, Any]], context: str) -> None:
_logBillingTransactionsMissingSysCreatedAt(rows, context)
valid = [r for r in rows if r.get("sysCreatedAt") is not None]
invalid = [r for r in rows if r.get("sysCreatedAt") is None]
valid.sort(key=_numericSysCreatedAtForSort, reverse=True)
rows[:] = valid + invalid
def _getAppDatabaseConnector() -> DatabaseConnector: def _getAppDatabaseConnector() -> DatabaseConnector:
"""App DB connector (same config as UserMandate reads in this module).""" """App DB connector (same config as UserMandate reads in this module)."""
return DatabaseConnector( return DatabaseConnector(
@ -160,8 +190,6 @@ class BillingObjects:
""" """
Get billing settings for a mandate. Get billing settings for a mandate.
Normalizes billingModel for API (legacy UNLIMITED PREPAY_MANDATE) and persists once.
Args: Args:
mandateId: Mandate ID mandateId: Mandate ID
@ -175,27 +203,7 @@ class BillingObjects:
) )
if not results: if not results:
return None return None
row = dict(results[0]) return dict(results[0])
raw_bm = row.get("billingModel")
parsed = parseBillingModelFromStoredValue(raw_bm)
if str(raw_bm or "").strip().upper() == "UNLIMITED":
try:
self.updateSettings(
row["id"],
{"billingModel": BillingModelEnum.PREPAY_MANDATE.value},
)
logger.info(
"Migrated billing settings for mandate %s: UNLIMITED → PREPAY_MANDATE",
mandateId,
)
except Exception as mig_err:
logger.warning(
"Could not persist billing model migration for mandate %s: %s",
mandateId,
mig_err,
)
row["billingModel"] = parsed.value
return row
except Exception as e: except Exception as e:
logger.error(f"Error getting billing settings: {e}") logger.error(f"Error getting billing settings: {e}")
return None return None
@ -226,13 +234,12 @@ class BillingObjects:
""" """
return self.db.recordModify(BillingSettings, settingsId, updates) return self.db.recordModify(BillingSettings, settingsId, updates)
def getOrCreateSettings(self, mandateId: str, defaultModel: BillingModelEnum = BillingModelEnum.PREPAY_MANDATE) -> Dict[str, Any]: def getOrCreateSettings(self, mandateId: str) -> Dict[str, Any]:
""" """
Get or create billing settings for a mandate. Get or create billing settings for a mandate.
Args: Args:
mandateId: Mandate ID mandateId: Mandate ID
defaultModel: Default billing model if creating
Returns: Returns:
BillingSettings dict BillingSettings dict
@ -243,8 +250,6 @@ class BillingObjects:
settings = BillingSettings( settings = BillingSettings(
mandateId=mandateId, mandateId=mandateId,
billingModel=defaultModel,
defaultUserCredit=0.0,
warningThresholdPercent=10.0, warningThresholdPercent=10.0,
notifyOnWarning=True, notifyOnWarning=True,
) )
@ -281,7 +286,7 @@ class BillingObjects:
BillingAccount, BillingAccount,
recordFilter={ recordFilter={
"mandateId": mandateId, "mandateId": mandateId,
"accountType": AccountTypeEnum.MANDATE.value "userId": None
} }
) )
return results[0] if results else None return results[0] if results else None
@ -305,8 +310,7 @@ class BillingObjects:
BillingAccount, BillingAccount,
recordFilter={ recordFilter={
"mandateId": mandateId, "mandateId": mandateId,
"userId": userId, "userId": userId
"accountType": AccountTypeEnum.USER.value
} }
) )
return results[0] if results else None return results[0] if results else None
@ -376,7 +380,6 @@ class BillingObjects:
account = BillingAccount( account = BillingAccount(
mandateId=mandateId, mandateId=mandateId,
accountType=AccountTypeEnum.MANDATE,
balance=initialBalance, balance=initialBalance,
enabled=True enabled=True
) )
@ -401,7 +404,6 @@ class BillingObjects:
account = BillingAccount( account = BillingAccount(
mandateId=mandateId, mandateId=mandateId,
userId=userId, userId=userId,
accountType=AccountTypeEnum.USER,
balance=initialBalance, balance=initialBalance,
enabled=True enabled=True
) )
@ -422,7 +424,7 @@ class BillingObjects:
def ensureAllMandateSettingsExist(self) -> int: def ensureAllMandateSettingsExist(self) -> int:
""" """
Efficiently ensure all mandates have billing settings. Efficiently ensure all mandates have billing settings.
Creates default settings (PREPAY_MANDATE, 0 CHF) for mandates without settings. Creates default settings (0 CHF) for mandates without settings.
Uses bulk queries to minimize database connections. Uses bulk queries to minimize database connections.
Returns: Returns:
@ -451,16 +453,13 @@ class BillingObjects:
if not mandateId or mandateId in existingMandateIds: if not mandateId or mandateId in existingMandateIds:
continue continue
# Create default billing settings
settings = BillingSettings( settings = BillingSettings(
mandateId=mandateId, mandateId=mandateId,
billingModel=BillingModelEnum.PREPAY_MANDATE,
defaultUserCredit=0.0,
warningThresholdPercent=10.0, warningThresholdPercent=10.0,
notifyOnWarning=True, notifyOnWarning=True,
) )
self.createSettings(settings) self.createSettings(settings)
existingMandateIds.add(mandateId) # Track newly created existingMandateIds.add(mandateId)
settingsCreated += 1 settingsCreated += 1
if settingsCreated > 0: if settingsCreated > 0:
@ -475,11 +474,7 @@ class BillingObjects:
def ensureAllUserAccountsExist(self) -> int: def ensureAllUserAccountsExist(self) -> int:
""" """
Ensure all users across all mandates have billing accounts. Ensure all users across all mandates have billing accounts.
User accounts are always created regardless of billing model (for audit trail). User accounts are always created for audit trail with initial balance 0.0.
Initial balance depends on billing model:
- PREPAY_USER: defaultUserCredit from settings only for the root mandate; other mandates get 0.0
- PREPAY_MANDATE: 0.0 (budget is on pool)
Uses bulk queries to minimize database connections. Uses bulk queries to minimize database connections.
Returns: Returns:
@ -488,44 +483,29 @@ class BillingObjects:
try: try:
accountsCreated = 0 accountsCreated = 0
appDb = _getAppDatabaseConnector() appDb = _getAppDatabaseConnector()
rootMandateId = _getCachedRootMandateId()
# Step 1: Get all billing settings (all mandates with settings get user accounts)
allSettings = self.db.getRecordset(BillingSettings) allSettings = self.db.getRecordset(BillingSettings)
billingMandates = {} # mandateId -> (billingModel, defaultCredit) billingMandateIds = set(
for s in allSettings: s.get("mandateId") for s in allSettings if s.get("mandateId")
billingModel = parseBillingModelFromStoredValue(s.get("billingModel")).value )
mid = s.get("mandateId")
isRoot = rootMandateId is not None and str(mid) == str(rootMandateId)
if billingModel == BillingModelEnum.PREPAY_USER.value:
defaultCredit = (
float(s.get("defaultUserCredit", 0.0) or 0.0) if isRoot else 0.0
)
else:
defaultCredit = 0.0
billingMandates[mid] = (billingModel, defaultCredit)
if not billingMandates: if not billingMandateIds:
logger.debug("No billable mandates found, skipping account check") logger.debug("No billable mandates found, skipping account check")
return 0 return 0
# Step 2: Get all existing USER accounts in one query allAccounts = self.db.getRecordset(BillingAccount)
allAccounts = self.db.getRecordset(
BillingAccount,
recordFilter={"accountType": AccountTypeEnum.USER.value}
)
existingAccountKeys = set() existingAccountKeys = set()
for acc in allAccounts: for acc in allAccounts:
if not acc.get("userId"):
continue
key = (acc.get("mandateId"), acc.get("userId")) key = (acc.get("mandateId"), acc.get("userId"))
existingAccountKeys.add(key) existingAccountKeys.add(key)
# Step 3: Get all user-mandate combinations from APP database
allUserMandates = appDb.getRecordset( allUserMandates = appDb.getRecordset(
UserMandate, UserMandate,
recordFilter={"enabled": True} recordFilter={"enabled": True}
) )
# Step 4: Create missing accounts
for um in allUserMandates: for um in allUserMandates:
mandateId = um.get("mandateId") mandateId = um.get("mandateId")
userId = um.get("userId") userId = um.get("userId")
@ -533,32 +513,20 @@ class BillingObjects:
if not mandateId or not userId: if not mandateId or not userId:
continue continue
if mandateId not in billingMandates: if mandateId not in billingMandateIds:
continue continue
key = (mandateId, userId) key = (mandateId, userId)
if key in existingAccountKeys: if key in existingAccountKeys:
continue continue
billingModel, defaultCredit = billingMandates[mandateId]
account = BillingAccount( account = BillingAccount(
mandateId=mandateId, mandateId=mandateId,
userId=userId, userId=userId,
accountType=AccountTypeEnum.USER, balance=0.0,
balance=defaultCredit,
enabled=True enabled=True
) )
created = self.createAccount(account) self.createAccount(account)
if defaultCredit > 0:
self.createTransaction(BillingTransaction(
accountId=created["id"],
transactionType=TransactionTypeEnum.CREDIT,
amount=defaultCredit,
description="Initial credit for new user",
referenceType=ReferenceTypeEnum.SYSTEM
))
existingAccountKeys.add(key) existingAccountKeys.add(key)
accountsCreated += 1 accountsCreated += 1
@ -662,6 +630,10 @@ class BillingObjects:
pagination=pagination, pagination=pagination,
recordFilter=recordFilter recordFilter=recordFilter
) )
_logBillingTransactionsMissingSysCreatedAt(
result["items"],
"getTransactions(accountId) paginated",
)
return PaginatedResult( return PaginatedResult(
items=result["items"], items=result["items"],
totalItems=result["totalItems"], totalItems=result["totalItems"],
@ -674,7 +646,7 @@ class BillingObjects:
if startDate or endDate: if startDate or endDate:
filtered = [] filtered = []
for t in results: for t in results:
createdAt = t.get("_createdAt") createdAt = t.get("sysCreatedAt")
if createdAt: if createdAt:
tDate = createdAt.date() if isinstance(createdAt, datetime) else createdAt tDate = createdAt.date() if isinstance(createdAt, datetime) else createdAt
if startDate and tDate < startDate: if startDate and tDate < startDate:
@ -684,7 +656,7 @@ class BillingObjects:
filtered.append(t) filtered.append(t)
results = filtered results = filtered
results.sort(key=lambda x: x.get("_createdAt", ""), reverse=True) _sortBillingTransactionsBySysCreatedAtDesc(results, "getTransactions(accountId)")
return results[offset:offset + limit] return results[offset:offset + limit]
except Exception as e: except Exception as e:
@ -739,7 +711,10 @@ class BillingObjects:
transactions = self.getTransactions(account["id"], limit=limit) transactions = self.getTransactions(account["id"], limit=limit)
allTransactions.extend(transactions) allTransactions.extend(transactions)
allTransactions.sort(key=lambda x: x.get("_createdAt", ""), reverse=True) _sortBillingTransactionsBySysCreatedAtDesc(
allTransactions,
"getTransactionsByMandate",
)
return allTransactions[:limit] return allTransactions[:limit]
# ========================================================================= # =========================================================================
@ -810,35 +785,14 @@ class BillingObjects:
""" """
Check if there's sufficient balance for an operation. Check if there's sufficient balance for an operation.
- PREPAY_USER: user.balance >= estimatedCost Checks mandate pool balance against estimatedCost.
- PREPAY_MANDATE: mandate pool balance >= estimatedCost User accounts are ensured to exist for audit trail.
Missing settings: treated as PREPAY_MANDATE with empty pool.
User accounts are always ensured to exist (for audit trail).
Root mandate + PREPAY_USER: initial credit from settings.defaultUserCredit on first create.
Missing settings: treated as PREPAY_MANDATE with empty pool (strict).
""" """
settings = self.getSettings(mandateId) self.getOrCreateUserAccount(mandateId, userId, initialBalance=0.0)
if not settings:
billingModel = BillingModelEnum.PREPAY_MANDATE
defaultCredit = 0.0
else:
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
defaultCredit = float(settings.get("defaultUserCredit", 0.0) or 0.0)
rootMandateId = _getCachedRootMandateId() poolAccount = self.getOrCreateMandateAccount(mandateId)
isRootMandate = rootMandateId is not None and str(mandateId) == str(rootMandateId) currentBalance = poolAccount.get("balance", 0.0)
if billingModel == BillingModelEnum.PREPAY_USER:
initialBalance = defaultCredit if isRootMandate else 0.0
else:
initialBalance = 0.0
self.getOrCreateUserAccount(mandateId, userId, initialBalance=initialBalance)
if billingModel == BillingModelEnum.PREPAY_USER:
account = self.getUserAccount(mandateId, userId)
currentBalance = account.get("balance", 0.0) if account else 0.0
else:
poolAccount = self.getOrCreateMandateAccount(mandateId)
currentBalance = poolAccount.get("balance", 0.0)
if currentBalance < estimatedCost: if currentBalance < estimatedCost:
return BillingCheckResult( return BillingCheckResult(
@ -846,10 +800,9 @@ class BillingObjects:
reason="INSUFFICIENT_BALANCE", reason="INSUFFICIENT_BALANCE",
currentBalance=currentBalance, currentBalance=currentBalance,
requiredAmount=estimatedCost, requiredAmount=estimatedCost,
billingModel=billingModel,
) )
return BillingCheckResult(allowed=True, currentBalance=currentBalance, billingModel=billingModel) return BillingCheckResult(allowed=True, currentBalance=currentBalance)
def recordUsage( def recordUsage(
self, self,
@ -870,10 +823,8 @@ class BillingObjects:
""" """
Record usage cost as a billing transaction. Record usage cost as a billing transaction.
Transaction is ALWAYS recorded on the user's account (clean audit trail). Transaction is recorded on the user's account (audit trail).
Balance is deducted from the appropriate account based on billing model: Balance is always deducted from the mandate pool account (PREPAY_MANDATE).
- PREPAY_USER: deduct from user's own balance
- PREPAY_MANDATE: deduct from mandate pool balance
""" """
if priceCHF <= 0: if priceCHF <= 0:
return None return None
@ -883,9 +834,6 @@ class BillingObjects:
logger.debug(f"No billing settings for mandate {mandateId}, skipping usage recording") logger.debug(f"No billing settings for mandate {mandateId}, skipping usage recording")
return None return None
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
# Transaction is ALWAYS on the user's account (audit trail)
userAccount = self.getOrCreateUserAccount(mandateId, userId) userAccount = self.getOrCreateUserAccount(mandateId, userId)
transaction = BillingTransaction( transaction = BillingTransaction(
@ -906,14 +854,165 @@ class BillingObjects:
errorCount=errorCount errorCount=errorCount
) )
# Determine where to deduct balance poolAccount = self.getOrCreateMandateAccount(mandateId)
if billingModel == BillingModelEnum.PREPAY_USER: return self.createTransaction(transaction, balanceAccountId=poolAccount["id"])
return self.createTransaction(transaction)
if billingModel == BillingModelEnum.PREPAY_MANDATE: def _parseSettingsDateTime(self, value: Any) -> Optional[datetime]:
poolAccount = self.getOrCreateMandateAccount(mandateId) """Parse datetime from billing settings row (ISO string or datetime)."""
return self.createTransaction(transaction, balanceAccountId=poolAccount["id"]) if value is None:
return None
if isinstance(value, datetime):
if value.tzinfo:
return value.astimezone(timezone.utc)
return value.replace(tzinfo=timezone.utc)
if isinstance(value, str):
s = value.replace("Z", "+00:00")
try:
dt = datetime.fromisoformat(s)
except ValueError:
return None
if dt.tzinfo:
return dt.astimezone(timezone.utc)
return dt.replace(tzinfo=timezone.utc)
return None return None
def resetStorageBillingPeriod(self, mandateId: str, periodStartAt: datetime) -> None:
"""Reset storage watermark state for a new subscription billing period (e.g. Stripe invoice.paid)."""
if periodStartAt.tzinfo is None:
periodStartAt = periodStartAt.replace(tzinfo=timezone.utc)
else:
periodStartAt = periodStartAt.astimezone(timezone.utc)
settings = self.getOrCreateSettings(mandateId)
prev = self._parseSettingsDateTime(settings.get("storagePeriodStartAt"))
if prev is not None and abs((prev - periodStartAt).total_seconds()) < 2:
return
from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot
usedMB = float(_getSubRoot().getMandateDataVolumeMB(mandateId))
self.updateSettings(
settings["id"],
{
"storageHighWatermarkMB": usedMB,
"storageBilledUpToMB": 0.0,
"storagePeriodStartAt": periodStartAt,
},
)
logger.info(
"Storage billing period reset for mandate %s at %s (usedMB=%.2f)",
mandateId,
periodStartAt.isoformat(),
usedMB,
)
def reconcileMandateStorageBilling(self, mandateId: str) -> Optional[Dict[str, Any]]:
"""Debit prepay pool for new storage overage using period high-watermark (no credit on delete)."""
settings = self.getSettings(mandateId)
if not settings:
return None
from modules.interfaces.interfaceDbSubscription import _getRootInterface as _getSubRoot
from modules.datamodels.datamodelSubscription import _getPlan
subIface = _getSubRoot()
usedMB = float(subIface.getMandateDataVolumeMB(mandateId))
sub = subIface.getOperativeForMandate(mandateId)
plan = _getPlan(sub.get("planKey", "")) if sub else None
includedMB = plan.maxDataVolumeMB if plan and plan.maxDataVolumeMB is not None else None
if includedMB is None:
return None
prevHigh = float(settings.get("storageHighWatermarkMB") or 0.0)
high = max(prevHigh, usedMB)
overageMB = max(0.0, high - float(includedMB))
billed = float(settings.get("storageBilledUpToMB") or 0.0)
deltaOverage = overageMB - billed
settingsUpdates: Dict[str, Any] = {}
if high != prevHigh:
settingsUpdates["storageHighWatermarkMB"] = high
if deltaOverage <= 1e-9:
if settingsUpdates:
self.updateSettings(settings["id"], settingsUpdates)
return None
costCHF = round((deltaOverage / 1024.0) * float(STORAGE_PRICE_PER_GB_CHF), 4)
if costCHF <= 0:
if settingsUpdates:
self.updateSettings(settings["id"], settingsUpdates)
return None
poolAccount = self.getOrCreateMandateAccount(mandateId)
transaction = BillingTransaction(
accountId=poolAccount["id"],
transactionType=TransactionTypeEnum.DEBIT,
amount=costCHF,
description=f"Speicher-Überhang ({deltaOverage:.2f} MB über Plan)",
referenceType=ReferenceTypeEnum.STORAGE,
referenceId=mandateId,
)
created = self.createTransaction(transaction)
settingsUpdates["storageBilledUpToMB"] = overageMB
self.updateSettings(settings["id"], settingsUpdates)
logger.info(
"Storage overage billed mandate=%s deltaOverageMB=%.4f costCHF=%s",
mandateId,
deltaOverage,
costCHF,
)
return created
# =========================================================================
# Subscription AI-Budget Credit
# =========================================================================
def creditSubscriptionBudget(self, mandateId: str, planKey: str, periodLabel: str = "") -> Optional[Dict[str, Any]]:
"""Credit the plan's budgetAiCHF to the mandate pool account.
Should be called once per billing period (initial activation + each invoice.paid).
Returns the created CREDIT transaction or None if budget is 0."""
from modules.datamodels.datamodelSubscription import _getPlan
plan = _getPlan(planKey)
if not plan or not plan.budgetAiCHF or plan.budgetAiCHF <= 0:
return None
poolAccount = self.getOrCreateMandateAccount(mandateId)
description = f"AI-Budget ({planKey})"
if periodLabel:
description += f" {periodLabel}"
transaction = BillingTransaction(
accountId=poolAccount["id"],
transactionType=TransactionTypeEnum.CREDIT,
amount=plan.budgetAiCHF,
description=description,
referenceType=ReferenceTypeEnum.SUBSCRIPTION,
referenceId=mandateId,
)
created = self.createTransaction(transaction)
logger.info(
"AI-Budget credited mandate=%s plan=%s amount=%.2f CHF",
mandateId, planKey, plan.budgetAiCHF,
)
return created
def ensureActivationBudget(self, mandateId: str, planKey: str) -> Optional[Dict[str, Any]]:
"""Idempotent: credit the activation budget only if no SUBSCRIPTION credit exists yet."""
poolAccount = self.getMandateAccount(mandateId)
if not poolAccount:
return self.creditSubscriptionBudget(mandateId, planKey, periodLabel="Erstaktivierung")
existing = self.db.getRecordset(
BillingTransaction,
recordFilter={
"accountId": poolAccount["id"],
"transactionType": TransactionTypeEnum.CREDIT.value,
"referenceType": ReferenceTypeEnum.SUBSCRIPTION.value,
},
)
if existing:
return None
return self.creditSubscriptionBudget(mandateId, planKey, periodLabel="Erstaktivierung")
# ========================================================================= # =========================================================================
# Workflow Cost Query # Workflow Cost Query
# ========================================================================= # =========================================================================
@ -928,112 +1027,6 @@ class BillingObjects:
) )
return sum(t.get("amount", 0.0) for t in transactions) return sum(t.get("amount", 0.0) for t in transactions)
# =========================================================================
# Billing Model Switch Operations
# =========================================================================
def switchBillingModel(self, mandateId: str, oldModel: BillingModelEnum, newModel: BillingModelEnum) -> Dict[str, Any]:
"""
Switch billing model with budget migration logged as BillingTransactions.
PREPAY_MANDATE -> PREPAY_USER: pool debited, equal shares credited to user accounts.
PREPAY_USER -> PREPAY_MANDATE: user wallets debited, pool credited with sum.
"""
result = {"oldModel": oldModel.value, "newModel": newModel.value, "migratedAmount": 0.0, "userCount": 0}
if oldModel == newModel:
return result
if oldModel == BillingModelEnum.PREPAY_MANDATE and newModel == BillingModelEnum.PREPAY_USER:
poolAccount = self.getMandateAccount(mandateId)
userAccounts = self.db.getRecordset(
BillingAccount,
recordFilter={"mandateId": mandateId, "accountType": AccountTypeEnum.USER.value}
)
poolBalance = poolAccount.get("balance", 0.0) if poolAccount else 0.0
n = len(userAccounts)
if poolAccount and poolBalance > 0:
self.createTransaction(
BillingTransaction(
accountId=poolAccount["id"],
transactionType=TransactionTypeEnum.DEBIT,
amount=poolBalance,
description="Model switch: distributed from mandate pool to user wallets",
referenceType=ReferenceTypeEnum.SYSTEM,
)
)
result["migratedAmount"] = poolBalance
if n > 0:
remaining = poolBalance
for i, acc in enumerate(userAccounts):
if i == n - 1:
share = round(remaining, 4)
else:
share = round(poolBalance / n, 4)
remaining -= share
if share > 0:
self.createTransaction(
BillingTransaction(
accountId=acc["id"],
transactionType=TransactionTypeEnum.CREDIT,
amount=share,
description="Model switch: share from mandate pool",
referenceType=ReferenceTypeEnum.SYSTEM,
)
)
result["userCount"] = n
logger.info(
"Switched %s MANDATE->USER: migrated %.4f CHF to %d user account(s) (transactions logged)",
mandateId,
result["migratedAmount"],
result["userCount"],
)
return result
if oldModel == BillingModelEnum.PREPAY_USER and newModel == BillingModelEnum.PREPAY_MANDATE:
userAccounts = self.db.getRecordset(
BillingAccount,
recordFilter={"mandateId": mandateId, "accountType": AccountTypeEnum.USER.value}
)
totalUserBalance = sum(acc.get("balance", 0.0) for acc in userAccounts)
for acc in userAccounts:
b = acc.get("balance", 0.0)
if b > 0:
self.createTransaction(
BillingTransaction(
accountId=acc["id"],
transactionType=TransactionTypeEnum.DEBIT,
amount=b,
description="Model switch: consolidated to mandate pool",
referenceType=ReferenceTypeEnum.SYSTEM,
)
)
poolAccount = self.getOrCreateMandateAccount(mandateId, initialBalance=0.0)
if totalUserBalance > 0:
self.createTransaction(
BillingTransaction(
accountId=poolAccount["id"],
transactionType=TransactionTypeEnum.CREDIT,
amount=totalUserBalance,
description="Model switch: consolidated from user accounts",
referenceType=ReferenceTypeEnum.SYSTEM,
)
)
result["migratedAmount"] = totalUserBalance
result["userCount"] = len(userAccounts)
logger.info(
"Switched %s USER->MANDATE: consolidated %.4f CHF from %d users into pool (transactions logged)",
mandateId,
totalUserBalance,
len(userAccounts),
)
return result
if newModel == BillingModelEnum.PREPAY_MANDATE:
self.getOrCreateMandateAccount(mandateId, initialBalance=0.0)
return result
# ========================================================================= # =========================================================================
# Statistics Operations # Statistics Operations
# ========================================================================= # =========================================================================
@ -1128,10 +1121,8 @@ class BillingObjects:
def getBalancesForUser(self, userId: str) -> List[BillingBalanceResponse]: def getBalancesForUser(self, userId: str) -> List[BillingBalanceResponse]:
""" """
Get all billing balances for a user across mandates. Get all billing balances for a user across mandates.
Shows the mandate pool balance (shared budget visible to user).
Shows the effective available budget:
- PREPAY_USER: user's own account balance
- PREPAY_MANDATE: mandate pool balance (shared budget visible to user)
Args: Args:
userId: User ID userId: User ID
@ -1154,7 +1145,7 @@ class BillingObjects:
continue continue
mandate = rootInterface.getMandate(mandateId) mandate = rootInterface.getMandate(mandateId)
if not mandate: if not mandate or not getattr(mandate, "enabled", True):
continue continue
mandateName = getattr(mandate, 'label', None) or getattr(mandate, 'name', None) or (mandate.get("label") or mandate.get("name", "") if isinstance(mandate, dict) else "") mandateName = getattr(mandate, 'label', None) or getattr(mandate, 'name', None) or (mandate.get("label") or mandate.get("name", "") if isinstance(mandate, dict) else "")
@ -1163,27 +1154,15 @@ class BillingObjects:
if not settings: if not settings:
continue continue
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel")) poolAccount = self.getOrCreateMandateAccount(mandateId)
if not poolAccount:
if billingModel == BillingModelEnum.PREPAY_USER:
account = self.getOrCreateUserAccount(mandateId, userId)
if not account:
continue
balance = account.get("balance", 0.0)
warningThreshold = account.get("warningThreshold", 0.0)
elif billingModel == BillingModelEnum.PREPAY_MANDATE:
poolAccount = self.getOrCreateMandateAccount(mandateId)
if not poolAccount:
continue
balance = poolAccount.get("balance", 0.0)
warningThreshold = poolAccount.get("warningThreshold", 0.0)
else:
continue continue
balance = poolAccount.get("balance", 0.0)
warningThreshold = poolAccount.get("warningThreshold", 0.0)
balances.append(BillingBalanceResponse( balances.append(BillingBalanceResponse(
mandateId=mandateId, mandateId=mandateId,
mandateName=mandateName, mandateName=mandateName,
billingModel=billingModel,
balance=balance, balance=balance,
warningThreshold=warningThreshold, warningThreshold=warningThreshold,
isWarning=balance <= warningThreshold, isWarning=balance <= warningThreshold,
@ -1244,7 +1223,7 @@ class BillingObjects:
except Exception as e: except Exception as e:
logger.error(f"Error getting transactions for user: {e}") logger.error(f"Error getting transactions for user: {e}")
allTransactions.sort(key=lambda x: x.get("_createdAt", ""), reverse=True) _sortBillingTransactionsBySysCreatedAtDesc(allTransactions, "getTransactionsForUser")
return allTransactions[:limit] return allTransactions[:limit]
# ========================================================================= # =========================================================================
@ -1280,36 +1259,25 @@ class BillingObjects:
if not mandateId: if not mandateId:
continue continue
billingModel = parseBillingModelFromStoredValue(settings.get("billingModel"))
# Get mandate info
mandate = appInterface.getMandate(mandateId) mandate = appInterface.getMandate(mandateId)
mandateName = "" mandateName = ""
if mandate: if mandate:
mandateName = getattr(mandate, 'label', None) or getattr(mandate, 'name', None) or (mandate.get("label") or mandate.get("name", "") if isinstance(mandate, dict) else "") mandateName = getattr(mandate, 'label', None) or getattr(mandate, 'name', None) or (mandate.get("label") or mandate.get("name", "") if isinstance(mandate, dict) else "")
# Get user accounts count (always exist now for audit trail) allMandateAccounts = self.db.getRecordset(
userAccounts = self.db.getRecordset(
BillingAccount, BillingAccount,
recordFilter={"mandateId": mandateId, "accountType": AccountTypeEnum.USER.value} recordFilter={"mandateId": mandateId}
) )
userCount = len(userAccounts) userCount = sum(1 for acc in allMandateAccounts if acc.get("userId"))
if billingModel == BillingModelEnum.PREPAY_USER: poolAccount = self.getMandateAccount(mandateId)
totalBalance = sum(acc.get("balance", 0.0) for acc in userAccounts) totalBalance = poolAccount.get("balance", 0.0) if poolAccount else 0.0
elif billingModel == BillingModelEnum.PREPAY_MANDATE:
poolAccount = self.getMandateAccount(mandateId)
totalBalance = poolAccount.get("balance", 0.0) if poolAccount else 0.0
else:
totalBalance = 0.0
balances.append({ balances.append({
"mandateId": mandateId, "mandateId": mandateId,
"mandateName": mandateName, "mandateName": mandateName,
"billingModel": billingModel.value,
"totalBalance": totalBalance, "totalBalance": totalBalance,
"userCount": userCount, "userCount": userCount,
"defaultUserCredit": float(settings.get("defaultUserCredit", 0.0) or 0.0),
"warningThresholdPercent": settings.get("warningThresholdPercent", 10.0), "warningThresholdPercent": settings.get("warningThresholdPercent", 10.0),
}) })
@ -1361,7 +1329,7 @@ class BillingObjects:
logger.error(f"Error getting mandate transactions: {e}") logger.error(f"Error getting mandate transactions: {e}")
# Sort by creation date descending and limit # Sort by creation date descending and limit
allTransactions.sort(key=lambda x: x.get("_createdAt", ""), reverse=True) _sortBillingTransactionsBySysCreatedAtDesc(allTransactions, "getMandateTransactions")
return allTransactions[:limit] return allTransactions[:limit]
# ========================================================================= # =========================================================================
@ -1385,9 +1353,8 @@ class BillingObjects:
try: try:
appInterface = getAppInterface(self.currentUser) appInterface = getAppInterface(self.currentUser)
# Get all user accounts allAccounts = self.db.getRecordset(BillingAccount)
accountFilter = {"accountType": AccountTypeEnum.USER.value} allAccounts = [acc for acc in allAccounts if acc.get("userId")]
allAccounts = self.db.getRecordset(BillingAccount, recordFilter=accountFilter)
# Filter by mandate if specified # Filter by mandate if specified
if mandateIds: if mandateIds:
@ -1549,5 +1516,5 @@ class BillingObjects:
logger.error(f"Error getting user transactions for mandates: {e}") logger.error(f"Error getting user transactions for mandates: {e}")
# Sort by creation date descending and limit # Sort by creation date descending and limit
allTransactions.sort(key=lambda x: x.get("_createdAt", ""), reverse=True) _sortBillingTransactionsBySysCreatedAtDesc(allTransactions, "getUserTransactionsForMandates")
return allTransactions[:limit] return allTransactions[:limit]

View file

@ -251,9 +251,8 @@ class ChatObjects:
objectFields[fieldName] = value objectFields[fieldName] = value
else: else:
# Field not in model - treat as scalar if simple, otherwise filter out # Field not in model - treat as scalar if simple, otherwise filter out
# BUT: always include metadata fields (_createdBy, _createdAt, etc.) as they're handled by connector # Underscore-prefixed keys (e.g. UI meta) pass through; sys* live on PowerOnModel subclasses
if fieldName.startswith("_"): if fieldName.startswith("_"):
# Metadata fields should be passed through to connector
simpleFields[fieldName] = value simpleFields[fieldName] = value
elif isinstance(value, (str, int, float, bool, type(None))): elif isinstance(value, (str, int, float, bool, type(None))):
simpleFields[fieldName] = value simpleFields[fieldName] = value
@ -652,6 +651,32 @@ class ChatObjects:
totalPages=totalPages totalPages=totalPages
) )
def getLastMessageTimestamp(self, workflowId: str) -> Optional[str]:
"""Return the latest publishedAt/sysCreatedAt from ChatMessage for a workflow."""
messages = self._getRecordset(ChatMessage, recordFilter={"workflowId": workflowId})
if not messages:
return None
latest = None
for msg in messages:
ts = msg.get("publishedAt") or msg.get("sysCreatedAt")
if ts and (latest is None or str(ts) > str(latest)):
latest = ts
return str(latest) if latest else None
def searchWorkflowsByContent(self, query: str, limit: int = 50) -> List[str]:
"""Return workflow IDs whose messages contain the query string (case-insensitive)."""
allMessages = self._getRecordset(ChatMessage)
matchedIds: set = set()
for msg in allMessages:
content = msg.get("message") or ""
if query in content.lower():
wfId = msg.get("workflowId")
if wfId:
matchedIds.add(wfId)
if len(matchedIds) >= limit:
break
return list(matchedIds)
def getWorkflow(self, workflowId: str) -> Optional[ChatWorkflow]: def getWorkflow(self, workflowId: str) -> Optional[ChatWorkflow]:
"""Returns a workflow by ID if user has access.""" """Returns a workflow by ID if user has access."""
# Use RBAC filtering with featureInstanceId for instance-level isolation # Use RBAC filtering with featureInstanceId for instance-level isolation
@ -885,7 +910,7 @@ class ChatObjects:
"role": msg.get("role", "assistant"), "role": msg.get("role", "assistant"),
"status": msg.get("status", "step"), "status": msg.get("status", "step"),
"sequenceNr": msg.get("sequenceNr", 0), "sequenceNr": msg.get("sequenceNr", 0),
"publishedAt": msg.get("publishedAt") or msg.get("_createdAt") or msg.get("timestamp") or 0, "publishedAt": msg.get("publishedAt") or msg.get("sysCreatedAt") or msg.get("timestamp") or 0,
"success": msg.get("success"), "success": msg.get("success"),
"actionId": msg.get("actionId"), "actionId": msg.get("actionId"),
"actionMethod": msg.get("actionMethod"), "actionMethod": msg.get("actionMethod"),
@ -1268,7 +1293,7 @@ class ChatObjects:
# CASCADE DELETE: Delete all related data first # CASCADE DELETE: Delete all related data first
# 1. Delete message documents (but NOT the files themselves) # 1. Delete message documents (but NOT the files themselves)
# Bypass RBAC -- workflow access already verified, child records may have different _createdBy # Bypass RBAC -- workflow access already verified, child records may have different sysCreatedBy
existing_docs = self.db.getRecordset(ChatDocument, recordFilter={"messageId": messageId}) existing_docs = self.db.getRecordset(ChatDocument, recordFilter={"messageId": messageId})
for doc in existing_docs: for doc in existing_docs:
self.db.recordDelete(ChatDocument, doc["id"]) self.db.recordDelete(ChatDocument, doc["id"])
@ -1296,7 +1321,7 @@ class ChatObjects:
# Get documents for this message from normalized table # Get documents for this message from normalized table
# Bypass RBAC -- workflow access already verified, child records may have different _createdBy # Bypass RBAC -- workflow access already verified, child records may have different sysCreatedBy
documents = self.db.getRecordset(ChatDocument, recordFilter={"messageId": messageId}) documents = self.db.getRecordset(ChatDocument, recordFilter={"messageId": messageId})
if not documents: if not documents:

View file

@ -29,6 +29,7 @@ class KnowledgeObjects:
def __init__(self): def __init__(self):
self.currentUser: Optional[User] = None self.currentUser: Optional[User] = None
self.userId: Optional[str] = None self.userId: Optional[str] = None
self._scopeCache: Dict[str, List[str]] = {}
self._initializeDatabase() self._initializeDatabase()
def _initializeDatabase(self): def _initializeDatabase(self):
@ -51,6 +52,7 @@ class KnowledgeObjects:
def setUserContext(self, user: User): def setUserContext(self, user: User):
self.currentUser = user self.currentUser = user
self.userId = user.id if user else None self.userId = user.id if user else None
self._scopeCache = {}
if self.userId: if self.userId:
self.db.updateContext(self.userId) self.db.updateContext(self.userId)
@ -89,10 +91,20 @@ class KnowledgeObjects:
def deleteFileContentIndex(self, fileId: str) -> bool: def deleteFileContentIndex(self, fileId: str) -> bool:
"""Delete a FileContentIndex and all associated ContentChunks.""" """Delete a FileContentIndex and all associated ContentChunks."""
existing = self.getFileContentIndex(fileId)
mandateId = (existing or {}).get("mandateId") or ""
chunks = self.db.getRecordset(ContentChunk, recordFilter={"fileId": fileId}) chunks = self.db.getRecordset(ContentChunk, recordFilter={"fileId": fileId})
for chunk in chunks: for chunk in chunks:
self.db.recordDelete(ContentChunk, chunk["id"]) self.db.recordDelete(ContentChunk, chunk["id"])
return self.db.recordDelete(FileContentIndex, fileId) ok = self.db.recordDelete(FileContentIndex, fileId)
if ok and mandateId:
try:
from modules.interfaces.interfaceDbBilling import _getRootInterface
_getRootInterface().reconcileMandateStorageBilling(str(mandateId))
except Exception as ex:
logger.warning("reconcileMandateStorageBilling after delete failed: %s", ex)
return ok
# ========================================================================= # =========================================================================
# ContentChunk CRUD # ContentChunk CRUD
@ -215,25 +227,88 @@ class KnowledgeObjects:
# Semantic Search # Semantic Search
# ========================================================================= # =========================================================================
def _buildScopeFilter(self, userId: str = None, featureInstanceId: str = None, mandateId: str = None) -> dict:
"""Build a scope-aware filter for RAG queries.
Returns a filter dict that includes records visible to this user context."""
return {
"userId": userId,
"featureInstanceId": featureInstanceId,
"mandateId": mandateId,
}
def _getScopedFileIds(self, userId: str = None, featureInstanceId: str = None, mandateId: str = None, isSysAdmin: bool = False) -> List[str]:
"""Collect FileContentIndex IDs visible under the scope union:
- scope=personal AND userId matches
- scope=featureInstance AND featureInstanceId matches
- scope=mandate AND mandateId matches
- scope=global (only if isSysAdmin)
"""
_cacheKey = f"{userId}:{featureInstanceId}:{mandateId}:{isSysAdmin}"
if _cacheKey in self._scopeCache:
return self._scopeCache[_cacheKey]
allIds: set = set()
if isSysAdmin:
globalIndexes = self.db.getRecordset(
FileContentIndex, recordFilter={"scope": "global"}
)
for idx in globalIndexes:
fid = idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
if fid:
allIds.add(fid)
if userId:
personalIndexes = self.db.getRecordset(
FileContentIndex, recordFilter={"scope": "personal", "userId": userId}
)
for idx in personalIndexes:
fid = idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
if fid:
allIds.add(fid)
if featureInstanceId:
instanceIndexes = self.db.getRecordset(
FileContentIndex, recordFilter={"scope": "featureInstance", "featureInstanceId": featureInstanceId}
)
for idx in instanceIndexes:
fid = idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
if fid:
allIds.add(fid)
if mandateId:
mandateIndexes = self.db.getRecordset(
FileContentIndex, recordFilter={"scope": "mandate", "mandateId": mandateId}
)
for idx in mandateIndexes:
fid = idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
if fid:
allIds.add(fid)
self._scopeCache[_cacheKey] = list(allIds)
return self._scopeCache[_cacheKey]
def semanticSearch( def semanticSearch(
self, self,
queryVector: List[float], queryVector: List[float],
userId: str = None, userId: str = None,
featureInstanceId: str = None, featureInstanceId: str = None,
mandateId: str = None, mandateId: str = None,
isShared: bool = None, scope: str = None,
limit: int = 10, limit: int = 10,
minScore: float = None, minScore: float = None,
contentType: str = None, contentType: str = None,
isSysAdmin: bool = False,
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""Semantic search across ContentChunks using pgvector cosine similarity. """Semantic search across ContentChunks using pgvector cosine similarity.
Args: Args:
queryVector: Query embedding vector. queryVector: Query embedding vector.
userId: Filter by user (Instance Layer). userId: Filter by user (personal scope).
featureInstanceId: Filter by feature instance. featureInstanceId: Filter by feature instance.
mandateId: Filter by mandate (for Shared Layer lookups). mandateId: Filter by mandate (scope=mandate means visible to all mandate users).
isShared: If True, search Shared Layer via FileContentIndex join. scope: If provided, filter by this specific scope value.
If not provided, use scope-union approach (personal + featureInstance + mandate + global).
limit: Max results. limit: Max results.
minScore: Minimum cosine similarity (0.0 - 1.0). minScore: Minimum cosine similarity (0.0 - 1.0).
contentType: Filter by content type (text, image, etc.). contentType: Filter by content type (text, image, etc.).
@ -242,25 +317,36 @@ class KnowledgeObjects:
List of ContentChunk records with _score field, sorted by relevance. List of ContentChunk records with _score field, sorted by relevance.
""" """
recordFilter = {} recordFilter = {}
if userId:
recordFilter["userId"] = userId
if featureInstanceId:
recordFilter["featureInstanceId"] = featureInstanceId
if contentType: if contentType:
recordFilter["contentType"] = contentType recordFilter["contentType"] = contentType
if isShared and mandateId: if scope:
sharedIndexes = self.db.getRecordset( scopeFilter: Dict[str, Any] = {"scope": scope}
FileContentIndex, if mandateId:
recordFilter={"mandateId": mandateId, "isShared": True}, scopeFilter["mandateId"] = mandateId
if featureInstanceId:
scopeFilter["featureInstanceId"] = featureInstanceId
scopedFileIds = self.db.getRecordset(
FileContentIndex, recordFilter=scopeFilter
) )
sharedFileIds = [idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None) for idx in sharedIndexes] fileIds = [
sharedFileIds = [fid for fid in sharedFileIds if fid] idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
if not sharedFileIds: for idx in scopedFileIds
]
fileIds = [fid for fid in fileIds if fid]
if not fileIds:
return [] return []
recordFilter.pop("userId", None) recordFilter["fileId"] = fileIds
recordFilter.pop("featureInstanceId", None) elif userId or featureInstanceId or mandateId:
recordFilter["fileId"] = sharedFileIds scopedFileIds = self._getScopedFileIds(
userId=userId,
featureInstanceId=featureInstanceId,
mandateId=mandateId,
isSysAdmin=isSysAdmin,
)
if not scopedFileIds:
return []
recordFilter["fileId"] = scopedFileIds
return self.db.semanticSearch( return self.db.semanticSearch(
modelClass=ContentChunk, modelClass=ContentChunk,
@ -317,7 +403,7 @@ class KnowledgeObjects:
if mandateId: if mandateId:
files_shared = self.db.getRecordset( files_shared = self.db.getRecordset(
FileContentIndex, FileContentIndex,
recordFilter={"mandateId": mandateId, "isShared": True}, recordFilter={"mandateId": mandateId, "scope": "mandate"},
) )
by_id: Dict[str, Dict[str, Any]] = {} by_id: Dict[str, Dict[str, Any]] = {}
@ -466,6 +552,76 @@ class KnowledgeObjects:
} }
def aggregateMandateRagTotalBytes(mandateId: str) -> int:
"""Sum FileContentIndex.totalSize for a mandate.
Primary strategy (relies on correct scope fields on FileContentIndex):
1. FileContentIndex rows with mandateId on the index
2. FileContentIndex rows with featureInstanceId of any mandate FeatureInstance
Deduplicates by id.
"""
if not mandateId:
return 0
from modules.datamodels.datamodelFeatures import FeatureInstance
from modules.interfaces.interfaceDbApp import getRootInterface
knowDb = getInterface(None).db
appDb = getRootInterface().db
byId: Dict[str, Dict[str, Any]] = {}
for row in knowDb.getRecordset(FileContentIndex, recordFilter={"mandateId": mandateId}):
rid = row.get("id")
if rid:
byId[str(rid)] = row
instances = appDb.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
instIds = [str(inst.get("id", "")) for inst in instances if inst.get("id")]
for instId in instIds:
for row in knowDb.getRecordset(FileContentIndex, recordFilter={"featureInstanceId": instId}):
rid = row.get("id")
if rid and str(rid) not in byId:
byId[str(rid)] = row
# DEPRECATED: file-ID-correlation fallback from poweron_management.
# Only needed for pre-migration data where mandateId/featureInstanceId on the
# FileContentIndex are empty. Remove once migrateRagScopeFields has been run.
_fallbackCount = 0
try:
from modules.datamodels.datamodelFiles import FileItem
from modules.interfaces.interfaceDbManagement import ComponentObjects
mgmtDb = ComponentObjects().db
knowledgeIf = getInterface(None)
fileIds: set = set()
for f in mgmtDb.getRecordset(FileItem, recordFilter={"mandateId": mandateId}):
fid = f.get("id") if isinstance(f, dict) else getattr(f, "id", None)
if fid:
fileIds.add(str(fid))
for instId in instIds:
for f in mgmtDb.getRecordset(FileItem, recordFilter={"featureInstanceId": instId}):
fid = f.get("id") if isinstance(f, dict) else getattr(f, "id", None)
if fid:
fileIds.add(str(fid))
for fid in fileIds:
if fid in byId:
continue
row = knowledgeIf.getFileContentIndex(fid)
if row:
byId[fid] = row
_fallbackCount += 1
except Exception as e:
logger.warning("aggregateMandateRagTotalBytes fallback failed: %s", e)
total = sum(int(r.get("totalSize") or 0) for r in byId.values())
logger.info(
"aggregateMandateRagTotalBytes(%s): %d indexes, %d bytes (fallback: %d)",
mandateId, len(byId), total, _fallbackCount,
)
return total
def getInterface(currentUser: Optional[User] = None) -> KnowledgeObjects: def getInterface(currentUser: Optional[User] = None) -> KnowledgeObjects:
"""Get or create a KnowledgeObjects singleton.""" """Get or create a KnowledgeObjects singleton."""
if "default" not in _instances: if "default" not in _instances:

View file

@ -175,12 +175,7 @@ class ComponentObjects:
# Complex objects that should be filtered out # Complex objects that should be filtered out
objectFields[fieldName] = value objectFields[fieldName] = value
else: else:
# Field not in model - treat as scalar if simple, otherwise filter out if isinstance(value, (str, int, float, bool, type(None))):
# BUT: always include metadata fields (_createdBy, _createdAt, etc.) as they're handled by connector
if fieldName.startswith("_"):
# Metadata fields should be passed through to connector
simpleFields[fieldName] = value
elif isinstance(value, (str, int, float, bool, type(None))):
simpleFields[fieldName] = value simpleFields[fieldName] = value
else: else:
objectFields[fieldName] = value objectFields[fieldName] = value
@ -609,7 +604,7 @@ class ComponentObjects:
""" """
isSysAdmin = self._isSysAdmin() isSysAdmin = self._isSysAdmin()
for prompt in prompts: for prompt in prompts:
isOwner = prompt.get("_createdBy") == self.userId isOwner = prompt.get("sysCreatedBy") == self.userId
prompt["_permissions"] = { prompt["_permissions"] = {
"canUpdate": isOwner or isSysAdmin, "canUpdate": isOwner or isSysAdmin,
"canDelete": isOwner or isSysAdmin "canDelete": isOwner or isSysAdmin
@ -621,13 +616,13 @@ class ComponentObjects:
Visibility rules: Visibility rules:
- SysAdmin: ALL prompts - SysAdmin: ALL prompts
- Regular user: own prompts (_createdBy) + system prompts (isSystem=True) - Regular user: own prompts (sysCreatedBy) + system prompts (isSystem=True)
""" """
if self._isSysAdmin(): if self._isSysAdmin():
return self.db.getRecordset(Prompt) return self.db.getRecordset(Prompt)
# Get own prompts # Get own prompts
ownPrompts = self.db.getRecordset(Prompt, recordFilter={"_createdBy": self.userId}) ownPrompts = self.db.getRecordset(Prompt, recordFilter={"sysCreatedBy": self.userId})
# Get system prompts # Get system prompts
systemPrompts = self.db.getRecordset(Prompt, recordFilter={"isSystem": True}) systemPrompts = self.db.getRecordset(Prompt, recordFilter={"isSystem": True})
@ -716,7 +711,7 @@ class ComponentObjects:
# Visibility check for non-SysAdmin: must be owner or system prompt # Visibility check for non-SysAdmin: must be owner or system prompt
if not self._isSysAdmin(): if not self._isSysAdmin():
isOwner = prompt.get("_createdBy") == self.userId isOwner = prompt.get("sysCreatedBy") == self.userId
isSystem = prompt.get("isSystem", False) isSystem = prompt.get("isSystem", False)
if not isOwner and not isSystem: if not isOwner and not isSystem:
return None return None
@ -747,7 +742,7 @@ class ComponentObjects:
raise ValueError(f"Prompt {promptId} not found") raise ValueError(f"Prompt {promptId} not found")
# Permission check: owner or SysAdmin # Permission check: owner or SysAdmin
isOwner = (getattr(prompt, '_createdBy', None) == self.userId) isOwner = (getattr(prompt, 'sysCreatedBy', None) == self.userId)
if not self._isSysAdmin() and not isOwner: if not self._isSysAdmin() and not isOwner:
raise PermissionError(f"No permission to update prompt {promptId}") raise PermissionError(f"No permission to update prompt {promptId}")
@ -784,7 +779,7 @@ class ComponentObjects:
return False return False
# Permission check: owner or SysAdmin # Permission check: owner or SysAdmin
isOwner = (getattr(prompt, '_createdBy', None) == self.userId) isOwner = (getattr(prompt, 'sysCreatedBy', None) == self.userId)
if not self._isSysAdmin() and not isOwner: if not self._isSysAdmin() and not isOwner:
raise PermissionError(f"No permission to delete prompt {promptId}") raise PermissionError(f"No permission to delete prompt {promptId}")
@ -798,7 +793,7 @@ class ComponentObjects:
def checkForDuplicateFile(self, fileHash: str, fileName: str) -> Optional[FileItem]: def checkForDuplicateFile(self, fileHash: str, fileName: str) -> Optional[FileItem]:
"""Checks if a file with the same hash AND fileName already exists for the current user. """Checks if a file with the same hash AND fileName already exists for the current user.
Duplicate = same user (_createdBy) + same fileHash + same fileName. Duplicate = same user (sysCreatedBy) + same fileHash + same fileName.
Same hash with different name is allowed (intentional copy by user). Same hash with different name is allowed (intentional copy by user).
Uses direct DB query (not RBAC) because files are isolated per user. Uses direct DB query (not RBAC) because files are isolated per user.
""" """
@ -809,7 +804,7 @@ class ComponentObjects:
matchingFiles = self.db.getRecordset( matchingFiles = self.db.getRecordset(
FileItem, FileItem,
recordFilter={ recordFilter={
"_createdBy": self.userId, "sysCreatedBy": self.userId,
"fileHash": fileHash, "fileHash": fileHash,
"fileName": fileName "fileName": fileName
} }
@ -828,7 +823,7 @@ class ComponentObjects:
mimeType=file["mimeType"], mimeType=file["mimeType"],
fileHash=file["fileHash"], fileHash=file["fileHash"],
fileSize=file["fileSize"], fileSize=file["fileSize"],
creationDate=file["creationDate"] sysCreatedAt=file.get("sysCreatedAt") or file.get("creationDate"),
) )
def getMimeType(self, fileName: str) -> str: def getMimeType(self, fileName: str) -> str:
@ -908,7 +903,7 @@ class ComponentObjects:
def _getFilesByCurrentUser(self, recordFilter: Dict[str, Any] = None) -> List[Dict[str, Any]]: def _getFilesByCurrentUser(self, recordFilter: Dict[str, Any] = None) -> List[Dict[str, Any]]:
"""Files are always user-scoped. Returns only files owned by the current user, """Files are always user-scoped. Returns only files owned by the current user,
regardless of role (including SysAdmin). This bypasses RBAC intentionally.""" regardless of role (including SysAdmin). This bypasses RBAC intentionally."""
filterDict = {"_createdBy": self.userId} filterDict = {"sysCreatedBy": self.userId}
if recordFilter: if recordFilter:
filterDict.update(recordFilter) filterDict.update(recordFilter)
return self.db.getRecordset(FileItem, recordFilter=filterDict) return self.db.getRecordset(FileItem, recordFilter=filterDict)
@ -927,20 +922,27 @@ class ComponentObjects:
If pagination is provided: PaginatedResult with items and metadata If pagination is provided: PaginatedResult with items and metadata
""" """
# User-scoping filter: every user only sees their own files (bypasses RBAC SysAdmin override) # User-scoping filter: every user only sees their own files (bypasses RBAC SysAdmin override)
recordFilter = {"_createdBy": self.userId} recordFilter = {"sysCreatedBy": self.userId}
def _convertFileItems(files): def _convertFileItems(files):
fileItems = [] fileItems = []
for file in files: for file in files:
try: try:
creationDate = file.get("creationDate") sysCreatedAt = file.get("sysCreatedAt") or file.get("creationDate")
if creationDate is None or not isinstance(creationDate, (int, float)) or creationDate <= 0: if sysCreatedAt is None or not isinstance(sysCreatedAt, (int, float)) or sysCreatedAt <= 0:
file["creationDate"] = getUtcTimestamp() file["sysCreatedAt"] = getUtcTimestamp()
else:
file["sysCreatedAt"] = sysCreatedAt
fileName = file.get("fileName") fileName = file.get("fileName")
if not fileName or fileName == "None": if not fileName or fileName == "None":
continue continue
if file.get("scope") is None:
file["scope"] = "personal"
if file.get("neutralize") is None:
file["neutralize"] = False
fileItem = FileItem(**file) fileItem = FileItem(**file)
fileItems.append(fileItem) fileItems.append(fileItem)
except Exception as e: except Exception as e:
@ -969,7 +971,7 @@ class ComponentObjects:
def getFile(self, fileId: str) -> Optional[FileItem]: def getFile(self, fileId: str) -> Optional[FileItem]:
"""Returns a file by ID if it belongs to the current user (user-scoped).""" """Returns a file by ID if it belongs to the current user (user-scoped)."""
# Files are always user-scoped: filter by _createdBy (bypasses RBAC SysAdmin override) # Files are always user-scoped: filter by sysCreatedBy (bypasses RBAC SysAdmin override)
filteredFiles = self._getFilesByCurrentUser(recordFilter={"id": fileId}) filteredFiles = self._getFilesByCurrentUser(recordFilter={"id": fileId})
if not filteredFiles: if not filteredFiles:
@ -977,20 +979,19 @@ class ComponentObjects:
file = filteredFiles[0] file = filteredFiles[0]
try: try:
# Get creation date from record or use current time sysCreatedAt = file.get("sysCreatedAt") or file.get("creationDate")
creationDate = file.get("creationDate") if not sysCreatedAt:
if not creationDate: sysCreatedAt = getUtcTimestamp()
creationDate = getUtcTimestamp()
return FileItem( return FileItem(
id=file.get("id"), id=file.get("id"),
mandateId=file.get("mandateId"), mandateId=file.get("mandateId"),
featureInstanceId=file.get("featureInstanceId", ""),
fileName=file.get("fileName"), fileName=file.get("fileName"),
mimeType=file.get("mimeType"), mimeType=file.get("mimeType"),
workflowId=file.get("workflowId"),
fileHash=file.get("fileHash"), fileHash=file.get("fileHash"),
fileSize=file.get("fileSize"), fileSize=file.get("fileSize"),
creationDate=creationDate sysCreatedAt=sysCreatedAt,
) )
except Exception as e: except Exception as e:
logger.error(f"Error converting file record: {str(e)}") logger.error(f"Error converting file record: {str(e)}")
@ -1053,15 +1054,20 @@ class ComponentObjects:
# Ensure fileName is unique # Ensure fileName is unique
uniqueName = self._generateUniquefileName(name) uniqueName = self._generateUniquefileName(name)
# Use mandateId and featureInstanceId from context for proper data isolation
# Convert None to empty string to satisfy Pydantic validation
mandateId = self.mandateId or "" mandateId = self.mandateId or ""
featureInstanceId = self.featureInstanceId or "" featureInstanceId = self.featureInstanceId or ""
# Create FileItem instance if featureInstanceId:
scope = "featureInstance"
elif mandateId:
scope = "mandate"
else:
scope = "personal"
fileItem = FileItem( fileItem = FileItem(
mandateId=mandateId, mandateId=mandateId,
featureInstanceId=featureInstanceId, featureInstanceId=featureInstanceId,
scope=scope,
fileName=uniqueName, fileName=uniqueName,
mimeType=mimeType, mimeType=mimeType,
fileSize=fileSize, fileSize=fileSize,
@ -1146,7 +1152,7 @@ class ComponentObjects:
self.db._ensure_connection() self.db._ensure_connection()
with self.db.connection.cursor() as cursor: with self.db.connection.cursor() as cursor:
cursor.execute( cursor.execute(
'SELECT "id" FROM "FileItem" WHERE "id" = ANY(%s) AND "_createdBy" = %s', 'SELECT "id" FROM "FileItem" WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s',
(uniqueIds, self.userId or ""), (uniqueIds, self.userId or ""),
) )
accessibleIds = [row["id"] for row in cursor.fetchall()] accessibleIds = [row["id"] for row in cursor.fetchall()]
@ -1157,7 +1163,7 @@ class ComponentObjects:
cursor.execute('DELETE FROM "FileData" WHERE "id" = ANY(%s)', (accessibleIds,)) cursor.execute('DELETE FROM "FileData" WHERE "id" = ANY(%s)', (accessibleIds,))
cursor.execute( cursor.execute(
'DELETE FROM "FileItem" WHERE "id" = ANY(%s) AND "_createdBy" = %s', 'DELETE FROM "FileItem" WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s',
(accessibleIds, self.userId or ""), (accessibleIds, self.userId or ""),
) )
deletedFiles = cursor.rowcount deletedFiles = cursor.rowcount
@ -1202,12 +1208,12 @@ class ComponentObjects:
def getFolder(self, folderId: str) -> Optional[Dict[str, Any]]: def getFolder(self, folderId: str) -> Optional[Dict[str, Any]]:
"""Returns a folder by ID if it belongs to the current user.""" """Returns a folder by ID if it belongs to the current user."""
folders = self.db.getRecordset(FileFolder, recordFilter={"id": folderId, "_createdBy": self.userId or ""}) folders = self.db.getRecordset(FileFolder, recordFilter={"id": folderId, "sysCreatedBy": self.userId or ""})
return folders[0] if folders else None return folders[0] if folders else None
def listFolders(self, parentId: Optional[str] = None) -> List[Dict[str, Any]]: def listFolders(self, parentId: Optional[str] = None) -> List[Dict[str, Any]]:
"""List folders for current user, optionally filtered by parentId.""" """List folders for current user, optionally filtered by parentId."""
recordFilter = {"_createdBy": self.userId or ""} recordFilter = {"sysCreatedBy": self.userId or ""}
if parentId is not None: if parentId is not None:
recordFilter["parentId"] = parentId recordFilter["parentId"] = parentId
return self.db.getRecordset(FileFolder, recordFilter=recordFilter) return self.db.getRecordset(FileFolder, recordFilter=recordFilter)
@ -1256,7 +1262,7 @@ class ComponentObjects:
self.db._ensure_connection() self.db._ensure_connection()
with self.db.connection.cursor() as cursor: with self.db.connection.cursor() as cursor:
cursor.execute( cursor.execute(
'SELECT "id" FROM "FileItem" WHERE "id" = ANY(%s) AND "_createdBy" = %s', 'SELECT "id" FROM "FileItem" WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s',
(uniqueIds, self.userId or ""), (uniqueIds, self.userId or ""),
) )
accessibleIds = [row["id"] for row in cursor.fetchall()] accessibleIds = [row["id"] for row in cursor.fetchall()]
@ -1265,8 +1271,8 @@ class ComponentObjects:
raise FileNotFoundError(f"Files not found or not accessible: {missingIds}") raise FileNotFoundError(f"Files not found or not accessible: {missingIds}")
cursor.execute( cursor.execute(
'UPDATE "FileItem" SET "folderId" = %s, "_modifiedAt" = %s, "_modifiedBy" = %s ' 'UPDATE "FileItem" SET "folderId" = %s, "sysModifiedAt" = %s, "sysModifiedBy" = %s '
'WHERE "id" = ANY(%s) AND "_createdBy" = %s', 'WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s',
(targetFolderId, getUtcTimestamp(), self.userId or "", accessibleIds, self.userId or ""), (targetFolderId, getUtcTimestamp(), self.userId or "", accessibleIds, self.userId or ""),
) )
movedFiles = cursor.rowcount movedFiles = cursor.rowcount
@ -1295,7 +1301,7 @@ class ComponentObjects:
existingInTarget = self.db.getRecordset( existingInTarget = self.db.getRecordset(
FileFolder, FileFolder,
recordFilter={"parentId": targetParentId or "", "_createdBy": self.userId or ""}, recordFilter={"parentId": targetParentId or "", "sysCreatedBy": self.userId or ""},
) )
existingNames = {f.get("name"): f.get("id") for f in existingInTarget} existingNames = {f.get("name"): f.get("id") for f in existingInTarget}
movingNames: Dict[str, str] = {} movingNames: Dict[str, str] = {}
@ -1316,8 +1322,8 @@ class ComponentObjects:
self.db._ensure_connection() self.db._ensure_connection()
with self.db.connection.cursor() as cursor: with self.db.connection.cursor() as cursor:
cursor.execute( cursor.execute(
'UPDATE "FileFolder" SET "parentId" = %s, "_modifiedAt" = %s, "_modifiedBy" = %s ' 'UPDATE "FileFolder" SET "parentId" = %s, "sysModifiedAt" = %s, "sysModifiedBy" = %s '
'WHERE "id" = ANY(%s) AND "_createdBy" = %s', 'WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s',
(targetParentId, getUtcTimestamp(), self.userId or "", uniqueIds, self.userId or ""), (targetParentId, getUtcTimestamp(), self.userId or "", uniqueIds, self.userId or ""),
) )
movedFolders = cursor.rowcount movedFolders = cursor.rowcount
@ -1335,7 +1341,7 @@ class ComponentObjects:
if not folder: if not folder:
raise FileNotFoundError(f"Folder {folderId} not found") raise FileNotFoundError(f"Folder {folderId} not found")
childFolders = self.db.getRecordset(FileFolder, recordFilter={"parentId": folderId, "_createdBy": self.userId or ""}) childFolders = self.db.getRecordset(FileFolder, recordFilter={"parentId": folderId, "sysCreatedBy": self.userId or ""})
childFiles = self._getFilesByCurrentUser(recordFilter={"folderId": folderId}) childFiles = self._getFilesByCurrentUser(recordFilter={"folderId": folderId})
if not recursive and (childFolders or childFiles): if not recursive and (childFolders or childFiles):
@ -1384,7 +1390,7 @@ class ComponentObjects:
self.db._ensure_connection() self.db._ensure_connection()
with self.db.connection.cursor() as cursor: with self.db.connection.cursor() as cursor:
cursor.execute( cursor.execute(
'SELECT "id" FROM "FileFolder" WHERE "id" = ANY(%s) AND "_createdBy" = %s', 'SELECT "id" FROM "FileFolder" WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s',
(uniqueIds, self.userId or ""), (uniqueIds, self.userId or ""),
) )
rootAccessibleIds = [row["id"] for row in cursor.fetchall()] rootAccessibleIds = [row["id"] for row in cursor.fetchall()]
@ -1397,12 +1403,12 @@ class ComponentObjects:
WITH RECURSIVE folder_tree AS ( WITH RECURSIVE folder_tree AS (
SELECT "id" SELECT "id"
FROM "FileFolder" FROM "FileFolder"
WHERE "id" = ANY(%s) AND "_createdBy" = %s WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s
UNION ALL UNION ALL
SELECT child."id" SELECT child."id"
FROM "FileFolder" child FROM "FileFolder" child
INNER JOIN folder_tree ft ON child."parentId" = ft."id" INNER JOIN folder_tree ft ON child."parentId" = ft."id"
WHERE child."_createdBy" = %s WHERE child."sysCreatedBy" = %s
) )
SELECT DISTINCT "id" FROM folder_tree SELECT DISTINCT "id" FROM folder_tree
""", """,
@ -1411,7 +1417,7 @@ class ComponentObjects:
allFolderIds = [row["id"] for row in cursor.fetchall()] allFolderIds = [row["id"] for row in cursor.fetchall()]
cursor.execute( cursor.execute(
'SELECT "id" FROM "FileItem" WHERE "folderId" = ANY(%s) AND "_createdBy" = %s', 'SELECT "id" FROM "FileItem" WHERE "folderId" = ANY(%s) AND "sysCreatedBy" = %s',
(allFolderIds, self.userId or ""), (allFolderIds, self.userId or ""),
) )
allFileIds = [row["id"] for row in cursor.fetchall()] allFileIds = [row["id"] for row in cursor.fetchall()]
@ -1419,7 +1425,7 @@ class ComponentObjects:
if allFileIds: if allFileIds:
cursor.execute('DELETE FROM "FileData" WHERE "id" = ANY(%s)', (allFileIds,)) cursor.execute('DELETE FROM "FileData" WHERE "id" = ANY(%s)', (allFileIds,))
cursor.execute( cursor.execute(
'DELETE FROM "FileItem" WHERE "id" = ANY(%s) AND "_createdBy" = %s', 'DELETE FROM "FileItem" WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s',
(allFileIds, self.userId or ""), (allFileIds, self.userId or ""),
) )
deletedFiles = cursor.rowcount deletedFiles = cursor.rowcount
@ -1427,7 +1433,7 @@ class ComponentObjects:
deletedFiles = 0 deletedFiles = 0
cursor.execute( cursor.execute(
'DELETE FROM "FileFolder" WHERE "id" = ANY(%s) AND "_createdBy" = %s', 'DELETE FROM "FileFolder" WHERE "id" = ANY(%s) AND "sysCreatedBy" = %s',
(allFolderIds, self.userId or ""), (allFolderIds, self.userId or ""),
) )
deletedFolders = cursor.rowcount deletedFolders = cursor.rowcount

View file

@ -293,9 +293,45 @@ class SubscriptionObjects:
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)
elif resourceType == "dataVolumeMB":
cap = plan.maxDataVolumeMB
if cap is None:
return True
currentMB = self.getMandateDataVolumeMB(mandateId)
if currentMB + delta > cap:
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
raise SubscriptionCapacityException(resourceType=resourceType, currentCount=int(currentMB), maxAllowed=cap)
return True return True
def getMandateDataVolumeMB(self, mandateId: str) -> float:
"""Total indexed data volume for the mandate (MB), for billing and capacity checks."""
return self._getMandateDataVolumeMB(mandateId)
def _getMandateDataVolumeMB(self, mandateId: str) -> float:
"""Sum RAG index size (FileContentIndex.totalSize) for the mandate; reads poweron_knowledge."""
try:
from modules.interfaces.interfaceDbKnowledge import aggregateMandateRagTotalBytes
return aggregateMandateRagTotalBytes(mandateId) / (1024 * 1024)
except Exception:
return 0.0
def getDataVolumeWarning(self, mandateId: str) -> Optional[Dict[str, Any]]:
"""Return a warning dict if mandate uses >=80% of maxDataVolumeMB, else None."""
sub = self.getOperativeForMandate(mandateId)
if not sub:
return None
plan = self.getPlan(sub.get("planKey", ""))
if not plan or not plan.maxDataVolumeMB:
return None
usedMB = self.getMandateDataVolumeMB(mandateId)
limitMB = plan.maxDataVolumeMB
percent = (usedMB / limitMB * 100) if limitMB > 0 else 0
if percent >= 80:
return {"usedMB": round(usedMB, 2), "limitMB": limitMB, "percent": round(percent, 1), "warning": True}
return {"usedMB": round(usedMB, 2), "limitMB": limitMB, "percent": round(percent, 1), "warning": False}
# ========================================================================= # =========================================================================
# Counting (cross-DB queries against poweron_app) # Counting (cross-DB queries against poweron_app)
# ========================================================================= # =========================================================================
@ -321,11 +357,18 @@ class SubscriptionObjects:
# Stripe quantity sync # Stripe quantity sync
# ========================================================================= # =========================================================================
def syncQuantityToStripe(self, subscriptionId: str) -> None: def syncQuantityToStripe(self, subscriptionId: str, *, raiseOnError: bool = False) -> None:
"""Update Stripe subscription item quantities to match actual active counts. """Update Stripe subscription item quantities to match actual active counts.
Takes subscriptionId, not mandateId.""" Takes subscriptionId, not mandateId.
Args:
raiseOnError: If True, propagate Stripe API errors instead of logging them.
Use True for billing-critical paths (store activation).
"""
sub = self.getById(subscriptionId) sub = self.getById(subscriptionId)
if not sub or not sub.get("stripeSubscriptionId"): if not sub or not sub.get("stripeSubscriptionId"):
if raiseOnError:
raise ValueError(f"Subscription {subscriptionId} hat keine Stripe-Anbindung — Abrechnung nicht möglich.")
return return
mandateId = sub["mandateId"] mandateId = sub["mandateId"]
@ -351,3 +394,5 @@ class SubscriptionObjects:
logger.info("Stripe quantity synced for sub %s: users=%d, instances=%d", subscriptionId, activeUsers, activeInstances) logger.info("Stripe quantity synced for sub %s: users=%d, instances=%d", subscriptionId, activeUsers, activeInstances)
except Exception as e: except Exception as e:
logger.error("syncQuantityToStripe(%s) failed: %s", subscriptionId, e) logger.error("syncQuantityToStripe(%s) failed: %s", subscriptionId, e)
if raiseOnError:
raise

View file

@ -57,7 +57,7 @@ class FeatureInterface:
records = self.db.getRecordset(Feature, recordFilter={"code": featureCode}) records = self.db.getRecordset(Feature, recordFilter={"code": featureCode})
if not records: if not records:
return None return None
cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_")} cleanedRecord = dict(records[0])
return Feature(**cleanedRecord) return Feature(**cleanedRecord)
except Exception as e: except Exception as e:
logger.error(f"Error getting feature {featureCode}: {e}") logger.error(f"Error getting feature {featureCode}: {e}")
@ -74,7 +74,7 @@ class FeatureInterface:
records = self.db.getRecordset(Feature) records = self.db.getRecordset(Feature)
result = [] result = []
for record in records: for record in records:
cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")} cleanedRecord = dict(record)
result.append(Feature(**cleanedRecord)) result.append(Feature(**cleanedRecord))
return result return result
except Exception as e: except Exception as e:
@ -120,7 +120,7 @@ class FeatureInterface:
records = self.db.getRecordset(FeatureInstance, recordFilter={"id": instanceId}) records = self.db.getRecordset(FeatureInstance, recordFilter={"id": instanceId})
if not records: if not records:
return None return None
cleanedRecord = {k: v for k, v in records[0].items() if not k.startswith("_")} cleanedRecord = dict(records[0])
return FeatureInstance(**cleanedRecord) return FeatureInstance(**cleanedRecord)
except Exception as e: except Exception as e:
logger.error(f"Error getting feature instance {instanceId}: {e}") logger.error(f"Error getting feature instance {instanceId}: {e}")
@ -144,7 +144,7 @@ class FeatureInterface:
records = self.db.getRecordset(FeatureInstance, recordFilter=recordFilter) records = self.db.getRecordset(FeatureInstance, recordFilter=recordFilter)
result = [] result = []
for record in records: for record in records:
cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")} cleanedRecord = dict(record)
result.append(FeatureInstance(**cleanedRecord)) result.append(FeatureInstance(**cleanedRecord))
return result return result
except Exception as e: except Exception as e:
@ -199,7 +199,7 @@ class FeatureInterface:
if copyTemplateRoles: if copyTemplateRoles:
self._copyTemplateRoles(featureCode, mandateId, instanceId) self._copyTemplateRoles(featureCode, mandateId, instanceId)
cleanedRecord = {k: v for k, v in createdInstance.items() if not k.startswith("_")} cleanedRecord = dict(createdInstance)
return FeatureInstance(**cleanedRecord) return FeatureInstance(**cleanedRecord)
except Exception as e: except Exception as e:
@ -208,7 +208,11 @@ class FeatureInterface:
def _copyTemplateRoles(self, featureCode: str, mandateId: str, instanceId: str) -> int: def _copyTemplateRoles(self, featureCode: str, mandateId: str, instanceId: str) -> int:
""" """
Copy global template roles for a feature to a new instance. Copy feature-specific template roles to a new instance.
INVARIANT: Feature instances MUST receive feature-specific roles
(e.g. workspace-admin, workspace-user). NEVER generic mandate roles.
Feature templates have featureCode set and isSystemRole=False.
Args: Args:
featureCode: Feature code featureCode: Feature code
@ -217,19 +221,30 @@ class FeatureInterface:
Returns: Returns:
Number of roles copied Number of roles copied
Raises:
ValueError: If no feature-specific template roles exist
""" """
try: try:
# Find global template roles for this feature (mandateId=None) allTemplates = self.db.getRecordset(
globalRoles = self.db.getRecordset(
Role, Role,
recordFilter={"featureCode": featureCode, "mandateId": None} recordFilter={"featureCode": featureCode}
) )
if not globalRoles: featureTemplates = [
logger.debug(f"No template roles found for feature {featureCode}") r for r in allTemplates
return 0 if r.get("mandateId") is None and r.get("featureInstanceId") is None
]
templateRoleIds = [r.get("id") for r in globalRoles] if not featureTemplates:
raise ValueError(
f"No feature-specific template roles found for '{featureCode}'. "
f"Each feature module must define TEMPLATE_ROLES and sync them to DB on startup."
)
logger.info(f"Found {len(featureTemplates)} feature-specific template roles for '{featureCode}'")
templateRoleIds = [r.get("id") for r in featureTemplates]
# BULK: Load all template AccessRules in one query # BULK: Load all template AccessRules in one query
allTemplateRules = [] allTemplateRules = []
@ -246,7 +261,7 @@ class FeatureInterface:
# Copy roles and their AccessRules # Copy roles and their AccessRules
copiedCount = 0 copiedCount = 0
for templateRole in globalRoles: for templateRole in featureTemplates:
newRoleId = str(uuid.uuid4()) newRoleId = str(uuid.uuid4())
# Create new role for this instance # Create new role for this instance
@ -282,9 +297,11 @@ class FeatureInterface:
logger.info(f"Copied {copiedCount} template roles for instance {instanceId}") logger.info(f"Copied {copiedCount} template roles for instance {instanceId}")
return copiedCount return copiedCount
except ValueError:
raise
except Exception as e: except Exception as e:
logger.error(f"Error copying template roles: {e}") logger.error(f"Error copying template roles: {e}")
return 0 raise ValueError(f"Failed to copy template roles for '{featureCode}': {e}")
def syncRolesFromTemplate(self, featureInstanceId: str, addOnly: bool = True) -> Dict[str, int]: def syncRolesFromTemplate(self, featureInstanceId: str, addOnly: bool = True) -> Dict[str, int]:
""" """
@ -309,11 +326,15 @@ class FeatureInterface:
featureCode = instance.featureCode featureCode = instance.featureCode
mandateId = instance.mandateId mandateId = instance.mandateId
# Get current template roles # Get feature-specific template roles (mandateId=None, featureInstanceId=None)
templateRoles = self.db.getRecordset( allForFeature = self.db.getRecordset(
Role, Role,
recordFilter={"featureCode": featureCode, "mandateId": None} recordFilter={"featureCode": featureCode}
) )
templateRoles = [
r for r in allForFeature
if r.get("mandateId") is None and r.get("featureInstanceId") is None
]
templateLabels = {r.get("roleLabel") for r in templateRoles} templateLabels = {r.get("roleLabel") for r in templateRoles}
# Get current instance roles # Get current instance roles
@ -414,7 +435,7 @@ class FeatureInterface:
updated = self.db.recordModify(FeatureInstance, instanceId, filteredData) updated = self.db.recordModify(FeatureInstance, instanceId, filteredData)
if updated: if updated:
cleanedRecord = {k: v for k, v in updated.items() if not k.startswith("_")} cleanedRecord = dict(updated)
return FeatureInstance(**cleanedRecord) return FeatureInstance(**cleanedRecord)
return None return None
except Exception as e: except Exception as e:
@ -463,7 +484,7 @@ class FeatureInterface:
records = self.db.getRecordset(Role, recordFilter=recordFilter) records = self.db.getRecordset(Role, recordFilter=recordFilter)
result = [] result = []
for record in records: for record in records:
cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")} cleanedRecord = dict(record)
result.append(Role(**cleanedRecord)) result.append(Role(**cleanedRecord))
return result return result
except Exception as e: except Exception as e:

View file

@ -17,7 +17,7 @@ Data Namespace Structure:
GROUP-Berechtigung: GROUP-Berechtigung:
- data.uam.*: GROUP filtert nach Mandant (via UserMandate) - data.uam.*: GROUP filtert nach Mandant (via UserMandate)
- data.chat.*, data.files.*, data.automation.*: GROUP = MY (benutzer-eigen); bei gesetztem featureInstanceId zusätzlich _createdBy - data.chat.*, data.files.*, data.automation.*: GROUP = MY (benutzer-eigen); bei gesetztem featureInstanceId zusätzlich sysCreatedBy
- data.feature.*: GROUP filtert nach mandateId/featureInstanceId - data.feature.*: GROUP filtert nach mandateId/featureInstanceId
""" """
@ -146,7 +146,7 @@ def getRecordsetWithRBAC(
mandateId: Explicit mandate context (from request header). Required for GROUP access. mandateId: Explicit mandate context (from request header). Required for GROUP access.
featureInstanceId: Explicit feature instance context featureInstanceId: Explicit feature instance context
enrichPermissions: If True, adds _permissions field to each record with row-level enrichPermissions: If True, adds _permissions field to each record with row-level
permissions { canUpdate, canDelete } based on RBAC rules and _createdBy permissions { canUpdate, canDelete } based on RBAC rules and sysCreatedBy
featureCode: Optional feature code for feature-specific tables (e.g., "trustee"). featureCode: Optional feature code for feature-specific tables (e.g., "trustee").
If None, table is treated as a system table. If None, table is treated as a system table.
@ -657,7 +657,7 @@ def buildRbacWhereClause(
# shared featureInstance (stale RBAC rules or merged roles). Same as MY. # shared featureInstance (stale RBAC rules or merged roles). Same as MY.
namespaceAll = TABLE_NAMESPACE.get(table, "system") namespaceAll = TABLE_NAMESPACE.get(table, "system")
if featureInstanceId and namespaceAll == "chat": if featureInstanceId and namespaceAll == "chat":
userIdFieldAll = "_createdBy" userIdFieldAll = "sysCreatedBy"
if table == "UserInDB": if table == "UserInDB":
userIdFieldAll = "id" userIdFieldAll = "id"
elif table == "UserConnection": elif table == "UserConnection":
@ -671,7 +671,7 @@ def buildRbacWhereClause(
return {"condition": " AND ".join(baseConditions), "values": baseValues} return {"condition": " AND ".join(baseConditions), "values": baseValues}
return None return None
# My records - filter by _createdBy or userId field # My records - filter by sysCreatedBy or userId field
if readLevel == AccessLevel.MY: if readLevel == AccessLevel.MY:
# Try common field names for creator # Try common field names for creator
userIdField = None userIdField = None
@ -680,7 +680,7 @@ def buildRbacWhereClause(
elif table == "UserConnection": elif table == "UserConnection":
userIdField = "userId" userIdField = "userId"
else: else:
userIdField = "_createdBy" userIdField = "sysCreatedBy"
conditions = list(baseConditions) conditions = list(baseConditions)
values = list(baseValues) values = list(baseValues)
@ -707,7 +707,7 @@ def buildRbacWhereClause(
if featureInstanceId and readLevel == AccessLevel.GROUP: if featureInstanceId and readLevel == AccessLevel.GROUP:
conditions = list(baseConditions) conditions = list(baseConditions)
values = list(baseValues) values = list(baseValues)
conditions.append('"_createdBy" = %s') conditions.append('"sysCreatedBy" = %s')
values.append(currentUser.id) values.append(currentUser.id)
return {"condition": " AND ".join(conditions), "values": values} return {"condition": " AND ".join(conditions), "values": values}
return {"condition": " AND ".join(baseConditions), "values": baseValues} return {"condition": " AND ".join(baseConditions), "values": baseValues}
@ -829,7 +829,7 @@ def _enrichRecordsWithPermissions(
Logic: Logic:
- AccessLevel.ALL ('a'): User can update/delete all records - AccessLevel.ALL ('a'): User can update/delete all records
- AccessLevel.MY ('m'): User can only update/delete records where _createdBy == userId - AccessLevel.MY ('m'): User can only update/delete records where sysCreatedBy == userId
- AccessLevel.GROUP ('g'): Same as MY for now (group-level ownership) - AccessLevel.GROUP ('g'): Same as MY for now (group-level ownership)
- AccessLevel.NONE ('n'): User cannot update/delete any records - AccessLevel.NONE ('n'): User cannot update/delete any records
@ -846,7 +846,7 @@ def _enrichRecordsWithPermissions(
for record in records: for record in records:
recordCopy = dict(record) recordCopy = dict(record)
createdBy = record.get("_createdBy") createdBy = record.get("sysCreatedBy")
# Determine canUpdate # Determine canUpdate
canUpdate = _checkRowPermission(permissions.update, userId, createdBy) canUpdate = _checkRowPermission(permissions.update, userId, createdBy)
@ -873,7 +873,7 @@ def _checkRowPermission(
Args: Args:
accessLevel: The permission level (ALL, MY, GROUP, NONE) accessLevel: The permission level (ALL, MY, GROUP, NONE)
userId: Current user's ID userId: Current user's ID
recordCreatedBy: The _createdBy value of the record recordCreatedBy: The sysCreatedBy value of the record
Returns: Returns:
True if user has permission, False otherwise True if user has permission, False otherwise
@ -884,9 +884,9 @@ def _checkRowPermission(
if accessLevel == AccessLevel.ALL: if accessLevel == AccessLevel.ALL:
return True return True
# MY and GROUP: Check ownership via _createdBy # MY and GROUP: Check ownership via sysCreatedBy
if accessLevel in (AccessLevel.MY, AccessLevel.GROUP): if accessLevel in (AccessLevel.MY, AccessLevel.GROUP):
# If record has no _createdBy, allow access (can't verify ownership) # If record has no sysCreatedBy, allow access (can't verify ownership)
if not recordCreatedBy: if not recordCreatedBy:
return True return True
# If no userId, can't verify - deny # If no userId, can't verify - deny

View file

@ -11,9 +11,7 @@ import logging
from typing import AsyncGenerator, Callable, Dict, Any, Optional, List from typing import AsyncGenerator, Callable, Dict, Any, Optional, List
from modules.connectors.connectorVoiceGoogle import ConnectorGoogleSpeech from modules.connectors.connectorVoiceGoogle import ConnectorGoogleSpeech
from modules.datamodels.datamodelVoice import VoiceSettings
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
from modules.shared.timeUtils import getUtcTimestamp
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -335,123 +333,6 @@ class VoiceObjects:
"error": str(e) "error": str(e)
} }
# Voice Settings Management
def getVoiceSettings(self, userId: str) -> Optional[VoiceSettings]:
"""
Get voice settings for a user.
Args:
userId: User ID to get settings for
Returns:
VoiceSettings object or None if not found
"""
try:
# This would typically query the database
# For now, return None as this is handled by the database interface
logger.debug(f"Getting voice settings for user: {userId}")
return None
except Exception as e:
logger.error(f"❌ Error getting voice settings: {e}")
return None
def createVoiceSettings(self, settingsData: Dict[str, Any]) -> Optional[VoiceSettings]:
"""
Create new voice settings.
Args:
settingsData: Dictionary containing voice settings data
Returns:
Created VoiceSettings object or None if failed
"""
try:
logger.info(f"Creating voice settings: {settingsData}")
# Ensure mandateId is set from context if not provided
if "mandateId" not in settingsData or not settingsData["mandateId"]:
if not self.mandateId:
raise ValueError("mandateId is required but not provided and context has no mandateId")
settingsData["mandateId"] = self.mandateId
# Add timestamps
currentTime = getUtcTimestamp()
settingsData["creationDate"] = currentTime
settingsData["lastModified"] = currentTime
# Create VoiceSettings object
voiceSettings = VoiceSettings(**settingsData)
logger.info(f"✅ Voice settings created: {voiceSettings.id}")
return voiceSettings
except Exception as e:
logger.error(f"❌ Error creating voice settings: {e}")
return None
def updateVoiceSettings(self, userId: str, settingsData: Dict[str, Any]) -> Optional[VoiceSettings]:
"""
Update existing voice settings.
Args:
userId: User ID to update settings for
settingsData: Dictionary containing updated voice settings data
Returns:
Updated VoiceSettings object or None if failed
"""
try:
logger.info(f"Updating voice settings for user {userId}: {settingsData}")
# Add last modified timestamp
settingsData["lastModified"] = getUtcTimestamp()
# Create updated VoiceSettings object
voiceSettings = VoiceSettings(**settingsData)
logger.info(f"✅ Voice settings updated: {voiceSettings.id}")
return voiceSettings
except Exception as e:
logger.error(f"❌ Error updating voice settings: {e}")
return None
def getOrCreateVoiceSettings(self, userId: str) -> Optional[VoiceSettings]:
"""
Get existing voice settings or create default ones.
Args:
userId: User ID to get/create settings for
Returns:
VoiceSettings object
"""
try:
# Try to get existing settings
existingSettings = self.getVoiceSettings(userId)
if existingSettings:
return existingSettings
# Create default settings if none exist
defaultSettings = {
"userId": userId,
"mandateId": self.mandateId,
"sttLanguage": "de-DE",
"ttsLanguage": "de-DE",
"ttsVoice": "de-DE-Wavenet-A",
"translationEnabled": True,
"targetLanguage": "en-US"
}
return self.createVoiceSettings(defaultSettings)
except Exception as e:
logger.error(f"❌ Error getting or creating voice settings: {e}")
return None
# Language and Voice Information # Language and Voice Information
async def getAvailableLanguages(self) -> Dict[str, Any]: async def getAvailableLanguages(self) -> Dict[str, Any]:

View file

@ -0,0 +1 @@
# Migration modules

View file

@ -0,0 +1,114 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Migration: Backfill FileContentIndex scope fields from FileItem (Single Source of Truth).
Fixes legacy rows in poweron_knowledge where scope/mandateId/featureInstanceId
are empty or default ("personal") despite the corresponding FileItem having correct values.
Idempotent safe to run multiple times. Uses a DB flag to skip if already completed.
"""
import logging
from modules.shared.configuration import APP_CONFIG
from modules.connectors.connectorDbPostgre import _get_cached_connector
logger = logging.getLogger(__name__)
_MIGRATION_FLAG_KEY = "migration_rag_scope_fields_completed"
def _isMigrationCompleted(appDb) -> bool:
try:
from modules.datamodels.datamodelUam import Mandate
records = appDb.getRecordset(Mandate, recordFilter={"name": _MIGRATION_FLAG_KEY})
return len(records) > 0
except Exception:
return False
def _setMigrationCompleted(appDb) -> None:
try:
from modules.datamodels.datamodelUam import Mandate
flag = Mandate(name=_MIGRATION_FLAG_KEY, description="RAG scope fields migration completed")
appDb.recordCreate(Mandate, flag)
except Exception as e:
logger.error("Could not set migration flag: %s", e)
def runMigration(appDb=None) -> dict:
"""Backfill FileContentIndex rows from FileItem metadata.
Returns dict with counts: {total, updated, skipped, orphaned}.
"""
from modules.datamodels.datamodelKnowledge import FileContentIndex
from modules.datamodels.datamodelFiles import FileItem
from modules.interfaces.interfaceDbKnowledge import getInterface as getKnowledgeInterface
from modules.interfaces.interfaceDbManagement import ComponentObjects
if appDb is None:
from modules.interfaces.interfaceDbApp import getRootInterface
appDb = getRootInterface().db
if _isMigrationCompleted(appDb):
logger.info("migrateRagScopeFields: already completed, skipping")
return {"total": 0, "updated": 0, "skipped": 0, "orphaned": 0}
knowDb = getKnowledgeInterface(None).db
mgmtDb = ComponentObjects().db
allIndexes = knowDb.getRecordset(FileContentIndex, recordFilter={})
total = len(allIndexes)
updated = 0
skipped = 0
orphaned = 0
logger.info("migrateRagScopeFields: processing %d FileContentIndex rows", total)
for idx in allIndexes:
idxId = idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
if not idxId:
skipped += 1
continue
fileItem = mgmtDb._loadRecord(FileItem, str(idxId))
if not fileItem:
orphaned += 1
continue
_get = (lambda k, d="": fileItem.get(k, d)) if isinstance(fileItem, dict) else (lambda k, d="": getattr(fileItem, k, d))
fiScope = _get("scope") or "personal"
fiMandateId = str(_get("mandateId") or "")
fiFeatureInstanceId = str(_get("featureInstanceId") or "")
idxGet = (lambda k, d="": idx.get(k, d)) if isinstance(idx, dict) else (lambda k, d="": getattr(idx, k, d))
currentScope = idxGet("scope") or "personal"
currentMandateId = str(idxGet("mandateId") or "")
currentFeatureInstanceId = str(idxGet("featureInstanceId") or "")
updates = {}
if fiScope != currentScope:
updates["scope"] = fiScope
if fiMandateId and fiMandateId != currentMandateId:
updates["mandateId"] = fiMandateId
if fiFeatureInstanceId and fiFeatureInstanceId != currentFeatureInstanceId:
updates["featureInstanceId"] = fiFeatureInstanceId
if updates:
try:
knowDb.recordModify(FileContentIndex, str(idxId), updates)
updated += 1
logger.debug("migrateRagScopeFields: updated %s -> %s", idxId, updates)
except Exception as e:
logger.error("migrateRagScopeFields: failed to update %s: %s", idxId, e)
skipped += 1
else:
skipped += 1
_setMigrationCompleted(appDb)
logger.info(
"migrateRagScopeFields complete: total=%d, updated=%d, skipped=%d, orphaned=%d",
total, updated, skipped, orphaned,
)
return {"total": total, "updated": updated, "skipped": skipped, "orphaned": orphaned}

View file

@ -0,0 +1,329 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Migration: Root-Mandant bereinigen.
Moves all end-user data from Root mandate shared instances to own mandates.
Called once from bootstrap, sets a DB flag to prevent re-execution.
"""
import logging
from typing import Optional, List, Dict, Any
logger = logging.getLogger(__name__)
_MIGRATION_FLAG_KEY = "migration_root_users_completed"
_DATA_TABLES = [
"ChatWorkflow",
"FileItem",
"DataSource",
"DataNeutralizerAttributes",
"FileContentIndex",
]
def _isMigrationCompleted(db) -> bool:
"""Check if migration has already been executed."""
try:
from modules.datamodels.datamodelUam import Mandate
records = db.getRecordset(Mandate, recordFilter={"name": _MIGRATION_FLAG_KEY})
return len(records) > 0
except Exception:
return False
def _setMigrationCompleted(db) -> None:
"""Set flag that migration is completed (uses a settings-like record)."""
if _isMigrationCompleted(db):
return
try:
from modules.datamodels.datamodelUam import Mandate
flag = Mandate(name=_MIGRATION_FLAG_KEY, label="Migration completed", enabled=False, isSystem=True)
db.recordCreate(Mandate, flag)
logger.info("Migration flag set: root user migration completed")
except Exception as e:
logger.error(f"Failed to set migration flag: {e}")
def _findOrCreateTargetInstance(db, featureInterface, featureCode: str, targetMandateId: str, rootInstance: dict) -> dict:
"""Find existing or create new FeatureInstance in target mandate. Idempotent."""
from modules.datamodels.datamodelFeatures import FeatureInstance
existing = db.getRecordset(FeatureInstance, recordFilter={
"featureCode": featureCode,
"mandateId": targetMandateId,
})
if existing:
logger.debug(f"Target instance already exists for {featureCode} in mandate {targetMandateId}")
return existing[0]
label = rootInstance.get("label") or featureCode
instance = featureInterface.createFeatureInstance(
featureCode=featureCode,
mandateId=targetMandateId,
label=label,
enabled=True,
copyTemplateRoles=True,
)
if isinstance(instance, dict):
return instance
return instance.model_dump() if hasattr(instance, "model_dump") else {"id": instance.id}
def _migrateDataRecords(db, oldInstanceId: str, newInstanceId: str, userId: str) -> int:
"""Bulk-update featureInstanceId on all data tables for records owned by userId."""
totalMigrated = 0
db._ensure_connection()
for tableName in _DATA_TABLES:
try:
with db.connection.cursor() as cursor:
cursor.execute(
f'UPDATE "{tableName}" '
f'SET "featureInstanceId" = %s '
f'WHERE "featureInstanceId" = %s AND "sysCreatedBy" = %s',
(newInstanceId, oldInstanceId, userId),
)
count = cursor.rowcount
db.connection.commit()
if count > 0:
logger.info(f" Migrated {count} rows in {tableName}: {oldInstanceId} -> {newInstanceId}")
totalMigrated += count
except Exception as e:
try:
db.connection.rollback()
except Exception:
pass
logger.debug(f" Table {tableName} skipped (may not exist or no matching column): {e}")
return totalMigrated
def _grantFeatureAccess(db, userId: str, featureInstanceId: str) -> dict:
"""Create FeatureAccess + admin role on a feature instance. Idempotent."""
from modules.datamodels.datamodelMembership import FeatureAccess, FeatureAccessRole
from modules.datamodels.datamodelRbac import Role
existing = db.getRecordset(FeatureAccess, recordFilter={
"userId": userId,
"featureInstanceId": featureInstanceId,
})
if existing:
logger.debug(f"FeatureAccess already exists for user {userId} on instance {featureInstanceId}")
return existing[0]
fa = FeatureAccess(userId=userId, featureInstanceId=featureInstanceId, enabled=True)
createdFa = db.recordCreate(FeatureAccess, fa.model_dump())
if not createdFa:
logger.warning(f"Failed to create FeatureAccess for user {userId} on instance {featureInstanceId}")
return {}
instanceRoles = db.getRecordset(Role, recordFilter={"featureInstanceId": featureInstanceId})
adminRoleId = None
for r in instanceRoles:
roleLabel = (r.get("roleLabel") or "").lower()
if roleLabel.endswith("-admin"):
adminRoleId = r.get("id")
break
if not adminRoleId:
raise ValueError(
f"No feature-specific admin role for instance {featureInstanceId}. "
f"Cannot create FeatureAccess without role — even in migration context."
)
far = FeatureAccessRole(featureAccessId=createdFa["id"], roleId=adminRoleId)
db.recordCreate(FeatureAccessRole, far.model_dump())
return createdFa
def migrateRootUsers(db, dryRun: bool = False) -> dict:
"""
Migrate all end-user feature data from Root mandate to personal mandates.
Algorithm:
STEP 1: For each user with FeatureAccess on Root instances:
- If user has own mandate: target = existing mandate
- If not: create personal mandate via _provisionMandateForUser
- For each FeatureAccess: create new instance in target, migrate data, transfer access
STEP 2: Clean up Root:
- Delete all FeatureInstances in Root
- Remove UserMandate for non-sysadmin users
Args:
db: Database connector
dryRun: If True, log actions without making changes
Returns:
Summary dict with migration statistics
"""
if _isMigrationCompleted(db):
logger.info("Root user migration already completed, skipping")
return {"status": "already_completed"}
from modules.datamodels.datamodelUam import Mandate, User, UserInDB
from modules.datamodels.datamodelMembership import (
UserMandate, UserMandateRole, FeatureAccess, FeatureAccessRole,
)
from modules.datamodels.datamodelFeatures import FeatureInstance
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.interfaces.interfaceFeatures import getFeatureInterface
rootInterface = getRootInterface()
featureInterface = getFeatureInterface(db)
stats = {
"usersProcessed": 0,
"mandatesCreated": 0,
"instancesMigrated": 0,
"dataRowsMigrated": 0,
"rootInstancesDeleted": 0,
"rootMembershipsRemoved": 0,
"dryRun": dryRun,
}
# Find root mandate
rootMandates = db.getRecordset(Mandate, recordFilter={"name": "root", "isSystem": True})
if not rootMandates:
logger.warning("No root mandate found, nothing to migrate")
return {"status": "no_root_mandate"}
rootMandateId = rootMandates[0].get("id")
# Get all feature instances in root
rootInstances = db.getRecordset(FeatureInstance, recordFilter={"mandateId": rootMandateId})
if not rootInstances:
logger.info("No feature instances in root mandate, nothing to migrate")
if not dryRun:
_setMigrationCompleted(db)
return {"status": "no_instances", **stats}
# Get all FeatureAccess on root instances
rootInstanceIds = {inst.get("id") for inst in rootInstances}
# Collect unique users with access on root instances
usersToMigrate = {}
for instanceId in rootInstanceIds:
accesses = db.getRecordset(FeatureAccess, recordFilter={"featureInstanceId": instanceId})
for access in accesses:
userId = access.get("userId")
if userId not in usersToMigrate:
usersToMigrate[userId] = []
usersToMigrate[userId].append({
"featureAccessId": access.get("id"),
"featureInstanceId": instanceId,
})
logger.info(f"Migration: {len(usersToMigrate)} users with {sum(len(v) for v in usersToMigrate.values())} accesses on {len(rootInstances)} root instances")
# STEP 1: Migrate users
for userId, accessList in usersToMigrate.items():
try:
# Find user
users = db.getRecordset(UserInDB, recordFilter={"id": userId})
if not users:
logger.warning(f"User {userId} not found, skipping")
continue
user = users[0]
username = user.get("username", "unknown")
# Check if user has own non-root mandate
userMandates = db.getRecordset(UserMandate, recordFilter={"userId": userId, "enabled": True})
targetMandateId = None
for um in userMandates:
mid = um.get("mandateId")
if mid != rootMandateId:
targetMandateId = mid
break
if not targetMandateId:
# Create personal mandate
if dryRun:
logger.info(f"[DRY RUN] Would create personal mandate for user {username}")
stats["mandatesCreated"] += 1
else:
try:
result = rootInterface._provisionMandateForUser(
userId=userId,
mandateName=f"Home {username}",
planKey="TRIAL_7D",
)
targetMandateId = result["mandateId"]
stats["mandatesCreated"] += 1
logger.info(f"Created personal mandate {targetMandateId} for user {username}")
except Exception as e:
logger.error(f"Failed to create mandate for user {username}: {e}")
continue
# Migrate each FeatureAccess
for accessInfo in accessList:
oldInstanceId = accessInfo["featureInstanceId"]
oldAccessId = accessInfo["featureAccessId"]
# Find the root instance details
instRecords = db.getRecordset(FeatureInstance, recordFilter={"id": oldInstanceId})
if not instRecords:
continue
featureCode = instRecords[0].get("featureCode")
if dryRun:
logger.info(f"[DRY RUN] Would migrate {featureCode} for {username} to mandate {targetMandateId}")
stats["instancesMigrated"] += 1
else:
targetInstance = _findOrCreateTargetInstance(
db, featureInterface, featureCode, targetMandateId, instRecords[0],
)
newInstanceId = targetInstance.get("id")
if not newInstanceId:
logger.error(f"Failed to obtain target instance for {featureCode} in mandate {targetMandateId}")
continue
migratedCount = _migrateDataRecords(db, oldInstanceId, newInstanceId, userId)
_grantFeatureAccess(db, userId, newInstanceId)
try:
db.recordDelete(FeatureAccess, oldAccessId)
except Exception as delErr:
logger.warning(f"Could not remove old FeatureAccess {oldAccessId}: {delErr}")
logger.info(
f"Migrated {featureCode} for {username}: "
f"instance {oldInstanceId} -> {newInstanceId}, {migratedCount} data rows moved"
)
stats["instancesMigrated"] += 1
stats["dataRowsMigrated"] += migratedCount
stats["usersProcessed"] += 1
except Exception as e:
logger.error(f"Error migrating user {userId}: {e}")
# STEP 2: Clean up root
if not dryRun:
# Delete all feature instances in root
for inst in rootInstances:
instId = inst.get("id")
try:
# First delete all FeatureAccess on this instance
accesses = db.getRecordset(FeatureAccess, recordFilter={"featureInstanceId": instId})
for access in accesses:
db.recordDelete(FeatureAccess, access.get("id"))
db.recordDelete(FeatureInstance, instId)
stats["rootInstancesDeleted"] += 1
except Exception as e:
logger.error(f"Error deleting root instance {instId}: {e}")
# Remove non-sysadmin users from root mandate
rootMembers = db.getRecordset(UserMandate, recordFilter={"mandateId": rootMandateId})
for membership in rootMembers:
membUserId = membership.get("userId")
userRecords = db.getRecordset(UserInDB, recordFilter={"id": membUserId})
if userRecords and userRecords[0].get("isSysAdmin"):
continue
try:
db.recordDelete(UserMandate, membership.get("id"))
stats["rootMembershipsRemoved"] += 1
except Exception as e:
logger.error(f"Error removing root membership for {membUserId}: {e}")
_setMigrationCompleted(db)
logger.info(f"Migration completed: {stats}")
return {"status": "completed", **stats}

View file

@ -0,0 +1,316 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Migration: Voice settings consolidation and CoachingDocument scope-tagging.
Moves VoiceSettings (workspace DB) and CoachingUserProfile voice fields (commcoach DB)
into the unified UserVoicePreferences model, and tags CoachingDocument files with
featureInstance scope before deleting the legacy records.
Called once from bootstrap, sets a DB flag to prevent re-execution.
"""
import logging
import uuid
from typing import Dict, List, Optional
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG
from modules.datamodels.datamodelUam import UserVoicePreferences
logger = logging.getLogger(__name__)
_MIGRATION_FLAG_KEY = "migration_voice_documents_completed"
def _isMigrationCompleted(db) -> bool:
"""Check if migration has already been executed."""
try:
from modules.datamodels.datamodelUam import Mandate
records = db.getRecordset(Mandate, recordFilter={"name": _MIGRATION_FLAG_KEY})
return len(records) > 0
except Exception:
return False
def _setMigrationCompleted(db) -> None:
"""Set flag that migration is completed (uses a settings-like record)."""
if _isMigrationCompleted(db):
return
try:
from modules.datamodels.datamodelUam import Mandate
flag = Mandate(name=_MIGRATION_FLAG_KEY, label="Migration completed", enabled=False, isSystem=True)
db.recordCreate(Mandate, flag)
logger.info("Migration flag set: voice & documents migration completed")
except Exception as e:
logger.error(f"Failed to set migration flag: {e}")
def _getRawRows(connector: DatabaseConnector, tableName: str, columns: List[str]) -> List[Dict]:
"""Read all rows from a table via raw SQL. Returns empty list if table doesn't exist."""
try:
connector._ensure_connection()
colList = ", ".join(f'"{c}"' for c in columns)
with connector.connection.cursor() as cur:
cur.execute(
"SELECT COUNT(*) FROM information_schema.tables "
"WHERE LOWER(table_name) = LOWER(%s) AND table_schema = 'public'",
(tableName,),
)
if cur.fetchone()["count"] == 0:
logger.info(f"Table '{tableName}' does not exist, skipping")
return []
cur.execute(f'SELECT {colList} FROM "{tableName}"')
return [dict(row) for row in cur.fetchall()]
except Exception as e:
logger.warning(f"Raw query on '{tableName}' failed: {e}")
try:
connector.connection.rollback()
except Exception:
pass
return []
def _deleteRawRow(connector: DatabaseConnector, tableName: str, rowId: str) -> bool:
"""Delete a single row by id via raw SQL."""
try:
connector._ensure_connection()
with connector.connection.cursor() as cur:
cur.execute(f'DELETE FROM "{tableName}" WHERE "id" = %s', (rowId,))
connector.connection.commit()
return True
except Exception as e:
logger.warning(f"Failed to delete row {rowId} from '{tableName}': {e}")
try:
connector.connection.rollback()
except Exception:
pass
return False
def _createDbConnector(dbName: str) -> Optional[DatabaseConnector]:
"""Create a DatabaseConnector for a named database, returns None on failure."""
try:
dbHost = APP_CONFIG.get("DB_HOST")
dbUser = APP_CONFIG.get("DB_USER")
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
return DatabaseConnector(
dbHost=dbHost,
dbDatabase=dbName,
dbUser=dbUser,
dbPassword=dbPassword,
dbPort=dbPort,
)
except Exception as e:
logger.warning(f"Could not connect to database '{dbName}': {e}")
return None
# ─── Part A ───────────────────────────────────────────────────────────────────
def _migrateVoiceSettings(db, wsDb: DatabaseConnector, dryRun: bool, stats: Dict) -> None:
"""Migrate VoiceSettings records from poweron_workspace into UserVoicePreferences."""
rows = _getRawRows(wsDb, "VoiceSettings", [
"id", "userId", "mandateId", "ttsVoiceMap", "sttLanguage", "ttsLanguage", "ttsVoice",
])
if not rows:
logger.info("Part A: No VoiceSettings records found, skipping")
return
for row in rows:
userId = row.get("userId")
if not userId:
continue
existing = db.getRecordset(UserVoicePreferences, recordFilter={"userId": userId})
if existing:
stats["voiceSettingsSkipped"] += 1
if not dryRun:
_deleteRawRow(wsDb, "VoiceSettings", row["id"])
continue
if dryRun:
logger.info(f"[DRY RUN] Would create UserVoicePreferences for user {userId} from VoiceSettings")
stats["voiceSettingsCreated"] += 1
continue
try:
import json
ttsVoiceMap = row.get("ttsVoiceMap")
if isinstance(ttsVoiceMap, str):
try:
ttsVoiceMap = json.loads(ttsVoiceMap)
except (json.JSONDecodeError, TypeError):
ttsVoiceMap = None
prefs = UserVoicePreferences(
userId=userId,
mandateId=row.get("mandateId"),
ttsVoiceMap=ttsVoiceMap,
sttLanguage=row.get("sttLanguage", "de-DE"),
ttsLanguage=row.get("ttsLanguage", "de-DE"),
ttsVoice=row.get("ttsVoice"),
)
db.recordCreate(UserVoicePreferences, prefs)
stats["voiceSettingsCreated"] += 1
_deleteRawRow(wsDb, "VoiceSettings", row["id"])
except Exception as e:
logger.error(f"Part A: Failed to migrate VoiceSettings {row['id']}: {e}")
stats["errors"] += 1
# ─── Part B ───────────────────────────────────────────────────────────────────
def _migrateCoachingProfileVoice(db, ccDb: DatabaseConnector, dryRun: bool, stats: Dict) -> None:
"""Migrate preferredLanguage/preferredVoice from CoachingUserProfile into UserVoicePreferences."""
rows = _getRawRows(ccDb, "CoachingUserProfile", [
"id", "userId", "mandateId", "preferredLanguage", "preferredVoice",
])
if not rows:
logger.info("Part B: No CoachingUserProfile records with voice data found, skipping")
return
for row in rows:
userId = row.get("userId")
prefLang = row.get("preferredLanguage")
prefVoice = row.get("preferredVoice")
if not userId or (not prefLang and not prefVoice):
continue
existing = db.getRecordset(UserVoicePreferences, recordFilter={"userId": userId})
if existing:
stats["coachingProfileSkipped"] += 1
continue
if dryRun:
logger.info(f"[DRY RUN] Would create UserVoicePreferences for user {userId} from CoachingUserProfile")
stats["coachingProfileCreated"] += 1
continue
try:
prefs = UserVoicePreferences(
userId=userId,
mandateId=row.get("mandateId"),
sttLanguage=prefLang or "de-DE",
ttsLanguage=prefLang or "de-DE",
ttsVoice=prefVoice,
)
db.recordCreate(UserVoicePreferences, prefs)
stats["coachingProfileCreated"] += 1
except Exception as e:
logger.error(f"Part B: Failed to migrate CoachingUserProfile {row['id']}: {e}")
stats["errors"] += 1
# ─── Part C ───────────────────────────────────────────────────────────────────
def _migrateCoachingDocuments(ccDb: DatabaseConnector, dryRun: bool, stats: Dict) -> None:
"""Tag FileItem/FileContentIndex with featureInstance scope for each CoachingDocument."""
from modules.datamodels.datamodelFiles import FileItem
from modules.datamodels.datamodelKnowledge import FileContentIndex
rows = _getRawRows(ccDb, "CoachingDocument", [
"id", "fileRef", "instanceId",
])
if not rows:
logger.info("Part C: No CoachingDocument records found, skipping")
return
mgmtDb = _createDbConnector("poweron_management")
knowledgeDb = _createDbConnector("poweron_knowledge")
if not mgmtDb:
logger.error("Part C: Cannot connect to poweron_management, aborting document migration")
return
for row in rows:
fileRef = row.get("fileRef")
instanceId = row.get("instanceId")
docId = row.get("id")
if not fileRef:
if not dryRun:
_deleteRawRow(ccDb, "CoachingDocument", docId)
continue
if dryRun:
logger.info(f"[DRY RUN] Would tag FileItem {fileRef} with featureInstanceId={instanceId}")
stats["documentsTagged"] += 1
continue
try:
fileRecords = mgmtDb.getRecordset(FileItem, recordFilter={"id": fileRef})
if fileRecords:
updateData = {"scope": "featureInstance"}
if instanceId:
updateData["featureInstanceId"] = instanceId
mgmtDb.recordModify(FileItem, fileRef, updateData)
stats["documentsTagged"] += 1
else:
logger.warning(f"Part C: FileItem {fileRef} not found in management DB")
if knowledgeDb:
fciRecords = knowledgeDb.getRecordset(FileContentIndex, recordFilter={"id": fileRef})
if fciRecords:
fciUpdate = {"scope": "featureInstance"}
if instanceId:
fciUpdate["featureInstanceId"] = instanceId
knowledgeDb.recordModify(FileContentIndex, fileRef, fciUpdate)
_deleteRawRow(ccDb, "CoachingDocument", docId)
except Exception as e:
logger.error(f"Part C: Failed to migrate CoachingDocument {docId}: {e}")
stats["errors"] += 1
# ─── Main entry ───────────────────────────────────────────────────────────────
def migrateVoiceAndDocuments(db, dryRun: bool = False) -> dict:
"""
Migrate VoiceSettings + CoachingUserProfile voice fields into UserVoicePreferences,
and tag CoachingDocument files with featureInstance scope.
Args:
db: Root database connector (poweron_app)
dryRun: If True, log actions without making changes
Returns:
Summary dict with migration statistics
"""
if _isMigrationCompleted(db):
logger.info("Voice & documents migration already completed, skipping")
return {"status": "already_completed"}
stats = {
"voiceSettingsCreated": 0,
"voiceSettingsSkipped": 0,
"coachingProfileCreated": 0,
"coachingProfileSkipped": 0,
"documentsTagged": 0,
"errors": 0,
"dryRun": dryRun,
}
wsDb = _createDbConnector("poweron_workspace")
ccDb = _createDbConnector("poweron_commcoach")
# Part A
if wsDb:
_migrateVoiceSettings(db, wsDb, dryRun, stats)
else:
logger.warning("Skipping Part A: poweron_workspace DB unavailable")
# Part B
if ccDb:
_migrateCoachingProfileVoice(db, ccDb, dryRun, stats)
else:
logger.warning("Skipping Part B: poweron_commcoach DB unavailable")
# Part C
if ccDb:
_migrateCoachingDocuments(ccDb, dryRun, stats)
else:
logger.warning("Skipping Part C: poweron_commcoach DB unavailable")
if not dryRun:
_setMigrationCompleted(db)
logger.info(f"Voice & documents migration completed: {stats}")
return {"status": "completed", **stats}

View file

@ -112,12 +112,12 @@ def _buildEnrichedAutomationEvents(currentUser: User) -> List[Dict[str, Any]]:
if automation: if automation:
if isinstance(automation, dict): if isinstance(automation, dict):
job["name"] = automation.get("label", "") job["name"] = automation.get("label", "")
job["createdBy"] = _resolveUsername(automation.get("_createdBy", "")) job["createdBy"] = _resolveUsername(automation.get("sysCreatedBy", ""))
job["mandate"] = _resolveMandateLabel(automation.get("mandateId", "")) job["mandate"] = _resolveMandateLabel(automation.get("mandateId", ""))
job["featureInstance"] = _resolveFeatureLabel(automation.get("featureInstanceId", "")) job["featureInstance"] = _resolveFeatureLabel(automation.get("featureInstanceId", ""))
else: else:
job["name"] = getattr(automation, "label", "") job["name"] = getattr(automation, "label", "")
job["createdBy"] = _resolveUsername(getattr(automation, "_createdBy", "")) job["createdBy"] = _resolveUsername(getattr(automation, "sysCreatedBy", ""))
job["mandate"] = _resolveMandateLabel(getattr(automation, "mandateId", "")) job["mandate"] = _resolveMandateLabel(getattr(automation, "mandateId", ""))
job["featureInstance"] = _resolveFeatureLabel(getattr(automation, "featureInstanceId", "")) job["featureInstance"] = _resolveFeatureLabel(getattr(automation, "featureInstanceId", ""))
else: else:

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