Merge branch 'int'
This commit is contained in:
commit
12c1d768ac
823 changed files with 17813 additions and 36778 deletions
179
app.py
179
app.py
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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.shared.fkRegistry import validateFkTargets
|
from modules.dbHelpers.fkRegistry import validateFkTargets
|
||||||
fkErrors = validateFkTargets()
|
fkErrors = validateFkTargets()
|
||||||
if fkErrors:
|
if fkErrors:
|
||||||
for err in fkErrors:
|
for err in fkErrors:
|
||||||
|
|
@ -311,6 +311,31 @@ 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
|
||||||
|
|
@ -329,6 +354,14 @@ 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
|
||||||
|
|
@ -342,8 +375,23 @@ 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.shared.i18nRegistry import syncRegistryToDb, loadCache
|
from modules.system.i18nBootSync import syncRegistryToDb, loadCache
|
||||||
await syncRegistryToDb()
|
from modules.serviceCenter.registry import IMPORTABLE_SERVICES
|
||||||
|
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:
|
||||||
|
|
@ -376,14 +424,74 @@ 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.workflows.scheduler.mainScheduler import setMainLoop as setSchedulerMainLoop
|
from modules.workflowAutomation.scheduler.mainScheduler import setMainLoop as setSchedulerMainLoop, setOnRunFailedCallback
|
||||||
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
|
||||||
|
|
@ -393,14 +501,24 @@ 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.shared.auditLogger import registerAuditLogCleanupScheduler
|
from modules.dbHelpers.auditLogger import registerAuditLogCleanupScheduler
|
||||||
registerAuditLogCleanupScheduler()
|
registerAuditLogCleanupScheduler()
|
||||||
|
|
||||||
# Register enterprise subscription auto-renewal scheduler
|
# Register enterprise subscription auto-renewal scheduler
|
||||||
|
|
@ -431,6 +549,26 @@ 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) ---
|
||||||
|
|
@ -441,7 +579,7 @@ async def lifespan(app: FastAPI):
|
||||||
# to finish (up to 120 s keepalive timeout) before the rest of
|
# to finish (up to 120 s keepalive timeout) before the rest of
|
||||||
# the shutdown can proceed.
|
# the shutdown can proceed.
|
||||||
try:
|
try:
|
||||||
from modules.serviceCenter.core.serviceStreaming.eventManager import get_event_manager as _getStreamingEM
|
from modules.shared.eventManager import get_event_manager as _getStreamingEM
|
||||||
_getStreamingEM().shutdown()
|
_getStreamingEM().shutdown()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Streaming EventManager shutdown failed: {e}")
|
logger.warning(f"Streaming EventManager shutdown failed: {e}")
|
||||||
|
|
@ -459,6 +597,18 @@ async def lifespan(app: FastAPI):
|
||||||
# 3. Stop scheduler (removes all pending cron/interval jobs)
|
# 3. Stop scheduler (removes all pending cron/interval jobs)
|
||||||
eventManager.stop()
|
eventManager.stop()
|
||||||
|
|
||||||
|
# 3.5 Stop WorkflowAutomation scheduler + email poller (System-Lifespan)
|
||||||
|
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)
|
# 4. Stop Feature Containers (Plug&Play)
|
||||||
try:
|
try:
|
||||||
mainModules = loadFeatureMainModules()
|
mainModules = loadFeatureMainModules()
|
||||||
|
|
@ -474,7 +624,7 @@ async def lifespan(app: FastAPI):
|
||||||
|
|
||||||
# 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.connectors._httpResilience import closeAllResilientHttp
|
from modules.shared.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}")
|
||||||
|
|
@ -655,8 +805,6 @@ 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)
|
||||||
|
|
||||||
|
|
@ -742,11 +890,8 @@ 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.routeWorkflowDashboard import router as workflowDashboardRouter
|
from modules.routes.routeWorkflowAutomation import router as workflowAutomationRouter
|
||||||
app.include_router(workflowDashboardRouter)
|
app.include_router(workflowAutomationRouter)
|
||||||
|
|
||||||
from modules.routes.routeAutomationWorkspace import router as automationWorkspaceRouter
|
|
||||||
app.include_router(automationWorkspaceRouter)
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# PLUG&PLAY FEATURE ROUTERS
|
# PLUG&PLAY FEATURE ROUTERS
|
||||||
|
|
@ -774,4 +919,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)
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
# 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).
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
# 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
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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 as _re
|
import 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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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,10 +12,9 @@ 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__)
|
||||||
|
|
@ -186,7 +185,7 @@ class ModelRegistry:
|
||||||
def getAvailableModels(
|
def getAvailableModels(
|
||||||
self,
|
self,
|
||||||
currentUser: Optional[User] = None,
|
currentUser: Optional[User] = None,
|
||||||
rbacInstance: Optional[RbacClass] = None,
|
rbacInstance: Optional[RbacProtocol] = None,
|
||||||
mandateId: Optional[str] = None,
|
mandateId: Optional[str] = None,
|
||||||
featureInstanceId: Optional[str] = None
|
featureInstanceId: Optional[str] = None
|
||||||
) -> List[AiModel]:
|
) -> List[AiModel]:
|
||||||
|
|
@ -237,7 +236,7 @@ class ModelRegistry:
|
||||||
self,
|
self,
|
||||||
models: List[AiModel],
|
models: List[AiModel],
|
||||||
currentUser: User,
|
currentUser: User,
|
||||||
rbacInstance: RbacClass,
|
rbacInstance: RbacProtocol,
|
||||||
mandateId: Optional[str] = None,
|
mandateId: Optional[str] = None,
|
||||||
featureInstanceId: Optional[str] = None
|
featureInstanceId: Optional[str] = None
|
||||||
) -> List[AiModel]:
|
) -> List[AiModel]:
|
||||||
|
|
@ -262,7 +261,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[RbacClass] = None) -> Optional[AiModel]:
|
def getModel(self, displayName: str, currentUser: Optional[User] = None, rbacInstance: Optional[RbacProtocol] = 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:
|
||||||
|
|
@ -284,8 +283,15 @@ 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}"
|
||||||
|
|
||||||
hasConnectorAccess = checkResourceAccess(rbacInstance, currentUser, connectorResourcePath)
|
try:
|
||||||
hasModelAccess = checkResourceAccess(rbacInstance, currentUser, modelResourcePath)
|
connPerms = rbacInstance.getUserPermissions(currentUser, AccessRuleContext.RESOURCE, connectorResourcePath)
|
||||||
|
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}")
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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()
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import httpx
|
import httpx
|
||||||
|
|
@ -655,9 +656,8 @@ 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 = _b64.b64decode(base64Data[:32])
|
rawHead = base64.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
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
import logging
|
import logging
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
import logging
|
import logging
|
||||||
import json as _json
|
import 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", {})
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
import logging
|
import logging
|
||||||
import json as _json
|
import 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)}",
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
import logging
|
import logging
|
||||||
import httpx
|
import httpx
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
AI Connector for PowerOn Private-LLM Service.
|
AI Connector for PowerOn Private-LLM Service.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""Tavily web search class.
|
"""Tavily web search class.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
Authentication and authorization modules for routes and services.
|
Authentication and authorization modules for routes and services.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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.shared.auditLogger import audit_logger
|
from modules.dbHelpers.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.shared.auditLogger import audit_logger
|
from modules.dbHelpers.auditLogger import audit_logger
|
||||||
audit_logger.logSecurityEvent(
|
audit_logger.logSecurityEvent(
|
||||||
userId=str(currentUser.id),
|
userId=str(currentUser.id),
|
||||||
mandateId="system",
|
mandateId="system",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
CSRF Protection Middleware for PowerOn Gateway
|
CSRF Protection Middleware for PowerOn Gateway
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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 timedelta
|
from datetime import datetime, 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
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
Short-lived signed tickets for OAuth data-connection popups.
|
Short-lived signed tickets for OAuth data-connection popups.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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)."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
Token Manager Service
|
Token Manager Service
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
Token Refresh Middleware for PowerOn Gateway
|
Token Refresh Middleware for PowerOn Gateway
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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.shared.auditLogger import audit_logger
|
from modules.dbHelpers.auditLogger import audit_logger
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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
|
||||||
|
|
@ -8,6 +11,7 @@ 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
|
||||||
|
|
@ -16,8 +20,6 @@ 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__)
|
||||||
|
|
||||||
|
|
@ -149,7 +151,6 @@ 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:
|
||||||
|
|
@ -177,10 +178,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})")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -995,8 +996,6 @@ 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):
|
||||||
|
|
@ -1173,25 +1172,6 @@ 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.
|
||||||
|
|
@ -1412,18 +1392,17 @@ 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 = _dt.strptime(str(fromVal), '%Y-%m-%d').replace(tzinfo=_tz.utc).timestamp()
|
fromTs = datetime.strptime(str(fromVal), '%Y-%m-%d').replace(tzinfo=timezone.utc).timestamp()
|
||||||
toTs = _dt.strptime(str(toVal), '%Y-%m-%d').replace(hour=23, minute=59, second=59, tzinfo=_tz.utc).timestamp()
|
toTs = datetime.strptime(str(toVal), '%Y-%m-%d').replace(hour=23, minute=59, second=59, tzinfo=timezone.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 = _dt.strptime(str(fromVal), '%Y-%m-%d').replace(tzinfo=_tz.utc).timestamp()
|
fromTs = datetime.strptime(str(fromVal), '%Y-%m-%d').replace(tzinfo=timezone.utc).timestamp()
|
||||||
where_parts.append(f'"{key}" >= %s')
|
where_parts.append(f'"{key}" >= %s')
|
||||||
values.append(fromTs)
|
values.append(fromTs)
|
||||||
else:
|
else:
|
||||||
toTs = _dt.strptime(str(toVal), '%Y-%m-%d').replace(hour=23, minute=59, second=59, tzinfo=_tz.utc).timestamp()
|
toTs = datetime.strptime(str(toVal), '%Y-%m-%d').replace(hour=23, minute=59, second=59, tzinfo=timezone.utc).timestamp()
|
||||||
where_parts.append(f'"{key}" <= %s')
|
where_parts.append(f'"{key}" <= %s')
|
||||||
values.append(toTs)
|
values.append(toTs)
|
||||||
elif isNumericCol:
|
elif isNumericCol:
|
||||||
|
|
@ -1498,7 +1477,6 @@ 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__
|
||||||
|
|
||||||
|
|
@ -1540,9 +1518,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -1578,7 +1553,6 @@ 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)
|
||||||
|
|
@ -1812,7 +1786,6 @@ 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)
|
||||||
|
|
@ -1829,16 +1802,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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
Azure Communication Services Email Connector
|
Azure Communication Services Email Connector
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
Twilio SMS Connector
|
Twilio SMS Connector
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
# Copyright (c) 2026 PowerOn AG
|
||||||
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
ÖREB WFS Connector
|
ÖREB WFS Connector
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
Preprocessor connector for executing SQL queries via HTTP API.
|
Preprocessor connector for executing SQL queries via HTTP API.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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,10 +13,13 @@ 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
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
from modules.connectors.connectorProviderBase import (
|
from modules.connectors.connectorProviderBase import (
|
||||||
ProviderConnector,
|
ProviderConnector,
|
||||||
|
|
@ -24,11 +27,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__)
|
||||||
|
|
||||||
# type metadata for ExternalEntry.metadata["cuType"]
|
_CLICKUP_API_BASE = "https://api.clickup.com/api/v2"
|
||||||
|
|
||||||
_CU_TEAM = "team"
|
_CU_TEAM = "team"
|
||||||
_CU_SPACE = "space"
|
_CU_SPACE = "space"
|
||||||
_CU_FOLDER = "folder"
|
_CU_FOLDER = "folder"
|
||||||
|
|
@ -45,14 +48,118 @@ 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
|
||||||
# Minimal service instance for API calls (no ServiceCenter context)
|
self._svc = ClickupApiClient(access_token)
|
||||||
self._svc = ClickupService(context=None, get_service=lambda _: None)
|
|
||||||
self._svc.setAccessToken(access_token)
|
|
||||||
|
|
||||||
async def browse(
|
async def browse(
|
||||||
self,
|
self,
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""FTP/SFTP ProviderConnector stub.
|
"""FTP/SFTP ProviderConnector stub.
|
||||||
|
|
||||||
|
|
@ -1,16 +1,19 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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.connectors._httpResilience import ResilientHttp
|
from modules.shared.httpResilience import ResilientHttp
|
||||||
from modules.datamodels.datamodelDataSource import ExternalEntry
|
from modules.datamodels.datamodelDataSource import ExternalEntry
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -29,8 +32,6 @@ 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)
|
||||||
|
|
||||||
|
|
@ -58,7 +59,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)
|
||||||
|
|
||||||
|
|
@ -92,7 +93,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")
|
||||||
|
|
||||||
|
|
@ -184,7 +185,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")
|
||||||
|
|
@ -228,7 +229,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"}
|
||||||
|
|
@ -281,7 +282,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", [])
|
||||||
|
|
@ -319,7 +320,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")
|
||||||
|
|
@ -350,7 +351,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})
|
||||||
|
|
@ -371,15 +372,13 @@ 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()
|
||||||
|
|
||||||
|
|
@ -390,7 +389,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", []):
|
||||||
|
|
@ -469,7 +468,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", [])
|
||||||
|
|
@ -504,7 +503,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", [])
|
||||||
|
|
@ -534,7 +533,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()
|
||||||
|
|
@ -573,7 +572,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 [
|
||||||
|
|
@ -629,7 +628,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 ""
|
||||||
|
|
@ -659,7 +658,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", [])
|
||||||
|
|
@ -669,7 +668,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 []
|
||||||
|
|
@ -681,7 +680,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
|
||||||
|
|
@ -717,7 +716,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()
|
||||||
|
|
@ -746,7 +745,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] = []
|
||||||
|
|
@ -770,7 +769,6 @@ 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(". ")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -790,7 +788,6 @@ 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")
|
||||||
|
|
@ -806,7 +803,6 @@ 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 "")
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""Infomaniak ProviderConnector -- kDrive + Calendar + Contacts via PAT.
|
"""Infomaniak ProviderConnector -- kDrive + Calendar + Contacts via PAT.
|
||||||
|
|
||||||
|
|
@ -45,7 +45,7 @@ from modules.connectors.connectorProviderBase import (
|
||||||
ServiceAdapter,
|
ServiceAdapter,
|
||||||
DownloadResult,
|
DownloadResult,
|
||||||
)
|
)
|
||||||
from modules.connectors._httpResilience import ResilientHttp
|
from modules.shared.httpResilience import ResilientHttp
|
||||||
from modules.datamodels.datamodelDataSource import ExternalEntry
|
from modules.datamodels.datamodelDataSource import ExternalEntry
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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,14 +6,17 @@ 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.connectors._httpResilience import ResilientHttp
|
from modules.shared.httpResilience import ResilientHttp
|
||||||
from modules.datamodels.datamodelDataSource import ExternalEntry
|
from modules.datamodels.datamodelDataSource import ExternalEntry
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -79,7 +82,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:
|
||||||
|
|
@ -176,7 +179,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:
|
||||||
|
|
@ -257,7 +260,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)]
|
||||||
|
|
@ -278,8 +281,6 @@ 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)
|
||||||
|
|
@ -368,7 +369,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):
|
||||||
|
|
@ -445,7 +446,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)"),
|
||||||
|
|
@ -470,7 +471,6 @@ 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,7 +572,6 @@ 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)
|
||||||
|
|
@ -587,7 +586,6 @@ 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)
|
||||||
|
|
@ -617,7 +615,6 @@ 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)
|
||||||
|
|
@ -629,7 +626,6 @@ 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],
|
||||||
|
|
@ -644,7 +640,6 @@ 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)
|
||||||
|
|
@ -656,7 +651,6 @@ 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
|
||||||
|
|
@ -727,7 +721,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]:
|
||||||
|
|
@ -764,7 +758,6 @@ 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."}
|
||||||
|
|
@ -778,7 +771,6 @@ 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."}
|
||||||
|
|
@ -818,7 +810,6 @@ 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:
|
||||||
|
|
@ -827,7 +818,6 @@ 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:
|
||||||
|
|
@ -845,7 +835,6 @@ 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")
|
||||||
|
|
@ -952,7 +941,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:
|
||||||
|
|
@ -1003,7 +992,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)]
|
||||||
|
|
@ -1099,7 +1088,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(
|
||||||
|
|
@ -1296,7 +1285,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(
|
||||||
|
|
@ -1448,7 +1437,6 @@ 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(". ")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1478,7 +1466,6 @@ 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)
|
||||||
|
|
@ -1491,7 +1478,6 @@ 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 "")
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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.providerMsft.connectorMsft import MsftConnector
|
from modules.connectors.connectorProviderMsft 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.providerGoogle.connectorGoogle import GoogleConnector
|
from modules.connectors.connectorProviderGoogle 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.providerFtp.connectorFtp import FtpConnector
|
from modules.connectors.connectorProviderFtp 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.providerClickup.connectorClickup import ClickupConnector
|
from modules.connectors.connectorProviderClickup 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.providerInfomaniak.connectorInfomaniak import InfomaniakConnector
|
from modules.connectors.connectorProviderInfomaniak import InfomaniakConnector
|
||||||
ConnectorResolver._providerRegistry["infomaniak"] = InfomaniakConnector
|
ConnectorResolver._providerRegistry["infomaniak"] = InfomaniakConnector
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning("InfomaniakConnector not available")
|
logger.warning("InfomaniakConnector not available")
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
# Copyright (c) 2026 PowerOn AG
|
||||||
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
Swiss Topo MapServer Connector (Simplified)
|
Swiss Topo MapServer Connector (Simplified)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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.serviceCenter.services.serviceClickup.mainServiceClickup import clickup_authorization_header
|
from modules.connectors.connectorProviderClickup import clickupAuthorizationHeader
|
||||||
|
|
||||||
|
|
||||||
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": clickup_authorization_header(self.apiToken),
|
"Authorization": clickupAuthorizationHeader(self.apiToken),
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2026 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""Redmine REST connector.
|
"""Redmine REST connector.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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 as _catalogDefaultVoice
|
from modules.shared.voiceCatalog import getDefaultVoice
|
||||||
|
|
||||||
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 _catalogDefaultVoice(languageCode)
|
return getDefaultVoice(languageCode)
|
||||||
|
|
||||||
async def getAvailableVoices(self, languageCode: Optional[str] = None) -> Dict[str, Any]:
|
async def getAvailableVoices(self, languageCode: Optional[str] = None) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
# Copyright (c) 2026 PowerOn AG
|
||||||
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
Swiss Parcel (Liegenschaften) Connector
|
Swiss Parcel (Liegenschaften) Connector
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
|
||||||
# All rights reserved.
|
|
||||||
"""ClickUp provider connector."""
|
|
||||||
|
|
||||||
from .connectorClickup import ClickupConnector
|
|
||||||
|
|
||||||
__all__ = ["ClickupConnector"]
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
|
||||||
# All rights reserved.
|
|
||||||
"""FTP/SFTP Provider Connector stub."""
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
|
||||||
# All rights reserved.
|
|
||||||
"""Google Provider Connector -- 1 Connection : n Services (Drive, Gmail)."""
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
|
||||||
# All rights reserved.
|
|
||||||
"""Infomaniak Provider Connector -- 1 Connection : n Services (kDrive, Mail)."""
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
|
||||||
# All rights reserved.
|
|
||||||
"""Microsoft Provider Connector -- 1 Connection : n Services (SharePoint, Outlook, Teams, OneDrive)."""
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
Unified modules.datamodels package.
|
Unified modules.datamodels package.
|
||||||
|
|
@ -13,4 +13,5 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
Audit Log Data Model for database-based audit logging.
|
Audit Log Data Model for database-based audit logging.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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)."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""Billing models: BillingAccount, BillingTransaction, BillingSettings, UsageStatistics."""
|
"""Billing models: BillingAccount, BillingTransaction, BillingSettings, UsageStatistics."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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 the GraphicalEditor "
|
"ChatWorkflow table (e.g. an Automation2Workflow in WorkflowAutomation "
|
||||||
"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."
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""DataSource and ExternalEntry models for external data integration.
|
"""DataSource and ExternalEntry models for external data integration.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
Document reference models for typed document references in workflows.
|
Document reference models for typed document references in workflows.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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")
|
||||||
|
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
# 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},
|
|
||||||
)
|
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""Feature models: Feature, FeatureInstance."""
|
"""Feature models: Feature definitions, instances, data sources, and shared feature types."""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any, List
|
||||||
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."""
|
||||||
|
|
@ -71,3 +75,147 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""File-related datamodels: FileItem, FilePreview, FileData."""
|
"""File-related datamodels: FileItem, FilePreview, FileData."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
Invitation model for self-service onboarding.
|
Invitation model for self-service onboarding.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""Knowledge Store data models: FileContentIndex, ContentChunk, WorkflowMemory.
|
"""Knowledge Store data models: FileContentIndex, ContentChunk, WorkflowMemory.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
Membership models: UserMandate, FeatureAccess, and Junction Tables.
|
Membership models: UserMandate, FeatureAccess, and Junction Tables.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""Messaging models: MessagingSubscription, MessagingSubscriptionRegistration, MessagingDelivery."""
|
"""Messaging models: MessagingSubscription, MessagingSubscriptionRegistration, MessagingDelivery."""
|
||||||
|
|
||||||
|
|
|
||||||
357
modules/datamodels/datamodelNavigation.py
Normal file
357
modules/datamodels/datamodelNavigation.py
Normal file
|
|
@ -0,0 +1,357 @@
|
||||||
|
# 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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
Notification model for in-app notifications.
|
Notification model for in-app notifications.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
Pagination models for server-side pagination, sorting, and filtering.
|
Pagination models for server-side pagination, sorting, and filtering.
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,13 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""Port type catalog and primitive types for the Graphical Editor workflow system."""
|
||||||
Typed Port System for the Graphical Editor.
|
|
||||||
|
|
||||||
Defines PortSchema, PORT_TYPE_CATALOG, SYSTEM_VARIABLES,
|
from typing import Dict, List, Optional
|
||||||
output normalizers, and Transit helpers.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import resolveText, t
|
from modules.shared.i18nRegistry import t
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Pydantic models
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class PortField(BaseModel):
|
class PortField(BaseModel):
|
||||||
model_config = ConfigDict(populate_by_name=True)
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
@ -53,23 +38,6 @@ class PortSchema(BaseModel):
|
||||||
carriesConnectionProvenance: bool = False
|
carriesConnectionProvenance: bool = False
|
||||||
|
|
||||||
|
|
||||||
class InputPortDef(BaseModel):
|
|
||||||
accepts: List[str] # list of accepted schema names
|
|
||||||
|
|
||||||
|
|
||||||
class OutputPortDef(BaseModel):
|
|
||||||
model_config = {"populate_by_name": True}
|
|
||||||
|
|
||||||
schema_: str = Field(alias="schema")
|
|
||||||
dynamic: bool = False
|
|
||||||
deriveFrom: Optional[str] = None
|
|
||||||
|
|
||||||
def model_dump(self, **kw):
|
|
||||||
d = super().model_dump(**kw)
|
|
||||||
d["schema"] = d.pop("schema_", d.get("schema"))
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# PORT_TYPE_CATALOG
|
# PORT_TYPE_CATALOG
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -577,18 +545,13 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
|
||||||
]),
|
]),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Catalog validator
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
# Primitives accepted as PortField.type in addition to catalog schema names.
|
# Primitives accepted as PortField.type in addition to catalog schema names.
|
||||||
PRIMITIVE_TYPES: frozenset = frozenset({
|
PRIMITIVE_TYPES: frozenset = frozenset({
|
||||||
"str", "int", "bool", "float", "Any", "Dict", "List",
|
"str", "int", "bool", "float", "Any", "Dict", "List",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
def _stripContainer(typeStr: str) -> List[str]:
|
def stripContainer(typeStr: str) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Extract referenced type names from a PortField.type string.
|
Extract referenced type names from a PortField.type string.
|
||||||
|
|
||||||
|
|
@ -603,474 +566,7 @@ def _stripContainer(typeStr: str) -> List[str]:
|
||||||
if not s:
|
if not s:
|
||||||
return []
|
return []
|
||||||
if "[" in s and s.endswith("]"):
|
if "[" in s and s.endswith("]"):
|
||||||
# outer container ignored, inner parts split by comma
|
|
||||||
inner = s[s.index("[") + 1 : -1]
|
inner = s[s.index("[") + 1 : -1]
|
||||||
parts = [p.strip() for p in inner.split(",") if p.strip()]
|
parts = [p.strip() for p in inner.split(",") if p.strip()]
|
||||||
return parts or [s]
|
return parts or [s]
|
||||||
return [s]
|
return [s]
|
||||||
|
|
||||||
|
|
||||||
def _isKnownType(typeName: str) -> bool:
|
|
||||||
return typeName in PRIMITIVE_TYPES or typeName in PORT_TYPE_CATALOG
|
|
||||||
|
|
||||||
|
|
||||||
def _validateCatalog() -> List[str]:
|
|
||||||
"""
|
|
||||||
Validate PORT_TYPE_CATALOG integrity.
|
|
||||||
|
|
||||||
Returns a list of error messages. Empty list means catalog is healthy.
|
|
||||||
|
|
||||||
Checks:
|
|
||||||
1. Every PortField.type references either a primitive or a known schema.
|
|
||||||
2. Discriminator fields exist, are typed "str", and at most one per schema.
|
|
||||||
3. No cyclic references via required schema-typed fields
|
|
||||||
(optional fields may form cycles intentionally, e.g. provenance).
|
|
||||||
4. Schema name in catalog key matches PortSchema.name.
|
|
||||||
"""
|
|
||||||
errors: List[str] = []
|
|
||||||
|
|
||||||
# Check 4: key consistency
|
|
||||||
for key, schema in PORT_TYPE_CATALOG.items():
|
|
||||||
if schema.name != key:
|
|
||||||
errors.append(f"Catalog key '{key}' does not match schema.name '{schema.name}'")
|
|
||||||
|
|
||||||
# Check 1 + 2: type refs and discriminators
|
|
||||||
for schemaName, schema in PORT_TYPE_CATALOG.items():
|
|
||||||
discriminatorCount = 0
|
|
||||||
for field in schema.fields:
|
|
||||||
for refName in _stripContainer(field.type):
|
|
||||||
if not _isKnownType(refName):
|
|
||||||
errors.append(
|
|
||||||
f"{schemaName}.{field.name}: unknown type '{refName}' "
|
|
||||||
f"(not a primitive and not in catalog)"
|
|
||||||
)
|
|
||||||
if field.discriminator:
|
|
||||||
discriminatorCount += 1
|
|
||||||
if field.type != "str":
|
|
||||||
errors.append(
|
|
||||||
f"{schemaName}.{field.name}: discriminator must be 'str', got '{field.type}'"
|
|
||||||
)
|
|
||||||
if discriminatorCount > 1:
|
|
||||||
errors.append(
|
|
||||||
f"{schemaName}: has {discriminatorCount} discriminator fields, max 1 allowed"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check 3: cycles via required schema-typed fields
|
|
||||||
def _requiredSchemaRefs(name: str) -> List[str]:
|
|
||||||
sch = PORT_TYPE_CATALOG.get(name)
|
|
||||||
if not sch:
|
|
||||||
return []
|
|
||||||
out: List[str] = []
|
|
||||||
for field in sch.fields:
|
|
||||||
if not field.required:
|
|
||||||
continue
|
|
||||||
for ref in _stripContainer(field.type):
|
|
||||||
if ref in PORT_TYPE_CATALOG:
|
|
||||||
out.append(ref)
|
|
||||||
return out
|
|
||||||
|
|
||||||
def _hasCycle(start: str) -> Optional[List[str]]:
|
|
||||||
stack: List[str] = [start]
|
|
||||||
path: List[str] = []
|
|
||||||
visiting: set = set()
|
|
||||||
|
|
||||||
def _dfs(name: str) -> Optional[List[str]]:
|
|
||||||
if name in visiting:
|
|
||||||
return path + [name]
|
|
||||||
visiting.add(name)
|
|
||||||
path.append(name)
|
|
||||||
for ref in _requiredSchemaRefs(name):
|
|
||||||
if ref == start and len(path) > 0:
|
|
||||||
return path + [ref]
|
|
||||||
cycle = _dfs(ref)
|
|
||||||
if cycle:
|
|
||||||
return cycle
|
|
||||||
path.pop()
|
|
||||||
visiting.discard(name)
|
|
||||||
return None
|
|
||||||
|
|
||||||
return _dfs(start)
|
|
||||||
|
|
||||||
for schemaName in PORT_TYPE_CATALOG.keys():
|
|
||||||
cycle = _hasCycle(schemaName)
|
|
||||||
if cycle and cycle[0] == schemaName:
|
|
||||||
errors.append(
|
|
||||||
f"{schemaName}: cyclic required-ref chain: {' -> '.join(cycle)}"
|
|
||||||
)
|
|
||||||
break # one cycle is enough — avoid spamming
|
|
||||||
|
|
||||||
return errors
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# SYSTEM_VARIABLES
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
SYSTEM_VARIABLES: Dict[str, Dict[str, str]] = {
|
|
||||||
"system.timestamp": {"type": "int", "description": "Unix timestamp (ms)"},
|
|
||||||
"system.date": {"type": "str", "description": "ISO date (YYYY-MM-DD)"},
|
|
||||||
"system.datetime": {"type": "str", "description": "ISO datetime"},
|
|
||||||
"system.time": {"type": "str", "description": "HH:MM:SS"},
|
|
||||||
"system.userId": {"type": "str", "description": "Current user ID"},
|
|
||||||
"system.userName": {"type": "str", "description": "Current user name"},
|
|
||||||
"system.userEmail": {"type": "str", "description": "Current user email"},
|
|
||||||
"system.workflowId": {"type": "str", "description": "Workflow ID"},
|
|
||||||
"system.runId": {"type": "str", "description": "Run ID"},
|
|
||||||
"system.instanceId": {"type": "str", "description": "Feature instance ID"},
|
|
||||||
"system.mandateId": {"type": "str", "description": "Mandate ID"},
|
|
||||||
"system.loopIndex": {"type": "int", "description": "Current loop index (only in loop)"},
|
|
||||||
"system.loopCount": {"type": "int", "description": "Loop item count (only in loop)"},
|
|
||||||
"system.uuid": {"type": "str", "description": "Random UUID"},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Graph inheritance (executeGraph materialization + ActionNodeExecutor wiring)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
#
|
|
||||||
# When a parameter declares ``graphInherit.kind == "primaryTextRef"``, executeGraph
|
|
||||||
# inserts an explicit DataRef before run (see pickNotPushMigration.materializePrimaryTextHandover).
|
|
||||||
# ``recommendedDataPickRef`` uses upstream ``outputPorts.dataPickOptions`` where ``recommended: true``
|
|
||||||
# (see pickNotPushMigration.materializeRecommendedDataPickRef).
|
|
||||||
# Schema names are catalog output port types (e.g. AiResult).
|
|
||||||
|
|
||||||
PRIMARY_TEXT_HANDOVER_REF_PATH: Dict[str, List[Any]] = {
|
|
||||||
"AiResult": ["response"],
|
|
||||||
"ActionResult": ["response"],
|
|
||||||
"TextResult": ["text"],
|
|
||||||
"ConsolidateResult": ["result"],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def resolveSystemVariable(variable: str, context: Dict[str, Any]) -> Any:
|
|
||||||
"""Resolve a system variable name to its runtime value."""
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
mapping = {
|
|
||||||
"system.timestamp": lambda: int(now.timestamp() * 1000),
|
|
||||||
"system.date": lambda: now.strftime("%Y-%m-%d"),
|
|
||||||
"system.datetime": lambda: now.isoformat(),
|
|
||||||
"system.time": lambda: now.strftime("%H:%M:%S"),
|
|
||||||
"system.userId": lambda: context.get("userId", ""),
|
|
||||||
"system.userName": lambda: context.get("userName", ""),
|
|
||||||
"system.userEmail": lambda: context.get("userEmail", ""),
|
|
||||||
"system.workflowId": lambda: context.get("workflowId", ""),
|
|
||||||
"system.runId": lambda: context.get("_runId", ""),
|
|
||||||
"system.instanceId": lambda: context.get("instanceId", ""),
|
|
||||||
"system.mandateId": lambda: context.get("mandateId", ""),
|
|
||||||
"system.loopIndex": lambda: (context.get("_loopState") or {}).get("currentIndex", -1),
|
|
||||||
"system.loopCount": lambda: len((context.get("_loopState") or {}).get("items", [])),
|
|
||||||
"system.uuid": lambda: str(uuid.uuid4()),
|
|
||||||
}
|
|
||||||
resolver = mapping.get(variable)
|
|
||||||
if resolver:
|
|
||||||
return resolver()
|
|
||||||
logger.warning("Unknown system variable: %s", variable)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Output normalizers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def _file_record_to_document(f: Any) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Map API / task-upload file dicts onto PortSchema ``Document`` fields."""
|
|
||||||
if f is None:
|
|
||||||
return None
|
|
||||||
if isinstance(f, str) and f.strip():
|
|
||||||
return {"id": f.strip()}
|
|
||||||
if not isinstance(f, dict):
|
|
||||||
return None
|
|
||||||
inner = f.get("file") if isinstance(f.get("file"), dict) else None
|
|
||||||
src = inner or f
|
|
||||||
out: Dict[str, Any] = {}
|
|
||||||
fid = src.get("id") or f.get("id")
|
|
||||||
if fid is not None and str(fid).strip():
|
|
||||||
out["id"] = str(fid).strip()
|
|
||||||
name = (
|
|
||||||
src.get("name")
|
|
||||||
or src.get("fileName")
|
|
||||||
or f.get("fileName")
|
|
||||||
or f.get("name")
|
|
||||||
)
|
|
||||||
if name is not None and str(name).strip():
|
|
||||||
out["name"] = str(name).strip()
|
|
||||||
mime = src.get("mimeType") or src.get("mime") or f.get("mimeType")
|
|
||||||
if mime is not None and str(mime).strip():
|
|
||||||
out["mimeType"] = str(mime).strip()
|
|
||||||
for k in ("sizeBytes", "downloadUrl", "filePath"):
|
|
||||||
v = src.get(k) if k in src else f.get(k)
|
|
||||||
if v is not None and v != "":
|
|
||||||
out[k] = v
|
|
||||||
return out if out else None
|
|
||||||
|
|
||||||
|
|
||||||
def _coerce_document_list_upload_fields(result: Dict[str, Any]) -> None:
|
|
||||||
"""
|
|
||||||
Human task ``input.upload`` completes with ``file`` / ``files`` / ``fileIds``.
|
|
||||||
DocumentList expects ``documents``. Without this, resume adds ``documents: []`` and drops the real files.
|
|
||||||
"""
|
|
||||||
docs = result.get("documents")
|
|
||||||
if isinstance(docs, list) and len(docs) > 0:
|
|
||||||
return
|
|
||||||
collected: List[Dict[str, Any]] = []
|
|
||||||
files = result.get("files")
|
|
||||||
if isinstance(files, list):
|
|
||||||
for item in files:
|
|
||||||
d = _file_record_to_document(item)
|
|
||||||
if d:
|
|
||||||
collected.append(d)
|
|
||||||
if not collected:
|
|
||||||
single = result.get("file")
|
|
||||||
d = _file_record_to_document(single)
|
|
||||||
if d:
|
|
||||||
collected.append(d)
|
|
||||||
if not collected and isinstance(result.get("fileIds"), list):
|
|
||||||
for fid in result["fileIds"]:
|
|
||||||
if fid is not None and str(fid).strip():
|
|
||||||
collected.append({"id": str(fid).strip()})
|
|
||||||
if not collected:
|
|
||||||
return
|
|
||||||
result["documents"] = collected
|
|
||||||
if not result.get("count"):
|
|
||||||
result["count"] = len(collected)
|
|
||||||
|
|
||||||
|
|
||||||
def normalizeToSchema(raw: Any, schemaName: str) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Normalize raw executor output to match the declared port schema.
|
|
||||||
Ensures _success/_error meta-fields are always present.
|
|
||||||
"""
|
|
||||||
if not isinstance(raw, dict):
|
|
||||||
raw = {"value": raw} if raw is not None else {}
|
|
||||||
|
|
||||||
result = dict(raw)
|
|
||||||
result.setdefault("_success", not bool(raw.get("error")))
|
|
||||||
result.setdefault("_error", raw.get("error"))
|
|
||||||
|
|
||||||
schema = PORT_TYPE_CATALOG.get(schemaName)
|
|
||||||
if not schema or schemaName == "Transit":
|
|
||||||
return result
|
|
||||||
|
|
||||||
if schemaName == "DocumentList":
|
|
||||||
_coerce_document_list_upload_fields(result)
|
|
||||||
|
|
||||||
# Only default **required** fields. Optional fields stay absent so DataRefs / context
|
|
||||||
# resolution never pick a synthetic `{}` or `[]` (e.g. AiResult.responseData when the
|
|
||||||
# model returned plain text only).
|
|
||||||
for field in schema.fields:
|
|
||||||
if field.name not in result and field.required:
|
|
||||||
result[field.name] = _defaultForType(field.type)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def _defaultForType(typeStr: str) -> Any:
|
|
||||||
"""Return a sensible default for a type string."""
|
|
||||||
if typeStr.startswith("List"):
|
|
||||||
return []
|
|
||||||
if typeStr.startswith("Dict"):
|
|
||||||
return {}
|
|
||||||
if typeStr == "bool":
|
|
||||||
return False
|
|
||||||
if typeStr == "int":
|
|
||||||
return 0
|
|
||||||
if typeStr == "str":
|
|
||||||
return ""
|
|
||||||
if typeStr in PORT_TYPE_CATALOG:
|
|
||||||
return {}
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _normalizeError(error: Exception, schemaName: str) -> Dict[str, Any]:
|
|
||||||
"""Build an error envelope matching the schema with _success=False."""
|
|
||||||
result = {"_success": False, "_error": str(error)}
|
|
||||||
schema = PORT_TYPE_CATALOG.get(schemaName)
|
|
||||||
if schema:
|
|
||||||
for field in schema.fields:
|
|
||||||
result.setdefault(field.name, _defaultForType(field.type))
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Transit helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def wrapTransit(data: Any, meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
"""Wrap data in a Transit envelope."""
|
|
||||||
return {"_transit": True, "_meta": meta, "data": data}
|
|
||||||
|
|
||||||
|
|
||||||
def unwrapTransit(output: Any) -> Any:
|
|
||||||
"""Unwrap a Transit envelope, returning the inner data."""
|
|
||||||
if isinstance(output, dict) and output.get("_transit"):
|
|
||||||
return output.get("data")
|
|
||||||
return output
|
|
||||||
|
|
||||||
|
|
||||||
def _resolveTransitChain(
|
|
||||||
nodeId: str,
|
|
||||||
nodeOutputs: Dict[str, Any],
|
|
||||||
connectionMap: Dict[str, list],
|
|
||||||
) -> Any:
|
|
||||||
"""
|
|
||||||
Follow _transit chain backwards until a real (non-transit) producer is found.
|
|
||||||
Returns the unwrapped output of the real producer.
|
|
||||||
"""
|
|
||||||
visited = set()
|
|
||||||
current = nodeId
|
|
||||||
while current and current not in visited:
|
|
||||||
visited.add(current)
|
|
||||||
out = nodeOutputs.get(current)
|
|
||||||
if not isinstance(out, dict) or not out.get("_transit"):
|
|
||||||
return out
|
|
||||||
sources = connectionMap.get(current, [])
|
|
||||||
if not sources:
|
|
||||||
return unwrapTransit(out)
|
|
||||||
srcId = sources[0][0] if sources else None
|
|
||||||
if not srcId:
|
|
||||||
return unwrapTransit(out)
|
|
||||||
current = srcId
|
|
||||||
return nodeOutputs.get(nodeId)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Schema derivation for dynamic outputs
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def deriveFormPayloadSchemaFromParam(
|
|
||||||
node: Dict[str, Any],
|
|
||||||
param_key: str,
|
|
||||||
name_field: str = "name",
|
|
||||||
type_field: str = "type",
|
|
||||||
label_field: str = "label",
|
|
||||||
schema_name: str = "FormPayload_dynamic",
|
|
||||||
) -> Optional[PortSchema]:
|
|
||||||
"""Derive an output schema from a graph-defined parameter.
|
|
||||||
|
|
||||||
Supports three parameter shapes:
|
|
||||||
- List[Dict] with ``name_field`` (e.g. ``fields[].name``, ``entries[].key``,
|
|
||||||
``mappings[].outputField``).
|
|
||||||
- Group-fields: ``type == "group"`` recursed via ``fields``.
|
|
||||||
- List[str]: each string is taken as a leaf path key (used for ``filterContext.keys``).
|
|
||||||
"""
|
|
||||||
from modules.features.graphicalEditor.nodeDefinitions.input import FORM_FIELD_TYPES
|
|
||||||
_FORM_TYPE_TO_PORT: Dict[str, str] = {f["id"]: f["portType"] for f in FORM_FIELD_TYPES}
|
|
||||||
|
|
||||||
fields_param = (node.get("parameters") or {}).get(param_key)
|
|
||||||
if not fields_param or not isinstance(fields_param, list):
|
|
||||||
return None
|
|
||||||
portFields: List[PortField] = []
|
|
||||||
|
|
||||||
def _append_field(fname: str, ftype: Any, lab: Any, required: bool) -> None:
|
|
||||||
_desc = resolveText(lab) if lab is not None else fname
|
|
||||||
if not str(_desc).strip():
|
|
||||||
_desc = fname
|
|
||||||
raw_type = str(ftype) if ftype is not None else "str"
|
|
||||||
port_type = _FORM_TYPE_TO_PORT.get(raw_type, raw_type)
|
|
||||||
portFields.append(PortField(
|
|
||||||
name=fname,
|
|
||||||
type=port_type,
|
|
||||||
description=_desc,
|
|
||||||
required=required,
|
|
||||||
))
|
|
||||||
|
|
||||||
for f in fields_param:
|
|
||||||
if isinstance(f, str):
|
|
||||||
if f.strip():
|
|
||||||
_append_field(f.strip(), "str", None, False)
|
|
||||||
continue
|
|
||||||
if not isinstance(f, dict):
|
|
||||||
continue
|
|
||||||
fname_raw = f.get(name_field)
|
|
||||||
if not fname_raw and name_field == "contextKey":
|
|
||||||
fname_raw = f.get("key")
|
|
||||||
if not fname_raw:
|
|
||||||
continue
|
|
||||||
fname = str(fname_raw)
|
|
||||||
if str(f.get(type_field, "")).lower() == "group" and isinstance(f.get("fields"), list):
|
|
||||||
for sub in f["fields"]:
|
|
||||||
if isinstance(sub, dict) and sub.get(name_field):
|
|
||||||
_append_field(
|
|
||||||
f"{fname}.{sub[name_field]}",
|
|
||||||
sub.get(type_field, "str"),
|
|
||||||
sub.get(label_field),
|
|
||||||
bool(sub.get("required", False)),
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
_append_field(
|
|
||||||
fname,
|
|
||||||
f.get(type_field, "str"),
|
|
||||||
f.get(label_field),
|
|
||||||
bool(f.get("required", False)),
|
|
||||||
)
|
|
||||||
return PortSchema(name=schema_name, fields=portFields) if portFields else None
|
|
||||||
|
|
||||||
|
|
||||||
def _deriveFormPayloadSchema(node: Dict[str, Any]) -> Optional[PortSchema]:
|
|
||||||
"""Derive output schema from form field definitions (``parameters.fields``)."""
|
|
||||||
return deriveFormPayloadSchemaFromParam(node, "fields")
|
|
||||||
|
|
||||||
|
|
||||||
def parse_graph_defined_output_schema(
|
|
||||||
node: Dict[str, Any],
|
|
||||||
output_port: Dict[str, Any],
|
|
||||||
) -> Optional[PortSchema]:
|
|
||||||
"""
|
|
||||||
Resolve a node's output port to a concrete PortSchema.
|
|
||||||
|
|
||||||
Supports:
|
|
||||||
- Static catalog name: ``schema: "ActionResult"``
|
|
||||||
- Graph-defined: ``schema: {"kind": "fromGraph", "parameter": "fields"}``
|
|
||||||
- Legacy: ``dynamic`` + ``deriveFrom`` on the port dict.
|
|
||||||
"""
|
|
||||||
if not isinstance(output_port, dict):
|
|
||||||
return None
|
|
||||||
schema_spec = output_port.get("schema")
|
|
||||||
if isinstance(schema_spec, dict) and schema_spec.get("kind") == "fromGraph":
|
|
||||||
param_key = str(schema_spec.get("parameter") or "fields")
|
|
||||||
name_field = str(schema_spec.get("nameField") or "name")
|
|
||||||
type_field = str(schema_spec.get("typeField") or "type")
|
|
||||||
label_field = str(schema_spec.get("labelField") or "label")
|
|
||||||
schema_name = str(schema_spec.get("schemaName") or "FormPayload_dynamic")
|
|
||||||
return deriveFormPayloadSchemaFromParam(
|
|
||||||
node, param_key,
|
|
||||||
name_field=name_field, type_field=type_field,
|
|
||||||
label_field=label_field, schema_name=schema_name,
|
|
||||||
)
|
|
||||||
if output_port.get("dynamic") and output_port.get("deriveFrom"):
|
|
||||||
name_field = str(output_port.get("deriveNameField") or "name")
|
|
||||||
return deriveFormPayloadSchemaFromParam(
|
|
||||||
node, str(output_port.get("deriveFrom")), name_field=name_field,
|
|
||||||
)
|
|
||||||
if isinstance(schema_spec, str) and schema_spec:
|
|
||||||
return PORT_TYPE_CATALOG.get(schema_spec)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_output_schema_name(node: Dict[str, Any], output_port: Dict[str, Any]) -> str:
|
|
||||||
"""Return a schema name for port compatibility / path listing."""
|
|
||||||
derived = parse_graph_defined_output_schema(node, output_port)
|
|
||||||
if derived:
|
|
||||||
return derived.name
|
|
||||||
spec = output_port.get("schema") if isinstance(output_port, dict) else None
|
|
||||||
if isinstance(spec, str) and spec:
|
|
||||||
return spec
|
|
||||||
return "Any"
|
|
||||||
|
|
||||||
|
|
||||||
def _deriveTransformSchema(node: Dict[str, Any]) -> Optional[PortSchema]:
|
|
||||||
"""Derive output schema from transform mappings."""
|
|
||||||
mappings = (node.get("parameters") or {}).get("mappings")
|
|
||||||
if not mappings or not isinstance(mappings, list):
|
|
||||||
return None
|
|
||||||
portFields = []
|
|
||||||
for m in mappings:
|
|
||||||
if isinstance(m, dict) and m.get("outputField"):
|
|
||||||
portFields.append(PortField(
|
|
||||||
name=m["outputField"],
|
|
||||||
type=m.get("type", "str"),
|
|
||||||
description=str(m.get("label", m["outputField"])),
|
|
||||||
))
|
|
||||||
return PortSchema(name="Transform_dynamic", fields=portFields) if portFields else None
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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
|
from typing import Optional, Dict, List, Protocol, runtime_checkable
|
||||||
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,6 +174,20 @@ 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"],
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
Security models: Token and AuthEvent.
|
Security models: Token and AuthEvent.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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).
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""Ticket datamodels used across Jira/ClickUp connectors."""
|
"""Ticket datamodels used across Jira/ClickUp connectors."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
UAM models: User, Mandate, UserConnection.
|
UAM models: User, Mandate, UserConnection.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""UI language sets: structured i18n entries (context, key, value)."""
|
"""UI language sets: structured i18n entries (context, key, value)."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""Utility datamodels: Prompt, TextMultilingual."""
|
"""Utility datamodels: Prompt, TextMultilingual."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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.features.neutralization.datamodelFeatureNeutralizer import DataNeutralizerAttributes
|
from modules.datamodels.datamodelFeatures 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.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow
|
from modules.datamodels.datamodelFeatures import AutoWorkflow
|
||||||
|
|
||||||
|
|
||||||
@i18nModel("Workflow (Ansicht)")
|
@i18nModel("Workflow (Ansicht)")
|
||||||
class Automation2WorkflowView(AutoWorkflow):
|
class AutoWorkflowView(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
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# 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.
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""Workflow Action models: WorkflowActionParameter, WorkflowActionDefinition."""
|
"""Workflow Action models: WorkflowActionParameter, WorkflowActionDefinition."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""GraphicalEditor models with Auto-prefix: AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask."""
|
"""Workflow Automation models: AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask.
|
||||||
|
|
||||||
|
Canonical location for all workflow-engine data models used across the platform.
|
||||||
|
"""
|
||||||
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import 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
|
||||||
import uuid
|
import uuid
|
||||||
|
|
@ -50,6 +53,9 @@ class AutoTemplateScope(str, Enum):
|
||||||
SYSTEM = "system"
|
SYSTEM = "system"
|
||||||
|
|
||||||
|
|
||||||
|
WORKFLOW_AUTOMATION_DATABASE = "poweron_graphicaleditor"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# AutoWorkflow
|
# AutoWorkflow
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
@ -71,14 +77,26 @@ class AutoWorkflow(PowerOnModel):
|
||||||
"fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"},
|
"fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
featureInstanceId: str = Field(
|
featureInstanceId: Optional[str] = Field(
|
||||||
description="Feature instance ID (GE owner instance / RBAC scope)",
|
default=None,
|
||||||
|
description="Feature instance ID (legacy GE owner — being phased out; NULL for mandate-level workflows)",
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"frontend_type": "text",
|
"frontend_type": "text",
|
||||||
"frontend_readonly": True,
|
"frontend_readonly": True,
|
||||||
"frontend_required": False,
|
"frontend_required": False,
|
||||||
"label": "Feature-Instanz-ID",
|
"label": "Feature-Instanz-ID",
|
||||||
"fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"},
|
"fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label", "softFk": True},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
runAsPrincipal: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Identity (userId or service-account) under which this workflow executes. Governs RBAC for data access at runtime.",
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_readonly": False,
|
||||||
|
"frontend_required": False,
|
||||||
|
"label": "Ausführungsidentität",
|
||||||
|
"fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username", "softFk": True},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
targetFeatureInstanceId: Optional[str] = Field(
|
targetFeatureInstanceId: Optional[str] = Field(
|
||||||
|
|
@ -124,11 +142,6 @@ class AutoWorkflow(PowerOnModel):
|
||||||
"frontend_readonly": True,
|
"frontend_readonly": True,
|
||||||
"frontend_required": False,
|
"frontend_required": False,
|
||||||
"label": "Vorlagen-Quelle",
|
"label": "Vorlagen-Quelle",
|
||||||
# Soft FK: holds either a real AutoWorkflow.id (UUID, when copied
|
|
||||||
# from a stored template) OR an in-code sentinel like
|
|
||||||
# "trustee-receipt-import" (when bootstrapped from
|
|
||||||
# featureModule.getTemplateWorkflows()). Sentinel values do not
|
|
||||||
# exist as DB rows by design — orphan cleanup MUST skip this column.
|
|
||||||
"fk_target": {
|
"fk_target": {
|
||||||
"db": "poweron_graphicaleditor",
|
"db": "poweron_graphicaleditor",
|
||||||
"table": "AutoWorkflow",
|
"table": "AutoWorkflow",
|
||||||
|
|
@ -198,7 +211,6 @@ class AutoWorkflow(PowerOnModel):
|
||||||
"frontend_format_labels": ["Ja", "-", "Nein"],
|
"frontend_format_labels": ["Ja", "-", "Nein"],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
# Legacy fields kept for backward compatibility during transition
|
|
||||||
graph: Dict[str, Any] = Field(
|
graph: Dict[str, Any] = Field(
|
||||||
default_factory=dict,
|
default_factory=dict,
|
||||||
description="Graph with nodes and connections (legacy; prefer AutoVersion.graph)",
|
description="Graph with nodes and connections (legacy; prefer AutoVersion.graph)",
|
||||||
|
|
@ -571,7 +583,7 @@ class AutoTask(PowerOnModel):
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Backward-compatible aliases for transition period
|
# Backward-compatible aliases
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
Automation2Workflow = AutoWorkflow
|
Automation2Workflow = AutoWorkflow
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
# Copyright (c) 2026 PowerOn AG
|
||||||
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
JSON Continuation Context Module
|
JSON Continuation Context Module
|
||||||
|
|
||||||
|
|
@ -21,7 +23,7 @@ Modulkonstanten:
|
||||||
Maximale Zeichen für den Overlap Context
|
Maximale Zeichen für den Overlap Context
|
||||||
|
|
||||||
Verwendung:
|
Verwendung:
|
||||||
>>> from modules.shared.jsonContinuation import getContexts
|
>>> from modules.datamodels.jsonContinuation import getContexts
|
||||||
>>> jsonStr = '{"users": [{"name": "John", "bio": "Hello Wor'
|
>>> jsonStr = '{"users": [{"name": "John", "bio": "Hello Wor'
|
||||||
>>> contexts = getContexts(jsonStr)
|
>>> contexts = getContexts(jsonStr)
|
||||||
>>> print(contexts.overlapContext)
|
>>> print(contexts.overlapContext)
|
||||||
171
modules/datamodels/serviceExceptions.py
Normal file
171
modules/datamodels/serviceExceptions.py
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
# 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})")
|
||||||
2
modules/dbHelpers/__init__.py
Normal file
2
modules/dbHelpers/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Copyright (c) 2026 PowerOn AG
|
||||||
|
# All rights reserved.
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""AI Audit Logger — records every AI provider call for compliance reporting.
|
"""AI Audit Logger — records every AI provider call for compliance reporting.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
from modules.shared.aiAuditLogger import aiAuditLogger
|
from modules.dbHelpers.aiAuditLogger import aiAuditLogger
|
||||||
aiAuditLogger.logAiCall(userId=..., mandateId=..., ...)
|
aiAuditLogger.logAiCall(userId=..., mandateId=..., ...)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
Audit Logging System for PowerOn Gateway
|
Audit Logging System for PowerOn Gateway
|
||||||
|
|
@ -14,6 +14,7 @@ GDPR Requirements Addressed:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
|
|
@ -395,7 +396,6 @@ class AuditLogger:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from modules.datamodels.datamodelAudit import AuditLogEntry
|
from modules.datamodels.datamodelAudit import AuditLogEntry
|
||||||
import time
|
|
||||||
|
|
||||||
# Calculate cutoff timestamp
|
# Calculate cutoff timestamp
|
||||||
cutoffTimestamp = time.time() - (retentionDays * 24 * 60 * 60)
|
cutoffTimestamp = time.time() - (retentionDays * 24 * 60 * 60)
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
Database optimizations for Multi-Tenant model.
|
Database optimizations for Multi-Tenant model.
|
||||||
|
|
@ -7,7 +7,7 @@ Applies indexes, immutable triggers, and foreign key constraints
|
||||||
for the junction tables used in the multi-tenant mandate model.
|
for the junction tables used in the multi-tenant mandate model.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
from modules.shared.dbMultiTenantOptimizations import applyMultiTenantOptimizations
|
from modules.dbHelpers.dbMultiTenantOptimizations import applyMultiTenantOptimizations
|
||||||
|
|
||||||
# Call after database tables are created
|
# Call after database tables are created
|
||||||
applyMultiTenantOptimizations(dbConnector)
|
applyMultiTenantOptimizations(dbConnector)
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
Dynamic database registry — each interface self-registers its DB on import.
|
Dynamic database registry — each interface self-registers its DB on import.
|
||||||
|
|
||||||
Usage in any interfaceDb*.py / interfaceFeature*.py:
|
Usage in any interfaceDb*.py / interfaceFeature*.py:
|
||||||
from modules.shared.dbRegistry import registerDatabase
|
from modules.dbHelpers.dbRegistry import registerDatabase
|
||||||
registerDatabase("poweron_xyz")
|
registerDatabase("poweron_xyz")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
214
modules/dbHelpers/fkLabelResolver.py
Normal file
214
modules/dbHelpers/fkLabelResolver.py
Normal file
|
|
@ -0,0 +1,214 @@
|
||||||
|
# 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
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2026 PowerOn AG
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
FK-Discovery — scans the Model-Registry for `fk_target` annotations and
|
FK-Discovery — scans the Model-Registry for `fk_target` annotations and
|
||||||
|
|
@ -14,7 +14,7 @@ for the *target* side. By collecting all such declarations we know which DB
|
||||||
each table lives in — no extra registration step needed.
|
each table lives in — no extra registration step needed.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
from modules.shared.fkRegistry import getFkRelationships
|
from modules.dbHelpers.fkRegistry import getFkRelationships
|
||||||
rels = getFkRelationships()
|
rels = getFkRelationships()
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -32,7 +32,7 @@ logger = logging.getLogger(__name__)
|
||||||
_modelsLoaded = False
|
_modelsLoaded = False
|
||||||
|
|
||||||
|
|
||||||
def _ensureModelsLoaded() -> None:
|
def ensureModelsLoaded() -> None:
|
||||||
"""Import all datamodel modules so that __init_subclass__ fills MODEL_REGISTRY.
|
"""Import all datamodel modules so that __init_subclass__ fills MODEL_REGISTRY.
|
||||||
|
|
||||||
In a running server the interfaces import the datamodels automatically.
|
In a running server the interfaces import the datamodels automatically.
|
||||||
|
|
@ -98,7 +98,7 @@ def _buildTableToDbMap() -> Dict[str, str]:
|
||||||
2. For models still unmapped, query each registered database's
|
2. For models still unmapped, query each registered database's
|
||||||
catalog (information_schema) to find the table there.
|
catalog (information_schema) to find the table there.
|
||||||
"""
|
"""
|
||||||
_ensureModelsLoaded()
|
ensureModelsLoaded()
|
||||||
|
|
||||||
mapping: Dict[str, str] = {}
|
mapping: Dict[str, str] = {}
|
||||||
for modelCls in MODEL_REGISTRY.values():
|
for modelCls in MODEL_REGISTRY.values():
|
||||||
|
|
@ -117,7 +117,7 @@ def _buildTableToDbMap() -> Dict[str, str]:
|
||||||
unmapped = [name for name in MODEL_REGISTRY if name not in mapping]
|
unmapped = [name for name in MODEL_REGISTRY if name not in mapping]
|
||||||
if unmapped:
|
if unmapped:
|
||||||
try:
|
try:
|
||||||
from modules.shared.dbRegistry import getRegisteredDatabases
|
from modules.dbHelpers.dbRegistry import getRegisteredDatabases
|
||||||
_resolveUnmappedTablesFromCatalog(mapping, unmapped, getRegisteredDatabases())
|
_resolveUnmappedTablesFromCatalog(mapping, unmapped, getRegisteredDatabases())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not resolve unmapped tables from catalog: {e}")
|
logger.warning(f"Could not resolve unmapped tables from catalog: {e}")
|
||||||
|
|
@ -260,7 +260,7 @@ def validateFkTargets() -> List[str]:
|
||||||
Each ``fk_target`` must contain exactly ``db``, ``table``, and ``labelField``
|
Each ``fk_target`` must contain exactly ``db``, ``table``, and ``labelField``
|
||||||
(``labelField`` may be ``None``).
|
(``labelField`` may be ``None``).
|
||||||
"""
|
"""
|
||||||
_ensureModelsLoaded()
|
ensureModelsLoaded()
|
||||||
errors: List[str] = []
|
errors: List[str] = []
|
||||||
for tableName, modelCls in MODEL_REGISTRY.items():
|
for tableName, modelCls in MODEL_REGISTRY.items():
|
||||||
for fieldName, fieldInfo in modelCls.model_fields.items():
|
for fieldName, fieldInfo in modelCls.model_fields.items():
|
||||||
543
modules/dbHelpers/paginationHelpers.py
Normal file
543
modules/dbHelpers/paginationHelpers.py
Normal file
|
|
@ -0,0 +1,543 @@
|
||||||
|
# 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
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
|
# 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().
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -11,14 +13,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
|
||||||
|
|
@ -32,7 +34,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
|
||||||
|
|
@ -43,7 +45,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)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
|
# 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.).
|
||||||
|
|
||||||
|
|
@ -18,7 +20,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 = ""
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
# Copyright (c) 2026 PowerOn AG
|
||||||
|
# All rights reserved.
|
||||||
"""
|
"""
|
||||||
Investor Demo April 2026
|
Investor Demo April 2026
|
||||||
|
|
||||||
|
|
@ -17,7 +19,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__)
|
||||||
|
|
||||||
|
|
@ -44,7 +46,6 @@ _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 = [
|
||||||
|
|
@ -52,17 +53,16 @@ _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, graph editor, and neutralization."
|
"trustee with RMA, workspace, workflow automation, and neutralization."
|
||||||
)
|
)
|
||||||
credentials = [
|
credentials = [
|
||||||
{
|
{
|
||||||
|
|
@ -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.interfaceBootstrap import copySystemRolesToMandate
|
from modules.interfaces.interfaceRbac import copySystemRolesToMandate
|
||||||
|
|
||||||
existing = db.getRecordset(Mandate, recordFilter={"name": mandateDef["name"]})
|
existing = db.getRecordset(Mandate, recordFilter={"name": mandateDef["name"]})
|
||||||
if existing:
|
if existing:
|
||||||
|
|
@ -492,8 +492,8 @@ class InvestorDemo2026(_BaseDemoConfig):
|
||||||
if not instId:
|
if not instId:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if featureCode == "graphicalEditor":
|
if featureCode == "workflowAutomation":
|
||||||
self._removeGraphicalEditorData(instId, mandateId, mandateLabel, summary)
|
self._removeWorkflowAutomationData(instId, mandateId, mandateLabel, summary)
|
||||||
|
|
||||||
if featureCode == "trustee":
|
if featureCode == "trustee":
|
||||||
self._removeTrusteeData(db, instId, mandateLabel, summary)
|
self._removeTrusteeData(db, instId, mandateLabel, summary)
|
||||||
|
|
@ -551,25 +551,26 @@ 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 _removeGraphicalEditorData(self, featureInstanceId: str, mandateId: str, mandateLabel: str, summary: Dict):
|
def _removeWorkflowAutomationData(self, featureInstanceId: str, mandateId: str, mandateLabel: str, summary: Dict):
|
||||||
"""Remove all AutoWorkflow data (workflows, runs, versions, logs, tasks) from the Greenfield DB."""
|
"""Remove all AutoWorkflow data (workflows, runs, versions, logs, tasks) from the WorkflowAutomation DB."""
|
||||||
try:
|
try:
|
||||||
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import (
|
from modules.datamodels.datamodelWorkflowAutomation 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
|
||||||
|
|
||||||
geDb = DatabaseConnector(
|
waDb = DatabaseConnector(
|
||||||
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
|
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
|
||||||
dbDatabase="poweron_graphicaleditor",
|
dbDatabase=WORKFLOW_AUTOMATION_DATABASE,
|
||||||
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 = geDb.getRecordset(AutoWorkflow, recordFilter={
|
workflows = waDb.getRecordset(AutoWorkflow, recordFilter={
|
||||||
"mandateId": mandateId,
|
"mandateId": mandateId,
|
||||||
"featureInstanceId": featureInstanceId,
|
"featureInstanceId": featureInstanceId,
|
||||||
}) or []
|
}) or []
|
||||||
|
|
@ -579,27 +580,27 @@ class InvestorDemo2026(_BaseDemoConfig):
|
||||||
if not wfId:
|
if not wfId:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
for version in geDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []:
|
for version in waDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []:
|
||||||
geDb.recordDelete(AutoVersion, version.get("id"))
|
waDb.recordDelete(AutoVersion, version.get("id"))
|
||||||
|
|
||||||
runs = geDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or []
|
runs = waDb.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 geDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []:
|
for stepLog in waDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []:
|
||||||
geDb.recordDelete(AutoStepLog, stepLog.get("id"))
|
waDb.recordDelete(AutoStepLog, stepLog.get("id"))
|
||||||
geDb.recordDelete(AutoRun, runId)
|
waDb.recordDelete(AutoRun, runId)
|
||||||
|
|
||||||
for task in geDb.getRecordset(AutoTask, recordFilter={"workflowId": wfId}) or []:
|
for task in waDb.getRecordset(AutoTask, recordFilter={"workflowId": wfId}) or []:
|
||||||
geDb.recordDelete(AutoTask, task.get("id"))
|
waDb.recordDelete(AutoTask, task.get("id"))
|
||||||
|
|
||||||
geDb.recordDelete(AutoWorkflow, wfId)
|
waDb.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)} graphical editor workflows for {mandateLabel}")
|
logger.info(f"Removed {len(workflows)} automation workflows for {mandateLabel}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
summary["errors"].append(f"GraphicalEditor cleanup for {mandateLabel}: {e}")
|
summary["errors"].append(f"WorkflowAutomation cleanup for {mandateLabel}: {e}")
|
||||||
logger.error(f"Failed to clean up graphical editor data for {mandateLabel}: {e}")
|
logger.error(f"Failed to clean up workflow automation 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."""
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
|
# 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"
|
||||||
- 4 features: workspace, trustee (BUHA PWG), graphicalEditor (PWG Automationen),
|
- 3 features: workspace, trustee (BUHA PWG), neutralization (Datenschutz)
|
||||||
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
|
||||||
|
|
@ -15,7 +16,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.
|
||||||
"""
|
"""
|
||||||
|
|
@ -27,7 +28,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__)
|
||||||
|
|
||||||
|
|
@ -49,23 +50,18 @@ _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, "
|
||||||
"Graph-Editor mit dem Pilot-Workflow für Jahresmietzinsbestätigungen "
|
"Workflow-Automation (als File importiert, active=false). Idempotent."
|
||||||
"(als File importiert, active=false). Idempotent."
|
|
||||||
)
|
)
|
||||||
credentials = [
|
credentials = [
|
||||||
{
|
{
|
||||||
|
|
@ -98,9 +94,6 @@ 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)
|
||||||
|
|
@ -163,7 +156,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.interfaceBootstrap import copySystemRolesToMandate
|
from modules.interfaces.interfaceRbac import copySystemRolesToMandate
|
||||||
|
|
||||||
existing = db.getRecordset(Mandate, recordFilter={"name": mandateDef["name"]})
|
existing = db.getRecordset(Mandate, recordFilter={"name": mandateDef["name"]})
|
||||||
if existing:
|
if existing:
|
||||||
|
|
@ -541,92 +534,6 @@ 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.
|
||||||
|
|
||||||
|
|
@ -678,8 +585,8 @@ class PwgDemo2026(_BaseDemoConfig):
|
||||||
if not instId:
|
if not instId:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if featureCode == "graphicalEditor":
|
if featureCode == "workflowAutomation":
|
||||||
self._removeGraphicalEditorData(instId, mandateId, mandateLabel, summary)
|
self._removeWorkflowAutomationData(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":
|
||||||
|
|
@ -724,36 +631,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 _removeGraphicalEditorData(self, featureInstanceId: str, mandateId: str, mandateLabel: str, summary: Dict):
|
def _removeWorkflowAutomationData(self, featureInstanceId: str, mandateId: str, mandateLabel: str, summary: Dict):
|
||||||
try:
|
try:
|
||||||
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import (
|
from modules.datamodels.datamodelWorkflowAutomation import (
|
||||||
AutoRun,
|
AutoRun,
|
||||||
AutoStepLog,
|
AutoStepLog,
|
||||||
AutoTask,
|
AutoTask,
|
||||||
AutoVersion,
|
AutoVersion,
|
||||||
AutoWorkflow,
|
AutoWorkflow,
|
||||||
)
|
)
|
||||||
geDb = _openGraphicalEditorDb()
|
waDb = _openWorkflowAutomationDb()
|
||||||
workflows = geDb.getRecordset(AutoWorkflow, recordFilter={
|
workflows = waDb.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 geDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []:
|
for version in waDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []:
|
||||||
geDb.recordDelete(AutoVersion, version.get("id"))
|
waDb.recordDelete(AutoVersion, version.get("id"))
|
||||||
for run in geDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or []:
|
for run in waDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or []:
|
||||||
runId = run.get("id")
|
runId = run.get("id")
|
||||||
for step in geDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []:
|
for step in waDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []:
|
||||||
geDb.recordDelete(AutoStepLog, step.get("id"))
|
waDb.recordDelete(AutoStepLog, step.get("id"))
|
||||||
geDb.recordDelete(AutoRun, runId)
|
waDb.recordDelete(AutoRun, runId)
|
||||||
for task in geDb.getRecordset(AutoTask, recordFilter={"workflowId": wfId}) or []:
|
for task in waDb.getRecordset(AutoTask, recordFilter={"workflowId": wfId}) or []:
|
||||||
geDb.recordDelete(AutoTask, task.get("id"))
|
waDb.recordDelete(AutoTask, task.get("id"))
|
||||||
geDb.recordDelete(AutoWorkflow, wfId)
|
waDb.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"GraphicalEditor cleanup for {mandateLabel}: {e}")
|
summary["errors"].append(f"WorkflowAutomation cleanup for {mandateLabel}: {e}")
|
||||||
|
|
||||||
def _removeTrusteeSeed(self, featureInstanceId: str, mandateLabel: str, summary: Dict):
|
def _removeTrusteeSeed(self, featureInstanceId: str, mandateLabel: str, summary: Dict):
|
||||||
try:
|
try:
|
||||||
|
|
@ -818,13 +725,14 @@ def _openTrusteeDb():
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _openGraphicalEditorDb():
|
def _openWorkflowAutomationDb():
|
||||||
"""Open a privileged DB connection to ``poweron_graphicaleditor``."""
|
"""Open a privileged DB connection to the workflow-automation database."""
|
||||||
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="poweron_graphicaleditor",
|
dbDatabase=WORKFLOW_AUTOMATION_DATABASE,
|
||||||
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)),
|
||||||
|
|
|
||||||
|
|
@ -1,178 +0,0 @@
|
||||||
# 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
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue