Compare commits

..

3 commits

826 changed files with 37531 additions and 17972 deletions

207
app.py
View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
import os import os
import sys import sys
@ -302,7 +302,7 @@ async def lifespan(app: FastAPI):
logger.info("Application is starting up") logger.info("Application is starting up")
# Validate FK metadata on all Pydantic models (fail-fast, no silent fallbacks) # Validate FK metadata on all Pydantic models (fail-fast, no silent fallbacks)
from modules.dbHelpers.fkRegistry import validateFkTargets from modules.shared.fkRegistry import validateFkTargets
fkErrors = validateFkTargets() fkErrors = validateFkTargets()
if fkErrors: if fkErrors:
for err in fkErrors: for err in fkErrors:
@ -311,31 +311,6 @@ async def lifespan(app: FastAPI):
# AI connectors already pre-warmed at module-load via _eager_prewarm() in aicoreModelRegistry. # AI connectors already pre-warmed at module-load via _eager_prewarm() in aicoreModelRegistry.
# Register system-component lifecycle hooks (Composition Root — inverts L4->L5b dependency)
from modules.shared.systemComponentRegistry import registerLifecycleHook
from modules.workflowAutomation.mainWorkflowAutomation import (
onBootstrap as _waOnBootstrap,
onMandateDelete as _waOnMandateDelete,
onInstanceCreate as _waOnInstanceCreate,
)
from modules.interfaces.interfaceDbBilling import (
onMandateDelete as _billingOnMandateDelete,
onMandateProvision as _billingOnMandateProvision,
onStorageChanged as _billingOnStorageChanged,
onUserMandateCreate as _billingOnUserMandateCreate,
onUserMandateDelete as _billingOnUserMandateDelete,
onUserBudgetAdjust as _billingOnUserBudgetAdjust,
)
registerLifecycleHook("onBootstrap", _waOnBootstrap)
registerLifecycleHook("onMandateDelete", _waOnMandateDelete)
registerLifecycleHook("onMandateDelete", _billingOnMandateDelete)
registerLifecycleHook("onMandateProvision", _billingOnMandateProvision)
registerLifecycleHook("onStorageChanged", _billingOnStorageChanged)
registerLifecycleHook("onInstanceCreate", _waOnInstanceCreate)
registerLifecycleHook("onUserMandateCreate", _billingOnUserMandateCreate)
registerLifecycleHook("onUserMandateDelete", _billingOnUserMandateDelete)
registerLifecycleHook("onUserBudgetAdjust", _billingOnUserBudgetAdjust)
# Bootstrap database if needed (creates initial users, mandates, roles, etc.) # Bootstrap database if needed (creates initial users, mandates, roles, etc.)
# This must happen before getting root interface # This must happen before getting root interface
from modules.security.rootAccess import getRootDbAppConnector from modules.security.rootAccess import getRootDbAppConnector
@ -354,14 +329,6 @@ async def lifespan(app: FastAPI):
catalogService = getCatalogService() catalogService = getCatalogService()
registerAllFeaturesInCatalog(catalogService) registerAllFeaturesInCatalog(catalogService)
logger.info("Feature catalog registration completed") logger.info("Feature catalog registration completed")
# Register service center RBAC objects (Composition Root — avoids system→serviceCenter import)
try:
from modules.serviceCenter import registerServiceObjects
registerServiceObjects(catalogService)
except Exception as e:
logger.warning(f"Service center RBAC registration failed: {e}")
# Persist the in-memory feature registry into the Feature DB-table so # Persist the in-memory feature registry into the Feature DB-table so
# the FeatureInstance.featureCode FK has real targets. Without this # the FeatureInstance.featureCode FK has real targets. Without this
# every FeatureInstance row would be flagged as orphan by the # every FeatureInstance row would be flagged as orphan by the
@ -375,23 +342,8 @@ async def lifespan(app: FastAPI):
# Sync gateway i18n registry to DB and load translation cache # Sync gateway i18n registry to DB and load translation cache
try: try:
from modules.system.i18nBootSync import syncRegistryToDb, loadCache from modules.shared.i18nRegistry import syncRegistryToDb, loadCache
from modules.serviceCenter.registry import IMPORTABLE_SERVICES await syncRegistryToDb()
serviceLabels = [svc.get("label") for svc in IMPORTABLE_SERVICES.values()]
accountingLabels = []
try:
from modules.features.trustee.accounting.accountingRegistry import getAccountingRegistry
registry = getAccountingRegistry()
for connectorType, connector in (registry._connectors or {}).items():
for field in connector.getRequiredConfigFields():
label = getattr(field, "label", "") or ""
if label:
accountingLabels.append({"label": label, "connectorType": connectorType})
except Exception:
pass
await syncRegistryToDb(serviceLabels=serviceLabels, accountingLabels=accountingLabels)
await loadCache() await loadCache()
logger.info("i18n registry sync + cache load completed") logger.info("i18n registry sync + cache load completed")
except Exception as e: except Exception as e:
@ -424,74 +376,14 @@ async def lifespan(app: FastAPI):
except Exception as e: except Exception as e:
logger.warning(f"Could not initialize feature containers: {e}") logger.warning(f"Could not initialize feature containers: {e}")
# Bootstrap Stripe prices for paid plans (composition root — upward import allowed here)
try:
from modules.serviceCenter.services.serviceSubscription.stripeBootstrap import bootstrapStripePrices
bootstrapStripePrices()
except Exception as e:
logger.error(f"Stripe price bootstrap failed: {e}")
# Bootstrap MIME map into ComponentObjects (composition root — upward import allowed here)
try:
from modules.serviceCenter.services.serviceExtraction.subRegistry import ExtractorRegistry
from modules.interfaces.interfaceDbManagement import ComponentObjects
_mimeRegistry = ExtractorRegistry()
_extensionToMime = _mimeRegistry.getExtensionToMimeMap()
_textMimes: set = set()
_seen: set = set()
for _ext in _mimeRegistry._map.values():
_eid = id(_ext)
if _eid in _seen:
continue
_seen.add(_eid)
_mimes = _ext.getSupportedMimeTypes()
if any(m.startswith("text/") for m in _mimes):
_textMimes.update(_mimes)
_textMimes.update({"application/json", "application/xml", "application/javascript", "application/sql", "application/x-yaml", "application/x-toml"})
ComponentObjects.setMimeMap(_extensionToMime, _textMimes)
except Exception as e:
logger.warning(f"MIME map bootstrap failed: {e}")
# --- Init Managers --- # --- Init Managers ---
import asyncio import asyncio
try: try:
main_loop = asyncio.get_running_loop() main_loop = asyncio.get_running_loop()
eventManager.set_event_loop(main_loop) eventManager.set_event_loop(main_loop)
from modules.workflowAutomation.scheduler.mainScheduler import setMainLoop as setSchedulerMainLoop, setOnRunFailedCallback from modules.workflows.scheduler.mainScheduler import setMainLoop as setSchedulerMainLoop
setSchedulerMainLoop(main_loop) setSchedulerMainLoop(main_loop)
# Inject run-failed notification callback (Composition Root — avoids workflows→serviceCenter import)
def _onRunFailed(workflowId, runId, error, mandateId=None, workflowLabel=None):
from modules.serviceCenter import getService
from modules.serviceCenter.context import ServiceCenterContext
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelMessaging import MessagingEventParameters
rootInterface = getRootInterface()
if not rootInterface:
return
eventUser = rootInterface.getUserByUsername("event")
if not eventUser:
return
ctx = ServiceCenterContext(
user=eventUser,
mandate_id=mandateId or "",
feature_instance_id="",
feature_code="workflowAutomation",
)
messagingService = getService("messaging", ctx)
subscriptionId = "WorkflowAutomationRunFailed"
eventParams = MessagingEventParameters(triggerData={
"workflowId": workflowId,
"workflowLabel": workflowLabel or workflowId,
"runId": runId,
"error": error,
"mandateId": mandateId or "",
})
messagingService.executeSubscription(subscriptionId, eventParams)
setOnRunFailedCallback(_onRunFailed)
# Suppress noisy ConnectionResetError from ProactorEventLoop on Windows # Suppress noisy ConnectionResetError from ProactorEventLoop on Windows
# when clients (browsers) close connections abruptly. This is a known # when clients (browsers) close connections abruptly. This is a known
# asyncio issue on Windows: https://bugs.python.org/issue39010 # asyncio issue on Windows: https://bugs.python.org/issue39010
@ -501,24 +393,14 @@ async def lifespan(app: FastAPI):
return return
if isinstance(exc, ConnectionAbortedError): if isinstance(exc, ConnectionAbortedError):
return return
if exc and "LocalProtocolError" in type(exc).__name__:
return
loop.default_exception_handler(ctx) loop.default_exception_handler(ctx)
main_loop.set_exception_handler(_suppressClientDisconnect) main_loop.set_exception_handler(_suppressClientDisconnect)
except RuntimeError: except RuntimeError:
pass pass
eventManager.start() eventManager.start()
# --- WorkflowAutomation: Scheduler boot (System-Lifespan, not Feature-onStart) ---
try:
from modules.workflowAutomation.scheduler.mainScheduler import start as _startWorkflowScheduler
_startWorkflowScheduler(eventUser)
logger.info("WorkflowAutomation scheduler started (system lifespan)")
except Exception as e:
logger.error(f"WorkflowAutomation scheduler failed to start: {e}")
# Register audit log cleanup scheduler # Register audit log cleanup scheduler
from modules.dbHelpers.auditLogger import registerAuditLogCleanupScheduler from modules.shared.auditLogger import registerAuditLogCleanupScheduler
registerAuditLogCleanupScheduler() registerAuditLogCleanupScheduler()
# Register enterprise subscription auto-renewal scheduler # Register enterprise subscription auto-renewal scheduler
@ -549,43 +431,12 @@ async def lifespan(app: FastAPI):
except Exception as e: except Exception as e:
logger.warning(f"KnowledgeIngestionConsumer registration failed (non-critical): {e}") logger.warning(f"KnowledgeIngestionConsumer registration failed (non-critical): {e}")
# Install force-exit handler AFTER uvicorn has registered its own SIGINT
# handler. Uvicorn's default timeout-graceful-shutdown is None (wait
# forever), so frontend polling keep-alive connections block the process.
# This wraps uvicorn's handler: on Ctrl+C, start a 3s timer that calls
# os._exit() if the graceful shutdown hasn't completed by then.
import signal as _sig
import threading as _thr
_prevSigint = _sig.getsignal(_sig.SIGINT)
def _onSigint(signum, frame):
_t = _thr.Timer(3.0, lambda: os._exit(0))
_t.daemon = True
_t.start()
if callable(_prevSigint) and _prevSigint not in (_sig.SIG_DFL, _sig.SIG_IGN):
_prevSigint(signum, frame)
else:
raise KeyboardInterrupt
_sig.signal(_sig.SIGINT, _onSigint)
yield yield
# --- Shutdown sequence (protected against CancelledError) --- # --- Shutdown sequence (protected against CancelledError) ---
try: try:
# 1. Drain SSE queues and cancel agent tasks FIRST so that open # 1. Signal DB layer to abort in-flight borrow waits immediately.
# streaming connections break out of their queue.get() loop # This MUST happen first so that sync worker threads stuck in
# immediately. Without this, uvicorn waits for the SSE generators
# to finish (up to 120 s keepalive timeout) before the rest of
# the shutdown can proceed.
try:
from modules.shared.eventManager import get_event_manager as _getStreamingEM
_getStreamingEM().shutdown()
except Exception as e:
logger.warning(f"Streaming EventManager shutdown failed: {e}")
# 2. Signal DB layer to abort in-flight borrow waits immediately.
# This MUST happen early so that sync worker threads stuck in
# _acquireConn (30 s poll loop) bail out within one backoff tick # _acquireConn (30 s poll loop) bail out within one backoff tick
# instead of blocking process exit for the full borrow timeout. # instead of blocking process exit for the full borrow timeout.
try: try:
@ -594,22 +445,10 @@ async def lifespan(app: FastAPI):
except Exception as e: except Exception as e:
logger.warning(f"Closing DB connection pools failed: {e}") logger.warning(f"Closing DB connection pools failed: {e}")
# 3. Stop scheduler (removes all pending cron/interval jobs) # 2. Stop scheduler (removes all pending cron/interval jobs)
eventManager.stop() eventManager.stop()
# 3.5 Stop WorkflowAutomation scheduler + email poller (System-Lifespan) # 3. Stop Feature Containers (Plug&Play)
try:
from modules.workflowAutomation.scheduler.mainScheduler import stop as _stopWorkflowScheduler
_stopWorkflowScheduler()
except Exception as e:
logger.warning(f"WorkflowAutomation scheduler stop failed: {e}")
try:
from modules.workflowAutomation.scheduler.emailPoller import stop as _stopEmailPoller
_stopEmailPoller(eventUser)
except Exception as e:
logger.warning(f"Email poller stop failed: {e}")
# 4. Stop Feature Containers (Plug&Play)
try: try:
mainModules = loadFeatureMainModules() mainModules = loadFeatureMainModules()
for featureName, module in mainModules.items(): for featureName, module in mainModules.items():
@ -622,9 +461,16 @@ async def lifespan(app: FastAPI):
except Exception as e: except Exception as e:
logger.warning(f"Could not shutdown feature containers: {e}") logger.warning(f"Could not shutdown feature containers: {e}")
# 4. Cancel all pending streaming EventManager tasks (cleanup sleeps, agent tasks)
try:
from modules.serviceCenter.core.serviceStreaming.eventManager import get_event_manager as _getStreamingEM
_getStreamingEM().shutdown()
except Exception as e:
logger.warning(f"Streaming EventManager shutdown failed: {e}")
# 5. Close shared HTTP sessions (ResilientHttp) to avoid TCP keepalive hang # 5. Close shared HTTP sessions (ResilientHttp) to avoid TCP keepalive hang
try: try:
from modules.shared.httpResilience import closeAllResilientHttp from modules.connectors._httpResilience import closeAllResilientHttp
await closeAllResilientHttp() await closeAllResilientHttp()
except Exception as e: except Exception as e:
logger.warning(f"Closing HTTP sessions failed: {e}") logger.warning(f"Closing HTTP sessions failed: {e}")
@ -705,8 +551,8 @@ def getAllowedOrigins():
# CORS origin regex pattern for wildcard subdomain support # CORS origin regex pattern for wildcard subdomain support
# Matches all subdomains of poweron.swiss # Matches all subdomains of poweron.swiss and poweron-center.net
CORS_ORIGIN_REGEX = r"https://.*\.poweron\.swiss" CORS_ORIGIN_REGEX = r"https://.*\.(poweron\.swiss|poweron-center\.net)"
# SlowAPI rate limiter initialization # SlowAPI rate limiter initialization
@ -805,6 +651,8 @@ app.include_router(connectionsRouter)
from modules.routes.routeRagInventory import router as ragInventoryRouter from modules.routes.routeRagInventory import router as ragInventoryRouter
app.include_router(ragInventoryRouter) app.include_router(ragInventoryRouter)
from modules.routes.routeTableViews import router as tableViewsRouter from modules.routes.routeTableViews import router as tableViewsRouter
app.include_router(tableViewsRouter) app.include_router(tableViewsRouter)
@ -890,8 +738,11 @@ from modules.routes.routeSystem import router as systemRouter, navigationRouter
app.include_router(systemRouter) app.include_router(systemRouter)
app.include_router(navigationRouter) app.include_router(navigationRouter)
from modules.routes.routeWorkflowAutomation import router as workflowAutomationRouter from modules.routes.routeWorkflowDashboard import router as workflowDashboardRouter
app.include_router(workflowAutomationRouter) app.include_router(workflowDashboardRouter)
from modules.routes.routeAutomationWorkspace import router as automationWorkspaceRouter
app.include_router(automationWorkspaceRouter)
# ============================================================================ # ============================================================================
# PLUG&PLAY FEATURE ROUTERS # PLUG&PLAY FEATURE ROUTERS
@ -919,4 +770,4 @@ if __name__ == "__main__":
], check=True) ], check=True)
except ImportError: except ImportError:
import uvicorn import uvicorn
uvicorn.run("app:app", host="0.0.0.0", port=port, workers=1, timeout_graceful_shutdown=2) uvicorn.run("app:app", host="0.0.0.0", port=port, workers=1, timeout_graceful_shutdown=2)

View file

@ -1,5 +1,3 @@
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Generate tenant-dossier.pdf for neutralization demo. Run: python _generateTenantDossierPdf.py """Generate tenant-dossier.pdf for neutralization demo. Run: python _generateTenantDossierPdf.py
Uses ReportLab so the PDF opens reliably in all viewers (stdlib-only PDFs are fragile). Uses ReportLab so the PDF opens reliably in all viewers (stdlib-only PDFs are fragile).

View file

@ -1,5 +1,3 @@
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""Generate the 3 fictitious PWG scan PDFs used by the pilot demo. """Generate the 3 fictitious PWG scan PDFs used by the pilot demo.
Run: python _generateScans.py Run: python _generateScans.py

View file

@ -22,7 +22,7 @@ APP_TOKEN_EXPIRY=300
MFA_REQUIRE_ADMINS = False MFA_REQUIRE_ADMINS = False
# CORS Configuration # CORS Configuration
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
# Logging configuration # Logging configuration
APP_LOGGING_LOG_LEVEL = DEBUG APP_LOGGING_LOG_LEVEL = DEBUG

View file

@ -4,7 +4,7 @@
APP_ENV_TYPE = int APP_ENV_TYPE = int
APP_ENV_LABEL = Integration Instance APP_ENV_LABEL = Integration Instance
APP_API_URL = https://api-int.poweron.swiss APP_API_URL = https://api-int.poweron.swiss
# Force SameSite=None+Secure for auth cookies. Optional if APP_API_URL is https:// # Force SameSite=None+Secure for auth cookies (cross-site UI on poweron-center.net). Optional if APP_API_URL is https://
APP_COOKIE_SECURE = true APP_COOKIE_SECURE = true
APP_KEY_SYSVAR = /srv/gateway/shared/secrets/master_key.txt APP_KEY_SYSVAR = /srv/gateway/shared/secrets/master_key.txt
APP_INIT_PASS_ADMIN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjWm41MWZ4TUZGaVlrX3pWZWNwakJsY3Facm0wLVZDd1VKeTFoZEVZQnItcEdUUnVJS1NXeDBpM2xKbGRsYmxOSmRhc29PZjJSU2txQjdLbUVrTTE1NEJjUXBHbV9NOVJWZUR3QlJkQnJvTEU9 APP_INIT_PASS_ADMIN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjWm41MWZ4TUZGaVlrX3pWZWNwakJsY3Facm0wLVZDd1VKeTFoZEVZQnItcEdUUnVJS1NXeDBpM2xKbGRsYmxOSmRhc29PZjJSU2txQjdLbUVrTTE1NEJjUXBHbV9NOVJWZUR3QlJkQnJvTEU9
@ -24,7 +24,7 @@ APP_TOKEN_EXPIRY=300
MFA_REQUIRE_ADMINS = True MFA_REQUIRE_ADMINS = True
# CORS Configuration # CORS Configuration
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://porta.poweron.swiss,https://porta-int.poweron.swiss,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://porta.poweron.swiss,https://porta-int.poweron.swiss,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
# Logging configuration # Logging configuration
APP_LOGGING_LOG_LEVEL = DEBUG APP_LOGGING_LOG_LEVEL = DEBUG

View file

@ -22,7 +22,7 @@ APP_TOKEN_EXPIRY=300
MFA_REQUIRE_ADMINS = True MFA_REQUIRE_ADMINS = True
# CORS Configuration # CORS Configuration
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://porta.poweron.swiss,https://porta-int.poweron.swiss,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://porta.poweron.swiss,https://porta-int.poweron.swiss,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
# Logging configuration # Logging configuration
APP_LOGGING_LOG_LEVEL = DEBUG APP_LOGGING_LOG_LEVEL = DEBUG

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
Base connector interface for AI connectors. Base connector interface for AI connectors.
@ -11,15 +11,15 @@ IMPORTANT: Model Registration Requirements
- If duplicate displayNames are detected during registration, an error will be raised - If duplicate displayNames are detected during registration, an error will be raised
""" """
import re import re as _re
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import List, Dict, Any, Optional, AsyncGenerator, Union 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( _RETRY_AFTER_PATTERN = _re.compile(
r"(?:try again in|retry after)\s+(\d+(?:\.\d+)?)\s*s", re.IGNORECASE r"(?:try again in|retry after)\s+(\d+(?:\.\d+)?)\s*s", _re.IGNORECASE
) )

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
Dynamic model registry that collects models from all AI connectors. Dynamic model registry that collects models from all AI connectors.
@ -12,9 +12,10 @@ import time
import threading import threading
from typing import Dict, List, Optional, Any, Tuple from typing import Dict, List, Optional, Any, Tuple
from modules.datamodels.datamodelAi import AiModel from modules.datamodels.datamodelAi import AiModel
from modules.datamodels.datamodelRbac import AccessRuleContext, RbacProtocol
from .aicoreBase import BaseConnectorAi from .aicoreBase import BaseConnectorAi
from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelUam import User
from modules.security.rbacHelpers import checkResourceAccess
from modules.security.rbac import RbacClass
from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.connectors.connectorDbPostgre import DatabaseConnector
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -185,7 +186,7 @@ class ModelRegistry:
def getAvailableModels( def getAvailableModels(
self, self,
currentUser: Optional[User] = None, currentUser: Optional[User] = None,
rbacInstance: Optional[RbacProtocol] = None, rbacInstance: Optional[RbacClass] = None,
mandateId: Optional[str] = None, mandateId: Optional[str] = None,
featureInstanceId: Optional[str] = None featureInstanceId: Optional[str] = None
) -> List[AiModel]: ) -> List[AiModel]:
@ -236,7 +237,7 @@ class ModelRegistry:
self, self,
models: List[AiModel], models: List[AiModel],
currentUser: User, currentUser: User,
rbacInstance: RbacProtocol, rbacInstance: RbacClass,
mandateId: Optional[str] = None, mandateId: Optional[str] = None,
featureInstanceId: Optional[str] = None featureInstanceId: Optional[str] = None
) -> List[AiModel]: ) -> List[AiModel]:
@ -261,7 +262,7 @@ class ModelRegistry:
logger.debug(f"User {currentUser.username} does not have access to model {model.displayName} (connector: {model.connectorType})") logger.debug(f"User {currentUser.username} does not have access to model {model.displayName} (connector: {model.connectorType})")
return filteredModels return filteredModels
def getModel(self, displayName: str, currentUser: Optional[User] = None, rbacInstance: Optional[RbacProtocol] = None) -> Optional[AiModel]: def getModel(self, displayName: str, currentUser: Optional[User] = None, rbacInstance: Optional[RbacClass] = None) -> Optional[AiModel]:
"""Get a specific model by displayName, optionally checking RBAC permissions. """Get a specific model by displayName, optionally checking RBAC permissions.
Args: Args:
@ -283,15 +284,8 @@ class ModelRegistry:
connectorResourcePath = f"ai.model.{model.connectorType}" connectorResourcePath = f"ai.model.{model.connectorType}"
modelResourcePath = f"ai.model.{model.connectorType}.{model.displayName}" modelResourcePath = f"ai.model.{model.connectorType}.{model.displayName}"
try: hasConnectorAccess = checkResourceAccess(rbacInstance, currentUser, connectorResourcePath)
connPerms = rbacInstance.getUserPermissions(currentUser, AccessRuleContext.RESOURCE, connectorResourcePath) hasModelAccess = checkResourceAccess(rbacInstance, currentUser, modelResourcePath)
modelPerms = rbacInstance.getUserPermissions(currentUser, AccessRuleContext.RESOURCE, modelResourcePath)
hasConnectorAccess = connPerms.view if connPerms else False
hasModelAccess = modelPerms.view if modelPerms else False
except Exception as e:
logger.error(f"Error checking resource access for {modelResourcePath}: {e}")
hasConnectorAccess = False
hasModelAccess = False
if not (hasConnectorAccess or hasModelAccess): if not (hasConnectorAccess or hasModelAccess):
logger.warning(f"User {currentUser.username} does not have access to model {displayName}") logger.warning(f"User {currentUser.username} does not have access to model {displayName}")

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
Simplified model selection based on model properties and priority-based sorting. Simplified model selection based on model properties and priority-based sorting.
@ -323,4 +323,4 @@ class ModelSelector:
# Global model selector instance # Global model selector instance
modelSelector = ModelSelector() modelSelector = ModelSelector()

View file

@ -1,6 +1,5 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
import base64
import json import json
import logging import logging
import httpx import httpx
@ -656,8 +655,9 @@ class AiAnthropic(BaseConnectorAi):
base64Data = parts[1] base64Data = parts[1]
_SUPPORTED = {"image/jpeg", "image/png", "image/gif", "image/webp"} _SUPPORTED = {"image/jpeg", "image/png", "image/gif", "image/webp"}
import base64 as _b64
try: try:
rawHead = base64.b64decode(base64Data[:32]) rawHead = _b64.b64decode(base64Data[:32])
if rawHead[:3] == b"\xff\xd8\xff": if rawHead[:3] == b"\xff\xd8\xff":
mimeType = "image/jpeg" mimeType = "image/jpeg"
elif rawHead[:8] == b"\x89PNG\r\n\x1a\n": elif rawHead[:8] == b"\x89PNG\r\n\x1a\n":
@ -862,4 +862,4 @@ def _convertToolsToAnthropicFormat(openaiTools: List[Dict[str, Any]]) -> List[Di
"description": fn.get("description", ""), "description": fn.get("description", ""),
"input_schema": fn.get("parameters", {"type": "object", "properties": {}}) "input_schema": fn.get("parameters", {"type": "object", "properties": {}})
}) })
return anthropicTools return anthropicTools

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
import logging import logging
from typing import List from typing import List

View file

@ -1,7 +1,7 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
import logging import logging
import json import json as _json
import httpx import httpx
from typing import List, Dict, Any, AsyncGenerator, Union from typing import List, Dict, Any, AsyncGenerator, Union
from fastapi import HTTPException from fastapi import HTTPException
@ -274,7 +274,7 @@ class AiMistral(BaseConnectorAi):
bodyStr = body.decode() bodyStr = body.decode()
if response.status_code == 429: if response.status_code == 429:
try: try:
errorMsg = json.loads(bodyStr).get("error", {}).get("message", "Rate limit exceeded") errorMsg = _json.loads(bodyStr).get("error", {}).get("message", "Rate limit exceeded")
except (ValueError, KeyError): except (ValueError, KeyError):
errorMsg = f"Rate limit exceeded for {model.name}" errorMsg = f"Rate limit exceeded for {model.name}"
raise RateLimitExceededException(f"Rate limit exceeded for {model.name}: {errorMsg}") raise RateLimitExceededException(f"Rate limit exceeded for {model.name}: {errorMsg}")
@ -287,8 +287,8 @@ class AiMistral(BaseConnectorAi):
if data.strip() == "[DONE]": if data.strip() == "[DONE]":
break break
try: try:
chunk = json.loads(data) chunk = _json.loads(data)
except json.JSONDecodeError: except _json.JSONDecodeError:
continue continue
delta = chunk.get("choices", [{}])[0].get("delta", {}) delta = chunk.get("choices", [{}])[0].get("delta", {})

View file

@ -1,7 +1,7 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
import logging import logging
import json import json as _json
import httpx import httpx
from typing import List, Dict, Any, AsyncGenerator, Union from typing import List, Dict, Any, AsyncGenerator, Union
from fastapi import HTTPException from fastapi import HTTPException
@ -477,7 +477,7 @@ class AiOpenai(BaseConnectorAi):
bodyStr = body.decode() bodyStr = body.decode()
if response.status_code == 429: if response.status_code == 429:
try: try:
errorMsg = json.loads(bodyStr).get("error", {}).get("message", "Rate limit exceeded") errorMsg = _json.loads(bodyStr).get("error", {}).get("message", "Rate limit exceeded")
except (ValueError, KeyError): except (ValueError, KeyError):
errorMsg = f"Rate limit exceeded for {model.name}" errorMsg = f"Rate limit exceeded for {model.name}"
raise RateLimitExceededException(f"Rate limit exceeded for {model.name}: {errorMsg}") raise RateLimitExceededException(f"Rate limit exceeded for {model.name}: {errorMsg}")
@ -490,8 +490,8 @@ class AiOpenai(BaseConnectorAi):
if data.strip() == "[DONE]": if data.strip() == "[DONE]":
break break
try: try:
chunk = json.loads(data) chunk = _json.loads(data)
except json.JSONDecodeError: except _json.JSONDecodeError:
continue continue
delta = chunk.get("choices", [{}])[0].get("delta", {}) delta = chunk.get("choices", [{}])[0].get("delta", {})
@ -730,4 +730,4 @@ class AiOpenai(BaseConnectorAi):
content="", content="",
success=False, success=False,
error=f"Error during image generation: {str(e)}", error=f"Error during image generation: {str(e)}",
) )

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
import logging import logging
import httpx import httpx

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
AI Connector for PowerOn Private-LLM Service. AI Connector for PowerOn Private-LLM Service.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""Tavily web search class. """Tavily web search class.
""" """

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
Authentication and authorization modules for routes and services. Authentication and authorization modules for routes and services.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
Authentication module for backend API. Authentication module for backend API.
@ -437,7 +437,7 @@ def requireSysAdmin(currentUser: User = Depends(getCurrentUser)) -> User:
# Audit for all SysAdmin actions # Audit for all SysAdmin actions
try: try:
from modules.dbHelpers.auditLogger import audit_logger from modules.shared.auditLogger import audit_logger
audit_logger.logSecurityEvent( audit_logger.logSecurityEvent(
userId=str(currentUser.id), userId=str(currentUser.id),
mandateId="system", mandateId="system",
@ -483,7 +483,7 @@ def requirePlatformAdmin(currentUser: User = Depends(getCurrentUser)) -> User:
# Audit for all Platform-Admin actions # Audit for all Platform-Admin actions
try: try:
from modules.dbHelpers.auditLogger import audit_logger from modules.shared.auditLogger import audit_logger
audit_logger.logSecurityEvent( audit_logger.logSecurityEvent(
userId=str(currentUser.id), userId=str(currentUser.id),
mandateId="system", mandateId="system",

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
CSRF Protection Middleware for PowerOn Gateway CSRF Protection Middleware for PowerOn Gateway

View file

@ -1,11 +1,11 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
JWT Service JWT Service
Centralizes local JWT creation and cookie helpers. Centralizes local JWT creation and cookie helpers.
""" """
from datetime import datetime, timedelta from datetime import timedelta
from typing import Optional, Tuple from typing import Optional, Tuple
from fastapi import Response from fastapi import Response
from jose import jwt from jose import jwt

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
MFA (Multi-Factor Authentication) Service. MFA (Multi-Factor Authentication) Service.
@ -27,7 +27,7 @@ _MFA_INTERVAL = 30
_MFA_VALID_WINDOW = 1 _MFA_VALID_WINDOW = 1
def getMfaIssuer() -> str: def _getMfaIssuer() -> str:
"""Build the TOTP issuer name, e.g. 'PowerOn' or 'PowerOn (Dev)'.""" """Build the TOTP issuer name, e.g. 'PowerOn' or 'PowerOn (Dev)'."""
envType = (APP_CONFIG.get("APP_ENV_TYPE") or "").strip().lower() envType = (APP_CONFIG.get("APP_ENV_TYPE") or "").strip().lower()
if envType in ("prod", ""): if envType in ("prod", ""):
@ -44,11 +44,11 @@ def _encryptSecret(plainSecret: str, userId: str = "system") -> str:
return encryptValue(plainSecret, userId=userId, keyName="mfa_secret") return encryptValue(plainSecret, userId=userId, keyName="mfa_secret")
def decryptSecret(encryptedSecret: str, userId: str = "system") -> str: def _decryptSecret(encryptedSecret: str, userId: str = "system") -> str:
return decryptValue(encryptedSecret, userId=userId, keyName="mfa_secret") return decryptValue(encryptedSecret, userId=userId, keyName="mfa_secret")
def buildTotp(plainSecret: str) -> pyotp.TOTP: def _buildTotp(plainSecret: str) -> pyotp.TOTP:
return pyotp.TOTP(plainSecret, digits=_MFA_DIGITS, interval=_MFA_INTERVAL) return pyotp.TOTP(plainSecret, digits=_MFA_DIGITS, interval=_MFA_INTERVAL)
@ -61,8 +61,8 @@ def generateSetup(userId: str, username: str) -> dict:
""" """
plain = _generateSecret() plain = _generateSecret()
encrypted = _encryptSecret(plain, userId=userId) encrypted = _encryptSecret(plain, userId=userId)
totp = buildTotp(plain) totp = _buildTotp(plain)
uri = totp.provisioning_uri(name=username, issuer_name=getMfaIssuer()) uri = totp.provisioning_uri(name=username, issuer_name=_getMfaIssuer())
return { return {
"encryptedSecret": encrypted, "encryptedSecret": encrypted,
"provisioningUri": uri, "provisioningUri": uri,
@ -72,8 +72,8 @@ def generateSetup(userId: str, username: str) -> dict:
def confirmSetup(encryptedSecret: str, code: str, userId: str = "system") -> bool: def confirmSetup(encryptedSecret: str, code: str, userId: str = "system") -> bool:
"""Verify a TOTP code against an encrypted secret (enrolment confirmation).""" """Verify a TOTP code against an encrypted secret (enrolment confirmation)."""
try: try:
plain = decryptSecret(encryptedSecret, userId=userId) plain = _decryptSecret(encryptedSecret, userId=userId)
totp = buildTotp(plain) totp = _buildTotp(plain)
return totp.verify(code, valid_window=_MFA_VALID_WINDOW) return totp.verify(code, valid_window=_MFA_VALID_WINDOW)
except Exception: except Exception:
logger.exception("MFA confirmSetup failed for userId=%s", userId) logger.exception("MFA confirmSetup failed for userId=%s", userId)
@ -83,8 +83,8 @@ def confirmSetup(encryptedSecret: str, code: str, userId: str = "system") -> boo
def verifyCode(encryptedSecret: str, code: str, userId: str = "system") -> bool: def verifyCode(encryptedSecret: str, code: str, userId: str = "system") -> bool:
"""Verify a TOTP code during login.""" """Verify a TOTP code during login."""
try: try:
plain = decryptSecret(encryptedSecret, userId=userId) plain = _decryptSecret(encryptedSecret, userId=userId)
totp = buildTotp(plain) totp = _buildTotp(plain)
return totp.verify(code, valid_window=_MFA_VALID_WINDOW) return totp.verify(code, valid_window=_MFA_VALID_WINDOW)
except Exception: except Exception:
logger.exception("MFA verifyCode failed for userId=%s", userId) logger.exception("MFA verifyCode failed for userId=%s", userId)

View file

@ -1,11 +1,11 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
Short-lived signed tickets for OAuth data-connection popups. Short-lived signed tickets for OAuth data-connection popups.
The UI authenticates API calls with a Bearer token in localStorage, but The UI authenticates API calls with a Bearer token in localStorage, but
``window.open(authUrl)`` cannot send that header. Cross-origin httpOnly cookies ``window.open(authUrl)`` cannot send that header. Cross-origin httpOnly cookies
are unreliable in cross-origin setups (UI and API on different subdomains). are unreliable in int/prod (UI on poweron-center.net, API on poweron.swiss).
Login popups work without a session because ``/auth/login`` is public; connect Login popups work without a session because ``/auth/login`` is public; connect
popups hit ``/auth/connect``, which used to require ``getCurrentUser``. popups hit ``/auth/connect``, which used to require ``getCurrentUser``.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""OAuth scope sets for split Auth- vs Data-apps (Google / Microsoft).""" """OAuth scope sets for split Auth- vs Data-apps (Google / Microsoft)."""

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
Token Manager Service Token Manager Service

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
Token Refresh Middleware for PowerOn Gateway Token Refresh Middleware for PowerOn Gateway

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
Token Refresh Service for PowerOn Gateway Token Refresh Service for PowerOn Gateway
@ -12,7 +12,7 @@ import logging
from typing import Dict, Any from typing import Dict, Any
from modules.datamodels.datamodelUam import UserConnection, AuthAuthority from modules.datamodels.datamodelUam import UserConnection, AuthAuthority
from modules.shared.timeUtils import getUtcTimestamp from modules.shared.timeUtils import getUtcTimestamp
from modules.dbHelpers.auditLogger import audit_logger from modules.shared.auditLogger import audit_logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""Shared HTTP resilience helpers for provider connectors. """Shared HTTP resilience helpers for provider connectors.

View file

@ -1,9 +1,6 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
import contextvars import contextvars
import copy
import json
import math
import re import re
import time import time
import psycopg2 import psycopg2
@ -11,7 +8,6 @@ import psycopg2.extras
import psycopg2.pool import psycopg2.pool
import logging import logging
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime, timezone
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
import uuid import uuid
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@ -20,6 +16,8 @@ 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.datamodelBase import PowerOnModel
from modules.datamodels.datamodelUam import User, AccessLevel, UserPermissions
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -151,6 +149,7 @@ def getModelFields(model_class) -> Dict[str, str]:
def parseRecordFields(record: Dict[str, Any], fields: Dict[str, str], context: str = "") -> None: def parseRecordFields(record: Dict[str, Any], fields: Dict[str, str], context: str = "") -> None:
"""Parse record fields in-place: numeric typing, vector parsing, JSONB deserialization.""" """Parse record fields in-place: numeric typing, vector parsing, JSONB deserialization."""
import json as _json
for fieldName, fieldType in fields.items(): for fieldName, fieldType in fields.items():
if fieldName not in record: if fieldName not in record:
@ -178,10 +177,10 @@ def parseRecordFields(record: Dict[str, Any], fields: Dict[str, str], context: s
elif fieldType == "JSONB" and value is not None: elif fieldType == "JSONB" and value is not None:
try: try:
if isinstance(value, str): if isinstance(value, str):
record[fieldName] = json.loads(value) record[fieldName] = _json.loads(value)
elif not isinstance(value, (dict, list)): elif not isinstance(value, (dict, list)):
record[fieldName] = json.loads(str(value)) record[fieldName] = _json.loads(str(value))
except (json.JSONDecodeError, TypeError, ValueError): except (_json.JSONDecodeError, TypeError, ValueError):
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})")
@ -996,6 +995,8 @@ class DatabaseConnector:
# Handle JSONB fields - ensure proper JSON format for PostgreSQL # Handle JSONB fields - ensure proper JSON format for PostgreSQL
elif col in fields and fields[col] == "JSONB" and value is not None: elif col in fields and fields[col] == "JSONB" and value is not None:
import json
if isinstance(value, (dict, list)): if isinstance(value, (dict, list)):
value = json.dumps(value) value = json.dumps(value)
elif isinstance(value, str): elif isinstance(value, str):
@ -1172,6 +1173,25 @@ 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.
@ -1392,17 +1412,18 @@ class DatabaseConnector:
isDateVal = bool(fromVal and re.match(r'^\d{4}-\d{2}-\d{2}$', str(fromVal))) or \ isDateVal = bool(fromVal and re.match(r'^\d{4}-\d{2}-\d{2}$', str(fromVal))) or \
bool(toVal and re.match(r'^\d{4}-\d{2}-\d{2}$', str(toVal))) bool(toVal and re.match(r'^\d{4}-\d{2}-\d{2}$', str(toVal)))
if isNumericCol and isDateVal: if isNumericCol and isDateVal:
from datetime import datetime as _dt, timezone as _tz
if fromVal and toVal: if fromVal and toVal:
fromTs = datetime.strptime(str(fromVal), '%Y-%m-%d').replace(tzinfo=timezone.utc).timestamp() fromTs = _dt.strptime(str(fromVal), '%Y-%m-%d').replace(tzinfo=_tz.utc).timestamp()
toTs = datetime.strptime(str(toVal), '%Y-%m-%d').replace(hour=23, minute=59, second=59, tzinfo=timezone.utc).timestamp() toTs = _dt.strptime(str(toVal), '%Y-%m-%d').replace(hour=23, minute=59, second=59, tzinfo=_tz.utc).timestamp()
where_parts.append(f'"{key}" >= %s AND "{key}" <= %s') where_parts.append(f'"{key}" >= %s AND "{key}" <= %s')
values.extend([fromTs, toTs]) values.extend([fromTs, toTs])
elif fromVal: elif fromVal:
fromTs = datetime.strptime(str(fromVal), '%Y-%m-%d').replace(tzinfo=timezone.utc).timestamp() fromTs = _dt.strptime(str(fromVal), '%Y-%m-%d').replace(tzinfo=_tz.utc).timestamp()
where_parts.append(f'"{key}" >= %s') where_parts.append(f'"{key}" >= %s')
values.append(fromTs) values.append(fromTs)
else: else:
toTs = datetime.strptime(str(toVal), '%Y-%m-%d').replace(hour=23, minute=59, second=59, tzinfo=timezone.utc).timestamp() toTs = _dt.strptime(str(toVal), '%Y-%m-%d').replace(hour=23, minute=59, second=59, tzinfo=_tz.utc).timestamp()
where_parts.append(f'"{key}" <= %s') where_parts.append(f'"{key}" <= %s')
values.append(toTs) values.append(toTs)
elif isNumericCol: elif isNumericCol:
@ -1477,6 +1498,7 @@ class DatabaseConnector:
If pagination is None, returns all records (no LIMIT/OFFSET). If pagination is None, returns all records (no LIMIT/OFFSET).
""" """
from modules.datamodels.datamodelPagination import PaginationParams from modules.datamodels.datamodelPagination import PaginationParams
import math
table = model_class.__name__ table = model_class.__name__
@ -1518,6 +1540,9 @@ class DatabaseConnector:
if fieldFilter and isinstance(fieldFilter, list): if fieldFilter and isinstance(fieldFilter, list):
records = [{f: r[f] for f in fieldFilter if f in r} for r in records] records = [{f: r[f] for f in fieldFilter if f in r} for r in records]
from modules.routes.routeHelpers import enrichRowsWithFkLabels
enrichRowsWithFkLabels(records, model_class)
pageSize = pagination.pageSize if pagination else max(totalItems, 1) pageSize = pagination.pageSize if pagination else max(totalItems, 1)
totalPages = math.ceil(totalItems / pageSize) if totalItems > 0 else 0 totalPages = math.ceil(totalItems / pageSize) if totalItems > 0 else 0
@ -1553,6 +1578,7 @@ class DatabaseConnector:
return [] return []
if pagination: if pagination:
import copy
pagination = copy.deepcopy(pagination) pagination = copy.deepcopy(pagination)
if pagination.filters and column in pagination.filters: if pagination.filters and column in pagination.filters:
pagination.filters.pop(column, None) pagination.filters.pop(column, None)
@ -1786,6 +1812,7 @@ class DatabaseConnector:
single inserts produce identical on-disk values (timestamps as floats, single inserts produce identical on-disk values (timestamps as floats,
enums as strings, vectors as pgvector text, JSONB as JSON strings). enums as strings, vectors as pgvector text, JSONB as JSON strings).
""" """
import json as _json
out = [] out = []
for col in columns: for col in columns:
value = record.get(col) value = record.get(col)
@ -1802,16 +1829,16 @@ class DatabaseConnector:
value = f"[{','.join(str(v) for v in value)}]" value = f"[{','.join(str(v) for v in value)}]"
elif col in fields and fields[col] == "JSONB" and value is not None: elif col in fields and fields[col] == "JSONB" and value is not None:
if isinstance(value, (dict, list)): if isinstance(value, (dict, list)):
value = json.dumps(value) value = _json.dumps(value)
elif isinstance(value, str): elif isinstance(value, str):
try: try:
json.loads(value) _json.loads(value)
except (ValueError, TypeError): except (ValueError, TypeError):
value = json.dumps(value) value = _json.dumps(value)
elif hasattr(value, "model_dump"): elif hasattr(value, "model_dump"):
value = json.dumps(value.model_dump()) value = _json.dumps(value.model_dump())
else: else:
value = json.dumps(value) value = _json.dumps(value)
out.append(value) out.append(value)
return tuple(out) return tuple(out)

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
Azure Communication Services Email Connector Azure Communication Services Email Connector

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
Twilio SMS Connector Twilio SMS Connector

View file

@ -1,5 +1,3 @@
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
""" """
ÖREB WFS Connector ÖREB WFS Connector

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
Preprocessor connector for executing SQL queries via HTTP API. Preprocessor connector for executing SQL queries via HTTP API.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""Abstract base classes for the Provider-Connector architecture (1:n). """Abstract base classes for the Provider-Connector architecture (1:n).

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""ConnectorResolver -- resolves a connectionId to the correct ProviderConnector and ServiceAdapter. """ConnectorResolver -- resolves a connectionId to the correct ProviderConnector and ServiceAdapter.
@ -44,31 +44,31 @@ class ConnectorResolver:
if ConnectorResolver._providerRegistry: if ConnectorResolver._providerRegistry:
return return
try: try:
from modules.connectors.connectorProviderMsft import MsftConnector from modules.connectors.providerMsft.connectorMsft import MsftConnector
ConnectorResolver._providerRegistry["msft"] = MsftConnector ConnectorResolver._providerRegistry["msft"] = MsftConnector
except ImportError: except ImportError:
logger.warning("MsftConnector not available") logger.warning("MsftConnector not available")
try: try:
from modules.connectors.connectorProviderGoogle import GoogleConnector from modules.connectors.providerGoogle.connectorGoogle import GoogleConnector
ConnectorResolver._providerRegistry["google"] = GoogleConnector ConnectorResolver._providerRegistry["google"] = GoogleConnector
except ImportError: except ImportError:
logger.debug("GoogleConnector not available (stub)") logger.debug("GoogleConnector not available (stub)")
try: try:
from modules.connectors.connectorProviderFtp import FtpConnector from modules.connectors.providerFtp.connectorFtp import FtpConnector
ConnectorResolver._providerRegistry["local:ftp"] = FtpConnector ConnectorResolver._providerRegistry["local:ftp"] = FtpConnector
except ImportError: except ImportError:
logger.debug("FtpConnector not available (stub)") logger.debug("FtpConnector not available (stub)")
try: try:
from modules.connectors.connectorProviderClickup import ClickupConnector from modules.connectors.providerClickup.connectorClickup import ClickupConnector
ConnectorResolver._providerRegistry["clickup"] = ClickupConnector ConnectorResolver._providerRegistry["clickup"] = ClickupConnector
except ImportError: except ImportError:
logger.warning("ClickupConnector not available") logger.warning("ClickupConnector not available")
try: try:
from modules.connectors.connectorProviderInfomaniak import InfomaniakConnector from modules.connectors.providerInfomaniak.connectorInfomaniak import InfomaniakConnector
ConnectorResolver._providerRegistry["infomaniak"] = InfomaniakConnector ConnectorResolver._providerRegistry["infomaniak"] = InfomaniakConnector
except ImportError: except ImportError:
logger.warning("InfomaniakConnector not available") logger.warning("InfomaniakConnector not available")

View file

@ -1,5 +1,3 @@
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
""" """
Swiss Topo MapServer Connector (Simplified) Swiss Topo MapServer Connector (Simplified)

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""ClickUp connector for CRUD operations (compatible with TicketInterface). """ClickUp connector for CRUD operations (compatible with TicketInterface).
@ -9,7 +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.connectors.connectorProviderClickup import clickupAuthorizationHeader from modules.serviceCenter.services.serviceClickup.mainServiceClickup import clickup_authorization_header
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -31,7 +31,7 @@ class ConnectorTicketClickup(TicketBase):
def _headers(self) -> dict: def _headers(self) -> dict:
return { return {
"Authorization": clickupAuthorizationHeader(self.apiToken), "Authorization": clickup_authorization_header(self.apiToken),
"Content-Type": "application/json", "Content-Type": "application/json",
} }

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""Jira connector for CRUD operations (neutralized to generic ticket interface). """Jira connector for CRUD operations (neutralized to generic ticket interface).

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2026 Patrick Motsch
# All rights reserved. # All rights reserved.
"""Redmine REST connector. """Redmine REST connector.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
Google Cloud Speech-to-Text and Translation Connector Google Cloud Speech-to-Text and Translation Connector
@ -15,7 +15,7 @@ from google.cloud import speech
from google.cloud import translate_v2 as translate from google.cloud import translate_v2 as translate
from google.cloud import texttospeech from google.cloud import texttospeech
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.shared.voiceCatalog import getDefaultVoice from modules.shared.voiceCatalog import getDefaultVoice as _catalogDefaultVoice
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -1097,7 +1097,7 @@ class ConnectorGoogleSpeech:
voice exists, in which case the caller omits `name` and Google voice exists, in which case the caller omits `name` and Google
auto-selects based on languageCode + ssml_gender. auto-selects based on languageCode + ssml_gender.
""" """
return getDefaultVoice(languageCode) return _catalogDefaultVoice(languageCode)
async def getAvailableVoices(self, languageCode: Optional[str] = None) -> Dict[str, Any]: async def getAvailableVoices(self, languageCode: Optional[str] = None) -> Dict[str, Any]:
""" """

View file

@ -1,5 +1,3 @@
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
""" """
Swiss Parcel (Liegenschaften) Connector Swiss Parcel (Liegenschaften) Connector

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

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""ClickUp ProviderConnector — virtual paths for teams → lists → tasks (table rows). """ClickUp ProviderConnector — virtual paths for teams → lists → tasks (table rows).
@ -13,13 +13,10 @@ Path convention (leading slash, no trailing slash except root):
from __future__ import annotations from __future__ import annotations
import asyncio
import json import json
import logging import logging
import re import re
from typing import Any, Dict, List, Optional, Union from typing import Any, Dict, List, Optional
import aiohttp
from modules.connectors.connectorProviderBase import ( from modules.connectors.connectorProviderBase import (
ProviderConnector, ProviderConnector,
@ -27,11 +24,11 @@ from modules.connectors.connectorProviderBase import (
DownloadResult, DownloadResult,
) )
from modules.datamodels.datamodelDataSource import ExternalEntry from modules.datamodels.datamodelDataSource import ExternalEntry
from modules.serviceCenter.services.serviceClickup.mainServiceClickup import ClickupService
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_CLICKUP_API_BASE = "https://api.clickup.com/api/v2" # type metadata for ExternalEntry.metadata["cuType"]
_CU_TEAM = "team" _CU_TEAM = "team"
_CU_SPACE = "space" _CU_SPACE = "space"
_CU_FOLDER = "folder" _CU_FOLDER = "folder"
@ -48,118 +45,14 @@ def _norm(path: str) -> str:
return p return p
def clickupAuthorizationHeader(token: str) -> str:
"""ClickUp: personal tokens are `pk_...` without Bearer; OAuth uses Bearer."""
t = (token or "").strip()
if t.startswith("pk_"):
return t
return f"Bearer {t}"
class ClickupApiClient:
"""Low-level ClickUp REST API v2 client. Pure HTTP — no service dependencies."""
def __init__(self, accessToken: str):
self.accessToken = accessToken
async def _request(
self,
method: str,
path: str,
*,
params: Optional[Dict[str, Any]] = None,
json_body: Optional[Dict[str, Any]] = None,
data: Optional[aiohttp.FormData] = None,
) -> Union[Dict[str, Any], List[Any], bytes, None]:
if not self.accessToken:
return {"error": "Access token is not set."}
url = f"{_CLICKUP_API_BASE}/{path.lstrip('/')}"
headers: Dict[str, str] = {
"Authorization": clickupAuthorizationHeader(self.accessToken),
}
if json_body is not None:
headers["Content-Type"] = "application/json"
timeout = aiohttp.ClientTimeout(total=60)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
kwargs: Dict[str, Any] = {"headers": headers, "params": params}
if json_body is not None:
kwargs["json"] = json_body
if data is not None:
kwargs["data"] = data
async with session.request(method.upper(), url, **kwargs) as resp:
if resp.status == 204:
return {}
text = await resp.text()
if resp.status >= 400:
log = logger.warning if resp.status == 404 else logger.error
log(f"ClickUp API {method} {url} -> {resp.status}: {text[:500]}")
return {"error": f"HTTP {resp.status}", "body": text}
if not text:
return {}
try:
return json.loads(text)
except Exception:
return {"raw": text}
except asyncio.TimeoutError:
return {"error": f"ClickUp API timeout: {path}"}
except Exception as e:
logger.error(f"ClickUp API error: {e}")
return {"error": str(e)}
async def getAuthorizedTeams(self) -> Dict[str, Any]:
return await self._request("GET", "/team")
async def getSpaces(self, teamId: str) -> Dict[str, Any]:
return await self._request("GET", f"/team/{teamId}/space")
async def getFolders(self, spaceId: str) -> Dict[str, Any]:
return await self._request("GET", f"/space/{spaceId}/folder")
async def getFolderlessLists(self, spaceId: str) -> Dict[str, Any]:
return await self._request("GET", f"/space/{spaceId}/list")
async def getListsInFolder(self, folderId: str) -> Dict[str, Any]:
return await self._request("GET", f"/folder/{folderId}/list")
async def getTasksInList(self, listId: str, *, page: int = 0) -> Dict[str, Any]:
params: Dict[str, Any] = {"page": page, "subtasks": "true", "include_closed": "false"}
return await self._request("GET", f"/list/{listId}/task", params=params)
async def getTask(self, taskId: str) -> Dict[str, Any]:
params = {"include_subtasks": "true"}
return await self._request("GET", f"/task/{taskId}", params=params)
async def searchTeamTasks(self, teamId: str, *, query: str, page: int = 0) -> Dict[str, Any]:
params = {"query": query, "page": page}
return await self._request("GET", f"/team/{teamId}/task", params=params)
async def uploadTaskAttachment(self, taskId: str, fileBytes: bytes, fileName: str) -> Dict[str, Any]:
if not self.accessToken:
return {"error": "Access token is not set."}
url = f"{_CLICKUP_API_BASE}/task/{taskId}/attachment"
headers = {"Authorization": clickupAuthorizationHeader(self.accessToken)}
formData = aiohttp.FormData()
formData.add_field("attachment", fileBytes, filename=fileName, content_type="application/octet-stream")
timeout = aiohttp.ClientTimeout(total=120)
try:
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.post(url, headers=headers, data=formData) as resp:
text = await resp.text()
if resp.status >= 400:
return {"error": f"HTTP {resp.status}", "body": text}
return json.loads(text) if text else {}
except Exception as e:
return {"error": str(e)}
class ClickupListsAdapter(ServiceAdapter): class ClickupListsAdapter(ServiceAdapter):
"""Maps ClickUp hierarchy + list tasks to browse/download/upload/search.""" """Maps ClickUp hierarchy + list tasks to browse/download/upload/search."""
def __init__(self, access_token: str): def __init__(self, access_token: str):
self._token = access_token self._token = access_token
self._svc = ClickupApiClient(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( async def browse(
self, self,

View file

@ -0,0 +1,3 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""FTP/SFTP Provider Connector stub."""

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""FTP/SFTP ProviderConnector stub. """FTP/SFTP ProviderConnector stub.

View file

@ -0,0 +1,3 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""Google Provider Connector -- 1 Connection : n Services (Drive, Gmail)."""

View file

@ -1,19 +1,16 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""Google ProviderConnector -- Drive and Gmail via Google OAuth.""" """Google ProviderConnector -- Drive and Gmail via Google OAuth."""
import asyncio import asyncio
import base64
import logging import logging
import re
import urllib.parse import urllib.parse
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
import aiohttp import aiohttp
from modules.connectors.connectorProviderBase import ProviderConnector, ServiceAdapter, DownloadResult from modules.connectors.connectorProviderBase import ProviderConnector, ServiceAdapter, DownloadResult
from modules.shared.httpResilience import ResilientHttp from modules.connectors._httpResilience import ResilientHttp
from modules.datamodels.datamodelDataSource import ExternalEntry from modules.datamodels.datamodelDataSource import ExternalEntry
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -32,6 +29,8 @@ def _parseGoogleDateRange(text: Optional[str]) -> tuple:
Supports two ISO dates, a single ISO date (~31 day window) or a YYYY-MM Supports two ISO dates, a single ISO date (~31 day window) or a YYYY-MM
month pattern. Returns RFC3339 UTC strings (timeMin, timeMax) or (None, None). month pattern. Returns RFC3339 UTC strings (timeMin, timeMax) or (None, None).
""" """
import re
from datetime import datetime, timedelta
if not text: if not text:
return (None, None) return (None, None)
@ -59,7 +58,7 @@ def _parseGoogleDateRange(text: Optional[str]) -> tuple:
return (None, None) return (None, None)
async def googleGet(token: str, url: str) -> Dict[str, Any]: async def _googleGet(token: str, url: str) -> Dict[str, Any]:
headers = {"Authorization": f"Bearer {token}"} headers = {"Authorization": f"Bearer {token}"}
return await _http.getJson(url, headers=headers) return await _http.getJson(url, headers=headers)
@ -93,7 +92,7 @@ class DriveAdapter(ServiceAdapter):
pageSize = max(1, min(int(limit or 100), 1000)) pageSize = max(1, min(int(limit or 100), 1000))
url = f"{_DRIVE_BASE}/files?q={query}&fields={fields}&pageSize={pageSize}&orderBy=folder,name" url = f"{_DRIVE_BASE}/files?q={query}&fields={fields}&pageSize={pageSize}&orderBy=folder,name"
result = await googleGet(self._token, url) result = await _googleGet(self._token, url)
if "error" in result: if "error" in result:
_raiseGoogleError(result, "Google Drive browse") _raiseGoogleError(result, "Google Drive browse")
@ -185,7 +184,7 @@ class DriveAdapter(ServiceAdapter):
if pageToken: if pageToken:
params["pageToken"] = pageToken params["pageToken"] = pageToken
url = f"{_DRIVE_BASE}/files?{urllib.parse.urlencode(params)}" url = f"{_DRIVE_BASE}/files?{urllib.parse.urlencode(params)}"
result = await googleGet(self._token, url) result = await _googleGet(self._token, url)
if "error" in result: if "error" in result:
if not entries: if not entries:
_raiseGoogleError(result, "Google Drive search") _raiseGoogleError(result, "Google Drive search")
@ -229,7 +228,7 @@ class GmailAdapter(ServiceAdapter):
if not cleanPath: if not cleanPath:
url = f"{_GMAIL_BASE}/users/me/labels" url = f"{_GMAIL_BASE}/users/me/labels"
result = await googleGet(self._token, url) result = await _googleGet(self._token, url)
if "error" in result: if "error" in result:
_raiseGoogleError(result, "Gmail labels") _raiseGoogleError(result, "Gmail labels")
_SYSTEM_LABELS = {"INBOX", "SENT", "DRAFT", "TRASH", "SPAM", "STARRED", "IMPORTANT"} _SYSTEM_LABELS = {"INBOX", "SENT", "DRAFT", "TRASH", "SPAM", "STARRED", "IMPORTANT"}
@ -282,7 +281,7 @@ class GmailAdapter(ServiceAdapter):
if not ref: if not ref:
return None return None
r = ref.strip() r = ref.strip()
result = await googleGet(self._token, f"{_GMAIL_BASE}/users/me/labels") result = await _googleGet(self._token, f"{_GMAIL_BASE}/users/me/labels")
if "error" in result: if "error" in result:
_raiseGoogleError(result, "Gmail labels") _raiseGoogleError(result, "Gmail labels")
labels = result.get("labels", []) labels = result.get("labels", [])
@ -320,7 +319,7 @@ class GmailAdapter(ServiceAdapter):
if pageToken: if pageToken:
p["pageToken"] = pageToken p["pageToken"] = pageToken
url = f"{_GMAIL_BASE}/users/me/messages?{urllib.parse.urlencode(p)}" url = f"{_GMAIL_BASE}/users/me/messages?{urllib.parse.urlencode(p)}"
result = await googleGet(self._token, url) result = await _googleGet(self._token, url)
if "error" in result: if "error" in result:
if not msgIds: if not msgIds:
_raiseGoogleError(result, "Gmail list messages") _raiseGoogleError(result, "Gmail list messages")
@ -351,7 +350,7 @@ class GmailAdapter(ServiceAdapter):
f"{_GMAIL_BASE}/users/me/messages/{msgId}" f"{_GMAIL_BASE}/users/me/messages/{msgId}"
f"?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=Date" f"?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=Date"
) )
detail = await googleGet(self._token, detailUrl) detail = await _googleGet(self._token, detailUrl)
if "error" in detail: if "error" in detail:
return ExternalEntry(name=f"Message {msgId}", path=f"{pathPrefix}/{msgId}", isFolder=False, return ExternalEntry(name=f"Message {msgId}", path=f"{pathPrefix}/{msgId}", isFolder=False,
metadata={"id": msgId}) metadata={"id": msgId})
@ -372,13 +371,15 @@ class GmailAdapter(ServiceAdapter):
async def download(self, path: str) -> DownloadResult: async def download(self, path: str) -> DownloadResult:
"""Download a Gmail message as RFC 822 EML via format=raw.""" """Download a Gmail message as RFC 822 EML via format=raw."""
import base64
import re
cleanPath = (path or "").strip("/") cleanPath = (path or "").strip("/")
msgId = cleanPath.split("/")[-1] if cleanPath else "" msgId = cleanPath.split("/")[-1] if cleanPath else ""
if not msgId: if not msgId:
return DownloadResult() return DownloadResult()
url = f"{_GMAIL_BASE}/users/me/messages/{msgId}?format=raw" url = f"{_GMAIL_BASE}/users/me/messages/{msgId}?format=raw"
result = await googleGet(self._token, url) result = await _googleGet(self._token, url)
if "error" in result: if "error" in result:
return DownloadResult() return DownloadResult()
@ -389,7 +390,7 @@ class GmailAdapter(ServiceAdapter):
emlBytes = base64.urlsafe_b64decode(rawB64) emlBytes = base64.urlsafe_b64decode(rawB64)
metaUrl = f"{_GMAIL_BASE}/users/me/messages/{msgId}?format=metadata&metadataHeaders=Subject" metaUrl = f"{_GMAIL_BASE}/users/me/messages/{msgId}?format=metadata&metadataHeaders=Subject"
meta = await googleGet(self._token, metaUrl) meta = await _googleGet(self._token, metaUrl)
subject = msgId subject = msgId
if "error" not in meta: if "error" not in meta:
for h in meta.get("payload", {}).get("headers", []): for h in meta.get("payload", {}).get("headers", []):
@ -468,7 +469,7 @@ class CalendarAdapter(ServiceAdapter):
cleanPath = (path or "").strip("/") cleanPath = (path or "").strip("/")
if not cleanPath: if not cleanPath:
url = f"{_CALENDAR_BASE}/users/me/calendarList?maxResults=250" url = f"{_CALENDAR_BASE}/users/me/calendarList?maxResults=250"
result = await googleGet(self._token, url) result = await _googleGet(self._token, url)
if "error" in result: if "error" in result:
_raiseGoogleError(result, "Google Calendar list") _raiseGoogleError(result, "Google Calendar list")
calendars = result.get("items", []) calendars = result.get("items", [])
@ -503,7 +504,7 @@ class CalendarAdapter(ServiceAdapter):
timeMin, timeMax = _parseGoogleDateRange(filter) timeMin, timeMax = _parseGoogleDateRange(filter)
if timeMin and timeMax: if timeMin and timeMax:
url += f"&timeMin={quote(timeMin, safe='')}&timeMax={quote(timeMax, safe='')}" url += f"&timeMin={quote(timeMin, safe='')}&timeMax={quote(timeMax, safe='')}"
result = await googleGet(self._token, url) result = await _googleGet(self._token, url)
if "error" in result: if "error" in result:
_raiseGoogleError(result, "Google Calendar events") _raiseGoogleError(result, "Google Calendar events")
events = result.get("items", []) events = result.get("items", [])
@ -533,7 +534,7 @@ class CalendarAdapter(ServiceAdapter):
return DownloadResult() return DownloadResult()
calendarId, eventId = cleanPath.split("/", 1) calendarId, eventId = cleanPath.split("/", 1)
url = f"{_CALENDAR_BASE}/calendars/{quote(calendarId, safe='')}/events/{quote(eventId, safe='')}" url = f"{_CALENDAR_BASE}/calendars/{quote(calendarId, safe='')}/events/{quote(eventId, safe='')}"
ev = await googleGet(self._token, url) ev = await _googleGet(self._token, url)
if "error" in ev: if "error" in ev:
logger.warning(f"Google Calendar event fetch failed: {ev['error']}") logger.warning(f"Google Calendar event fetch failed: {ev['error']}")
return DownloadResult() return DownloadResult()
@ -572,7 +573,7 @@ class CalendarAdapter(ServiceAdapter):
f"{_CALENDAR_BASE}/calendars/{quote(calendarId, safe='')}/events" f"{_CALENDAR_BASE}/calendars/{quote(calendarId, safe='')}/events"
f"?q={quote(query, safe='')}&maxResults={effectiveLimit}&singleEvents=true" f"?q={quote(query, safe='')}&maxResults={effectiveLimit}&singleEvents=true"
) )
result = await googleGet(self._token, url) result = await _googleGet(self._token, url)
if "error" in result: if "error" in result:
_raiseGoogleError(result, "Google Calendar search") _raiseGoogleError(result, "Google Calendar search")
return [ return [
@ -628,7 +629,7 @@ class ContactsAdapter(ServiceAdapter):
), ),
] ]
url = f"{_PEOPLE_BASE}/contactGroups?pageSize=200" url = f"{_PEOPLE_BASE}/contactGroups?pageSize=200"
result = await googleGet(self._token, url) result = await _googleGet(self._token, url)
if "error" not in result: if "error" not in result:
for grp in result.get("contactGroups", []): for grp in result.get("contactGroups", []):
name = grp.get("formattedName") or grp.get("name") or "" name = grp.get("formattedName") or grp.get("name") or ""
@ -658,7 +659,7 @@ class ContactsAdapter(ServiceAdapter):
f"{_PEOPLE_BASE}/people/me/connections" f"{_PEOPLE_BASE}/people/me/connections"
f"?pageSize={min(effectiveLimit, 1000)}&personFields={self._PERSON_FIELDS}" f"?pageSize={min(effectiveLimit, 1000)}&personFields={self._PERSON_FIELDS}"
) )
result = await googleGet(self._token, url) result = await _googleGet(self._token, url)
if "error" in result: if "error" in result:
_raiseGoogleError(result, "Google People connections") _raiseGoogleError(result, "Google People connections")
people = result.get("connections", []) people = result.get("connections", [])
@ -668,7 +669,7 @@ class ContactsAdapter(ServiceAdapter):
f"{_PEOPLE_BASE}/{quote(groupResource, safe='/')}" f"{_PEOPLE_BASE}/{quote(groupResource, safe='/')}"
f"?maxMembers={min(effectiveLimit, 1000)}" f"?maxMembers={min(effectiveLimit, 1000)}"
) )
grpResult = await googleGet(self._token, grpUrl) grpResult = await _googleGet(self._token, grpUrl)
if "error" in grpResult: if "error" in grpResult:
_raiseGoogleError(grpResult, "Google contactGroup detail") _raiseGoogleError(grpResult, "Google contactGroup detail")
memberResourceNames = grpResult.get("memberResourceNames") or [] memberResourceNames = grpResult.get("memberResourceNames") or []
@ -680,7 +681,7 @@ class ContactsAdapter(ServiceAdapter):
chunk = memberResourceNames[i : i + chunkSize] chunk = memberResourceNames[i : i + chunkSize]
params = "&".join(f"resourceNames={quote(rn, safe='/')}" for rn in chunk) params = "&".join(f"resourceNames={quote(rn, safe='/')}" for rn in chunk)
batchUrl = f"{_PEOPLE_BASE}/people:batchGet?{params}&personFields={self._PERSON_FIELDS}" batchUrl = f"{_PEOPLE_BASE}/people:batchGet?{params}&personFields={self._PERSON_FIELDS}"
batchResult = await googleGet(self._token, batchUrl) batchResult = await _googleGet(self._token, batchUrl)
if "error" in batchResult: if "error" in batchResult:
logger.warning(f"Google People batchGet failed: {batchResult['error']}") logger.warning(f"Google People batchGet failed: {batchResult['error']}")
continue continue
@ -716,7 +717,7 @@ class ContactsAdapter(ServiceAdapter):
if not personSuffix: if not personSuffix:
return DownloadResult() return DownloadResult()
url = f"{_PEOPLE_BASE}/people/{quote(personSuffix, safe='')}?personFields={self._PERSON_FIELDS}" url = f"{_PEOPLE_BASE}/people/{quote(personSuffix, safe='')}?personFields={self._PERSON_FIELDS}"
person = await googleGet(self._token, url) person = await _googleGet(self._token, url)
if "error" in person: if "error" in person:
logger.warning(f"Google People fetch failed: {person['error']}") logger.warning(f"Google People fetch failed: {person['error']}")
return DownloadResult() return DownloadResult()
@ -745,7 +746,7 @@ class ContactsAdapter(ServiceAdapter):
f"?query={quote(query, safe='')}&pageSize={min(effectiveLimit, 30)}" f"?query={quote(query, safe='')}&pageSize={min(effectiveLimit, 30)}"
f"&readMask={self._PERSON_FIELDS}" f"&readMask={self._PERSON_FIELDS}"
) )
result = await googleGet(self._token, url) result = await _googleGet(self._token, url)
if "error" in result: if "error" in result:
_raiseGoogleError(result, "Google Contacts search") _raiseGoogleError(result, "Google Contacts search")
entries: List[ExternalEntry] = [] entries: List[ExternalEntry] = []
@ -769,6 +770,7 @@ class ContactsAdapter(ServiceAdapter):
def _googleSafeFileName(name: str) -> str: def _googleSafeFileName(name: str) -> str:
import re
return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name or "")[:80].strip(". ") return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name or "")[:80].strip(". ")
@ -788,6 +790,7 @@ def _googleIcsDateTime(value: Optional[str]) -> Optional[str]:
"""Convert a Google Calendar dateTime/date string to RFC 5545 format (UTC).""" """Convert a Google Calendar dateTime/date string to RFC 5545 format (UTC)."""
if not value: if not value:
return None return None
from datetime import datetime, timezone
try: try:
if "T" not in value: if "T" not in value:
dt = datetime.strptime(value, "%Y-%m-%d") dt = datetime.strptime(value, "%Y-%m-%d")
@ -803,6 +806,7 @@ def _googleIcsDateTime(value: Optional[str]) -> Optional[str]:
def _googleEventToIcs(event: Dict[str, Any]) -> bytes: def _googleEventToIcs(event: Dict[str, Any]) -> bytes:
"""Build a minimal RFC 5545 VCALENDAR/VEVENT for a Google Calendar event.""" """Build a minimal RFC 5545 VCALENDAR/VEVENT for a Google Calendar event."""
from datetime import datetime, timezone
uid = event.get("iCalUID") or event.get("id") or "unknown@poweron" uid = event.get("iCalUID") or event.get("id") or "unknown@poweron"
summary = _googleIcsEscape(event.get("summary") or "") summary = _googleIcsEscape(event.get("summary") or "")
location = _googleIcsEscape(event.get("location") or "") location = _googleIcsEscape(event.get("location") or "")

View file

@ -0,0 +1,3 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""Infomaniak Provider Connector -- 1 Connection : n Services (kDrive, Mail)."""

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""Infomaniak ProviderConnector -- kDrive + Calendar + Contacts via PAT. """Infomaniak ProviderConnector -- kDrive + Calendar + Contacts via PAT.
@ -31,7 +31,6 @@ Path conventions (leading slash, ``ServiceAdapter`` paths always start with
/{addressBookId}/{contactId} -- single contact (.vcf download) /{addressBookId}/{contactId} -- single contact (.vcf download)
""" """
import json
import logging import logging
import re import re
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
@ -45,7 +44,7 @@ from modules.connectors.connectorProviderBase import (
ServiceAdapter, ServiceAdapter,
DownloadResult, DownloadResult,
) )
from modules.shared.httpResilience import ResilientHttp from modules.connectors._httpResilience import ResilientHttp
from modules.datamodels.datamodelDataSource import ExternalEntry from modules.datamodels.datamodelDataSource import ExternalEntry
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -392,115 +391,8 @@ class KdriveAdapter(ServiceAdapter):
return DownloadResult() return DownloadResult()
return DownloadResult(data=content, fileName=fileName, mimeType=mimeType) return DownloadResult(data=content, fileName=fileName, mimeType=mimeType)
async def _createDirectory(self, driveId: str, parentId: str, name: str) -> Optional[str]:
"""Create a single directory and return its ID.
If the directory already exists (409), lists the parent to find
the existing folder's ID -- kDrive directory creation is not
idempotent.
"""
url = f"{_API_BASE}/3/drive/{driveId}/files/{parentId}/directory"
headers = {
"Authorization": f"Bearer {self._token}",
"Content-Type": "application/json",
}
body = json.dumps({"name": name})
result = await _http.request("POST", url, headers=headers, data=body)
if isinstance(result, dict) and not result.get("error"):
data = _unwrapData(result)
if isinstance(data, dict) and data.get("id"):
return str(data["id"])
errorStr = str(result.get("error", "")) if isinstance(result, dict) else ""
if "already_exists" in errorStr or "409" in errorStr:
children = await self._listChildren(driveId, fileId=parentId, limit=1000)
for child in children:
if child.isFolder and child.name == name:
return (child.metadata or {}).get("id") or child.path.strip("/").split("/")[-1]
logger.warning("kDrive mkdir %s/%s in %s failed: %s", driveId, name, parentId, result)
return None
async def _ensureDirectoryPath(self, driveId: str, parentId: str, pathSegments: List[str]) -> Optional[str]:
"""Walk *pathSegments* and create each level that does not exist yet.
Returns the numeric folder ID of the deepest directory, or
``None`` if any step fails.
"""
currentId = parentId
for segment in pathSegments:
folderId = await self._createDirectory(driveId, currentId, segment)
if not folderId:
return None
currentId = folderId
return currentId
async def upload(self, path: str, data: bytes, fileName: str) -> dict: async def upload(self, path: str, data: bytes, fileName: str) -> dict:
"""Upload a file to kDrive. return {"error": "kDrive upload not yet implemented"}
Path formats:
/{driveId} -> upload to drive root
/{driveId}/{folderId} -> upload into folder by numeric ID
/{driveId}/{folderId}/Sub/Path -> create Sub/Path under folderId, then upload
/{driveId}/Some/Human/Path -> create path from drive root (id 1), then upload
Directories are created step-by-step via the v3 mkdir endpoint;
existing directories are reused (idempotent). File upload uses
the v3 upload endpoint (max 1 GB).
"""
segments = [s for s in (path or "").strip("/").split("/") if s]
if not segments:
return {"error": "Upload path must include at least a drive ID"}
driveId = segments[0]
targetDirId: Optional[str] = None
if len(segments) > 1:
subSegments = segments[1:]
numericPrefix: List[str] = []
nameSegments: List[str] = []
for i, seg in enumerate(subSegments):
if seg.isdigit() and not nameSegments:
numericPrefix.append(seg)
else:
nameSegments = subSegments[i:]
break
parentId = numericPrefix[-1] if numericPrefix else "1"
if nameSegments and nameSegments[-1] == fileName:
nameSegments = nameSegments[:-1]
if nameSegments:
targetDirId = await self._ensureDirectoryPath(driveId, parentId, nameSegments)
if not targetDirId:
return {"error": f"Failed to create directory path: {'/'.join(nameSegments)}"}
else:
targetDirId = parentId
params = [
f"file_name={quote(fileName)}",
f"total_size={len(data)}",
"conflict=version",
]
if targetDirId:
params.append(f"directory_id={targetDirId}")
endpoint = f"/3/drive/{driveId}/upload?{'&'.join(params)}"
url = f"{_API_BASE.rstrip('/')}/{endpoint.lstrip('/')}"
headers = {
"Authorization": f"Bearer {self._token}",
"Content-Type": "application/octet-stream",
}
result = await _http.request(
"POST", url, headers=headers, data=data,
timeout=aiohttp.ClientTimeout(total=120),
)
if isinstance(result, dict) and result.get("error"):
return result
unwrapped = _unwrapData(result) if isinstance(result, dict) else result
return unwrapped if isinstance(unwrapped, dict) else {"data": unwrapped}
async def search( async def search(
self, self,

View file

@ -0,0 +1,3 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""Microsoft Provider Connector -- 1 Connection : n Services (SharePoint, Outlook, Teams, OneDrive)."""

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""Microsoft ProviderConnector -- one MSFT connection serves SharePoint, Outlook, Teams, OneDrive. """Microsoft ProviderConnector -- one MSFT connection serves SharePoint, Outlook, Teams, OneDrive.
@ -6,17 +6,14 @@ All ServiceAdapters share the same OAuth access token obtained from the
UserConnection (authority=msft). UserConnection (authority=msft).
""" """
import json
import logging import logging
import re
import aiohttp import aiohttp
import asyncio import asyncio
import urllib.parse import urllib.parse
from datetime import datetime, timedelta, timezone
from typing import Dict, Any, List, Optional from typing import Dict, Any, List, Optional
from modules.connectors.connectorProviderBase import ProviderConnector, ServiceAdapter, DownloadResult from modules.connectors.connectorProviderBase import ProviderConnector, ServiceAdapter, DownloadResult
from modules.shared.httpResilience import ResilientHttp from modules.connectors._httpResilience import ResilientHttp
from modules.datamodels.datamodelDataSource import ExternalEntry from modules.datamodels.datamodelDataSource import ExternalEntry
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -82,7 +79,7 @@ async def _handleResponse(resp: aiohttp.ClientResponse) -> Dict[str, Any]:
return {"error": f"{resp.status}: {errorText}"} return {"error": f"{resp.status}: {errorText}"}
def stripGraphBase(url: str) -> str: def _stripGraphBase(url: str) -> str:
"""Convert an absolute Graph URL (used by @odata.nextLink) into the """Convert an absolute Graph URL (used by @odata.nextLink) into the
relative endpoint that ``_makeGraphCall`` expects.""" relative endpoint that ``_makeGraphCall`` expects."""
if not url: if not url:
@ -179,7 +176,7 @@ class SharepointAdapter(_GraphApiMixin, ServiceAdapter):
if effectiveLimit is not None and len(items) >= effectiveLimit: if effectiveLimit is not None and len(items) >= effectiveLimit:
break break
nextLink = result.get("@odata.nextLink") nextLink = result.get("@odata.nextLink")
endpoint = stripGraphBase(nextLink) if nextLink else None endpoint = _stripGraphBase(nextLink) if nextLink else None
entries = [_graphItemToExternalEntry(item, path) for item in items] entries = [_graphItemToExternalEntry(item, path) for item in items]
if filter: if filter:
@ -260,7 +257,7 @@ class SharepointAdapter(_GraphApiMixin, ServiceAdapter):
if effectiveLimit is not None and len(items) >= effectiveLimit: if effectiveLimit is not None and len(items) >= effectiveLimit:
break break
nextLink = result.get("@odata.nextLink") nextLink = result.get("@odata.nextLink")
endpoint = stripGraphBase(nextLink) if nextLink else None endpoint = _stripGraphBase(nextLink) if nextLink else None
entries = [_graphItemToExternalEntry(item) for item in items] entries = [_graphItemToExternalEntry(item) for item in items]
if effectiveLimit is not None: if effectiveLimit is not None:
entries = entries[: max(1, effectiveLimit)] entries = entries[: max(1, effectiveLimit)]
@ -281,6 +278,8 @@ def _parseDateRange(filterStr: Optional[str]) -> tuple:
(treated as a ~31 day window), or a YYYY-MM month pattern. Returns (treated as a ~31 day window), or a YYYY-MM month pattern. Returns
(startDateTime, endDateTime) ISO strings, or (None, None) if not parseable. (startDateTime, endDateTime) ISO strings, or (None, None) if not parseable.
""" """
import re
from datetime import datetime, timedelta
if not filterStr: if not filterStr:
return (None, None) return (None, None)
isoMatch = re.findall(r'\d{4}-\d{2}-\d{2}(?:T[\d:]+)?', filterStr) isoMatch = re.findall(r'\d{4}-\d{2}-\d{2}(?:T[\d:]+)?', filterStr)
@ -369,7 +368,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
if not nextLink: if not nextLink:
endpoint = None endpoint = None
else: else:
endpoint = stripGraphBase(nextLink) endpoint = _stripGraphBase(nextLink)
# Guarantee Inbox is present (well-known name, locale-independent) # Guarantee Inbox is present (well-known name, locale-independent)
if not any((f.get("displayName") or "").lower() in ("inbox", "posteingang") for f in folders): if not any((f.get("displayName") or "").lower() in ("inbox", "posteingang") for f in folders):
@ -446,7 +445,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
if len(messages) >= effectiveLimit: if len(messages) >= effectiveLimit:
break break
nextLink = result.get("@odata.nextLink") nextLink = result.get("@odata.nextLink")
endpoint = stripGraphBase(nextLink) if nextLink else None endpoint = _stripGraphBase(nextLink) if nextLink else None
entries = [ entries = [
ExternalEntry( ExternalEntry(
name=m.get("subject", "(no subject)"), name=m.get("subject", "(no subject)"),
@ -471,6 +470,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
async def download(self, path: str) -> DownloadResult: async def download(self, path: str) -> DownloadResult:
"""Download a mail message as RFC 822 EML via Graph API $value endpoint.""" """Download a mail message as RFC 822 EML via Graph API $value endpoint."""
import re
messageId = path.strip("/").split("/")[-1] messageId = path.strip("/").split("/")[-1]
meta = await self._graphGet(f"me/messages/{messageId}?$select=subject") meta = await self._graphGet(f"me/messages/{messageId}?$select=subject")
@ -572,6 +572,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
attachments: Optional[List[Dict]] = None, attachments: Optional[List[Dict]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Send an email via Microsoft Graph. bodyType: 'Text' or 'HTML'.""" """Send an email via Microsoft Graph. bodyType: 'Text' or 'HTML'."""
import json
message = self._buildMessage(to, subject, body, bodyType, cc, attachments) message = self._buildMessage(to, subject, body, bodyType, cc, attachments)
payload = json.dumps({"message": message, "saveToSentItems": True}).encode("utf-8") payload = json.dumps({"message": message, "saveToSentItems": True}).encode("utf-8")
result = await self._graphPost("me/sendMail", payload) result = await self._graphPost("me/sendMail", payload)
@ -586,6 +587,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
attachments: Optional[List[Dict]] = None, attachments: Optional[List[Dict]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Create a draft email in the user's Drafts folder via Microsoft Graph.""" """Create a draft email in the user's Drafts folder via Microsoft Graph."""
import json
message = self._buildMessage(to, subject, body, bodyType, cc, attachments) message = self._buildMessage(to, subject, body, bodyType, cc, attachments)
payload = json.dumps(message).encode("utf-8") payload = json.dumps(message).encode("utf-8")
result = await self._graphPost("me/messages", payload) result = await self._graphPost("me/messages", payload)
@ -615,6 +617,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
Preserves the conversation thread and the ``AW:`` prefix in Outlook -- Preserves the conversation thread and the ``AW:`` prefix in Outlook --
unlike sendMail() which creates a brand-new conversation. unlike sendMail() which creates a brand-new conversation.
""" """
import json
endpointAction = "replyAll" if replyAll else "reply" endpointAction = "replyAll" if replyAll else "reply"
payload = json.dumps({"comment": comment}).encode("utf-8") payload = json.dumps({"comment": comment}).encode("utf-8")
result = await self._graphPost(f"me/messages/{messageId}/{endpointAction}", payload) result = await self._graphPost(f"me/messages/{messageId}/{endpointAction}", payload)
@ -626,6 +629,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
self, messageId: str, to: List[str], comment: str = "", self, messageId: str, to: List[str], comment: str = "",
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Forward an existing message to new recipients.""" """Forward an existing message to new recipients."""
import json
payload = json.dumps({ payload = json.dumps({
"comment": comment, "comment": comment,
"toRecipients": [{"emailAddress": {"address": addr}} for addr in to], "toRecipients": [{"emailAddress": {"address": addr}} for addr in to],
@ -640,6 +644,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
replyAll: bool = False, replyAll: bool = False,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Create a reply-draft (in the Drafts folder) that the user can edit before sending.""" """Create a reply-draft (in the Drafts folder) that the user can edit before sending."""
import json
endpointAction = "createReplyAll" if replyAll else "createReply" endpointAction = "createReplyAll" if replyAll else "createReply"
payload = json.dumps({"comment": comment}).encode("utf-8") if comment else b"{}" payload = json.dumps({"comment": comment}).encode("utf-8") if comment else b"{}"
result = await self._graphPost(f"me/messages/{messageId}/{endpointAction}", payload) result = await self._graphPost(f"me/messages/{messageId}/{endpointAction}", payload)
@ -651,6 +656,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
self, messageId: str, to: Optional[List[str]] = None, comment: str = "", self, messageId: str, to: Optional[List[str]] = None, comment: str = "",
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Create a forward-draft (in the Drafts folder) that the user can edit before sending.""" """Create a forward-draft (in the Drafts folder) that the user can edit before sending."""
import json
body: Dict[str, Any] = {} body: Dict[str, Any] = {}
if comment: if comment:
body["comment"] = comment body["comment"] = comment
@ -721,7 +727,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
"childFolderCount": f.get("childFolderCount", 0), "childFolderCount": f.get("childFolderCount", 0),
}) })
nextLink = result.get("@odata.nextLink") nextLink = result.get("@odata.nextLink")
endpoint = stripGraphBase(nextLink) if nextLink else None endpoint = _stripGraphBase(nextLink) if nextLink else None
return folders return folders
async def _resolveFolderId(self, folderRef: str) -> Optional[str]: async def _resolveFolderId(self, folderRef: str) -> Optional[str]:
@ -758,6 +764,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
self, messageId: str, destinationFolder: str, self, messageId: str, destinationFolder: str,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Move a message to another folder (well-known name, displayName, or folder id).""" """Move a message to another folder (well-known name, displayName, or folder id)."""
import json
destId = await self._resolveFolderId(destinationFolder) destId = await self._resolveFolderId(destinationFolder)
if not destId: if not destId:
return {"error": f"Folder not found: '{destinationFolder}'. Use listMailFolders to inspect available folders."} return {"error": f"Folder not found: '{destinationFolder}'. Use listMailFolders to inspect available folders."}
@ -771,6 +778,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
self, messageId: str, destinationFolder: str, self, messageId: str, destinationFolder: str,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Copy a message into another folder (original stays in place).""" """Copy a message into another folder (original stays in place)."""
import json
destId = await self._resolveFolderId(destinationFolder) destId = await self._resolveFolderId(destinationFolder)
if not destId: if not destId:
return {"error": f"Folder not found: '{destinationFolder}'. Use listMailFolders to inspect available folders."} return {"error": f"Folder not found: '{destinationFolder}'. Use listMailFolders to inspect available folders."}
@ -810,6 +818,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
async def markMailAsRead(self, messageId: str) -> Dict[str, Any]: async def markMailAsRead(self, messageId: str) -> Dict[str, Any]:
"""Mark a message as read (sets ``isRead=true``).""" """Mark a message as read (sets ``isRead=true``)."""
import json
payload = json.dumps({"isRead": True}).encode("utf-8") payload = json.dumps({"isRead": True}).encode("utf-8")
result = await self._graphPatch(f"me/messages/{messageId}", payload) result = await self._graphPatch(f"me/messages/{messageId}", payload)
if "error" in result: if "error" in result:
@ -818,6 +827,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
async def markMailAsUnread(self, messageId: str) -> Dict[str, Any]: async def markMailAsUnread(self, messageId: str) -> Dict[str, Any]:
"""Mark a message as unread (sets ``isRead=false``).""" """Mark a message as unread (sets ``isRead=false``)."""
import json
payload = json.dumps({"isRead": False}).encode("utf-8") payload = json.dumps({"isRead": False}).encode("utf-8")
result = await self._graphPatch(f"me/messages/{messageId}", payload) result = await self._graphPatch(f"me/messages/{messageId}", payload)
if "error" in result: if "error" in result:
@ -835,6 +845,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
``"notFlagged"`` -- the three values Microsoft Graph recognises for ``"notFlagged"`` -- the three values Microsoft Graph recognises for
``followupFlag.flagStatus``. ``followupFlag.flagStatus``.
""" """
import json
if flagStatus not in ("flagged", "complete", "notFlagged"): if flagStatus not in ("flagged", "complete", "notFlagged"):
return {"error": f"Invalid flagStatus '{flagStatus}'. Use one of: flagged, complete, notFlagged."} return {"error": f"Invalid flagStatus '{flagStatus}'. Use one of: flagged, complete, notFlagged."}
payload = json.dumps({"flag": {"flagStatus": flagStatus}}).encode("utf-8") payload = json.dumps({"flag": {"flagStatus": flagStatus}}).encode("utf-8")
@ -941,7 +952,7 @@ class OneDriveAdapter(_GraphApiMixin, ServiceAdapter):
if effectiveLimit is not None and len(items) >= effectiveLimit: if effectiveLimit is not None and len(items) >= effectiveLimit:
break break
nextLink = result.get("@odata.nextLink") nextLink = result.get("@odata.nextLink")
endpoint = stripGraphBase(nextLink) if nextLink else None endpoint = _stripGraphBase(nextLink) if nextLink else None
entries = [_graphItemToExternalEntry(item, path) for item in items] entries = [_graphItemToExternalEntry(item, path) for item in items]
if filter: if filter:
@ -992,7 +1003,7 @@ class OneDriveAdapter(_GraphApiMixin, ServiceAdapter):
if effectiveLimit is not None and len(items) >= effectiveLimit: if effectiveLimit is not None and len(items) >= effectiveLimit:
break break
nextLink = result.get("@odata.nextLink") nextLink = result.get("@odata.nextLink")
endpoint = stripGraphBase(nextLink) if nextLink else None endpoint = _stripGraphBase(nextLink) if nextLink else None
entries = [_graphItemToExternalEntry(item) for item in items] entries = [_graphItemToExternalEntry(item) for item in items]
if effectiveLimit is not None: if effectiveLimit is not None:
entries = entries[: max(1, effectiveLimit)] entries = entries[: max(1, effectiveLimit)]
@ -1088,7 +1099,7 @@ class CalendarAdapter(_GraphApiMixin, ServiceAdapter):
if len(events) >= effectiveLimit: if len(events) >= effectiveLimit:
break break
nextLink = result.get("@odata.nextLink") nextLink = result.get("@odata.nextLink")
endpoint = stripGraphBase(nextLink) if nextLink else None endpoint = _stripGraphBase(nextLink) if nextLink else None
return [ return [
ExternalEntry( ExternalEntry(
@ -1285,7 +1296,7 @@ class ContactsAdapter(_GraphApiMixin, ServiceAdapter):
if len(contacts) >= effectiveLimit: if len(contacts) >= effectiveLimit:
break break
nextLink = result.get("@odata.nextLink") nextLink = result.get("@odata.nextLink")
endpoint = stripGraphBase(nextLink) if nextLink else None endpoint = _stripGraphBase(nextLink) if nextLink else None
return [ return [
ExternalEntry( ExternalEntry(
@ -1437,6 +1448,7 @@ def _matchFilter(entry: ExternalEntry, pattern: str) -> bool:
def _safeFileName(name: str) -> str: def _safeFileName(name: str) -> str:
"""Strip path-unsafe characters and trim length so the result is a usable file name.""" """Strip path-unsafe characters and trim length so the result is a usable file name."""
import re
return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name or "")[:80].strip(". ") return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name or "")[:80].strip(". ")
@ -1466,6 +1478,7 @@ def _icsDateTime(value: Optional[str]) -> Optional[str]:
"""Convert an ISO datetime string to an RFC 5545 DATE-TIME value (UTC).""" """Convert an ISO datetime string to an RFC 5545 DATE-TIME value (UTC)."""
if not value: if not value:
return None return None
from datetime import datetime, timezone
try: try:
normalized = value.replace("Z", "+00:00") if value.endswith("Z") else value normalized = value.replace("Z", "+00:00") if value.endswith("Z") else value
dt = datetime.fromisoformat(normalized) dt = datetime.fromisoformat(normalized)
@ -1478,6 +1491,7 @@ def _icsDateTime(value: Optional[str]) -> Optional[str]:
def _eventToIcs(event: Dict[str, Any]) -> bytes: def _eventToIcs(event: Dict[str, Any]) -> bytes:
"""Build a minimal RFC 5545 VCALENDAR/VEVENT for a Graph event payload.""" """Build a minimal RFC 5545 VCALENDAR/VEVENT for a Graph event payload."""
from datetime import datetime, timezone
uid = event.get("iCalUId") or event.get("id") or "unknown@poweron" uid = event.get("iCalUId") or event.get("id") or "unknown@poweron"
summary = _icsEscape(event.get("subject") or "") summary = _icsEscape(event.get("subject") or "")
location = _icsEscape((event.get("location") or {}).get("displayName") or "") location = _icsEscape((event.get("location") or {}).get("displayName") or "")

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
Unified modules.datamodels package. Unified modules.datamodels package.
@ -13,5 +13,4 @@ from . import datamodelSecurity as security
from . import datamodelChat as chat from . import datamodelChat as chat
from . import datamodelFiles as files from . import datamodelFiles as files
from . import datamodelVoice as voice from . import datamodelVoice as voice
from . import datamodelUtils as utils from . import datamodelUtils as utils
from . import jsonContinuation

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
from typing import Optional, List, Dict, Any, Callable, TYPE_CHECKING, Tuple from typing import Optional, List, Dict, Any, Callable, TYPE_CHECKING, Tuple
from pydantic import BaseModel, Field, ConfigDict from pydantic import BaseModel, Field, ConfigDict
@ -351,4 +351,4 @@ class CodeContentPromptArgs(BaseModel):
class CodeStructurePromptArgs(BaseModel): class CodeStructurePromptArgs(BaseModel):
"""Type-safe arguments for code structure prompt builder.""" """Type-safe arguments for code structure prompt builder."""
userPrompt: str userPrompt: str
contentParts: List[ContentPart] = Field(default_factory=list) contentParts: List[ContentPart] = Field(default_factory=list)

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""AI Audit Log data model for Compliance & AI-Datenfluss tracking. """AI Audit Log data model for Compliance & AI-Datenfluss tracking.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
Audit Log Data Model for database-based audit logging. Audit Log Data Model for database-based audit logging.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""Background job models: generic, reusable infrastructure for long-running tasks. """Background job models: generic, reusable infrastructure for long-running tasks.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""Base Pydantic model with system-managed fields (DB + API + UI metadata).""" """Base Pydantic model with system-managed fields (DB + API + UI metadata)."""

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""Billing models: BillingAccount, BillingTransaction, BillingSettings, UsageStatistics.""" """Billing models: BillingAccount, BillingTransaction, BillingSettings, UsageStatistics."""

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""Chat models: ChatWorkflow, ChatMessage, ChatLog, ChatDocument.""" """Chat models: ChatWorkflow, ChatMessage, ChatLog, ChatDocument."""
@ -131,7 +131,7 @@ class ChatWorkflow(PowerOnModel):
None, None,
description=( description=(
"Optional foreign key linking this chat to an entity outside the " "Optional foreign key linking this chat to an entity outside the "
"ChatWorkflow table (e.g. an Automation2Workflow in WorkflowAutomation " "ChatWorkflow table (e.g. an Automation2Workflow in the GraphicalEditor "
"AI editor chat). NULL for the default workspace chats. Combined with " "AI editor chat). NULL for the default workspace chats. Combined with "
"featureInstanceId this gives a 1:1 relation entity ↔ chat per feature." "featureInstanceId this gives a 1:1 relation entity ↔ chat per feature."
), ),

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""Content Object data models for the container and content extraction pipeline. """Content Object data models for the container and content extraction pipeline.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""DataSource and ExternalEntry models for external data integration. """DataSource and ExternalEntry models for external data integration.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
Document reference models for typed document references in workflows. Document reference models for typed document references in workflows.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
from typing import Any, Dict, List, Optional, Literal, Union from typing import Any, Dict, List, Optional, Literal, Union
from pydantic import BaseModel, Field, field_serializer from pydantic import BaseModel, Field, field_serializer

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
from typing import Any, Dict, List, Optional, Literal from typing import Any, Dict, List, Optional, Literal
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@ -112,4 +112,4 @@ class ExtractionOptions(BaseModel):
# Additional processing options # Additional processing options
enableParallelProcessing: bool = Field(default=True, description="Enable parallel processing of chunks") enableParallelProcessing: bool = Field(default=True, description="Enable parallel processing of chunks")
maxConcurrentChunks: int = Field(default=5, ge=1, le=20, description="Maximum number of chunks to process concurrently") maxConcurrentChunks: int = Field(default=5, ge=1, le=20, description="Maximum number of chunks to process concurrently")

View file

@ -0,0 +1,83 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""FeatureDataSource model for exposing feature instance data to the AI workspace.
A FeatureDataSource links a FeatureInstance table (DATA_OBJECT) to a workspace
so the agent can query structured feature data (e.g. TrusteePosition rows).
"""
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.i18nRegistry import i18nModel
import uuid
@i18nModel("Feature-Datenquelle")
class FeatureDataSource(PowerOnModel):
"""Feature-Instanz-Tabelle als Datenquelle im AI-Workspace."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID"},
)
featureInstanceId: str = Field(
description="FK to FeatureInstance",
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
)
featureCode: str = Field(
description="Feature code (e.g. trustee, commcoach)",
json_schema_extra={"label": "Feature", "fk_target": {"db": "poweron_app", "table": "Feature", "column": "code", "labelField": "code"}},
)
tableName: str = Field(
description="Table name from DATA_OBJECTS meta (e.g. TrusteePosition)",
json_schema_extra={"label": "Tabelle"},
)
objectKey: str = Field(
description="RBAC object key (e.g. data.feature.trustee.TrusteePosition)",
json_schema_extra={"label": "Objekt-Schluessel"},
)
label: str = Field(
description="User-visible label",
json_schema_extra={"label": "Bezeichnung"},
)
mandateId: str = Field(
default="",
description="Mandate scope (set automatically from featureInstance.mandateId on create).",
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
)
neutralize: Optional[bool] = Field(
default=None,
description=(
"Three-state neutralization flag with cascade-inherit semantics. "
"None = inherit; True/False = explicit. Cascade-reset on parent toggle."
),
json_schema_extra={"label": "Neutralisieren", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False},
)
ragIndexEnabled: Optional[bool] = Field(
default=None,
description=(
"Three-state RAG-indexing flag with cascade-inherit semantics. "
"None = inherit; True/False = explicit. Cascade-reset on parent toggle."
),
json_schema_extra={"label": "RAG-Indexierung", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False},
)
neutralizeFields: Optional[List[str]] = Field(
default=None,
description="Column names whose values are replaced with placeholders before AI processing",
json_schema_extra={"label": "Zu neutralisierende Felder", "frontend_type": "multiselect", "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'}",
json_schema_extra={"label": "Datensatzfilter"},
)
settings: Optional[Dict[str, Any]] = Field(
default=None,
description=(
"FeatureDataSource-scoped settings (JSON). Currently used keys: "
"ragLimits.{maxBytes,maxFileSize,maxItems,maxDepth}. "
"Mirror of DataSource.settings so the UDB settings modal can target both."
),
json_schema_extra={"label": "Einstellungen", "frontend_type": "json", "frontend_readonly": True, "frontend_required": False},
)

View file

@ -1,19 +1,15 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""Feature models: Feature definitions, instances, data sources, and shared feature types.""" """Feature models: Feature, FeatureInstance."""
import uuid import uuid
from typing import Optional, Dict, Any, List from typing import Optional, Dict, Any
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.i18nRegistry import i18nModel from modules.shared.i18nRegistry import i18nModel
from modules.datamodels.datamodelUtils import TextMultilingual from modules.datamodels.datamodelUtils import TextMultilingual
# ---------------------------------------------------------------------------
# Feature & FeatureInstance
# ---------------------------------------------------------------------------
@i18nModel("Feature") @i18nModel("Feature")
class Feature(PowerOnModel): class Feature(PowerOnModel):
"""Feature-Definition (global, z.B. 'trustee', 'commcoach'). Verfuegbare Funktionalitaeten der Plattform.""" """Feature-Definition (global, z.B. 'trustee', 'commcoach'). Verfuegbare Funktionalitaeten der Plattform."""
@ -75,147 +71,3 @@ class FeatureInstance(PowerOnModel):
description="Instance-specific configuration (JSONB). Structure depends on featureCode.", description="Instance-specific configuration (JSONB). Structure depends on featureCode.",
json_schema_extra={"label": "Konfiguration", "frontend_type": "json", "frontend_readonly": False, "frontend_required": False} json_schema_extra={"label": "Konfiguration", "frontend_type": "json", "frontend_readonly": False, "frontend_required": False}
) )
# ---------------------------------------------------------------------------
# FeatureDataSource
# ---------------------------------------------------------------------------
@i18nModel("Feature-Datenquelle")
class FeatureDataSource(PowerOnModel):
"""Feature-Instanz-Tabelle als Datenquelle im AI-Workspace."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"label": "ID"},
)
featureInstanceId: str = Field(
description="FK to FeatureInstance",
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
)
featureCode: str = Field(
description="Feature code (e.g. trustee, commcoach)",
json_schema_extra={"label": "Feature", "fk_target": {"db": "poweron_app", "table": "Feature", "column": "code", "labelField": "code"}},
)
tableName: str = Field(
description="Table name from DATA_OBJECTS meta (e.g. TrusteePosition)",
json_schema_extra={"label": "Tabelle"},
)
objectKey: str = Field(
description="RBAC object key (e.g. data.feature.trustee.TrusteePosition)",
json_schema_extra={"label": "Objekt-Schluessel"},
)
label: str = Field(
description="User-visible label",
json_schema_extra={"label": "Bezeichnung"},
)
mandateId: str = Field(
default="",
description="Mandate scope (set automatically from featureInstance.mandateId on create).",
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
)
neutralize: Optional[bool] = Field(
default=None,
description=(
"Three-state neutralization flag with cascade-inherit semantics. "
"None = inherit; True/False = explicit. Cascade-reset on parent toggle."
),
json_schema_extra={"label": "Neutralisieren", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False},
)
ragIndexEnabled: Optional[bool] = Field(
default=None,
description=(
"Three-state RAG-indexing flag with cascade-inherit semantics. "
"None = inherit; True/False = explicit. Cascade-reset on parent toggle."
),
json_schema_extra={"label": "RAG-Indexierung", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False},
)
neutralizeFields: Optional[List[str]] = Field(
default=None,
description="Column names whose values are replaced with placeholders before AI processing",
json_schema_extra={"label": "Zu neutralisierende Felder", "frontend_type": "multiselect", "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'}",
json_schema_extra={"label": "Datensatzfilter"},
)
settings: Optional[Dict[str, Any]] = Field(
default=None,
description=(
"FeatureDataSource-scoped settings (JSON). Currently used keys: "
"ragLimits.{maxBytes,maxFileSize,maxItems,maxDepth}. "
"Mirror of DataSource.settings so the UDB settings modal can target both."
),
json_schema_extra={"label": "Einstellungen", "frontend_type": "json", "frontend_readonly": True, "frontend_required": False},
)
# ---------------------------------------------------------------------------
# DataNeutralizerAttributes
# ---------------------------------------------------------------------------
@i18nModel("Neutralisiertes Datenattribut")
class DataNeutralizerAttributes(PowerOnModel):
"""Zuordnung Originaltext zu Platzhalter fuer neutralisierte Daten."""
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Unique ID of the attribute mapping (used as UID in neutralized files)",
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
mandateId: str = Field(
description="ID of the mandate this attribute belongs to",
json_schema_extra={
"label": "Mandanten-ID",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": True,
"fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"},
},
)
featureInstanceId: str = Field(
description="ID of the feature instance this attribute belongs to",
json_schema_extra={
"label": "Feature-Instanz-ID",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": True,
"fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"},
},
)
userId: str = Field(
description="ID of the user who created this attribute",
json_schema_extra={
"label": "Benutzer-ID",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": True,
"fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"},
},
)
originalText: str = Field(
description="Original text that was neutralized",
json_schema_extra={"label": "Originaltext", "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={
"label": "Datei-ID",
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"fk_target": {"db": "poweron_management", "table": "FileItem", "labelField": "fileName"},
},
)
patternType: str = Field(
description="Type of pattern that matched (email, phone, name, etc.)",
json_schema_extra={"label": "Mustertyp", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
)
# ---------------------------------------------------------------------------
# AutoWorkflow — re-exported from canonical location (datamodelWorkflowAutomation)
# ---------------------------------------------------------------------------
from modules.datamodels.datamodelWorkflowAutomation import AutoWorkflow # noqa: F401

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""File-related datamodels: FileItem, FilePreview, FileData.""" """File-related datamodels: FileItem, FilePreview, FileData."""

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
Invitation model for self-service onboarding. Invitation model for self-service onboarding.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
Unified JSON document schema and helpers used by both generation prompts and renderers. Unified JSON document schema and helpers used by both generation prompts and renderers.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""Knowledge Store data models: FileContentIndex, ContentChunk, WorkflowMemory. """Knowledge Store data models: FileContentIndex, ContentChunk, WorkflowMemory.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
Membership models: UserMandate, FeatureAccess, and Junction Tables. Membership models: UserMandate, FeatureAccess, and Junction Tables.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""Messaging models: MessagingSubscription, MessagingSubscriptionRegistration, MessagingDelivery.""" """Messaging models: MessagingSubscription, MessagingSubscriptionRegistration, MessagingDelivery."""

View file

@ -1,357 +0,0 @@
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Navigation structure data (Layer L1 - datamodels).
Single source of truth for UI navigation sections used by RBAC and frontend.
"""
from modules.shared.i18nRegistry import t
# =============================================================================
# Navigation Structure (Single Source of Truth)
# =============================================================================
#
# Block Order (gemaess Navigation-API-Konzept):
# - System: 10
# - <dynamic/features>: 15 (wird in routeSystem.py eingefuegt)
# - Basisdaten: 30
# - Administration: 200
#
# NOTE: Workflows and Migrate sections removed - now handled as features
#
# Item Order: Default-Abstand 10 pro Item
# uiComponent: Abgeleitet von objectKey (ui.system.home -> page.system.home)
# icon: Wird intern gehalten aber NICHT in der API Response zurueckgegeben
NAVIGATION_SECTIONS = [
# --- Meine Sicht (with top-level item + subgroups) ---
{
"id": "system",
"title": t("Meine Sicht"),
"order": 10,
"items": [
{
"id": "home",
"objectKey": "ui.system.home",
"label": t("Start"),
"icon": "FaHome",
"path": "/",
"order": 10,
"public": True,
},
],
"subgroups": [
{
"id": "system-overviews",
"title": t("Übersichten"),
"order": 15,
"items": [
{
"id": "integrations",
"objectKey": "ui.system.integrations",
"label": t("Integrationen"),
"icon": "FaProjectDiagram",
"path": "/integrations",
"order": 10,
"public": True,
},
{
"id": "compliance-audit",
"objectKey": "ui.system.complianceAudit",
"label": t("Compliance & Audit"),
"icon": "FaShieldAlt",
"path": "/compliance-audit",
"order": 20,
},
],
},
{
"id": "system-basedata",
"title": t("Basisdaten"),
"order": 20,
"items": [
{
"id": "connections",
"objectKey": "ui.system.connections",
"label": t("Verbindungen"),
"icon": "FaLink",
"path": "/basedata/connections",
"order": 10,
"public": True,
},
{
"id": "files",
"objectKey": "ui.system.files",
"label": t("Dateien"),
"icon": "FaRegFileAlt",
"path": "/basedata/files",
"order": 20,
"public": True,
},
{
"id": "prompts",
"objectKey": "ui.system.prompts",
"label": t("Prompts"),
"icon": "FaLightbulb",
"path": "/basedata/prompts",
"order": 30,
"public": True,
},
],
},
{
"id": "system-usage",
"title": t("Nutzung"),
"order": 30,
"items": [
{
"id": "billing-admin",
"objectKey": "ui.system.billingAdmin",
"label": t("Abrechnung"),
"icon": "FaMoneyBillAlt",
"path": "/billing/admin",
"order": 10,
},
{
"id": "statistics",
"objectKey": "ui.system.statistics",
"label": t("Statistiken"),
"icon": "FaChartBar",
"path": "/billing/transactions",
"order": 20,
},
{
"id": "rag-inventory",
"objectKey": "ui.system.ragInventory",
"label": t("RAG-Inventar"),
"icon": "FaDatabase",
"path": "/rag-inventory",
"order": 35,
},
{
"id": "store",
"objectKey": "ui.system.store",
"label": t("Store"),
"icon": "FaStore",
"path": "/store",
"order": 40,
"public": True,
},
{
"id": "settings",
"objectKey": "ui.system.settings",
"label": t("Einstellungen"),
"icon": "FaCog",
"path": "/settings",
"order": 50,
"public": True,
},
],
},
],
},
# --- Solution Design (System-Komponente, cross-mandate) ---
# Single nav entry; tabs are managed internally by WorkflowAutomationHubPage.
{
"id": "workflowAutomation",
"title": t("Lösungsdesign"),
"order": 25,
"items": [
{
"id": "wa-hub",
"objectKey": "ui.system.workflowAutomation",
"label": t("Workflow-Automation"),
"icon": "FaSitemap",
"path": "/workflow-automation",
"order": 10,
},
],
},
# --- Administration (with subgroups) ---
{
"id": "admin",
"title": t("Administration"),
"order": 200,
"subgroups": [
{
"id": "admin-wizards",
"title": t("Wizards"),
"order": 10,
"items": [
{
"id": "admin-mandate-wizard",
"objectKey": "ui.admin.mandateWizard",
"label": t("Mandanten-Wizard"),
"icon": "FaMagic",
"path": "/admin/mandate-wizard",
"order": 10,
"adminOnly": True,
},
{
"id": "admin-invitation-wizard",
"objectKey": "ui.admin.invitationWizard",
"label": t("Einladungs-Wizard"),
"icon": "FaEnvelopeOpenText",
"path": "/admin/invitation-wizard",
"order": 20,
"adminOnly": True,
},
],
},
{
"id": "admin-users-group",
"title": t("Benutzer"),
"order": 20,
"items": [
{
"id": "admin-users",
"objectKey": "ui.admin.users",
"label": t("Übersicht"),
"icon": "FaUsers",
"path": "/admin/users",
"order": 10,
"adminOnly": True,
},
{
"id": "admin-invitations",
"objectKey": "ui.admin.invitations",
"label": t("Einladungen"),
"icon": "FaEnvelopeOpenText",
"path": "/admin/invitations",
"order": 20,
"adminOnly": True,
},
{
"id": "admin-user-access-overview",
"objectKey": "ui.admin.userAccessOverview",
"label": t("Zugriffe"),
"icon": "FaClipboardList",
"path": "/admin/user-access-overview",
"order": 30,
"adminOnly": True,
},
{
"id": "admin-subscriptions",
"objectKey": "ui.admin.subscriptions",
"label": t("Abonnements"),
"icon": "FaFileContract",
"path": "/admin/subscriptions",
"order": 40,
"adminOnly": True,
},
],
},
{
"id": "admin-system-group",
"title": t("System"),
"order": 30,
"items": [
{
"id": "admin-roles",
"objectKey": "ui.admin.roles",
"label": t("Rollen"),
"icon": "FaUserTag",
"path": "/admin/mandate-roles",
"order": 10,
"adminOnly": True,
},
{
"id": "admin-mandate-role-permissions",
"objectKey": "ui.admin.mandateRolePermissions",
"label": t("Rollen-Berechtigungen"),
"icon": "FaKey",
"path": "/admin/mandate-role-permissions",
"order": 20,
"adminOnly": True,
},
{
"id": "admin-mandates",
"objectKey": "ui.admin.mandates",
"label": t("Mandanten"),
"icon": "FaBuilding",
"path": "/admin/mandates",
"order": 30,
"adminOnly": True,
},
{
"id": "admin-user-mandates",
"objectKey": "ui.admin.userMandates",
"label": t("Mandanten-Mitglieder"),
"icon": "FaUserFriends",
"path": "/admin/user-mandates",
"order": 40,
"adminOnly": True,
},
{
"id": "admin-access",
"objectKey": "ui.admin.access",
"label": t("Zugriffsverwaltung"),
"icon": "FaBuilding",
"path": "/admin/access",
"order": 50,
"adminOnly": True,
},
{
"id": "admin-feature-instances",
"objectKey": "ui.admin.featureInstances",
"label": t("Feature-Instanzen"),
"icon": "FaCubes",
"path": "/admin/feature-instances",
"order": 60,
"adminOnly": True,
},
{
"id": "admin-feature-roles",
"objectKey": "ui.admin.featureRoles",
"label": t("Features Rollen-Vorlagen"),
"icon": "FaShieldAlt",
"path": "/admin/feature-roles",
"order": 70,
"adminOnly": True,
"sysAdminOnly": True,
},
{
"id": "admin-logs",
"objectKey": "ui.admin.logs",
"label": t("Logs"),
"icon": "FaFileAlt",
"path": "/admin/logs",
"order": 90,
"adminOnly": True,
"sysAdminOnly": True,
},
{
"id": "admin-languages",
"objectKey": "ui.admin.languages",
"label": t("UI-Sprachen"),
"icon": "FaGlobe",
"path": "/admin/languages",
"order": 95,
"adminOnly": True,
"sysAdminOnly": True,
},
{
"id": "admin-database-health",
"objectKey": "ui.admin.databaseHealth",
"label": t("Datenbank-Gesundheit"),
"icon": "FaDatabase",
"path": "/admin/database-health",
"order": 98,
"adminOnly": True,
"sysAdminOnly": True,
},
{
"id": "admin-demo-config",
"objectKey": "ui.admin.demoConfig",
"label": t("Demo Config"),
"icon": "FaCubes",
"path": "/admin/demo-config",
"order": 100,
"adminOnly": True,
"sysAdminOnly": True,
},
],
},
],
},
]

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
Notification model for in-app notifications. Notification model for in-app notifications.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
Pagination models for server-side pagination, sorting, and filtering. Pagination models for server-side pagination, sorting, and filtering.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
RBAC models: AccessRule, AccessRuleContext, Role. RBAC models: AccessRule, AccessRuleContext, Role.
@ -10,7 +10,7 @@ Multi-Tenant Design:
""" """
import uuid import uuid
from typing import Optional, Dict, List, Protocol, runtime_checkable 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.datamodels.datamodelBase import PowerOnModel
@ -174,20 +174,6 @@ class AccessRule(PowerOnModel):
) )
@runtime_checkable
class RbacProtocol(Protocol):
"""Structural type for RBAC checkers — allows aicore (L3) to reference
the RBAC contract without importing from security (L4)."""
def checkResourceAccessBulk(
self,
user: "User",
resourcePaths: List[str],
mandateId: Optional[str] = None,
featureInstanceId: Optional[str] = None,
) -> Dict[str, bool]: ...
# IMMUTABLE Fields Definition - für Enforcement auf Application-Level # IMMUTABLE Fields Definition - für Enforcement auf Application-Level
IMMUTABLE_FIELDS = { IMMUTABLE_FIELDS = {
"Role": ["mandateId", "featureInstanceId", "featureCode"], "Role": ["mandateId", "featureInstanceId", "featureCode"],

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
Security models: Token and AuthEvent. Security models: Token and AuthEvent.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""Subscription models: SubscriptionPlan (catalog), MandateSubscription (instance per mandate), """Subscription models: SubscriptionPlan (catalog), MandateSubscription (instance per mandate),
StripePlanPrice (persisted Stripe IDs per plan). StripePlanPrice (persisted Stripe IDs per plan).

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""Ticket datamodels used across Jira/ClickUp connectors.""" """Ticket datamodels used across Jira/ClickUp connectors."""

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
Utility data models and classes for common tools and mappings. Utility data models and classes for common tools and mappings.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
UAM models: User, Mandate, UserConnection. UAM models: User, Mandate, UserConnection.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""Unified Document Model (UDM) — hierarchical document tree and ContentPart bridge.""" """Unified Document Model (UDM) — hierarchical document tree and ContentPart bridge."""
from __future__ import annotations from __future__ import annotations

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""UI language sets: structured i18n entries (context, key, value).""" """UI language sets: structured i18n entries (context, key, value)."""

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""Utility datamodels: Prompt, TextMultilingual.""" """Utility datamodels: Prompt, TextMultilingual."""

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
View models for the /api/attributes/ endpoint. View models for the /api/attributes/ endpoint.
@ -24,7 +24,7 @@ from modules.datamodels.datamodelBilling import BillingTransaction
from modules.datamodels.datamodelSubscription import MandateSubscription from modules.datamodels.datamodelSubscription import MandateSubscription
from modules.datamodels.datamodelUiLanguage import UiLanguageSet from modules.datamodels.datamodelUiLanguage import UiLanguageSet
from modules.datamodels.datamodelRbac import Role from modules.datamodels.datamodelRbac import Role
from modules.datamodels.datamodelFeatures import DataNeutralizerAttributes from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutralizerAttributes
from modules.shared.i18nRegistry import i18nModel from modules.shared.i18nRegistry import i18nModel
@ -243,11 +243,11 @@ class RoleView(Role):
# Automation Workflow — dashboard view with synthesized fields # Automation Workflow — dashboard view with synthesized fields
# ============================================================================ # ============================================================================
from modules.datamodels.datamodelFeatures import AutoWorkflow from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow
@i18nModel("Workflow (Ansicht)") @i18nModel("Workflow (Ansicht)")
class AutoWorkflowView(AutoWorkflow): class Automation2WorkflowView(AutoWorkflow):
"""AutoWorkflow extended with computed dashboard fields. """AutoWorkflow extended with computed dashboard fields.
Used exclusively for /api/attributes/ so the frontend can resolve column Used exclusively for /api/attributes/ so the frontend can resolve column

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""Voice settings datamodel — re-exported from UAM for central voice preferences.""" """Voice settings datamodel — re-exported from UAM for central voice preferences."""

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
Workflow execution models for action definitions, AI responses, and workflow-level structures. Workflow execution models for action definitions, AI responses, and workflow-level structures.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
"""Workflow Action models: WorkflowActionParameter, WorkflowActionDefinition.""" """Workflow Action models: WorkflowActionParameter, WorkflowActionDefinition."""

View file

@ -1,171 +0,0 @@
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Shared service exception classes.
Centralises the three cross-layer exception types so that both the
serviceCenter layer and the workflows/interfaces layers can import them
from one place without creating circular dependencies.
"""
from typing import Dict, Any, Optional
from modules.shared.i18nRegistry import t
from modules.datamodels.datamodelSubscription import SubscriptionStatusEnum
# ============================================================================
# Subscription action / reason constants
# ============================================================================
SUBSCRIPTION_USER_ACTION_UPGRADE = "UPGRADE_SUBSCRIPTION"
SUBSCRIPTION_USER_ACTION_REACTIVATE = "REACTIVATE_SUBSCRIPTION"
SUBSCRIPTION_USER_ACTION_ADD_PAYMENT = "ADD_PAYMENT_METHOD"
SUBSCRIPTION_USER_ACTION_CONTACT_ADMIN = "CONTACT_ADMIN"
SUBSCRIPTION_REASONS = {
"SUBSCRIPTION_INACTIVE",
"SUBSCRIPTION_PAYMENT_REQUIRED",
"SUBSCRIPTION_PAYMENT_PENDING",
"SUBSCRIPTION_EXPIRED",
}
# ============================================================================
# Subscription helper functions
# ============================================================================
def _subscriptionReasonForStatus(status: SubscriptionStatusEnum) -> str:
if status == SubscriptionStatusEnum.PENDING:
return "SUBSCRIPTION_PAYMENT_PENDING"
if status == SubscriptionStatusEnum.PAST_DUE:
return "SUBSCRIPTION_PAYMENT_REQUIRED"
if status == SubscriptionStatusEnum.EXPIRED:
return "SUBSCRIPTION_EXPIRED"
return "SUBSCRIPTION_INACTIVE"
def _subscriptionUserActionForStatus(status: SubscriptionStatusEnum) -> str:
if status in (SubscriptionStatusEnum.PAST_DUE, SubscriptionStatusEnum.PENDING):
return SUBSCRIPTION_USER_ACTION_ADD_PAYMENT
return SUBSCRIPTION_USER_ACTION_UPGRADE
def _subscriptionLimitsHint() -> str:
return " " + t(
"Details zu Ihrem Abonnement, den enthaltenen Limits und Upgrade-Optionen: "
"Menü «Administration» → «Billing» → Registerkarte «Abonnement»."
)
def _enterpriseLimitsHint() -> str:
return " " + t(
"Ihr Enterprise-Abonnement wird vom Plattform-Administrator verwaltet. "
"Bitte kontaktieren Sie den Administrator für eine Anpassung der Limiten."
)
# ============================================================================
# Exception classes
# ============================================================================
class SubscriptionInactiveException(Exception):
def __init__(self, status: SubscriptionStatusEnum, mandateId: str = "", message: Optional[str] = None):
self.status = status
self.mandateId = mandateId
self.reason = _subscriptionReasonForStatus(status)
self.userAction = _subscriptionUserActionForStatus(status)
self.message = message or t(
"Kein aktives Abonnement für diesen Mandanten. Bitte wählen Sie einen Plan unter Billing."
)
super().__init__(self.message)
def toClientDict(self) -> Dict[str, Any]:
out: Dict[str, Any] = {
"error": self.reason, "message": self.message,
"userAction": self.userAction, "subscriptionUiPath": "/admin/billing?tab=subscription",
}
if self.mandateId:
out["mandateId"] = self.mandateId
return out
class SubscriptionCapacityException(Exception):
def __init__(self, resourceType: str, currentCount: int, maxAllowed: int,
message: Optional[str] = None, isEnterprise: bool = False):
self.resourceType = resourceType
self.currentCount = currentCount
self.maxAllowed = maxAllowed
self.isEnterprise = isEnterprise
hint = _enterpriseLimitsHint() if isEnterprise else _subscriptionLimitsHint()
if message is not None:
self.message = message
elif resourceType == "users":
self.message = t(
"Mit dem aktuellen Abonnement sind für diesen Mandanten höchstens {maxAllowed} "
"Benutzer zulässig (derzeit {currentCount}). "
"Ohne Planwechsel können keine weiteren Benutzer hinzugefügt werden."
).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint
elif resourceType == "featureInstances":
self.message = t(
"Es sind höchstens {maxAllowed} aktive Module erlaubt (derzeit {currentCount}). "
"Bitte Abonnement erweitern oder ein Modul entfernen."
).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint
elif resourceType == "dataVolumeMB":
self.message = t(
"Das im Abonnement enthaltene Datenvolumen ({maxAllowed} MB) reicht nicht "
"(aktuell ca. {currentCount} MB). Bitte Speicher-Limit oder Plan anpassen."
).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint
else:
self.message = t(
"Abonnement-Limit überschritten (Ressource «{resourceType}»: "
"aktuell {currentCount}, erlaubt {maxAllowed})."
).format(resourceType=resourceType, currentCount=currentCount, maxAllowed=maxAllowed) + hint
super().__init__(self.message)
def toClientDict(self) -> Dict[str, Any]:
action = SUBSCRIPTION_USER_ACTION_CONTACT_ADMIN if self.isEnterprise else SUBSCRIPTION_USER_ACTION_UPGRADE
return {
"error": f"SUBSCRIPTION_{self.resourceType.upper()}_LIMIT",
"currentCount": self.currentCount, "maxAllowed": self.maxAllowed,
"message": self.message, "userAction": action,
"subscriptionUiPath": "/admin/billing?tab=subscription",
}
class BillingContextError(Exception):
"""Raised when billing context is incomplete (missing mandateId, user, etc.).
This is a FAIL-SAFE error: AI calls MUST NOT proceed without valid billing context.
Acts like a 0 CHF credit card pre-authorization check - validates that billing
CAN be recorded before any expensive AI operation starts.
"""
def __init__(self, message: str = None):
self.message = message or "Billing context incomplete - AI call blocked"
super().__init__(self.message)
# ============================================================================
# Workflow execution pause exceptions
# (Canonical location — formerly in automation2/executors/inputExecutor.py)
# ============================================================================
class PauseForHumanTaskError(Exception):
"""Raised when execution must pause for a human task. Contains runId, taskId."""
def __init__(self, runId: str, taskId: str, nodeId: str):
self.runId = runId
self.taskId = taskId
self.nodeId = nodeId
super().__init__(f"Pause for human task {taskId} (run {runId}, node {nodeId})")
class PauseForEmailWaitError(Exception):
"""Raised when execution must pause waiting for a new email. Background poller will resume."""
def __init__(self, runId: str, nodeId: str, waitConfig: Dict[str, Any]):
self.runId = runId
self.nodeId = nodeId
self.waitConfig = waitConfig
super().__init__(f"Pause for email wait (run {runId}, node {nodeId})")

View file

@ -1,2 +0,0 @@
# Copyright (c) 2026 PowerOn AG
# All rights reserved.

View file

@ -1,214 +0,0 @@
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
FK label resolution: resolve foreign-key IDs to human-readable labels.
Works with the fk_target annotations on Pydantic models (see fkRegistry.py)
to auto-build label resolvers for paginated record sets.
"""
import logging
from functools import partial
from typing import Any, Callable, Dict, List, Optional
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Individual FK label resolvers (db, ids) -> {id: label}
# ---------------------------------------------------------------------------
def resolveMandateLabels(db, ids: List[str]) -> Dict[str, Optional[str]]:
"""Resolve mandate IDs to labels. Returns None (not the ID!) for
unresolvable entries so the caller can distinguish "resolved" from "missing".
"""
from modules.datamodels.datamodelUam import Mandate
uniqueIds = list(set(ids))
records = db.getRecordset(Mandate, recordFilter={"id": uniqueIds}) or []
found: Dict[str, dict] = {}
for rec in records:
mid = rec.get("id", "")
found[mid] = rec
result: Dict[str, Optional[str]] = {}
for mid in ids:
m = found.get(mid)
label = (m.get("label") or m.get("name")) if m else None
if not label:
logger.debug("resolveMandateLabels: no label for id=%s (found=%s)", mid, m is not None)
result[mid] = label or None
return result
def resolveInstanceLabels(db, ids: List[str]) -> Dict[str, Optional[str]]:
"""Resolve feature-instance IDs to labels. Returns None for unresolvable."""
from modules.datamodels.datamodelFeatures import FeatureInstance
result: Dict[str, Optional[str]] = {}
for iid in ids:
records = db.getRecordset(FeatureInstance, recordFilter={"id": iid})
if records:
label = records[0].get("label") or None
result[iid] = label
else:
logger.debug("resolveInstanceLabels: no label for id=%s", iid)
result[iid] = None
return result
def resolveUserLabels(db, ids: List[str]) -> Dict[str, Optional[str]]:
"""Resolve user IDs to display names. Returns None for unresolvable."""
from modules.datamodels.datamodelUam import UserInDB as _UserInDB
uniqueIds = list(set(ids))
users = db.getRecordset(
_UserInDB,
recordFilter={"id": uniqueIds},
)
result: Dict[str, Optional[str]] = {}
found: Dict[str, dict] = {}
for u in (users or []):
uid = u.get("id", "")
found[uid] = u
for uid in ids:
u = found.get(uid)
if u:
result[uid] = u.get("displayName") or u.get("username") or u.get("email") or None
else:
result[uid] = None
return result
def resolveRoleLabels(db, ids: List[str]) -> Dict[str, Optional[str]]:
"""Resolve Role.id to roleLabel. Returns None for unresolvable."""
if not ids:
return {}
from modules.datamodels.datamodelRbac import Role as _Role
recs = db.getRecordset(
_Role,
recordFilter={"id": list(set(ids))},
) or []
out: Dict[str, Optional[str]] = {i: None for i in ids}
for r in recs:
rid = r.get("id")
if rid:
out[rid] = r.get("roleLabel") or None
for rid in ids:
if out.get(rid) is None:
logger.debug("resolveRoleLabels: no label for id=%s", rid)
return out
def resolveFileLabels(db, ids: List[str]) -> Dict[str, Optional[str]]:
"""Resolve FileItem IDs to fileName. Returns None for unresolvable."""
if not ids:
return {}
from modules.datamodels.datamodelFiles import FileItem as _FileItem
recs = db.getRecordset(
_FileItem,
recordFilter={"id": list(set(ids))},
) or []
out: Dict[str, Optional[str]] = {i: None for i in ids}
for r in recs:
fid = r.get("id")
if fid:
out[fid] = r.get("fileName") or None
return out
# ---------------------------------------------------------------------------
# Resolver registry
# ---------------------------------------------------------------------------
_BUILTIN_FK_RESOLVERS: Dict[str, Callable] = {
"Mandate": resolveMandateLabels,
"FeatureInstance": resolveInstanceLabels,
"UserInDB": resolveUserLabels,
"Role": resolveRoleLabels,
"FileItem": resolveFileLabels,
}
def buildLabelResolversFromModel(
modelClass: type,
db=None,
) -> Dict[str, Callable[[List[str]], Dict[str, str]]]:
"""
Auto-build labelResolvers dict from ``json_schema_extra.fk_target`` on a Pydantic model.
Maps field names to resolver functions when the target table has a registered builtin
resolver and ``fk_target.labelField`` is set (non-None).
When ``db`` is provided, the returned resolvers are pre-bound with partial(resolver, db)
so they can be called as resolver(ids).
"""
resolvers: Dict[str, Callable[[List[str]], Dict[str, str]]] = {}
for name, fieldInfo in modelClass.model_fields.items():
extra = fieldInfo.json_schema_extra
if not extra or not isinstance(extra, dict):
continue
tgt = extra.get("fk_target")
if not isinstance(tgt, dict):
continue
if tgt.get("labelField") is None:
continue
fkModel = tgt.get("table")
if fkModel and fkModel in _BUILTIN_FK_RESOLVERS:
fn = _BUILTIN_FK_RESOLVERS[fkModel]
resolvers[name] = partial(fn, db) if db else fn
return resolvers
def enrichRowsWithFkLabels(
rows: List[Dict[str, Any]],
modelClass: type = None,
*,
db=None,
labelResolvers: Optional[Dict[str, Callable[[List[str]], Dict[str, Optional[str]]]]] = None,
extraResolvers: Optional[Dict[str, Callable[[List[str]], Dict[str, Optional[str]]]]] = None,
) -> List[Dict[str, Any]]:
"""Add ``{field}Label`` columns to each row for every FK field that has a
registered resolver.
``modelClass`` if provided, resolvers are auto-built from ``fk_target``
annotations on the Pydantic model (via ``buildLabelResolversFromModel``).
Requires ``db`` to be passed.
``labelResolvers`` explicit resolver map that overrides auto-built ones.
Each resolver has signature ``(ids: List[str]) -> Dict[str, Optional[str]]``.
``extraResolvers`` merged on top of auto-built / explicit resolvers. Use
for ad-hoc fields that are not FK-annotated on the model (e.g.
``createdByUserId`` on billing transactions).
If a label cannot be resolved the ``{field}Label`` value is ``None``
(never the raw ID that would reintroduce the silent-truncation bug).
"""
resolvers: Dict[str, Callable] = {}
if modelClass is not None and labelResolvers is None:
resolvers = buildLabelResolversFromModel(modelClass, db)
elif labelResolvers is not None:
resolvers = dict(labelResolvers)
if extraResolvers:
resolvers.update(extraResolvers)
if not resolvers or not rows:
return rows
for field, resolver in resolvers.items():
ids = list({str(r.get(field)) for r in rows if r.get(field)})
if not ids:
continue
try:
labelMap = resolver(ids)
except Exception as e:
logger.error("enrichRowsWithFkLabels: resolver for '%s' raised: %s", field, e)
labelMap = {}
labelKey = f"{field}Label"
for r in rows:
fkVal = r.get(field)
if fkVal:
r[labelKey] = labelMap.get(str(fkVal))
else:
r[labelKey] = None
return rows

View file

@ -1,543 +0,0 @@
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""
Pagination, filtering and sorting helpers for paginated record sets.
Provides unified logic for:
- mode=filterValues: distinct column values for filter dropdowns (cross-filtered)
- mode=ids: all IDs matching current filters (for bulk selection)
- In-memory equivalents for enriched/non-SQL routes
- FK-label-aware sorting (cross-DB)
"""
import copy
import json
import logging
import math
from datetime import datetime, timezone
from typing import Any, Callable, Dict, List, Optional
from fastapi.responses import JSONResponse
from modules.datamodels.datamodelPagination import (
PaginationParams,
normalize_pagination_dict,
)
from modules.shared.i18nRegistry import resolveText
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Cross-filter pagination parsing
# ---------------------------------------------------------------------------
def parseCrossFilterPagination(
column: str,
paginationJson: Optional[str],
) -> Optional[PaginationParams]:
"""
Parse pagination JSON, remove the requested column from filters (cross-filtering),
and drop sort used for filter-values requests.
"""
if not paginationJson:
return None
try:
paginationDict = json.loads(paginationJson)
if not paginationDict:
return None
paginationDict = normalize_pagination_dict(paginationDict)
filters = paginationDict.get("filters", {})
filters.pop(column, None)
paginationDict["filters"] = filters
paginationDict.pop("sort", None)
return PaginationParams(**paginationDict)
except (json.JSONDecodeError, ValueError, TypeError):
return None
def parsePaginationForIds(
paginationJson: Optional[str],
) -> Optional[PaginationParams]:
"""
Parse pagination JSON for mode=ids keep filters, drop sort and page/pageSize.
"""
if not paginationJson:
return None
try:
paginationDict = json.loads(paginationJson)
if not paginationDict:
return None
paginationDict = normalize_pagination_dict(paginationDict)
paginationDict.pop("sort", None)
return PaginationParams(**paginationDict)
except (json.JSONDecodeError, ValueError, TypeError):
return None
# ---------------------------------------------------------------------------
# SQL-based helpers (delegate to DB connector)
# ---------------------------------------------------------------------------
def handleFilterValuesMode(
db,
modelClass: type,
column: str,
paginationJson: Optional[str] = None,
recordFilter: Optional[Dict[str, Any]] = None,
enrichFn: Optional[Callable[[str, Optional[PaginationParams], Optional[Dict[str, Any]]], List[str]]] = None,
) -> List[str]:
"""
SQL-based distinct column values with cross-filtering.
If enrichFn is provided and the column is enriched (computed/joined),
enrichFn(column, crossPagination, recordFilter) is called instead of SQL DISTINCT.
"""
crossPagination = parseCrossFilterPagination(column, paginationJson)
if enrichFn:
try:
result = enrichFn(column, crossPagination, recordFilter)
if result is not None:
return JSONResponse(content=result)
except Exception as e:
logger.warning(f"handleFilterValuesMode enrichFn failed for {column}: {e}")
try:
values = db.getDistinctColumnValues(
modelClass, column,
pagination=crossPagination,
recordFilter=recordFilter,
) or []
return JSONResponse(content=values)
except Exception as e:
logger.error(f"handleFilterValuesMode SQL failed for {modelClass.__name__}.{column}: {e}")
return JSONResponse(content=[])
def handleIdsMode(
db,
modelClass: type,
paginationJson: Optional[str] = None,
recordFilter: Optional[Dict[str, Any]] = None,
idField: str = "id",
) -> List[str]:
"""
Return all IDs matching the current filters (no LIMIT/OFFSET).
Uses the same WHERE clause as getRecordsetPaginated.
"""
pagination = parsePaginationForIds(paginationJson)
table = modelClass.__name__
try:
if not db._ensureTableExists(modelClass):
return JSONResponse(content=[])
where_clause, _, _, values, _ = db._buildPaginationClauses(
modelClass, pagination, recordFilter,
)
sql = f'SELECT "{idField}"::TEXT AS val FROM "{table}"{where_clause} ORDER BY "{idField}"'
with db.borrowCursor() as cursor:
cursor.execute(sql, values)
return JSONResponse(content=[row["val"] for row in cursor.fetchall()])
except Exception as e:
logger.error(f"handleIdsMode failed for {table}: {e}")
return JSONResponse(content=[])
# ---------------------------------------------------------------------------
# In-memory helpers (for enriched / non-SQL routes)
# ---------------------------------------------------------------------------
def applyFiltersAndSort(
items: List[Dict[str, Any]],
paginationParams: Optional[PaginationParams],
) -> List[Dict[str, Any]]:
"""
Apply filters and sorting to a list of dicts in-memory.
Does NOT paginate (no page/pageSize slicing).
"""
if not paginationParams:
return items
result = list(items)
if paginationParams.filters:
filters = paginationParams.filters
searchTerm = filters.get("search", "").lower() if filters.get("search") else None
if searchTerm:
result = [
item for item in result
if any(
searchTerm in str(v).lower()
for v in item.values()
if v is not None
)
]
for field, filterValue in filters.items():
if field == "search":
continue
if isinstance(filterValue, dict) and "operator" in filterValue:
operator = filterValue.get("operator", "equals")
value = filterValue.get("value")
else:
operator = "equals"
value = filterValue
if value is None:
result = [
item for item in result
if item.get(field) is None or item.get(field) == ""
]
continue
if value == "":
continue
result = [
item for item in result
if _matchesFilter(item, field, operator, value)
]
if paginationParams.sort:
for sortField in reversed(paginationParams.sort):
fieldName = sortField.field
ascending = sortField.direction == "asc"
noneItems = [item for item in result if item.get(fieldName) is None]
nonNoneItems = [item for item in result if item.get(fieldName) is not None]
def _getSortKey(item: Dict[str, Any], _fn=fieldName):
value = item.get(_fn)
if isinstance(value, bool):
return (0, int(value), "")
if isinstance(value, (int, float)):
return (0, value, "")
return (1, 0, str(value).lower())
nonNoneItems = sorted(nonNoneItems, key=_getSortKey, reverse=not ascending)
result = nonNoneItems + noneItems
return result
def _matchesFilter(item: Dict[str, Any], field: str, operator: str, value: Any) -> bool:
"""Single-field filter match for in-memory filtering."""
itemValue = item.get(field)
if itemValue is None:
return False
itemStr = str(itemValue).lower()
valueStr = str(value).lower()
if operator in ("equals", "eq"):
return itemStr == valueStr
if operator == "contains":
return valueStr in itemStr
if operator == "startsWith":
return itemStr.startswith(valueStr)
if operator == "endsWith":
return itemStr.endswith(valueStr)
if operator in ("gt", "gte", "lt", "lte"):
try:
itemNum = float(itemValue)
valueNum = float(value)
if operator == "gt":
return itemNum > valueNum
if operator == "gte":
return itemNum >= valueNum
if operator == "lt":
return itemNum < valueNum
return itemNum <= valueNum
except (ValueError, TypeError):
return False
if operator == "between":
return _matchesBetween(itemValue, itemStr, value)
if operator == "in":
if isinstance(value, list):
return itemStr in [str(x).lower() for x in value]
return False
if operator == "notIn":
if isinstance(value, list):
return itemStr not in [str(x).lower() for x in value]
return True
return True
def _matchesBetween(itemValue: Any, itemStr: str, value: Any) -> bool:
"""Handle 'between' operator for date ranges and numeric ranges."""
if not isinstance(value, dict):
return True
fromVal = value.get("from", "")
toVal = value.get("to", "")
if not fromVal and not toVal:
return True
try:
fromTs = None
toTs = None
if fromVal:
fromTs = datetime.strptime(str(fromVal), "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp()
if toVal:
toTs = datetime.strptime(str(toVal), "%Y-%m-%d").replace(
hour=23, minute=59, second=59, tzinfo=timezone.utc
).timestamp()
itemNum = float(itemValue) if not isinstance(itemValue, (int, float)) else itemValue
if itemNum > 10000000000:
itemNum = itemNum / 1000
if fromTs is not None and toTs is not None:
return fromTs <= itemNum <= toTs
if fromTs is not None:
return itemNum >= fromTs
if toTs is not None:
return itemNum <= toTs
except (ValueError, TypeError):
try:
itemNum = float(itemValue)
fromNum = float(fromVal) if fromVal not in (None, "") else None
toNum = float(toVal) if toVal not in (None, "") else None
if fromNum is not None and toNum is not None:
return fromNum <= itemNum <= toNum
if fromNum is not None:
return itemNum >= fromNum
if toNum is not None:
return itemNum <= toNum
except (ValueError, TypeError):
pass
fromStr = str(fromVal).lower() if fromVal else ""
toStr = str(toVal).lower() if toVal else ""
if fromStr and toStr:
return fromStr <= itemStr <= toStr
if fromStr:
return itemStr >= fromStr
if toStr:
return itemStr <= toStr
return True
def _extractDistinctValues(
items: List[Dict[str, Any]],
columnKey: str,
requestLang: Optional[str] = None,
) -> list:
"""Extract sorted distinct display values for a column from enriched items.
When the items contain a ``{columnKey}Label`` field (FK enrichment convention),
returns ``{value, label}`` objects so the frontend shows human-readable
labels in filter dropdowns. Otherwise returns plain strings.
Includes ``None`` as the last entry when at least one row has a null/empty
value this enables the "(Leer)" filter option in the frontend.
"""
_MISSING = object()
labelKey = f"{columnKey}Label"
hasFkLabels = any(labelKey in item for item in items[:20])
if hasFkLabels:
byVal: Dict[str, str] = {}
hasEmpty = False
for item in items:
val = item.get(columnKey, _MISSING)
if val is _MISSING:
continue
if val is None or val == "":
hasEmpty = True
continue
strVal = str(val)
if strVal not in byVal:
label = item.get(labelKey)
byVal[strVal] = str(label) if label else f"NA({strVal[:8]})"
result: list = sorted(
[{"value": v, "label": l} for v, l in byVal.items()],
key=lambda x: x["label"].lower(),
)
if hasEmpty:
result.append(None)
return result
values = set()
hasEmpty = False
for item in items:
val = item.get(columnKey, _MISSING)
if val is _MISSING:
continue
if val is None or val == "":
hasEmpty = True
continue
if isinstance(val, bool):
values.add("true" if val else "false")
elif isinstance(val, (int, float)):
values.add(str(val))
elif isinstance(val, dict):
text = resolveText(val, requestLang)
if text:
values.add(text)
else:
values.add(str(val))
result = sorted(values, key=lambda v: v.lower())
if hasEmpty:
result.append(None)
return result
def handleFilterValuesInMemory(
items: List[Dict[str, Any]],
column: str,
paginationJson: Optional[str] = None,
requestLang: Optional[str] = None,
) -> JSONResponse:
"""
In-memory filter-values: apply cross-filters, then extract distinct values.
For routes that build enriched in-memory lists.
Returns JSONResponse to bypass FastAPI response_model validation.
"""
crossFilterParams = parseCrossFilterPagination(column, paginationJson)
crossFiltered = applyFiltersAndSort(items, crossFilterParams)
return JSONResponse(content=_extractDistinctValues(crossFiltered, column, requestLang))
def handleIdsInMemory(
items: List[Dict[str, Any]],
paginationJson: Optional[str] = None,
idField: str = "id",
) -> JSONResponse:
"""
In-memory IDs: apply filters, return all IDs.
For routes that build enriched in-memory lists.
Returns JSONResponse to bypass FastAPI response_model validation.
"""
pagination = parsePaginationForIds(paginationJson)
filtered = applyFiltersAndSort(items, pagination)
ids = []
for item in filtered:
val = item.get(idField)
if val is not None:
ids.append(str(val))
return JSONResponse(content=ids)
def getRecordsetPaginatedWithFkSort(
db,
modelClass: type,
pagination,
recordFilter: Optional[Dict[str, Any]] = None,
labelResolvers: Optional[Dict[str, Callable[[List[str]], Dict[str, str]]]] = None,
fieldFilter: Optional[List[str]] = None,
idField: str = "id",
) -> Dict[str, Any]:
"""
Wrapper around db.getRecordsetPaginated that handles FK-label sorting.
If the current sort field is a FK with a registered labelResolver, the
function fetches all filtered IDs + FK values, resolves labels cross-DB,
sorts in-memory by label, and returns only the requested page.
If no FK sort is active, delegates directly to db.getRecordsetPaginated.
"""
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels, buildLabelResolversFromModel
if not pagination or not pagination.sort:
result = db.getRecordsetPaginated(modelClass, pagination, recordFilter, fieldFilter)
enrichRowsWithFkLabels(result.get("items", []), modelClass, db=db)
return result
if labelResolvers is None:
labelResolvers = buildLabelResolversFromModel(modelClass, db)
if not labelResolvers:
result = db.getRecordsetPaginated(modelClass, pagination, recordFilter, fieldFilter)
enrichRowsWithFkLabels(result.get("items", []), modelClass, db=db)
return result
fkSortField = None
fkSortDir = "asc"
for sf in pagination.sort:
sfField = sf.get("field") if isinstance(sf, dict) else getattr(sf, "field", None)
sfDir = sf.get("direction", "asc") if isinstance(sf, dict) else getattr(sf, "direction", "asc")
if sfField and sfField in labelResolvers:
fkSortField = sfField
fkSortDir = str(sfDir).lower()
break
if not fkSortField:
result = db.getRecordsetPaginated(modelClass, pagination, recordFilter, fieldFilter)
enrichRowsWithFkLabels(result.get("items", []), modelClass, db=db)
return result
try:
distinctIds = db.getDistinctColumnValues(
modelClass, fkSortField, recordFilter=recordFilter,
) or []
labelMap = {}
if distinctIds:
try:
labelMap = labelResolvers[fkSortField](distinctIds)
except Exception as e:
logger.warning(f"getRecordsetPaginatedWithFkSort: resolver for {fkSortField} failed: {e}")
filterOnlyPagination = copy.deepcopy(pagination)
filterOnlyPagination.sort = []
filterOnlyPagination.page = 1
filterOnlyPagination.pageSize = 999999
lightRows = db.getRecordsetPaginated(
modelClass, filterOnlyPagination, recordFilter,
fieldFilter=[idField, fkSortField],
)
allRows = lightRows.get("items", [])
totalItems = len(allRows)
if totalItems == 0:
return {"items": [], "totalItems": 0, "totalPages": 0}
def _sortKey(row):
fkVal = row.get(fkSortField, "") or ""
label = labelMap.get(str(fkVal), str(fkVal)).lower()
return label
reverse = fkSortDir == "desc"
allRows.sort(key=_sortKey, reverse=reverse)
pageSize = pagination.pageSize
offset = (pagination.page - 1) * pageSize
pageSlice = allRows[offset:offset + pageSize]
pageIds = [row[idField] for row in pageSlice if row.get(idField)]
if not pageIds:
return {"items": [], "totalItems": totalItems, "totalPages": math.ceil(totalItems / pageSize)}
pageItems = db.getRecordset(modelClass, recordFilter={idField: pageIds}, fieldFilter=fieldFilter)
idOrder = {pid: idx for idx, pid in enumerate(pageIds)}
pageItems.sort(key=lambda r: idOrder.get(r.get(idField), 999999))
enrichRowsWithFkLabels(pageItems, modelClass, db=db)
totalPages = math.ceil(totalItems / pageSize) if totalItems > 0 else 0
return {"items": pageItems, "totalItems": totalItems, "totalPages": totalPages}
except Exception as e:
logger.error(f"getRecordsetPaginatedWithFkSort failed for {modelClass.__name__}: {e}")
result = db.getRecordsetPaginated(modelClass, pagination, recordFilter, fieldFilter)
enrichRowsWithFkLabels(result.get("items", []), modelClass, db=db)
return result
def paginateInMemory(
items: List[Dict[str, Any]],
paginationParams: Optional[PaginationParams],
) -> tuple:
"""
Apply pagination (page/pageSize slicing) to an already-filtered+sorted list.
Returns (pageItems, totalItems).
"""
totalItems = len(items)
if not paginationParams:
return items, totalItems
offset = (paginationParams.page - 1) * paginationParams.pageSize
pageItems = items[offset:offset + paginationParams.pageSize]
return pageItems, totalItems

View file

@ -1,9 +1,7 @@
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
""" """
Demo Configs Auto-Discovery Module Demo Configs Auto-Discovery Module
Scans this folder for Python files that contain subclasses of BaseDemoConfig Scans this folder for Python files that contain subclasses of _BaseDemoConfig
and exposes them via getAvailableDemoConfigs(). and exposes them via getAvailableDemoConfigs().
""" """
@ -13,14 +11,14 @@ import logging
import pkgutil import pkgutil
from typing import Dict from typing import Dict
from modules.demoConfigs.baseDemoConfig import BaseDemoConfig from modules.demoConfigs._baseDemoConfig import _BaseDemoConfig
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_configCache: Dict[str, BaseDemoConfig] = {} _configCache: Dict[str, _BaseDemoConfig] = {}
def getAvailableDemoConfigs() -> Dict[str, BaseDemoConfig]: def getAvailableDemoConfigs() -> Dict[str, _BaseDemoConfig]:
"""Return a dict of code -> instance for every discovered demo config.""" """Return a dict of code -> instance for every discovered demo config."""
if _configCache: if _configCache:
return _configCache return _configCache
@ -34,7 +32,7 @@ def getAvailableDemoConfigs() -> Dict[str, BaseDemoConfig]:
try: try:
module = importlib.import_module(f"{package}.{moduleName}") module = importlib.import_module(f"{package}.{moduleName}")
for name, obj in inspect.getmembers(module, inspect.isclass): for name, obj in inspect.getmembers(module, inspect.isclass):
if issubclass(obj, BaseDemoConfig) and obj is not BaseDemoConfig: if issubclass(obj, _BaseDemoConfig) and obj is not _BaseDemoConfig:
instance = obj() instance = obj()
if instance.code: if instance.code:
_configCache[instance.code] = instance _configCache[instance.code] = instance
@ -45,7 +43,7 @@ def getAvailableDemoConfigs() -> Dict[str, BaseDemoConfig]:
return _configCache return _configCache
def getDemoConfigByCode(code: str) -> BaseDemoConfig | None: def getDemoConfigByCode(code: str) -> _BaseDemoConfig | None:
"""Get a specific demo config by its code.""" """Get a specific demo config by its code."""
configs = getAvailableDemoConfigs() configs = getAvailableDemoConfigs()
return configs.get(code) return configs.get(code)

View file

@ -1,9 +1,7 @@
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
""" """
Base class for demo configurations. Base class for demo configurations.
Each demo config file in this folder extends BaseDemoConfig and provides Each demo config file in this folder extends _BaseDemoConfig and provides
idempotent load() and remove() methods for setting up / tearing down idempotent load() and remove() methods for setting up / tearing down
a complete demo environment (mandates, users, features, test data, etc.). a complete demo environment (mandates, users, features, test data, etc.).
@ -20,7 +18,7 @@ from typing import Any, Dict, List
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class BaseDemoConfig(ABC): class _BaseDemoConfig(ABC):
"""Abstract base for demo configurations.""" """Abstract base for demo configurations."""
code: str = "" code: str = ""

View file

@ -1,5 +1,3 @@
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
""" """
Investor Demo April 2026 Investor Demo April 2026
@ -19,7 +17,7 @@ import logging
import uuid import uuid
from typing import Dict, Any, Optional, List from typing import Dict, Any, Optional, List
from modules.demoConfigs.baseDemoConfig import BaseDemoConfig from modules.demoConfigs._baseDemoConfig import _BaseDemoConfig
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -46,6 +44,7 @@ _USER = {
_FEATURES_HAPPYLIFE = [ _FEATURES_HAPPYLIFE = [
{"code": "workspace", "label": "Dokumentenablage"}, {"code": "workspace", "label": "Dokumentenablage"},
{"code": "trustee", "label": "Buchhaltung"}, {"code": "trustee", "label": "Buchhaltung"},
{"code": "graphicalEditor", "label": "Automationen"},
{"code": "neutralization", "label": "Datenschutz"}, {"code": "neutralization", "label": "Datenschutz"},
] ]
_FEATURES_ALPINA = [ _FEATURES_ALPINA = [
@ -53,16 +52,17 @@ _FEATURES_ALPINA = [
{"code": "trustee", "label": "BUHA Müller Immobilien GmbH"}, {"code": "trustee", "label": "BUHA Müller Immobilien GmbH"},
{"code": "trustee", "label": "BUHA Schneider Gastro AG"}, {"code": "trustee", "label": "BUHA Schneider Gastro AG"},
{"code": "trustee", "label": "BUHA Weber Consulting"}, {"code": "trustee", "label": "BUHA Weber Consulting"},
{"code": "graphicalEditor", "label": "Automationen"},
{"code": "neutralization", "label": "Datenschutz"}, {"code": "neutralization", "label": "Datenschutz"},
] ]
class InvestorDemo2026(BaseDemoConfig): class InvestorDemo2026(_BaseDemoConfig):
code = "investor-demo-2026" code = "investor-demo-2026"
label = "Investor Demo April 2026" label = "Investor Demo April 2026"
description = ( description = (
"Two mandates (HappyLife AG + Alpina Treuhand AG), one SysAdmin user, " "Two mandates (HappyLife AG + Alpina Treuhand AG), one SysAdmin user, "
"trustee with RMA, workspace, workflow automation, and neutralization." "trustee with RMA, workspace, graph editor, and neutralization."
) )
credentials = [ credentials = [
{ {
@ -119,7 +119,7 @@ class InvestorDemo2026(BaseDemoConfig):
# remove # remove
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def remove(self, db) -> Dict[str, Any]: def remove(self, db) -> Dict[str, Any]:
summary: Dict[str, Any] = {"removed": [], "skipped": [], "errors": []} summary: Dict[str, Any] = {"removed": [], "errors": []}
from modules.datamodels.datamodelUam import Mandate, UserInDB from modules.datamodels.datamodelUam import Mandate, UserInDB
from modules.datamodels.datamodelMembership import UserMandate from modules.datamodels.datamodelMembership import UserMandate
@ -171,7 +171,7 @@ class InvestorDemo2026(BaseDemoConfig):
def _ensureMandate(self, db, mandateDef: Dict, summary: Dict) -> Optional[str]: def _ensureMandate(self, db, mandateDef: Dict, summary: Dict) -> Optional[str]:
from modules.datamodels.datamodelUam import Mandate from modules.datamodels.datamodelUam import Mandate
from modules.interfaces.interfaceRbac import copySystemRolesToMandate from modules.interfaces.interfaceBootstrap import copySystemRolesToMandate
existing = db.getRecordset(Mandate, recordFilter={"name": mandateDef["name"]}) existing = db.getRecordset(Mandate, recordFilter={"name": mandateDef["name"]})
if existing: if existing:
@ -395,8 +395,8 @@ class InvestorDemo2026(BaseDemoConfig):
apiKey = APP_CONFIG.get("Demo_RMA_ApiKey", "") apiKey = APP_CONFIG.get("Demo_RMA_ApiKey", "")
if not apiBaseUrl or not apiKey: if not apiBaseUrl or not apiKey:
summary["skipped"].append( summary["errors"].append(
f"RMA credentials not configured (Demo_RMA_ApiBaseUrl, Demo_RMA_ClientName, Demo_RMA_ApiKey) for {mandateLabel} — optional external integration" f"RMA credentials missing in config.ini (Demo_RMA_ApiBaseUrl, Demo_RMA_ClientName, Demo_RMA_ApiKey) for {mandateLabel}"
) )
return return
@ -492,8 +492,8 @@ class InvestorDemo2026(BaseDemoConfig):
if not instId: if not instId:
continue continue
if featureCode == "workflowAutomation": if featureCode == "graphicalEditor":
self._removeWorkflowAutomationData(instId, mandateId, mandateLabel, summary) self._removeGraphicalEditorData(instId, mandateId, mandateLabel, summary)
if featureCode == "trustee": if featureCode == "trustee":
self._removeTrusteeData(db, instId, mandateLabel, summary) self._removeTrusteeData(db, instId, mandateLabel, summary)
@ -551,26 +551,25 @@ class InvestorDemo2026(BaseDemoConfig):
except Exception as e: except Exception as e:
summary["errors"].append(f"Billing cleanup for {mandateLabel}: {e}") summary["errors"].append(f"Billing cleanup for {mandateLabel}: {e}")
def _removeWorkflowAutomationData(self, featureInstanceId: str, mandateId: str, mandateLabel: str, summary: Dict): def _removeGraphicalEditorData(self, featureInstanceId: str, mandateId: str, mandateLabel: str, summary: Dict):
"""Remove all AutoWorkflow data (workflows, runs, versions, logs, tasks) from the WorkflowAutomation DB.""" """Remove all AutoWorkflow data (workflows, runs, versions, logs, tasks) from the Greenfield DB."""
try: try:
from modules.datamodels.datamodelWorkflowAutomation import ( from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import (
AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask, AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask,
WORKFLOW_AUTOMATION_DATABASE,
) )
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
waDb = DatabaseConnector( geDb = DatabaseConnector(
dbHost=APP_CONFIG.get("DB_HOST", "localhost"), dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
dbDatabase=WORKFLOW_AUTOMATION_DATABASE, dbDatabase="poweron_graphicaleditor",
dbUser=APP_CONFIG.get("DB_USER"), dbUser=APP_CONFIG.get("DB_USER"),
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
userId=None, userId=None,
) )
workflows = waDb.getRecordset(AutoWorkflow, recordFilter={ workflows = geDb.getRecordset(AutoWorkflow, recordFilter={
"mandateId": mandateId, "mandateId": mandateId,
"featureInstanceId": featureInstanceId, "featureInstanceId": featureInstanceId,
}) or [] }) or []
@ -580,27 +579,27 @@ class InvestorDemo2026(BaseDemoConfig):
if not wfId: if not wfId:
continue continue
for version in waDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []: for version in geDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []:
waDb.recordDelete(AutoVersion, version.get("id")) geDb.recordDelete(AutoVersion, version.get("id"))
runs = waDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or [] runs = geDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or []
for run in runs: for run in runs:
runId = run.get("id") runId = run.get("id")
for stepLog in waDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []: for stepLog in geDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []:
waDb.recordDelete(AutoStepLog, stepLog.get("id")) geDb.recordDelete(AutoStepLog, stepLog.get("id"))
waDb.recordDelete(AutoRun, runId) geDb.recordDelete(AutoRun, runId)
for task in waDb.getRecordset(AutoTask, recordFilter={"workflowId": wfId}) or []: for task in geDb.getRecordset(AutoTask, recordFilter={"workflowId": wfId}) or []:
waDb.recordDelete(AutoTask, task.get("id")) geDb.recordDelete(AutoTask, task.get("id"))
waDb.recordDelete(AutoWorkflow, wfId) geDb.recordDelete(AutoWorkflow, wfId)
if workflows: if workflows:
summary["removed"].append(f"{len(workflows)} AutoWorkflows in {mandateLabel}") summary["removed"].append(f"{len(workflows)} AutoWorkflows in {mandateLabel}")
logger.info(f"Removed {len(workflows)} automation workflows for {mandateLabel}") logger.info(f"Removed {len(workflows)} graphical editor workflows for {mandateLabel}")
except Exception as e: except Exception as e:
summary["errors"].append(f"WorkflowAutomation cleanup for {mandateLabel}: {e}") summary["errors"].append(f"GraphicalEditor cleanup for {mandateLabel}: {e}")
logger.error(f"Failed to clean up workflow automation data for {mandateLabel}: {e}") logger.error(f"Failed to clean up graphical editor data for {mandateLabel}: {e}")
def _removeTrusteeData(self, db, featureInstanceId: str, mandateLabel: str, summary: Dict): def _removeTrusteeData(self, db, featureInstanceId: str, mandateLabel: str, summary: Dict):
"""Remove TrusteeAccountingConfig for a feature instance.""" """Remove TrusteeAccountingConfig for a feature instance."""

View file

@ -1,12 +1,11 @@
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
"""PWG Pilot Demo (April 2026) """PWG Pilot Demo (April 2026)
Bootstraps a complete PWG-Pilot demo environment in an empty dev/demo install: Bootstraps a complete PWG-Pilot demo environment in an empty dev/demo install:
- 1 mandate "Stiftung PWG" - 1 mandate "Stiftung PWG"
- 1 SysAdmin demo user "pwg.demo" - 1 SysAdmin demo user "pwg.demo"
- 3 features: workspace, trustee (BUHA PWG), neutralization (Datenschutz) - 4 features: workspace, trustee (BUHA PWG), graphicalEditor (PWG Automationen),
neutralization (Datenschutz)
- Trustee seed-data (5 fictitious tenants with monthly rent journal lines for - Trustee seed-data (5 fictitious tenants with monthly rent journal lines for
the current year, loaded from ``demoData/pwg/_seedTrusteeData.json``) the current year, loaded from ``demoData/pwg/_seedTrusteeData.json``)
- Pilot workflow imported from - Pilot workflow imported from
@ -16,7 +15,7 @@ Bootstraps a complete PWG-Pilot demo environment in an empty dev/demo install:
Idempotent: ``load()`` skips anything that already exists; ``remove()`` deletes Idempotent: ``load()`` skips anything that already exists; ``remove()`` deletes
mandate, user, seed data and imported workflow cleanly. mandate, user, seed data and imported workflow cleanly.
Pattern: subclass of :class:`BaseDemoConfig`, auto-discovered by Pattern: subclass of :class:`_BaseDemoConfig`, auto-discovered by
``demoConfigs/__init__.py``. See ``investorDemo2026.py`` for the reference ``demoConfigs/__init__.py``. See ``investorDemo2026.py`` for the reference
implementation we mirror here. implementation we mirror here.
""" """
@ -28,7 +27,7 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from modules.demoConfigs.baseDemoConfig import BaseDemoConfig from modules.demoConfigs._baseDemoConfig import _BaseDemoConfig
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -50,18 +49,23 @@ _USER = {
_FEATURES_PWG = [ _FEATURES_PWG = [
{"code": "workspace", "label": "Dokumentenablage PWG"}, {"code": "workspace", "label": "Dokumentenablage PWG"},
{"code": "trustee", "label": "Buchhaltung PWG"}, {"code": "trustee", "label": "Buchhaltung PWG"},
{"code": "graphicalEditor", "label": "PWG Automationen"},
{"code": "neutralization", "label": "Datenschutz"}, {"code": "neutralization", "label": "Datenschutz"},
] ]
# Filename markers used to identify the imported pilot workflow on remove().
_PILOT_WORKFLOW_LABEL = "PWG Pilot: Jahresmietzinsbestätigung"
_PILOT_WORKFLOW_FILE = "pwg-mietzinsbestaetigung-pilot.workflow.json"
_SEED_TRUSTEE_FILE = "_seedTrusteeData.json" _SEED_TRUSTEE_FILE = "_seedTrusteeData.json"
class PwgDemo2026(BaseDemoConfig): class PwgDemo2026(_BaseDemoConfig):
code = "pwg-demo-2026" code = "pwg-demo-2026"
label = "PWG Pilot Demo (Mietzinsbestätigungen)" label = "PWG Pilot Demo (Mietzinsbestätigungen)"
description = ( description = (
"Stiftung PWG, ein Demo-Sachbearbeiter, Trustee mit fiktiven Mietern, " "Stiftung PWG, ein Demo-Sachbearbeiter, Trustee mit fiktiven Mietern, "
"Workflow-Automation (als File importiert, active=false). Idempotent." "Graph-Editor mit dem Pilot-Workflow für Jahresmietzinsbestätigungen "
"(als File importiert, active=false). Idempotent."
) )
credentials = [ credentials = [
{ {
@ -94,6 +98,9 @@ class PwgDemo2026(BaseDemoConfig):
if trusteeInstanceId: if trusteeInstanceId:
self._ensureTrusteeSeed(mandateId, trusteeInstanceId, summary) self._ensureTrusteeSeed(mandateId, trusteeInstanceId, summary)
graphInstanceId = self._getFeatureInstanceId(db, mandateId, "graphicalEditor", "PWG Automationen")
if graphInstanceId:
self._ensurePilotWorkflow(mandateId, graphInstanceId, summary)
except Exception as e: except Exception as e:
logger.error(f"PWG demo load failed: {e}", exc_info=True) logger.error(f"PWG demo load failed: {e}", exc_info=True)
@ -156,7 +163,7 @@ class PwgDemo2026(BaseDemoConfig):
def _ensureMandate(self, db, mandateDef: Dict, summary: Dict) -> Optional[str]: def _ensureMandate(self, db, mandateDef: Dict, summary: Dict) -> Optional[str]:
from modules.datamodels.datamodelUam import Mandate from modules.datamodels.datamodelUam import Mandate
from modules.interfaces.interfaceRbac import copySystemRolesToMandate from modules.interfaces.interfaceBootstrap import copySystemRolesToMandate
existing = db.getRecordset(Mandate, recordFilter={"name": mandateDef["name"]}) existing = db.getRecordset(Mandate, recordFilter={"name": mandateDef["name"]})
if existing: if existing:
@ -534,6 +541,92 @@ class PwgDemo2026(BaseDemoConfig):
if skippedTenants: if skippedTenants:
summary["skipped"].append(f"PWG seed: {skippedTenants} tenants already present") summary["skipped"].append(f"PWG seed: {skippedTenants} tenants already present")
def _ensurePilotWorkflow(self, mandateId: str, featureInstanceId: str, summary: Dict):
"""Import the pilot workflow JSON into the graphical-editor DB.
Uses the schema-aware import pipeline introduced in Phase 1
(``_workflowFileSchema.envelopeToWorkflowData`` +
``GraphicalEditorObjects.importWorkflowFromDict``). The workflow is
always created with ``active=False`` so a manual trigger is required
this matches the demo-bootstrap safety default.
"""
envelopePath = _demoDataDir() / "workflows" / _PILOT_WORKFLOW_FILE
if not envelopePath.is_file():
summary["errors"].append(f"Pilot workflow file missing: {envelopePath}")
return
try:
envelope = json.loads(envelopePath.read_text(encoding="utf-8"))
except Exception as exc:
summary["errors"].append(f"Pilot workflow file unreadable: {exc}")
return
try:
geDb = _openGraphicalEditorDb()
except Exception as exc:
summary["errors"].append(f"GraphicalEditor DB connection failed: {exc}")
return
from modules.features.graphicalEditor._workflowFileSchema import (
envelopeToWorkflowData,
validateFileEnvelope,
)
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow
from modules.features.graphicalEditor.nodeRegistry import STATIC_NODE_TYPES
existing = geDb.getRecordset(AutoWorkflow, recordFilter={
"mandateId": mandateId,
"featureInstanceId": featureInstanceId,
"label": _PILOT_WORKFLOW_LABEL,
}) or []
if existing:
summary["skipped"].append(f"Pilot workflow already imported ({existing[0].get('id')})")
return
knownTypes = [n.get("id") for n in STATIC_NODE_TYPES if isinstance(n, dict) and n.get("id")]
try:
normalized, warnings = validateFileEnvelope(envelope, knownNodeTypes=knownTypes)
except Exception as exc:
summary["errors"].append(f"Pilot workflow envelope invalid: {exc}")
return
if warnings:
summary["created"].append(f"Pilot workflow warnings: {warnings}")
data = envelopeToWorkflowData(
normalized,
mandateId=mandateId,
featureInstanceId=featureInstanceId,
)
# Inject the trustee feature-instance id into the parameters so the
# node runtime resolves it without manual editor cleanup.
trusteeInstanceId = self._guessTrusteeInstanceId(mandateId)
if trusteeInstanceId:
for node in data.get("graph", {}).get("nodes", []) or []:
params = node.get("parameters") or {}
if "featureInstanceId" in params and not params["featureInstanceId"]:
params["featureInstanceId"] = trusteeInstanceId
node["parameters"] = params
# Force-import: AutoWorkflow.create accepts our envelope-derived data
# (graph, label, invocations, …) verbatim; we add ids/timestamps that
# AutoWorkflow expects.
record = AutoWorkflow(
id=str(uuid.uuid4()),
mandateId=mandateId,
featureInstanceId=featureInstanceId,
label=data.get("label") or _PILOT_WORKFLOW_LABEL,
description=data.get("description") or "",
tags=data.get("tags") or [],
graph=data.get("graph") or {"nodes": [], "connections": []},
invocations=data.get("invocations") or [],
templateScope=data.get("templateScope") or "instance",
sharedReadOnly=bool(data.get("sharedReadOnly")),
notifyOnFailure=bool(data.get("notifyOnFailure", True)),
active=False,
)
created = geDb.recordCreate(AutoWorkflow, record)
summary["created"].append(f"Pilot workflow imported (active=false, id={created.get('id')})")
logger.info(f"Imported pilot workflow into graphicalEditor instance {featureInstanceId}")
def _guessTrusteeInstanceId(self, mandateId: str) -> Optional[str]: def _guessTrusteeInstanceId(self, mandateId: str) -> Optional[str]:
"""Return the first trustee feature-instance id of the given mandate. """Return the first trustee feature-instance id of the given mandate.
@ -585,8 +678,8 @@ class PwgDemo2026(BaseDemoConfig):
if not instId: if not instId:
continue continue
if featureCode == "workflowAutomation": if featureCode == "graphicalEditor":
self._removeWorkflowAutomationData(instId, mandateId, mandateLabel, summary) self._removeGraphicalEditorData(instId, mandateId, mandateLabel, summary)
if featureCode == "trustee": if featureCode == "trustee":
self._removeTrusteeSeed(instId, mandateLabel, summary) self._removeTrusteeSeed(instId, mandateLabel, summary)
if featureCode == "neutralization": if featureCode == "neutralization":
@ -631,36 +724,36 @@ class PwgDemo2026(BaseDemoConfig):
except Exception as e: except Exception as e:
summary["errors"].append(f"Billing cleanup for {mandateLabel}: {e}") summary["errors"].append(f"Billing cleanup for {mandateLabel}: {e}")
def _removeWorkflowAutomationData(self, featureInstanceId: str, mandateId: str, mandateLabel: str, summary: Dict): def _removeGraphicalEditorData(self, featureInstanceId: str, mandateId: str, mandateLabel: str, summary: Dict):
try: try:
from modules.datamodels.datamodelWorkflowAutomation import ( from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import (
AutoRun, AutoRun,
AutoStepLog, AutoStepLog,
AutoTask, AutoTask,
AutoVersion, AutoVersion,
AutoWorkflow, AutoWorkflow,
) )
waDb = _openWorkflowAutomationDb() geDb = _openGraphicalEditorDb()
workflows = waDb.getRecordset(AutoWorkflow, recordFilter={ workflows = geDb.getRecordset(AutoWorkflow, recordFilter={
"mandateId": mandateId, "mandateId": mandateId,
"featureInstanceId": featureInstanceId, "featureInstanceId": featureInstanceId,
}) or [] }) or []
for wf in workflows: for wf in workflows:
wfId = wf.get("id") wfId = wf.get("id")
for version in waDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []: for version in geDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []:
waDb.recordDelete(AutoVersion, version.get("id")) geDb.recordDelete(AutoVersion, version.get("id"))
for run in waDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or []: for run in geDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or []:
runId = run.get("id") runId = run.get("id")
for step in waDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []: for step in geDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []:
waDb.recordDelete(AutoStepLog, step.get("id")) geDb.recordDelete(AutoStepLog, step.get("id"))
waDb.recordDelete(AutoRun, runId) geDb.recordDelete(AutoRun, runId)
for task in waDb.getRecordset(AutoTask, recordFilter={"workflowId": wfId}) or []: for task in geDb.getRecordset(AutoTask, recordFilter={"workflowId": wfId}) or []:
waDb.recordDelete(AutoTask, task.get("id")) geDb.recordDelete(AutoTask, task.get("id"))
waDb.recordDelete(AutoWorkflow, wfId) geDb.recordDelete(AutoWorkflow, wfId)
if workflows: if workflows:
summary["removed"].append(f"{len(workflows)} AutoWorkflows in {mandateLabel}") summary["removed"].append(f"{len(workflows)} AutoWorkflows in {mandateLabel}")
except Exception as e: except Exception as e:
summary["errors"].append(f"WorkflowAutomation cleanup for {mandateLabel}: {e}") summary["errors"].append(f"GraphicalEditor cleanup for {mandateLabel}: {e}")
def _removeTrusteeSeed(self, featureInstanceId: str, mandateLabel: str, summary: Dict): def _removeTrusteeSeed(self, featureInstanceId: str, mandateLabel: str, summary: Dict):
try: try:
@ -725,14 +818,13 @@ def _openTrusteeDb():
) )
def _openWorkflowAutomationDb(): def _openGraphicalEditorDb():
"""Open a privileged DB connection to the workflow-automation database.""" """Open a privileged DB connection to ``poweron_graphicaleditor``."""
from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.datamodels.datamodelWorkflowAutomation import WORKFLOW_AUTOMATION_DATABASE
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
return DatabaseConnector( return DatabaseConnector(
dbHost=APP_CONFIG.get("DB_HOST", "localhost"), dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
dbDatabase=WORKFLOW_AUTOMATION_DATABASE, dbDatabase="poweron_graphicaleditor",
dbUser=APP_CONFIG.get("DB_USER"), dbUser=APP_CONFIG.get("DB_USER"),
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"), dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),

View file

@ -0,0 +1,178 @@
# CommCoach Communication Coach for Leaders
## Product Goal
An AI coaching agent for executives that:
- Captures topics, concerns, and questions
- Asks active diagnostic follow-up questions
- Builds a continuable context per topic (Dossier)
- Conducts daily training conversations
- Makes progress visible (Gamification)
- Supports voice natively (STT/TTS, voice selection)
## Architecture
### Layers
```
Transport (REST/SSE) → routeFeatureCommcoach.py
Orchestration → serviceCommcoach.py
AI Pipeline → serviceCommcoachAi.py
Scheduler → serviceCommcoachScheduler.py
Domain / Storage → interfaceFeatureCommcoach.py
Data Models → datamodelCommcoach.py
Feature Registration → mainCommcoach.py
```
### Reuse from Existing Codebase
| Component | Source | Usage |
|-----------|--------|-------|
| Feature Plug&Play | `registry.py` | Auto-discovery via `routeFeature*.py` |
| RequestContext + RBAC | `authentication.py`, `interfaceRbac.py` | Auth + ownership |
| DatabaseConnector | `connectorDbPostgre.py` | New DB `poweron_commcoach` |
| VoiceObjects (STT/TTS) | `interfaceVoiceObjects.py` | Voice pipeline |
| MessagingInterface | `interfaceMessaging.py` | Email summaries |
| SSE Pattern | workspace `routeFeatureWorkspace.py` | Chat streaming |
| PDF Renderer | `rendererPdf.py` | Dossier export (Iteration 2) |
| EventManagement | `eventManagement.py` | Scheduled reminders |
## Domain Model
### Entities
```
User (1) ──── owns ──── (N) CoachingContext
CoachingContext (1) ────── (N) CoachingSession
CoachingSession (1) ───── (N) CoachingMessage
CoachingContext (1) ────── (N) CoachingTask
CoachingContext (1) ────── (N) CoachingScore
User (1) ──────────────── (1) CoachingUserProfile
```
### Status Models
```
CoachingContext: active → paused → active | archived → active | completed
CoachingSession: active → completed | cancelled
CoachingTask: open → in_progress → done | skipped
```
## API Design
```
PREFIX: /api/commcoach/{instanceId}
# Contexts (Dossier)
GET /contexts
POST /contexts
GET /contexts/{contextId}
PUT /contexts/{contextId}
DELETE /contexts/{contextId}
POST /contexts/{contextId}/archive
POST /contexts/{contextId}/activate
# Sessions
GET /contexts/{contextId}/sessions
POST /contexts/{contextId}/sessions/start
GET /sessions/{sessionId}
POST /sessions/{sessionId}/complete
POST /sessions/{sessionId}/cancel
# Streaming Chat
POST /sessions/{sessionId}/message/stream
POST /sessions/{sessionId}/audio/stream
GET /sessions/{sessionId}/stream
# Tasks
GET /contexts/{contextId}/tasks
POST /contexts/{contextId}/tasks
PUT /tasks/{taskId}
PUT /tasks/{taskId}/status
DELETE /tasks/{taskId}
# Dashboard
GET /dashboard
# User Profile
GET /profile
PUT /profile
# Voice
GET /voice/languages
GET /voice/voices
POST /voice/tts
```
### SSE Event Types
- `message` Complete message
- `messageChunk` Streaming token
- `sessionState` Status update
- `taskCreated` New task from coach
- `insightGenerated` New insight
- `scoreUpdate` Score change
- `status` UI status label
- `complete` Stream ended
- `error` Error
- `ping` Keepalive
## RBAC Model
### Ownership Rules (Critical)
- **Strict MY-only**: User sees only own contexts/sessions/messages/tasks/scores
- **SysAdmin**: Only technical monitoring, NO content access
- **No admin override** on userId filter
### Template Roles
- `commcoach-user`: DATA=MY on all entities, UI=ALL, RESOURCE=ALL
- `commcoach-admin`: DATA=MY (intentionally not ALL), UI=ALL, RESOURCE=ALL
### Audit Events
- `commcoach.context.created/archived`
- `commcoach.session.started/completed`
- `commcoach.export.requested`
## Iterations
### Iteration 1 (MVP)
- Context management (create, switch, archive)
- Chat + SSE streaming
- STT/TTS with language/voice selection
- Coaching session with active diagnostic questions
- Auto session protocol
- Tasks/Checklist per context
- Session summary via email
- RBAC + strict ownership
- Basic dashboard: continuity, competence score, goal progress
- Long-session compression: ab 25 Nachrichten wird der aeltere Verlauf per AI zusammengefasst, letzte 15 Nachrichten bleiben vollstaendig (Teamsbot-Pattern)
- Context Memory (Phasen 1-7): previousSessionSummaries im Chat, keyTopics bei completeSession, Intent-Erkennung (summarize_all, recall_session, recall_topic), Datums-Lookup, Topic-Suche, Rolling Overview, RAG-Platzhalter
### Iteration 2
- Roleplay personas (critical CFO, difficult employee, etc.)
- Document upload + context binding
- Exports (Markdown/PDF)
- Extended gamification (streaks, levels, badges)
- Better scoring/insights
## Database
- Database name: `poweron_commcoach`
- Tables auto-created from Pydantic models via `DatabaseConnector`
## Frontend
### Views
- `CommcoachDashboardView` KPIs, streaks, quick start
- `CommcoachCoachingView` Chat UI with voice + context tabs
- `CommcoachDossierView` Dossier: timeline, tasks, scores
- `CommcoachSettingsView` Voice, reminder, profile settings
### UX
- Multiple active contexts as quick-switch tabs/chips
- "Daily Coach" entry point prominent
- Voice first, always with text fallback
- Dossier view: timeline, learnings, tasks, next exercise

View file

@ -1,3 +1 @@
# Copyright (c) 2026 PowerOn AG
# All rights reserved.
# CommCoach Feature Container # CommCoach Feature Container

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
CommCoach Feature - Data Models. CommCoach Feature - Data Models.

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
Interface to CommCoach database. Interface to CommCoach database.
@ -11,7 +11,7 @@ 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.dbHelpers.dbRegistry import registerDatabase from modules.shared.dbRegistry import registerDatabase
from modules.shared.timeUtils import getIsoTimestamp, getUtcTimestamp from modules.shared.timeUtils import getIsoTimestamp, getUtcTimestamp
from modules.shared.configuration import APP_CONFIG from modules.shared.configuration import APP_CONFIG
from modules.shared.i18nRegistry import resolveText, t from modules.shared.i18nRegistry import resolveText, t

View file

@ -1,4 +1,4 @@
# Copyright (c) 2026 PowerOn AG # Copyright (c) 2025 Patrick Motsch
# All rights reserved. # All rights reserved.
""" """
CommCoach Feature Container - Main Module. CommCoach Feature Container - Main Module.
@ -537,73 +537,3 @@ def _ensureAccessRulesForRole(rootInterface, roleId: str, ruleTemplates: List[Di
logger.debug(f"Created {createdCount} AccessRules for role {roleId}") logger.debug(f"Created {createdCount} AccessRules for role {roleId}")
return createdCount return createdCount
# ---------------------------------------------------------------------------
# Feature Lifecycle Hooks
# ---------------------------------------------------------------------------
def onMandateDelete(mandateId: str, instances: list) -> None:
"""Cascade-delete all commcoach data for deleted mandate."""
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG
from modules.features.commcoach.datamodelCommcoach import (
TrainingModule, CoachingSession, CoachingMessage, CoachingTask,
CoachingScore, CoachingUserProfile, CoachingPersona,
ModulePersonaMapping, CoachingBadge,
)
try:
featureInstances = [
inst for inst in instances
if (inst.get("featureCode") if isinstance(inst, dict) else getattr(inst, "featureCode", "")) == FEATURE_CODE
]
if not featureInstances:
return
db = DatabaseConnector(
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
dbDatabase="poweron_commcoach",
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)),
userId=None,
)
totalDeleted = 0
for inst in featureInstances:
instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None)
if not instId:
continue
# Models scoped by instanceId
for ModelClass in [
TrainingModule, CoachingSession, CoachingUserProfile,
ModulePersonaMapping, CoachingBadge,
]:
records = db.getRecordset(ModelClass, recordFilter={"instanceId": instId}) or []
for rec in records:
db.recordDelete(ModelClass, rec.get("id"))
totalDeleted += len(records)
# CoachingPersona: only delete mandate-scoped (not builtin with null mandateId)
records = db.getRecordset(CoachingPersona, recordFilter={"instanceId": instId, "mandateId": mandateId}) or []
for rec in records:
db.recordDelete(CoachingPersona, rec.get("id"))
totalDeleted += len(records)
# Models scoped by mandateId only (no instanceId)
for ModelClass in [CoachingTask, CoachingScore]:
records = db.getRecordset(ModelClass, recordFilter={"mandateId": mandateId}) or []
for rec in records:
db.recordDelete(ModelClass, rec.get("id"))
totalDeleted += len(records)
# CoachingMessage: scoped via sessionId (orphans cleaned up when sessions are deleted)
if totalDeleted:
logger.info(f"Cascade: deleted {totalDeleted} commcoach record(s) for mandate {mandateId}")
db.close()
except Exception as e:
logger.warning(f"Failed to cascade-delete commcoach data for mandate {mandateId}: {e}")

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