Compare commits
3 commits
main
...
feat/tenan
| Author | SHA1 | Date | |
|---|---|---|---|
| 1672cf2ce1 | |||
| dd25fa603d | |||
| 2c1ed16464 |
833 changed files with 38425 additions and 20935 deletions
243
app.py
243
app.py
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
import os
|
||||
import sys
|
||||
|
|
@ -21,7 +21,7 @@ from datetime import datetime
|
|||
from modules.shared.configuration import APP_CONFIG
|
||||
from modules.shared.eventManagement import eventManager
|
||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||
from modules.system.registry import loadFeatureMainModules, registerAllFeaturesInCatalog, syncCatalogFeaturesToDb
|
||||
from modules.system.registry import loadFeatureMainModules
|
||||
|
||||
class DailyRotatingFileHandler(RotatingFileHandler):
|
||||
"""
|
||||
|
|
@ -176,20 +176,6 @@ def initLogging():
|
|||
pass
|
||||
return True
|
||||
|
||||
# Suppress h11 LocalProtocolError ("Can't send data when our state is ERROR")
|
||||
# from uvicorn when a client disconnects mid-response (browser abort, HMR, navigation).
|
||||
# The asyncio event-loop handler (below) only catches event-loop-level exceptions;
|
||||
# uvicorn logs this via the standard logging module before it reaches the event loop.
|
||||
class ClientDisconnectFilter(logging.Filter):
|
||||
def filter(self, record):
|
||||
if record.exc_info:
|
||||
excType = record.exc_info[0]
|
||||
if excType and getattr(excType, "__name__", "") == "LocalProtocolError":
|
||||
return False
|
||||
if isinstance(record.msg, str) and "LocalProtocolError" in record.msg:
|
||||
return False
|
||||
return True
|
||||
|
||||
# Add filter to normalize problematic unicode (e.g., arrows) to ASCII for terminals like cp1252
|
||||
class UnicodeArrowFilter(logging.Filter):
|
||||
def filter(self, record):
|
||||
|
|
@ -218,7 +204,6 @@ def initLogging():
|
|||
consoleHandler.addFilter(ChromeDevToolsFilter())
|
||||
consoleHandler.addFilter(HttpcoreStarFilter())
|
||||
consoleHandler.addFilter(HTTPDebugFilter())
|
||||
consoleHandler.addFilter(ClientDisconnectFilter())
|
||||
consoleHandler.addFilter(EmojiFilter())
|
||||
consoleHandler.addFilter(UnicodeArrowFilter())
|
||||
handlers.append(consoleHandler)
|
||||
|
|
@ -242,7 +227,6 @@ def initLogging():
|
|||
fileHandler.addFilter(ChromeDevToolsFilter())
|
||||
fileHandler.addFilter(HttpcoreStarFilter())
|
||||
fileHandler.addFilter(HTTPDebugFilter())
|
||||
fileHandler.addFilter(ClientDisconnectFilter())
|
||||
fileHandler.addFilter(EmojiFilter())
|
||||
fileHandler.addFilter(UnicodeArrowFilter())
|
||||
handlers.append(fileHandler)
|
||||
|
|
@ -271,12 +255,6 @@ def initLogging():
|
|||
for loggerName in noisyLoggers:
|
||||
logging.getLogger(loggerName).setLevel(logging.WARNING)
|
||||
|
||||
# Apply ClientDisconnectFilter to uvicorn's own logger so the
|
||||
# h11 LocalProtocolError is suppressed regardless of handler setup.
|
||||
_disconnectFilter = ClientDisconnectFilter()
|
||||
for _uvName in ("uvicorn.error", "uvicorn"):
|
||||
logging.getLogger(_uvName).addFilter(_disconnectFilter)
|
||||
|
||||
# Log the current logging configuration
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.info(f"Logging initialized with level {logLevelName}")
|
||||
|
|
@ -324,7 +302,7 @@ async def lifespan(app: FastAPI):
|
|||
logger.info("Application is starting up")
|
||||
|
||||
# Validate FK metadata on all Pydantic models (fail-fast, no silent fallbacks)
|
||||
from modules.dbHelpers.fkRegistry import validateFkTargets
|
||||
from modules.shared.fkRegistry import validateFkTargets
|
||||
fkErrors = validateFkTargets()
|
||||
if fkErrors:
|
||||
for err in fkErrors:
|
||||
|
|
@ -333,31 +311,6 @@ async def lifespan(app: FastAPI):
|
|||
|
||||
# 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.)
|
||||
# This must happen before getting root interface
|
||||
from modules.security.rootAccess import getRootDbAppConnector
|
||||
|
|
@ -369,28 +322,13 @@ async def lifespan(app: FastAPI):
|
|||
except Exception as e:
|
||||
logger.warning(f"Bootstrap check failed (may already be initialized): {str(e)}")
|
||||
|
||||
# Migrate vector column dimensions (idempotent — safe on every startup)
|
||||
try:
|
||||
from modules.interfaces.interfaceDbKnowledge import migrateVectorDimensions
|
||||
migrateVectorDimensions()
|
||||
logger.info("Vector dimension migration check completed")
|
||||
except Exception as e:
|
||||
logger.warning(f"Vector dimension migration failed (non-critical): {e}")
|
||||
|
||||
# Register all feature definitions in RBAC catalog (for /api/features/ endpoint)
|
||||
try:
|
||||
from modules.security.rbacCatalog import getCatalogService
|
||||
from modules.system.registry import registerAllFeaturesInCatalog, syncCatalogFeaturesToDb
|
||||
catalogService = getCatalogService()
|
||||
registerAllFeaturesInCatalog(catalogService)
|
||||
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
|
||||
# the FeatureInstance.featureCode FK has real targets. Without this
|
||||
# every FeatureInstance row would be flagged as orphan by the
|
||||
|
|
@ -404,23 +342,8 @@ async def lifespan(app: FastAPI):
|
|||
|
||||
# Sync gateway i18n registry to DB and load translation cache
|
||||
try:
|
||||
from modules.system.i18nBootSync import syncRegistryToDb, loadCache
|
||||
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)
|
||||
from modules.shared.i18nRegistry import syncRegistryToDb, loadCache
|
||||
await syncRegistryToDb()
|
||||
await loadCache()
|
||||
logger.info("i18n registry sync + cache load completed")
|
||||
except Exception as e:
|
||||
|
|
@ -453,73 +376,14 @@ async def lifespan(app: FastAPI):
|
|||
except Exception as 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 ---
|
||||
import asyncio
|
||||
try:
|
||||
main_loop = asyncio.get_running_loop()
|
||||
eventManager.set_event_loop(main_loop)
|
||||
from modules.workflowAutomation.scheduler.mainScheduler import setMainLoop as setSchedulerMainLoop, setOnRunFailedCallback
|
||||
from modules.workflows.scheduler.mainScheduler import setMainLoop as setSchedulerMainLoop
|
||||
setSchedulerMainLoop(main_loop)
|
||||
|
||||
# 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.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
|
||||
# when clients (browsers) close connections abruptly. This is a known
|
||||
# asyncio issue on Windows: https://bugs.python.org/issue39010
|
||||
|
|
@ -529,34 +393,20 @@ async def lifespan(app: FastAPI):
|
|||
return
|
||||
if isinstance(exc, ConnectionAbortedError):
|
||||
return
|
||||
if exc and "LocalProtocolError" in type(exc).__name__:
|
||||
return
|
||||
loop.default_exception_handler(ctx)
|
||||
main_loop.set_exception_handler(_suppressClientDisconnect)
|
||||
except RuntimeError:
|
||||
pass
|
||||
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
|
||||
from modules.dbHelpers.auditLogger import registerAuditLogCleanupScheduler
|
||||
from modules.shared.auditLogger import registerAuditLogCleanupScheduler
|
||||
registerAuditLogCleanupScheduler()
|
||||
|
||||
# Register enterprise subscription auto-renewal scheduler
|
||||
from modules.serviceCenter.services.serviceSubscription.enterpriseRenewalScheduler import registerEnterpriseRenewalScheduler
|
||||
registerEnterpriseRenewalScheduler()
|
||||
|
||||
# Register token and trusted device cleanup scheduler
|
||||
from modules.auth.trustedDeviceService import registerTokenCleanupScheduler
|
||||
registerTokenCleanupScheduler()
|
||||
|
||||
# Recover background jobs that were RUNNING when the previous worker died
|
||||
try:
|
||||
from modules.serviceCenter.services.serviceBackgroundJobs.mainBackgroundJobService import (
|
||||
|
|
@ -581,43 +431,12 @@ async def lifespan(app: FastAPI):
|
|||
except Exception as 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
|
||||
|
||||
# --- Shutdown sequence (protected against CancelledError) ---
|
||||
try:
|
||||
# 1. Drain SSE queues and cancel agent tasks FIRST so that open
|
||||
# streaming connections break out of their queue.get() loop
|
||||
# immediately. Without this, uvicorn waits for the SSE generators
|
||||
# to finish (up to 120 s keepalive timeout) before the rest of
|
||||
# the shutdown can proceed.
|
||||
try:
|
||||
from modules.shared.eventManager import get_event_manager as _getStreamingEM
|
||||
_getStreamingEM().shutdown()
|
||||
except Exception as e:
|
||||
logger.warning(f"Streaming EventManager shutdown failed: {e}")
|
||||
|
||||
# 2. Signal DB layer to abort in-flight borrow waits immediately.
|
||||
# This MUST happen early so that sync worker threads stuck in
|
||||
# 1. Signal DB layer to abort in-flight borrow waits immediately.
|
||||
# This MUST happen first so that sync worker threads stuck in
|
||||
# _acquireConn (30 s poll loop) bail out within one backoff tick
|
||||
# instead of blocking process exit for the full borrow timeout.
|
||||
try:
|
||||
|
|
@ -626,22 +445,10 @@ async def lifespan(app: FastAPI):
|
|||
except Exception as e:
|
||||
logger.warning(f"Closing DB connection pools failed: {e}")
|
||||
|
||||
# 3. Stop scheduler (removes all pending cron/interval jobs)
|
||||
# 2. Stop scheduler (removes all pending cron/interval jobs)
|
||||
eventManager.stop()
|
||||
|
||||
# 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)
|
||||
# 3. Stop Feature Containers (Plug&Play)
|
||||
try:
|
||||
mainModules = loadFeatureMainModules()
|
||||
for featureName, module in mainModules.items():
|
||||
|
|
@ -654,9 +461,16 @@ async def lifespan(app: FastAPI):
|
|||
except Exception as e:
|
||||
logger.warning(f"Could not shutdown feature containers: {e}")
|
||||
|
||||
# 4. Cancel all pending streaming EventManager tasks (cleanup sleeps, agent tasks)
|
||||
try:
|
||||
from modules.serviceCenter.core.serviceStreaming.eventManager import get_event_manager as _getStreamingEM
|
||||
_getStreamingEM().shutdown()
|
||||
except Exception as e:
|
||||
logger.warning(f"Streaming EventManager shutdown failed: {e}")
|
||||
|
||||
# 5. Close shared HTTP sessions (ResilientHttp) to avoid TCP keepalive hang
|
||||
try:
|
||||
from modules.shared.httpResilience import closeAllResilientHttp
|
||||
from modules.connectors._httpResilience import closeAllResilientHttp
|
||||
await closeAllResilientHttp()
|
||||
except Exception as e:
|
||||
logger.warning(f"Closing HTTP sessions failed: {e}")
|
||||
|
|
@ -737,8 +551,8 @@ def getAllowedOrigins():
|
|||
|
||||
|
||||
# CORS origin regex pattern for wildcard subdomain support
|
||||
# Matches all subdomains of poweron.swiss
|
||||
CORS_ORIGIN_REGEX = r"https://.*\.poweron\.swiss"
|
||||
# Matches all subdomains of poweron.swiss and poweron-center.net
|
||||
CORS_ORIGIN_REGEX = r"https://.*\.(poweron\.swiss|poweron-center\.net)"
|
||||
|
||||
|
||||
# SlowAPI rate limiter initialization
|
||||
|
|
@ -837,6 +651,8 @@ app.include_router(connectionsRouter)
|
|||
from modules.routes.routeRagInventory import router as ragInventoryRouter
|
||||
app.include_router(ragInventoryRouter)
|
||||
|
||||
|
||||
|
||||
from modules.routes.routeTableViews import router as tableViewsRouter
|
||||
app.include_router(tableViewsRouter)
|
||||
|
||||
|
|
@ -903,10 +719,6 @@ app.include_router(demoConfigRouter)
|
|||
from modules.routes.routeAdminDatabaseHealth import router as adminDatabaseHealthRouter
|
||||
app.include_router(adminDatabaseHealthRouter)
|
||||
|
||||
from modules.routes.routeAdminSessions import router as adminSessionsRouter, trustedDeviceRouter as adminTrustedDeviceRouter
|
||||
app.include_router(adminSessionsRouter)
|
||||
app.include_router(adminTrustedDeviceRouter)
|
||||
|
||||
from modules.routes.routeGdpr import router as gdprRouter
|
||||
app.include_router(gdprRouter)
|
||||
|
||||
|
|
@ -926,8 +738,11 @@ from modules.routes.routeSystem import router as systemRouter, navigationRouter
|
|||
app.include_router(systemRouter)
|
||||
app.include_router(navigationRouter)
|
||||
|
||||
from modules.routes.routeWorkflowAutomation import router as workflowAutomationRouter
|
||||
app.include_router(workflowAutomationRouter)
|
||||
from modules.routes.routeWorkflowDashboard import router as workflowDashboardRouter
|
||||
app.include_router(workflowDashboardRouter)
|
||||
|
||||
from modules.routes.routeAutomationWorkspace import router as automationWorkspaceRouter
|
||||
app.include_router(automationWorkspaceRouter)
|
||||
|
||||
# ============================================================================
|
||||
# PLUG&PLAY FEATURE ROUTERS
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# All rights reserved.
|
||||
"""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).
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# All rights reserved.
|
||||
"""Generate the 3 fictitious PWG scan PDFs used by the pilot demo.
|
||||
|
||||
Run: python _generateScans.py
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ APP_TOKEN_EXPIRY=300
|
|||
MFA_REQUIRE_ADMINS = False
|
||||
|
||||
# CORS Configuration
|
||||
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss
|
||||
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
|
||||
|
||||
# Logging configuration
|
||||
APP_LOGGING_LOG_LEVEL = DEBUG
|
||||
|
|
@ -58,7 +58,7 @@ Service_CLICKUP_OAUTH_REDIRECT_URI = http://localhost:8000/api/clickup/auth/conn
|
|||
|
||||
# Stripe Billing (both end with _SECRET for encryption script)
|
||||
STRIPE_SECRET_KEY_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5aHNGejgzQmpTdmprdzQxR19KZkh3MlhYUTNseFN3WnlaWjh2SDZyalN6aU9xSktkbUQwUnZrVnlvbGVRQm4yZFdiRU5aSEk5WVJuUnR4VUwtTm9OVk1WWmJQeU5QaDdib0hfVWV5U1BfYTFXRmdoOWdnOWxkb3JFQmF3bm45UjFUVUxmWGtGRkFKUGd6bmhpQlFnaVI3Q2lLdDlsY1VESk1vOEM0ZFBJNW1qcVZ0N2tPYmRLNmVKajZ2M3o3S05lWnRRVG5LdkRseW4wQ3VjNHNQZTZUdz09
|
||||
STRIPE_WEBHOOK_SECRET = DEV_ENC:Z0FBQUFBQnFLeUFlb2dfSjZPaWIyRjZsNjhiSDFQNFpxdW50YmlLUjFLX1lJMGdCWUtBUEdrRGhvSzVVWnkxNVZEdmtkQmk5X05YS0JVU1NyX3VQZTV2VjVwakd0RGM2WUl6TTlzbms1d1NCOTQtdURiVjhxdXZGVlR1ZVNTbUkwOFh1R04yUUxxay0=
|
||||
STRIPE_WEBHOOK_SECRET = DEV_ENC:Z0FBQUFBQnB5dkd5dDJMSHBrVk8wTzJhU2xzTTZCZWdvWmU2NGI2WklfRXRJZVUzaVYyOU9GLUZsalUwa2lPdEgtUHo0dVVvRDU1cy1saHJyU0Rxa2xQZjBuakExQzk3bmxBcU9WbEIxUEtpR1JoUFMxZG9ISGRZUXFhdFpSMGxvQUV3a0VLQllfUUtCOHZwTGdteV9rYTFOazBfSlN3ekNWblFpakJlZVlCTmNkWWQ4Sm01a1RCWTlnTlFHWVA0MkZYMlprUExrWFN2V0NVU1BTd1NKczFJbVo3VHpLdlc4UT09
|
||||
STRIPE_API_VERSION = 2026-01-28.clover
|
||||
STRIPE_AUTOMATIC_TAX_ENABLED = false
|
||||
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0
|
||||
|
|
@ -71,7 +71,7 @@ Connector_AiTavily_API_SECRET = DEV_ENC:Z0FBQUFBQnFCdlFlY1R2WGpuazk5M05SeDIyLWd3
|
|||
Connector_AiPrivateLlm_API_SECRET = DEV_ENC:Z0FBQUFBQnBudkpGRHM5eFdUVmVZU1R1cHBwN1RlMUx4T0NlLTJLUFFVX3J2OElDWFpuZmJHVmp4Z3BNNWMwZUVVZUd2TFhRSjVmVkVlcFlVRWtybXh0ZHloZ01ZcnVvX195YjdlWVdEcjZSWFFTTlNBWUlaTlNoLWhqVFBIb0thVlBiaWhjYjFQOFY=
|
||||
Connector_AiMistral_API_SECRET = DEV_ENC:Z0FBQUFBQnFCdlFlelh2T2hqNGcxV0hMV1FKbmFDZjVHUWF6T2FXbGlCSnQzSzNXLWJHeXBFWE1nUlh1b1NHY1JRSEVtTVEtc1MtUnZrX2ZCcURqQ2FYNmFWa2xudGJtS3g2eVo4MFZMd09nZTBNMmo1ZHU0bzBJdFRqLVhHSVZNb2Zrc0VkUXI0SVk=
|
||||
|
||||
Service_MSFT_TENANT_ID = organizations
|
||||
Service_MSFT_TENANT_ID = common
|
||||
|
||||
# Google Cloud Speech Services configuration
|
||||
Connector_GoogleSpeech_API_KEY_SECRET = DEV_ENC:Z0FBQUFBQnFIc3YtU0x4LTlHbTY1NUVGY2V2bUdmck85dDh1ZWVKa2ktR0N6NjdlTGFrUHMybVQ2bVRLN01XNFRZR2lyN0ZNSHhzWVVGNnVtZjRjV2hhR0ViTDYwT25lSmxJY0pSTkl3OUEyT0JxMFVYRndfUFJudExMajdTYUNXS01JU2lhQzZmNWFYdXA4aVZ5Zkh4Zko1Z00tcEE5ZFEwQkFVa1oyR296YXozRFI2WUdXN0ZSREFFclFNaTd6OUVlSmFxS1BTSlNJbnlWNHNfbkk4QzVOUGlkMzdfQUZxUlJOVEZzUlN1aWRWY01JZmlRM0JNZE1EZ3BmbW10c3BDdERpa2FMakstQUlqVEVlRC1hUmZoeFVoQ3pYNXRlRFVSTlI3ekJrU0QwSHBSaWxiSGU0akFGMXUtY2Q0RnUzS0tPOEQtcTdVdWhQeHFDM1hRRVVMcUxCeklvWHNWRUN2bjVHZUUwLTVtaGpUbWdPUnJabWlIcHZ5UjNtN0NMTUNRN29ZRGVXU28xQmhJTVg2eEZnaUdrcW9UVklHMHJycm1nT0JkdGJReVVHeV8tYm12UDlOU0lpNHFidXBQbUFSSVVmWUl1M1BVMFFncm0xSldkVzBrb2poRFMyaVUwcUZvMHl0QlZIZ1h1MjZwR3AtZWhqdzN4UVhtT2hUa1lQU3VudzNXdW1FcVY3VnQ3RmpkQnFQemlrQlF3WGhBNWxOZXJ6Zm9KVFlEZExUXzlqODhYaFNNMzVWTzFNMmVTcWdodDZoRmZTUzlhLVlOSU5fYW1vNXctaFpFMC1pUllRZW11d1JQN25sbldHVjI1anc2UC1ycndjTGtxWk55WmpJeU1wOVR0RnlTdFpad1dkRmlUNDE0d240TDlKc3JFUXdOYzd5UTFYSXUzLTQ2Y1ZGcWE3R2RyQ0I1WDMtMHBScEFzZDV4UEkyanh4ckJZUjdTYnJGZjAxQkU3MEJ6OXdybGRaWHNod1hZZEhVOXRpMWRLbVJsRGd0UDRDN3JsRzF4T0RpcnczRU5TM0RKVjVkWTRqNTl6bmhQdmdvaEg1U2kya0QtQ0l4ZHVUcGxkNi1vNVVVOEcyWXhxZWc5N1lKMk4tT0o3ZFVzYjJtT3NVZFJiSTFNUnpaSmFOeDZaLWVpZlc0VUhZRHdXOUMyQ3cwaXBQUDRJN1g1YkwzaTFiRVRxRFY5UTdZU1dSaGR6NUw3aEtac2RENXF3WEpVN0dXVTlQR0F6MFlpWl83MU44NVR1ZUtPVUNlZ205YUIwOFoxUDBvTlI0SU52emVvQ3VZXy1jTlFXRWZXQ0d5RHJ0eV9JeE5wMHl0b3FVSjNoVzg2d21hYVNYY3Q0dkFaVEZwa09tRnFBbEtoOUlGY2xkeVJoZGYzQUxYNFZfb0ZiaU5VRjJPbGhieXYtWTFKckZwenVCUGFva1IwVVFORVQ4SDMxWHVuRWhBRGd0cVlsc3kyQ0RyY2ZIVDlwcGh5ampySV9uOVpsVmlWbGoxMEg3SXh6NzRJbmZXRlhMMWc0RXhzeWtnQlJ0VnZSdENkbEpOdENwUzItUjZhZWFYRFhzbDM1WDBxaGFPX19CSG1KZjRTTU5JemcxZzJRSFY5bkx4TTlIZFNHOW1USWxBYWhEZ1FSNVdSSDJETUZwMi1Hd0RESkF2cVA1TVJGTEtPUl9oN3gzVEIwSzZOVzlOWXhNa2I1Vzc1SV9tdENfRy1rQTNzRlZGSTYwQmJIaGswZUNWSnRDVXFfdWFCckZZcnJOT2Rfb3FrcWI4S1lVRTMyRnZJQTRZV1VsU0xobGRjekhtbG9LamR2d1hfVklsM3JBeW9SRzJnWVdiWDRzN1ltcXdSVGoxRVBvczViVXNjMUxBazZUdS1WbkRQX0h1MzdNd3ltVDUzd2FGdi1XeUMybV9ia1YxQVBPdnUxY1dfT2M5eEpZR2JHMkdZbWdDZTRERXRYOWxodndkTXltVW40c0t0bVA5YWxuRzM3LWlCdmJiYmF5dkNBY3ozbUw1Zm5zRmpBdk5ORmFZRWJKM3Q2UDdKNl9zaUV5eVVGbkF0QmZSZzk5dGo3UjNIQWxwcjRlVTdUT2s1VGFjdndvX2c3d1VmaHRMZU10M1ZKVk9Ma3dZb1kwYVV5Z2NlTjUxdUYtZXRnRTRzQlp1aFp0OUF5TVBwN1gzU21kRmJ6OUlOeUFOOEhEOU5WSENNZndvLXdoVUFJYVFDTWEyakJEcTVSVDhJOWJscU8taThqNUZkdThCOUlXcldndFBTZk9QVnlMaUphUU5sUktpb1plZDZOQnFzNFNMUzRWbWFVQWhUWmJfem96X0cxWXVTcUxCeDhOc3E2OEpFa2lzWHFIV0p3eGdBZmN1aXBhYjExZTZqaUY4S0ZudTNhcUx2WlpuTU9lNUk2ZmNyN0JCODdYMGNEU2JsZkZXYlRFaTJQUTI5RU5SMmtkV1NHQTVTTjEyZGZLYnhTNTg2Nl9aaWJqX2Q1U1NwQ3pRTGRBSUw0N3FNQ0ItMks1QVZmbURYVWdHMWFZTWhGNURVOUg0bGVuMUozanlxTnRwbVlGX2RnN2FBVTZlZjhDaXVzZEtVR1Z5azhzWHRrS1dYSG9rYkowTjQ1N0hyRWdNVWMya1ZmWmZvSnVTdHNiMHFDODNLckpjQ081SFlieGxuM0picGhKMnNQRURwY2hpQzF3dHRnNEFWcUlPYjVxZEhod0JDbWZhU01Ob21UWmRwd0NQRlpjOE5CUFBOT004U2JKNkFSUlFzRklYZGJobUoxQzZzT2wzZ3J1Z05aYThRVVNzcFktMGJDcXFfSkxVS2hhajI3dTdrR2poa21ZM3Z4UzFRblFsOFlOZVVUM0YxaFRuNjFWQ2E4ZlhvZjZpMWFtOGRuaGx0MTZxZE9TY1dsTTMyMHhsNXJ2MkduaGRkZXpYUWJ3cEt1U3YwMC1IRzM5eWRCb0lvaUhTQ2R4XzhEZl9zRk5GeHhCSWx2X3BkUkJ4NFZLVzdVRFZkbnpNNkpjUTFHY1pDV0ZOMFBaNTVpLUlmSnFrX1N5X05MTjRUeTVERUs5MG9kMFJ3di03U3BpMUM4YXNwaG1fangwYURIVjBpSVdCUkt4UW5HbWtGOUh3TUdPZjMxYXpVZDcwTmlDcTR6WldZb3VzbHRpRUgyN2lFTjlpUV85T0M4blJxMWx0cC1iU0FDOHhueDBLYjdLZGhNbjFPbE1RdmhhNlEzX3ZpT2ZsYllwNkU5TE9fZWFabDE4RWRoRWxiMk5aVFZrWmxjaW5MX1VrUGhUN29vbU1tWldESnczYTNBQ1RPd1VTNGNJdjdJU3p3QXZQLVlDNkQ1cTh4Rk1WNnRMUi1DT3VGREFPa28xejc2NUl1dzJSa2hCTlJublBRNGkydlJVRjlFbFotOWtraWFqQkNNTXBpT1hZM0NXNEpObGMxQUNuS29rOExMSnMxT3NLbjNfLTdpQW1BcDMxR1RZdVRvbElGbENWbHJqRlVrTXhYbFdiMmItUzlxR2ZxT2FCWXpMVVJYZXBfSFVwNTczU3JHUVhET3hSWm80Ry1KcE9mV3FYejVHSEVSS0pxOUtCc3V2VHNFVkRqYk5Od20tM0ttdFQ1eGdsc091WGFYNFgybzNVd3ZvbzEwUDJ0T0hvTVd3YnlHNnpNWC0wbkJOQTIwQ3VYdlUzaXY5NFhDNlNOOW9UdGZNUk4zZ0VJakpwS21SZlJtQjVWLUxfejFYZFc1cjRwR3ZUOGdZb2VJaTdJUS1MYlRJb0ZFYW9uYzM3MDd4b09BR1pnTEh3RFpnaGhxZURQamllNUhqTHg0cHJfN08wMkdGSVQwQUlqWDhLVGViY3J5NlVFTzY3RGhGQ0R6aXNsb2w4dnBVYndTd1Jhd3IwS1BxY0h1X05RcGsySzVNbXR5YlBVQi1IOGFUNkh5QjhRZk5BQmZvcGF6ZTNXenZkdy1GRjFGdE1saGdMSnotUkIyX1VqTlZFWnJER1YyNGQtMFZHU3hmRVNPUWFCdXV3QUxzOGVSbF9EdEZGUFNxbTdiYm5oWHdYak5qa3Zoem5WY1ZUdDREVUxGX0VQeS1jckhqS2lRLXQ1Y2tyOFRjYnVhajNUZmZOUE9kbU9PYXdqdk5DYUtEOVFiMW9yZTYxMFNUaDdvUTExUFZ1bklYSkRKTnJ1RURvOTR3ODREcWdWeHpRS2RETjZqeXpvbUpxMW5lWl84RzVocmJFQ3JfZlpMd3RCZEo5RWZ0MzIxNWV6bHlwdWJJWXhoaWxlM2FHSjBhWG14Sk94ZV96cXFvU1JwWDdKZldmZWdvdWVKdXVfaS1jZjdENXQzSzNyb1d3eWhUMU53QzgxemRiTTlkdFRxZU1OdEN5c1kxOEd2MTJMcnBJWEE0eXdJdFpOYVNMQTNLR292UFlGb0Ztdz0=
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
APP_ENV_TYPE = int
|
||||
APP_ENV_LABEL = Integration Instance
|
||||
APP_API_URL = https://api-int.poweron.swiss
|
||||
# Force SameSite=None+Secure for auth cookies. Optional if APP_API_URL is https://
|
||||
# Force SameSite=None+Secure for auth cookies (cross-site UI on poweron-center.net). Optional if APP_API_URL is https://
|
||||
APP_COOKIE_SECURE = true
|
||||
APP_KEY_SYSVAR = /srv/gateway/shared/secrets/master_key.txt
|
||||
APP_INIT_PASS_ADMIN_SECRET = INT_ENC:Z0FBQUFBQm8xSVRjWm41MWZ4TUZGaVlrX3pWZWNwakJsY3Facm0wLVZDd1VKeTFoZEVZQnItcEdUUnVJS1NXeDBpM2xKbGRsYmxOSmRhc29PZjJSU2txQjdLbUVrTTE1NEJjUXBHbV9NOVJWZUR3QlJkQnJvTEU9
|
||||
|
|
@ -24,7 +24,7 @@ APP_TOKEN_EXPIRY=300
|
|||
MFA_REQUIRE_ADMINS = True
|
||||
|
||||
# CORS Configuration
|
||||
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://porta.poweron.swiss,https://porta-int.poweron.swiss,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss
|
||||
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://porta.poweron.swiss,https://porta-int.poweron.swiss,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
|
||||
|
||||
# Logging configuration
|
||||
APP_LOGGING_LOG_LEVEL = DEBUG
|
||||
|
|
@ -60,7 +60,7 @@ Service_CLICKUP_OAUTH_REDIRECT_URI = https://api-int.poweron.swiss/api/clickup/a
|
|||
|
||||
# Stripe Billing (both end with _SECRET for encryption script)
|
||||
STRIPE_SECRET_KEY_SECRET = INT_ENC:Z0FBQUFBQnB5dkd5ekdBaGNGVUlOQUpncTlzLWlTV0V5OWZzQkpDczhCUGw4U1JpTHZ0d3pfYlFNWElLRlNiNlNsaDRYTGZUTkg2OUFrTW1GZXpOUjBVbmRQWjN6ekhHd2ZSQ195OHlaeWh1TmxrUm10V2R3YmdncmFLbFMzVjdqcWJMSUJPR2xuSEozclNoZG1rZVBTaWg3OFQ1Qzdxb0wyQ2RKazc2dG1aZXBUTXlvbDZqLS1KOVI5M3BGc3NQZkZRbnFpRjIwWmh2ZHlVNlpxZVo2dWNmMjQ5eW02QmtzUT09
|
||||
STRIPE_WEBHOOK_SECRET = INT_ENC:Z0FBQUFBQnFLeUFWUUtMZ25NQ2ZOWE5nRF9CaFNwcXhSU2tKRktLaElLRHJMM295OXNkVEFLekVUMzN0YUpIZHJfWGNqa0xxOFZRVHZEUXVLZ3ItVGZWc2VFQ2thcUlJalY1b0JDSmR6RF96d1A3OGhyd0w1MHZPeFNZRkl0c19kYUJQcHVwR2tsd0s=
|
||||
STRIPE_WEBHOOK_SECRET = INT_ENC:Z0FBQUFBQnA4UXZiUUVqTl9lREVRWTh1aHFDcFpwcXRkOUx4MS1ham9Ddkl6T0xzMnJuM1hhUHdGNG5CenY1MUg4RlJBOGFQTWl5cVd5MjJ2REItcHYyRmdLX3ZlT2p5Z3BRVkMtQnRoTVkteXlfaU92MVBtOEI0Ni1kbGlfa0NiRmFRRXNHLVE2NHI=
|
||||
STRIPE_API_VERSION = 2026-01-28.clover
|
||||
STRIPE_AUTOMATIC_TAX_ENABLED = false
|
||||
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQd14OUoIL0Osj7A0ZQlr0
|
||||
|
|
@ -73,7 +73,7 @@ Connector_AiTavily_API_SECRET = INT_ENC:Z0FBQUFBQnFCdlFlbmRSZVRjTzVKRklFbFgwdVZJ
|
|||
Connector_AiPrivateLlm_API_SECRET = INT_ENC:Z0FBQUFBQnBudkpGSjZ1NWh0aWc1R3Z4MHNaeS1HamtUbndhcUZFZDlqUDhjSmg5eHFfdlVkU0RsVkJ2UVRaMWs3aWhraG5jSlc0YkxNWHVmR2JoSW5ENFFCdkJBM0VienlKSnhzNnBKbTJOUTFKczRfWlQ3bWpmUkRTT1I1OGNUSTlQdExacGRpeXg=
|
||||
Connector_AiMistral_API_SECRET = INT_ENC:Z0FBQUFBQnFCdlFlU2tMLTFnQWhET2Nia2pTcVpBakRaSVFDdUpHRzZ1bkhGVVhMeEVlSnFZU3F3UFRBUkNMMU4tQU92OUdTeDlpM2VZbXJzLURQZ1lPLVB3azgxSDZabkhkSHJ5Y005aWhtcDJzajk3a2JDQUxCZlNKRGw5elJuSzJMUUpTZ2hiSlU=
|
||||
|
||||
Service_MSFT_TENANT_ID = organizations
|
||||
Service_MSFT_TENANT_ID = common
|
||||
|
||||
# Google Cloud Speech Services configuration
|
||||
Connector_GoogleSpeech_API_KEY_SECRET = INT_ENC:Z0FBQUFBQnFIc3YtSjhlcklrU2JCOW5mdHFHd0dLTUZZZk9PT3o5RWt5RjAxX2s3ekJRLUUzU0dNSnNseTE4bUpNTnZSTWg0QV9mWm5iX19aWjV4YnRXU1JBSm1INVB5dXNRT2JiYk1tLWRSS29pdTRMdS1lMDZxMkx4VTh3bU5aVWh3cEwyOE1QcXVockgtZWh5bzdNVXQyemFuSmZqRzZZYmNGN21JdjNwNWpPRXB6WU1qSU5rZUVSb3JBS0lhcThvakkwbTRUUHhBdjRZdWNsZ1Z1RmFaNGZLcEpaNVNLdFAxYzFXdTJydU9COWJ0bkNyYUF2X2FNc1BfT05teEs1SE9PeGhPd3VJSFY2VFJ5VEl6V3R3bzd6OTVKTEVRcmt5ZzdBMXBFY1A5dUFJRFJONFBlaDlJcjNBQnBraC0wMTBhNW8wYWZaeHNWclVTOVotLTdWSmVuYzJKcUZSUkdrdXB3VEVESzd4UTI0bGd6SzdCajdoazZXVTVCaGRiaWJaOHg5Z2thSWItcS05U25DbUdrT2M1QV81WEg2dlJfMlBtZU9Bc3V5bmtBWHRoRUVLR2lWNHY3M3hHcU1raFRFOWQwSEtUU1RDWDFRNFlkNHVnTkZDbk5zS3RZeGR2Z015RnRGc3NndFVEQjc4bVpNeE81bXc1MnQ2QjNZeHZCbUJJZVJ2TE5xWEd4M3hHT2hJWW5DOWMxQlNmZE9uMVRGVnRwTUlXZjZCRUZBLU9GWVZGWFpZbUE3WVlpZU1DX1Z0bWQ0bjlaRThHOE9WR3VOVzlYWS1JampTNmxkNmFxWG54WDJjallIT3UyT0tGSzJpeG1tX0JoQjZxbEpESHBhMWZFa205bjdvTVFwSVVidnVzdURZVDAzVVpkekJ2SVZTZmhxQVJ2OWpuRGR2WFE3elMtb3B2ZzhpQVNvRmkzbzRrY1BuamVzM0E2eVM0bXBHTHgtYmhsVG5jNlB1Q1JHZU9HUlNfaTJSQkcwS2FSZnZSOW9oZzdXa1RUVTVTZTgwY01GYXQyQ0xWX1Fnb0xaOTRQY3hTclgweVJ5clc5OVpRWWlDb0JQVXoxVDA0bW8zUE55aGowb1ZZNEpBN2UtSTZTY2llRGhISFFkYWFYVlVBQ0IzbGxzVTQ2V2dsUGV1Y2I5bEZLRnlwdXRHMWZVcnBaTXNzNzNkUVFqR2xnSEQ1VlpTdXpwMFVVYjQ0enFlUnk0d3dDQUtSS1dUVnNyYnBKQW9TRjJxN2JNY2NhRWNONWRpWU5RbzNNZVJBS3EzN2ZMZ1E5VXQtMDFTZklLY1JiSDNYRlFuOF9VYUktS0xoY2IyR0xkT19qTEpIV1p6RFExUWNCQTdqN1kyS0Jaa2lyMDluenc1MS1vdmhPVlE5OUphWEY2dXFYNE04Z3lBUG5DNGZjTUVnYzEzYWhzTHpMdVBzT0dzRGJaT2x5b0pVbWJtUzJxdEd2VGtrc01kTlNPNURoVHhwZzU1d3pTZGJiTUZIME5tQ0xqNWJ2QS1QSEJHV2FEOExHWDByV19rVnc2R2pibnNENEo1cTh4bGNMX2ZpSTBMcjRvQWRhbW5xYVBiZkZzWTRERlVESEU2aHpvdzNMTjlCazRYeEJhMmZwdXY5T25IYkFTaUM3SmdIV1FCX2xxRXctWHZQOHgxLXI1c1JkWmcydkFTUmxFSU03cGtnallnTXplOElQbEJRSEE2aW5KREU0YUxwX25wOFhuS2RIbms1dXNIRHBtNjFtb3B3UGVGb0hwOENKM1hMclBwa3NBa2pFYnZYbEtFbUF0Y3pmeFRmMDNMaTZrR1BZWnBrNUQ1WlU1NVZQSWUxN3dwcXhhcjdXNTl4LVVpYVF3Y0wtRmFyNXZRNTE3UUc2cHVaVVNpaVdHbXRqQVJNZWZmNjdQQ2lwTGd6RFFZN2tSY2NEdmxvaXk4MTZMcmg0VGo3MTN2R2V6cmV3YjdQVlNEZTQySUpaY2pkTHZzUzdJLVJ2WnlOQ3Vmem5FZXRaWjBMWjF4ZEF3ZHJ4VF8tMVNsRnljejVsaEpGOU5JbnhydjNVdzNMOENrWUVsbXp0ZEhuVE1Vd0RJcnp2N0RXUGFuNDM2OXBPbV9LRDUwTWk1NHYwaDhlVEhKUmtEa09INURwNjV5ZE1VWmpRSGdjeXJNc3FqcjZDdmx5WXluNWZ2VlpsWmR2TXVXVnBubEFmQlRfaGRwRndCVXVkMjkyLWVhaDQtZDN1cmFZLUoybGRwbGQ5MTExU2NnZ2lueVNfSjFDQ2NkWGtNX2M1T2I4YnVJOUFueGIxbG1EYlZOcFYtQlE3cm90SE40X0ZjalhLdXM5S2l5aW84ZUJPMlR4MU9EVkhZcHdrX1Zqc0NhWEJacDZHMzQwSzdkdi1Rd2s4Y1dfLS1ES0NfYTNxYl84UTN1S0lIM0pVTTNEYlJ0YW55Tk4yVjBONXNTQWtVZTJ2V3B5eHBJcG9IWGRMMklob0hMbVVZZzJKbTFMUExOQm5HSEZzWHU0VGVIWlJMVzFLeFB0NkkyWFkwWk0wdjdHRmxSWFFoSkJ2Vm5NUWNQQlp6YWlIc2NKLUdhOVVycHd5N3NFMDNVWlAxZGQ1NzRGbm9LcWxEb2tKR1RnVEtvRUc1d3l4aU1IOUQ5RldUT3Z0a3lpRHpVSWJ4MjU4RWY5MEpCQ0VFdHNMbnkxOGswcE44QzJwNXFCVGpIa0VGc2VNXy1qdzVNRU9DaXg2MW9VX3FjUk41QVFVLURwVGFLRTkyNWlENy1IcGZjNW9wY0Y5Q3d5eFg5emVUUF9hV3ZTQWNaNEN0VzdJRlFBR0picXJoUERacWNLbDZhTE8wdWlfZ3kxd2QzOXBOZV9uaUNGMkNJbGhNd3k0S2t3dTRGWVVxTTFRRlg3Ui1zLW1FLU1Mai1yaURjb2Fob2c4MDUyRHN5aldUVWMxLTVNbm5VQTdrYy0zLVFyOHRkNzZ3dGdhbXZXN3JHNkdfZ2RuRXFDM3R2TVB1cDNOdWZGTmpFNnNFTmMxTmFuZDdJUld5bERyQkJ0TGZXRk54NEdqN09hSmVMYV91NXUwNXFvMl9KV0hBNlB4bklNQ2U5WGZLUTdlX2dJenVGcDYwWHBsdTNpbE5mWGhWeXFuUkFPV0puR2h0RkhrR2MwTzJGUmp4bUR6UFlUWTlNbTJLa19hTUZZR0dscVpBbFBReTBRMDNseXo4SXNnZWt4VFdpOERqLV9ZczRkR0QwRFJQM0pqdHluWktDUlp6WU9XSjVNZi1tYnNzcVlGTDRFMzNlSmRTazFfTkNxSjAwM0wxNk9Sd2h1SWpfOW5MVWMtVXYyYlVZR0VuaHRpN1pnNnpHME5raVBMd2h2dDRyMV8yZGFJNnlkcmhtSWdmNlpLN19NcjNkc002dXFxQzhTaDZzRlgzNUJ1SzVpVnp6NVU1Y2luUlM4UEJoajNTOUJadnE1MlhzV0kxSzBObXkteVhNM3RKYW9heDVWWFJ1NGlDM0l0elRPbThwUU9oYkVkbC1PZFNLSHY3WHJiZWpEamNIVC00MlNNWV9qcHdjNDRjRlVhZXlrLTlicVBNaDlDeXdRb0Fwc3RmUGFvbURQZ29yckliaS1VUDNxcXVlYTJJRUhXNUVobk1KUDhHZE16UzBLeDViYVRwZWY3d2w0d253eEZYcExKRGpsaGlBUElaTzB3eUVadnROX1dabENGb3R4ZF9aS05KY0dHTVZaYzRFc1Z4TlZGbFd2NjdYRzJMTzVwU2NaN1Y3MzQ2Z2pzV2RSMzJBbjg0MEhaZmhoREloY0oxOFdjNDZNdVZfYlRKU1Q1M2hYdHgwUjVsTV9USjZCZXlQTTdNRWc3bUxOcXRDVkpTdnJxR0hkWWpaRUdrOEFyNHk4MENwVzdob0hUSkJvam4zZW1kcGxZUjg0RXFRNnBxSUg1MDVHdHRwVlFkWWhHM0ZyZVFvMF96R2V5YjBuMnVZTU5CQ3pVci16SGJlQTQtbnFLa1E2eHFncUg3UmYyYlZvOF82a3d2ZE4tbmxIUlNYYjlrck9QYk5CcV9faXludS1yem1JNjFBdVYyb21RQWFMMFkxX0s1TjQ4czZ2WXI3X0FzRWdNTlZndHl4bnVOTHl2YlZfaURQV053dHl4N1czRFdzaVFnRHB0MWRDV2ZuU2lzX1NZZkRQYzhsT3ItZWw0dVJlVmtFWUM5cEppOGxuYVdpQkN5dV9hQ2dodTJvV3REVkw2dVVDaGtvc0Zqd0V2dldLZEVNRVRRNVRUVmw5aHZmZEpHdk1wS0xwRFc5Vmx4dTdfdGZDRUtCU29qdEVIOW5VdjBmeGpFMFZHSUthamtVN1E2bDZqaEFackVSQnZMN0tyaUhIcUs1ZHMzMzl2TnhadGIwZW5QNS1BM3pSODY3WVFsLU1jeUpCMG1PWmhPVT0=
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ APP_TOKEN_EXPIRY=300
|
|||
MFA_REQUIRE_ADMINS = True
|
||||
|
||||
# CORS Configuration
|
||||
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://porta.poweron.swiss,https://porta-int.poweron.swiss,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss
|
||||
APP_ALLOWED_ORIGINS=http://localhost:8080,http://localhost:5176,https://porta.poweron.swiss,https://porta-int.poweron.swiss,https://nyla.poweron.swiss,https://nyla-int.poweron.swiss,https://nyla.poweron-center.net,https://nyla-int.poweron-center.net
|
||||
|
||||
# Logging configuration
|
||||
APP_LOGGING_LOG_LEVEL = DEBUG
|
||||
|
|
@ -58,7 +58,7 @@ Service_CLICKUP_OAUTH_REDIRECT_URI = https://api.poweron.swiss/api/clickup/auth/
|
|||
|
||||
# Stripe Billing (both end with _SECRET for encryption script)
|
||||
STRIPE_SECRET_KEY_SECRET = PROD_ENC:Z0FBQUFBQnB5dkd6aVA3R3VRS3VHMUgzUEVjYkR4eUZKWFhPUzFTTVlHNnBvT3FienNQaUlBWVpPLXJyVGpGMWk4LXktMXphX0J6ZTVESkJxdjNNa3ZJbF9wX2ppYzdjYlF0cmdVamlEWWJDSmJYYkJseHctTlh4dnNoQWs4SG5haVl2TTNDdXpuaFpqeDBtNkFCbUxMa0RaWG14dmxyOEdILTNrZ2licmNpbXVkN2lFSWoxZW1BODNpV0ZTQ0VaeXRmR1d4RjExMlVFS3MtQU9zZXZlZE1mTmY3OWctUXJHdz09
|
||||
STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnFLeUFNQ1FhVE94ZzM3V3NCVGVVWnltUndsOG1Ra0hQTmJ3QWY5aXVWeTJsX3A4a3VBSnFQd3drWFRZNFVDdWxCeFgyQ0RpNGQ0SlJOcm9tVE5KZmVqQU1WUjFjeDRJeGE5THdmR0g1V2dQUk5SSjcySnAzR245NW5NUFVDT3lJUWpjWFo=
|
||||
STRIPE_WEBHOOK_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGNUpTWldsakYydFhFelBrR1lSaWxYT3kyMENOMUljZTJUZHBWcEhhdWVCMzYxZXQ5b3VlTFVRalFiTVdsbGxrdUx0RDFwSEpsOC1sTDJRTEJNQlA3S3ZaQzBtV1h6bWp5VnlMZUgwUlF3cXYxcnljZVE5SWdzLVg3V0syOWRYS08=
|
||||
STRIPE_API_VERSION = 2026-01-28.clover
|
||||
STRIPE_AUTOMATIC_TAX_ENABLED = false
|
||||
STRIPE_TAX_RATE_ID_CH_VAT = txr_1TOQZG8WqlVsabrfFEu49pah
|
||||
|
|
@ -72,7 +72,7 @@ Connector_AiTavily_API_SECRET = PROD_ENC:Z0FBQUFBQnFGdnVLcTlLSFJ5b0gwRmJLMFB5MzA
|
|||
Connector_AiPrivateLlm_API_SECRET = PROD_ENC:Z0FBQUFBQnBudkpGanZ6U3pzZWkwXzVPWGtIQ040XzFrTXc5QWRnazdEeEktaUJ0akJmNnEzbWUzNHczLTJfc2dIdzBDY0FTaXZYcDhxNFdNbTNtbEJTb2VRZ0ZYd05hdlNLR1h6SUFzVml2Z1FLY1BjTl90UWozUGxtak1URnhhZmNDRWFTb0dKVUo=
|
||||
Connector_AiMistral_API_SECRET = PROD_ENC:Z0FBQUFBQnFGdnVLRHplbzNheDhIdndsU0xUeGlBYVVXWDRzOF9Tek41WjEtSmNqbnVHRXFaZ0dramlfZWlQelpJWVh5T0F2azBaQWU3ajU0TWljaGpMeTlra0g0LVhKeTRKNGxKY0ZqSkxwdTJLdWM5cWdMVC1TVkpLb2lPdHhyeWtieFJFOHdkVy0=
|
||||
|
||||
Service_MSFT_TENANT_ID = organizations
|
||||
Service_MSFT_TENANT_ID = common
|
||||
|
||||
# Google Cloud Speech Services configuration
|
||||
Connector_GoogleSpeech_API_KEY_SECRET = PROD_ENC:Z0FBQUFBQnFIc3YtNDZzenJuZEZiQnVMOWRmZjl3R29QOWZRaGlPdk56WG1DR0FSZU5DM3dENWdoMmRpaks1U1VDNDJkZ3d3UXhSbXlkZ2h3SGZfdk54WXVidF82VkdJQXZiRTk0UlhZaUY1b2kwNzNPSm52VFdsdkwtaHJBb2dpRDBVLXRwd19Bb0dUZDkyV1VWZDJ1TG5mZ0ktYXpuS3U1U0JkZUk5TXpMdnhOaUtMN3BIb0pEZ1N0SlpFN3NNby15VTRfWWtxaF9DYjlJcnVKb0ZualVMTUx2aVNGY0JJdE1oZy1xSVBUZDF1aDM0TGVlTzVrNkFHcjlhcEk0SmRIMTFGdDFTMVUxX1dERk9NTXZMb0tVTFRoc20xME1uRkdVV0Z5N200ZTQzSjVsVExoa2VRZmFBU21ZczF0Vm9Ib3BZM2ZneDkwak12UmFyWWd0eng3ZVVFTUFLVzNOazcxeUhLVWUxcEFIZWtNRi1mT29kM1pqNGJJUUh3UVBlNGY3SlotOWZFUk5aQXFXcUFVdnUzc0Z5bERXYUNPbG14VnBNenFvb2tiQ3lZeHNHUVBlQTdTdVdXOEkxaGxCX016WWktWmN2WFcwM0VmVHdvMHVnY212VFE2cjJwUjdENkFCZF9GcUktWWpmWlNXNWVTMHBPdzVxRi15d3FSRDFra2k0NEFmTmpUeVh3SHRuZWE3WGJ4eUNIcE5tdnRqX2NCZnJoMEI2emU4U0ZYN1Nmdlhva1NacFo3UFh3WnpSdGw5ZmNpSGhicFo0ZThReXl3LW9vUzZaMkFHX2lJalFEMWtjZVdqbVpIZGk0cEdEU01TMl9xQkdSNDllTS1GV3lXS0xROTJvSlhaTjlXenJhQ3lOd2p0VjR5ZjEyektUZGJ3UThJOVJuMzhsTTVBVW9BcDFtcjk5Y0pVeW0zX3R0Nk81R3VDRWEzZnRqSXhFUW5ONHFTSWlwQU4yazlDb01KYlFQRjBFVTljdEJIY29WdF9hUkRJOThVTVFfWlJQUXI0Z3RzWFlzR1ZxUWFBd2I1SW1EMWlKdVprT3dKYTlaREp6TkZEZmVsZGEyalZGc3dHaUkyamdmQWtUT2czNzBCZEg0Vk1HSHFpRnhRYzBRNnN3TFkyaE9uMTVXN1VJTmJwbTNUMTdZbVRyc2d6Yl9aaVBXNmFvanROQVhfbWpXTDRlR1RfbklnYnJUQTZPX2JfNnlrWDVDUWJ4Z3YwNXVsTkJFQlRhTG5DVHpwejdsMGl1bzRfRXRTU2dmb3BVMUo4VkQwa0hsTmFBZnVjVzRrQmNzS2R0ZHNGV24yQnktWENtMUp6eG1MQW1ENE1vWFpFUF9PMEpWZVlxX05hSW1QUGlVT1l3MFp4bDBDZVVldHlEUlVCY1VvVlBNTlBhWFlmcVRobDNqRHo0QjZvNDBqVUVKN3JOb2dtYXQxSWw5NERSeEVRdHNUWndzUkY5RjdBOG1FZFRiVTNVSzl5bDNwdTl2SVd5aW5Ub2Q1YlBDRnpBUDkteU44YnV5X05ONmNndm9teUpqaFZVcVlHdGVRcXRpZkJLVnRuMTJSUFhGWndibExqRW03YUJTWXZXUXJ5WXlvd01ISDFuUFpaMFJzNFVQbWRUb2h1Zi1rcXJXMkRQSUFPeWFJN3lzOFc1d3BjWG1kbWlQWGUwelNiSnJXbUpnajdlQTlQR19XNTF0Q3JYcUMzaGp3eU0yZGhKa3FtX0tleHBfekZaWlRJRlZlSzNDVU56cml0TnFJeUc3b09uYVlwbGxFVFR6WFJVMzRmak5yWjBhcjl5ZmJpQ3hpajRXV1dwbDF5N25tNnI2bWtFem1TS08yV3JybUF0enYxRXpkUVdTNVp4WVB0aldJUUN3TnhHcHdMczh5MTFETzNWLXZFSktsdU1vM1JSNXhraDlJRDl0MEhvR1NOQWRaQW1NdzhpZnFVa1hvdXNwY2FvaThHQjVMOXdySnNIcWJlWERfLXVOcHhpN2ZZOW4yVzB3VTI2a3hvVmFkc29aX2ZUZkY5bi04WEV4MTlxNXQ4cTcwaHE4X3hDWkQxelRwSUl2amZOQ0JXRlJjRFhJNVhjNjRmaXp5eG15LTN1MFRvN3BHTFRZQ1ZFVFYyNUxleFpKTHlIVzRnVHk1Y3ZUbV9RUDdqN1Z2M2ZqVG8wa2RoVHJPeENFRDNHV0wwdi1DbEdOVDFJZnRiZGEydlZyM2tQVExOVlo3LXhIUnhZUnB6a2UzZXNtTjR0S2NzUmFNOWNiSHhHTnJDWHowWk1tbVFKUC14M25aQ1hyYjhJM2pxOEtZY0J1WTZrU3l6cDJOdk5iSXpBUk41MFFVellVZFU4UWVDZXFkQnJFbGxQX2J0S3pReU8zZUdsZUgtTnJuSlpfTjdxR3UxWTBEV0JaRV93eE9qa2dNa2tVTHRxMWNyeUh2VWNrYkdKM3BZOURkUlBxUDA3R2M4NnlMTVR2dmNMZi1lZlhzalRJWlFocGRleVRJYXBBY2hCXzFGZEU4ZVFxbHNic3RDV2FYN1dNaWpkaGdwYTEzRkZYRlEtRXR1cERHdnJKX1Zzb1Q0MnVYZkVhb0VYU1JPdFhoV29TMlhTaEppR1lTTURLYmZnNS1pSzl4T1k5MXJ0YV9qX0ZyQ1R6RFFzRndrTW9IUVlxcG5jcTEyYVU3dkpIR0tZZTZiOXNIRFpIalRtUDFBLVNyd1NfNUMtLW52NVpFZGpQenJCOGw0UlJZNlZVT1ZXTm92R3k4c3hTQXFoNFE3TUFHcjRWc01zT082anJZT0laakl5VUk1WDdDaWlubjIwS3RNcjBjTTdpbUNxSmxNR05JaWtEQURlS1h6N2h0NE9CcW5rQ3NXWkwyNXVBUU5mLTU5MG8xX29xZ0t6Z2pKWmhMNG1BNXBhYWkzY0loSmluUXNKdURwQWRIV2laM2dHQTFxV19lbkZXWmdfWEdiWEZsMGVIWDdoMnJ5dzM0ZGtBM3BSRVp2QzFNbFJSWXBManN5WmFVMlp6aUpWMF9jMTRPbWptM1lsTE41NG1kUW4tT0ZqTzNaZnZ5ZzBLZzNNc1N1X2FMMVJ0N3o4a25LMkxKVUE0dTNhU3hZX3RFMUtKcEgtX1B0cTdEMmYyMzdPaEhoeWhaUGRITC11NzRWYTJnZldiUkFvdG95a1RwWnNKaERkT0kxN1RJMzZQZzFiSjl1SlJieTJjaHBMYmZDUlhTT2hvQnRPaTNhS3NzaVc1Tms0X0FyUHRsSXdCLW1OUWk1RkRKc3pqSjVQTFFROEN5M3pxUGVjZHI4SVM3Qmx1S1A2bEEzNWlVWkFndGpUSm4wcV9jRjQ5T0l1c3ZqN0w3Z1dMV2ZtbU9MbTVSOXphX3VLMko2ZEs3U0NIaFFIMVFIcnN0OGIxSjdxNGlHUHRnOEJDaGwzcXJYNFBnOGdFSVFuSGUyOWJ3WmtlVGhGQWk0THdZd1hUbGRydk83SWVzWUJrb21tSlNvVkJjdWYtcWo0aEc1Ri1XNTZoSENaRWJISmp3UlJNMU9vSnNzZ0VudXpxMDA3aGdfSDBNZlA0Y1gybkF4dGl6SzFOc1VMN0dzVkQxVllkSDhyby12SWNxTFRYdThJUm13S3p3cGFYc05TbVc2YVNtZEdCOFBCUXhadkIzNmdkbXpnc1pLYUhzOEtsY2kxVmNYZm9wOS1LOERLRHJhY2VhanNjaThUZW1rS01wUW05SFJxOGd1VF9STlJZWDRiTV92dXlQTkdxN3BYYTN1SUhRSjRNTy1PZWpGd0xhUlVES0hiWE5LUkM5dHNvenR3TVMySC1ueUZXUkxFY2VyRmhISGc2U2ZxeXY2VkJULV9pOTU1QkI5VUNndnVQcVItTW96VTBqRTdzem1IQ1UxVWtWdjhvTERFeGJ6M3dJNERUV1BTeUlRcG1fbUVjQ0lNREF5QkpLeHJHRkFxQS1kZEE4bXJ2aVVSckVoTkZwNGtoRElIcUktQjA1bkNRclM4dWlqUVRXXzdlQ0VjQWZGSTZlR01NQmU5bHQ3bGNtZWU1eHVvRVdQRVU4Rmx0OFRTaWF3cGgyeFJoM25sRk1GNXJtdEpfcEJmYVFrZXd4eXl0c0ZKVjQ3MkFNRjh5bDBTbFZNd256dmxpQlo5Z1FRM1ZmVTJSb3VrZTk3cXVQYmZ6SnNUWGhlSUhrUjVWUHFwemNmbW1scWVxTkcxT1p5dVlvUjhCSVJaSnBjU0dpc3YzVkt1WUtrd2xoQlVNQXh1eDhmTXNISWMyUnBUMmIwamxlS0tjMVRiWDlBcE03b1BHR1FmdmlsX2ZlMTNCaFNvNG1TeTNiQXRNZ2Y1eE1IaFAxTUZGZ1YyZjEzTG9PaGRCdHJzVlB5Mm12T1NiX2RyT2d2RERCRWFHT0dadW5DZjNtdXE4cHhEQlpub2l3bz0=
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
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
|
||||
"""
|
||||
|
||||
import re
|
||||
import re as _re
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, Dict, Any, Optional, AsyncGenerator, Union
|
||||
from modules.datamodels.datamodelAi import AiModel, AiModelCall, AiModelResponse
|
||||
|
||||
|
||||
_RETRY_AFTER_PATTERN = re.compile(
|
||||
r"(?:try again in|retry after)\s+(\d+(?:\.\d+)?)\s*s", re.IGNORECASE
|
||||
_RETRY_AFTER_PATTERN = _re.compile(
|
||||
r"(?:try again in|retry after)\s+(\d+(?:\.\d+)?)\s*s", _re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Dynamic model registry that collects models from all AI connectors.
|
||||
|
|
@ -12,9 +12,10 @@ import time
|
|||
import threading
|
||||
from typing import Dict, List, Optional, Any, Tuple
|
||||
from modules.datamodels.datamodelAi import AiModel
|
||||
from modules.datamodels.datamodelRbac import AccessRuleContext, RbacProtocol
|
||||
from .aicoreBase import BaseConnectorAi
|
||||
from modules.datamodels.datamodelUam import User
|
||||
from modules.security.rbacHelpers import checkResourceAccess
|
||||
from modules.security.rbac import RbacClass
|
||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -185,7 +186,7 @@ class ModelRegistry:
|
|||
def getAvailableModels(
|
||||
self,
|
||||
currentUser: Optional[User] = None,
|
||||
rbacInstance: Optional[RbacProtocol] = None,
|
||||
rbacInstance: Optional[RbacClass] = None,
|
||||
mandateId: Optional[str] = None,
|
||||
featureInstanceId: Optional[str] = None
|
||||
) -> List[AiModel]:
|
||||
|
|
@ -236,7 +237,7 @@ class ModelRegistry:
|
|||
self,
|
||||
models: List[AiModel],
|
||||
currentUser: User,
|
||||
rbacInstance: RbacProtocol,
|
||||
rbacInstance: RbacClass,
|
||||
mandateId: Optional[str] = None,
|
||||
featureInstanceId: Optional[str] = None
|
||||
) -> List[AiModel]:
|
||||
|
|
@ -261,7 +262,7 @@ class ModelRegistry:
|
|||
logger.debug(f"User {currentUser.username} does not have access to model {model.displayName} (connector: {model.connectorType})")
|
||||
return filteredModels
|
||||
|
||||
def getModel(self, displayName: str, currentUser: Optional[User] = None, rbacInstance: Optional[RbacProtocol] = None) -> Optional[AiModel]:
|
||||
def getModel(self, displayName: str, currentUser: Optional[User] = None, rbacInstance: Optional[RbacClass] = None) -> Optional[AiModel]:
|
||||
"""Get a specific model by displayName, optionally checking RBAC permissions.
|
||||
|
||||
Args:
|
||||
|
|
@ -283,15 +284,8 @@ class ModelRegistry:
|
|||
connectorResourcePath = f"ai.model.{model.connectorType}"
|
||||
modelResourcePath = f"ai.model.{model.connectorType}.{model.displayName}"
|
||||
|
||||
try:
|
||||
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
|
||||
hasConnectorAccess = checkResourceAccess(rbacInstance, currentUser, connectorResourcePath)
|
||||
hasModelAccess = checkResourceAccess(rbacInstance, currentUser, modelResourcePath)
|
||||
|
||||
if not (hasConnectorAccess or hasModelAccess):
|
||||
logger.warning(f"User {currentUser.username} does not have access to model {displayName}")
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Simplified model selection based on model properties and priority-based sorting.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import httpx
|
||||
|
|
@ -656,8 +655,9 @@ class AiAnthropic(BaseConnectorAi):
|
|||
base64Data = parts[1]
|
||||
|
||||
_SUPPORTED = {"image/jpeg", "image/png", "image/gif", "image/webp"}
|
||||
import base64 as _b64
|
||||
try:
|
||||
rawHead = base64.b64decode(base64Data[:32])
|
||||
rawHead = _b64.b64decode(base64Data[:32])
|
||||
if rawHead[:3] == b"\xff\xd8\xff":
|
||||
mimeType = "image/jpeg"
|
||||
elif rawHead[:8] == b"\x89PNG\r\n\x1a\n":
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
import logging
|
||||
from typing import List
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
import logging
|
||||
import json
|
||||
import json as _json
|
||||
import httpx
|
||||
from typing import List, Dict, Any, AsyncGenerator, Union
|
||||
from fastapi import HTTPException
|
||||
|
|
@ -274,7 +274,7 @@ class AiMistral(BaseConnectorAi):
|
|||
bodyStr = body.decode()
|
||||
if response.status_code == 429:
|
||||
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):
|
||||
errorMsg = f"Rate limit exceeded for {model.name}"
|
||||
raise RateLimitExceededException(f"Rate limit exceeded for {model.name}: {errorMsg}")
|
||||
|
|
@ -287,8 +287,8 @@ class AiMistral(BaseConnectorAi):
|
|||
if data.strip() == "[DONE]":
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
chunk = _json.loads(data)
|
||||
except _json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
||||
|
|
@ -343,8 +343,7 @@ class AiMistral(BaseConnectorAi):
|
|||
content="", success=False, error="No embeddingInput provided"
|
||||
)
|
||||
|
||||
from modules.datamodels.datamodelKnowledge import KNOWLEDGE_EMBEDDING_DIMENSIONS
|
||||
payload = {"model": model.name, "input": texts, "output_dimension": KNOWLEDGE_EMBEDDING_DIMENSIONS}
|
||||
payload = {"model": model.name, "input": texts}
|
||||
response = await self.httpClient.post(model.apiUrl, json=payload)
|
||||
|
||||
if response.status_code != 200:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
import logging
|
||||
import json
|
||||
import json as _json
|
||||
import httpx
|
||||
from typing import List, Dict, Any, AsyncGenerator, Union
|
||||
from fastapi import HTTPException
|
||||
|
|
@ -297,6 +297,27 @@ class AiOpenai(BaseConnectorAi):
|
|||
version="text-embedding-3-small",
|
||||
calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.00002
|
||||
),
|
||||
AiModel(
|
||||
name="text-embedding-3-large",
|
||||
displayName="OpenAI Embedding Large",
|
||||
connectorType="openai",
|
||||
apiUrl="https://api.openai.com/v1/embeddings",
|
||||
temperature=0.0,
|
||||
maxTokens=0,
|
||||
contextLength=8191,
|
||||
costPer1kTokensInput=0.00013, # $0.13/M tokens
|
||||
costPer1kTokensOutput=0.0,
|
||||
speedRating=9,
|
||||
qualityRating=10,
|
||||
functionCall=self.callEmbedding,
|
||||
priority=PriorityEnum.QUALITY,
|
||||
processingMode=ProcessingModeEnum.ADVANCED,
|
||||
operationTypes=createOperationTypeRatings(
|
||||
(OperationTypeEnum.EMBEDDING, 10)
|
||||
),
|
||||
version="text-embedding-3-large",
|
||||
calculatepriceCHF=lambda processingTime, bytesSent, bytesReceived: (bytesSent / 4 / 1000) * 0.00013
|
||||
),
|
||||
AiModel(
|
||||
name="gpt-image-1",
|
||||
displayName="OpenAI GPT Image",
|
||||
|
|
@ -456,7 +477,7 @@ class AiOpenai(BaseConnectorAi):
|
|||
bodyStr = body.decode()
|
||||
if response.status_code == 429:
|
||||
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):
|
||||
errorMsg = f"Rate limit exceeded for {model.name}"
|
||||
raise RateLimitExceededException(f"Rate limit exceeded for {model.name}: {errorMsg}")
|
||||
|
|
@ -469,8 +490,8 @@ class AiOpenai(BaseConnectorAi):
|
|||
if data.strip() == "[DONE]":
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
chunk = _json.loads(data)
|
||||
except _json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
||||
|
|
@ -526,8 +547,7 @@ class AiOpenai(BaseConnectorAi):
|
|||
content="", success=False, error="No embeddingInput provided"
|
||||
)
|
||||
|
||||
from modules.datamodels.datamodelKnowledge import KNOWLEDGE_EMBEDDING_DIMENSIONS
|
||||
payload = {"model": model.name, "input": texts, "dimensions": KNOWLEDGE_EMBEDDING_DIMENSIONS}
|
||||
payload = {"model": model.name, "input": texts}
|
||||
response = await self.httpClient.post(model.apiUrl, json=payload)
|
||||
|
||||
if response.status_code != 200:
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
import logging
|
||||
import httpx
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
AI Connector for PowerOn Private-LLM Service.
|
||||
|
|
@ -14,7 +14,7 @@ Models (current — L4 24 GB):
|
|||
Models (next-gen — RTX PRO 6000 96 GB, auto-activated when pulled in Ollama):
|
||||
- poweron-text-reasoning: Reasoning (deepseek-r1:70b); complex logic, math, planning
|
||||
- poweron-vision-general: Vision (llama4:scout); multimodal, long-context documents
|
||||
- poweron-embed: Embedding (mxbai-embed-large); local RAG embedding (1024 dim)
|
||||
- poweron-embed: Embedding (nomic-embed-text); local RAG embedding
|
||||
|
||||
Pricing: byte-based (~per-token via bytes/4), configured via the PRICE_* constants below.
|
||||
"""
|
||||
|
|
@ -377,7 +377,7 @@ class AiPrivateLlm(BaseConnectorAi):
|
|||
),
|
||||
"ollamaModel": "llama4:scout"
|
||||
},
|
||||
# Local Embedding (mxbai-embed-large — nativ 1024 dim, MTEB 64.68)
|
||||
# Local Embedding (nomic-embed-text — replaces OpenAI text-embedding-3-small)
|
||||
{
|
||||
"model": AiModel(
|
||||
name="poweron-embed",
|
||||
|
|
@ -386,21 +386,21 @@ class AiPrivateLlm(BaseConnectorAi):
|
|||
apiUrl=f"{self.baseUrl}/v1/embeddings",
|
||||
temperature=0.0,
|
||||
maxTokens=0,
|
||||
contextLength=512,
|
||||
contextLength=8192,
|
||||
costPer1kTokensInput=PRICE_EMBED_PER_1K,
|
||||
costPer1kTokensOutput=0.0,
|
||||
speedRating=10,
|
||||
qualityRating=8,
|
||||
functionCall=self.callEmbedding,
|
||||
functionCall=self.callAiText,
|
||||
priority=PriorityEnum.COST,
|
||||
processingMode=ProcessingModeEnum.BASIC,
|
||||
operationTypes=createOperationTypeRatings(
|
||||
(OperationTypeEnum.EMBEDDING, 9),
|
||||
),
|
||||
version="mxbai-embed-large",
|
||||
version="nomic-embed-text",
|
||||
calculatepriceCHF=_calcPrivateEmbedPriceCHF
|
||||
),
|
||||
"ollamaModel": "mxbai-embed-large"
|
||||
"ollamaModel": "nomic-embed-text"
|
||||
},
|
||||
]
|
||||
|
||||
|
|
@ -505,46 +505,6 @@ class AiPrivateLlm(BaseConnectorAi):
|
|||
logger.error(f"Error calling Private-LLM text API: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Error calling Private-LLM API: {str(e)}")
|
||||
|
||||
async def callEmbedding(self, modelCall: AiModelCall) -> AiModelResponse:
|
||||
"""Generate embeddings via the Private-LLM Embedding endpoint (OpenAI-compatible)."""
|
||||
try:
|
||||
model = modelCall.model
|
||||
texts = modelCall.embeddingInput or []
|
||||
if not texts:
|
||||
return AiModelResponse(
|
||||
content="", success=False, error="No embeddingInput provided"
|
||||
)
|
||||
|
||||
payload = {"model": model.version, "input": texts}
|
||||
response = await self.httpClient.post(model.apiUrl, json=payload)
|
||||
|
||||
if response.status_code != 200:
|
||||
errorMessage = f"Private-LLM Embedding API error: {response.status_code} - {response.text}"
|
||||
if response.status_code == 429:
|
||||
raise RateLimitExceededException(errorMessage)
|
||||
raise HTTPException(status_code=500, detail=errorMessage)
|
||||
|
||||
responseJson = response.json()
|
||||
embeddings = [item["embedding"] for item in responseJson["data"]]
|
||||
usage = responseJson.get("usage", {})
|
||||
|
||||
return AiModelResponse(
|
||||
content="",
|
||||
success=True,
|
||||
modelId=model.name,
|
||||
tokensUsed={
|
||||
"input": usage.get("prompt_tokens", 0),
|
||||
"output": 0,
|
||||
"total": usage.get("total_tokens", 0),
|
||||
},
|
||||
metadata={"embeddings": embeddings},
|
||||
)
|
||||
except (RateLimitExceededException, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error calling Private-LLM Embedding API: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Error calling Private-LLM Embedding API: {str(e)}")
|
||||
|
||||
async def callAiVision(self, modelCall: AiModelCall) -> AiModelResponse:
|
||||
"""
|
||||
Call the Private-LLM API for vision-based analysis.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Tavily web search class.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Authentication and authorization modules for routes and services.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Authentication module for backend API.
|
||||
|
|
@ -437,7 +437,7 @@ def requireSysAdmin(currentUser: User = Depends(getCurrentUser)) -> User:
|
|||
|
||||
# Audit for all SysAdmin actions
|
||||
try:
|
||||
from modules.dbHelpers.auditLogger import audit_logger
|
||||
from modules.shared.auditLogger import audit_logger
|
||||
audit_logger.logSecurityEvent(
|
||||
userId=str(currentUser.id),
|
||||
mandateId="system",
|
||||
|
|
@ -483,7 +483,7 @@ def requirePlatformAdmin(currentUser: User = Depends(getCurrentUser)) -> User:
|
|||
|
||||
# Audit for all Platform-Admin actions
|
||||
try:
|
||||
from modules.dbHelpers.auditLogger import audit_logger
|
||||
from modules.shared.auditLogger import audit_logger
|
||||
audit_logger.logSecurityEvent(
|
||||
userId=str(currentUser.id),
|
||||
mandateId="system",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
CSRF Protection Middleware for PowerOn Gateway
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# All rights reserved.
|
||||
"""Ensure new users receive a Home mandate on first login."""
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def ensureHomeMandate(rootInterface, user) -> None:
|
||||
"""Ensure user has a Home mandate, but only if they have no mandate memberships
|
||||
AND no pending invitations.
|
||||
|
||||
Invited users should NOT get a Home mandate — they join existing mandates via
|
||||
invitation acceptance and can create their own later via onboarding.
|
||||
"""
|
||||
userId = str(user.id)
|
||||
userMandates = rootInterface.getUserMandates(userId)
|
||||
|
||||
if userMandates:
|
||||
for um in userMandates:
|
||||
mandate = rootInterface.getMandate(um.mandateId)
|
||||
if mandate and (mandate.name or "").startswith("Home ") and not mandate.isSystem:
|
||||
return
|
||||
logger.debug(
|
||||
f"User {user.username} has {len(userMandates)} mandate(s) but no Home — skipping auto-creation"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
normalizedEmail = (user.email or "").strip().lower() if user.email else None
|
||||
pendingByUsername = rootInterface.getInvitationsByTargetUsername(user.username)
|
||||
pendingByEmail = (
|
||||
rootInterface.getInvitationsByEmail(normalizedEmail) if normalizedEmail else []
|
||||
)
|
||||
seenIds = set()
|
||||
for inv in pendingByUsername + pendingByEmail:
|
||||
if inv.id in seenIds:
|
||||
continue
|
||||
seenIds.add(inv.id)
|
||||
if not inv.revokedAt and (inv.currentUses or 0) < (inv.maxUses or 1):
|
||||
logger.info(
|
||||
f"User {user.username} has pending invitation(s) — skipping Home mandate creation"
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not check pending invitations for {user.username}: {e}")
|
||||
|
||||
homeMandateLabel = f"Home {user.username}"
|
||||
rootInterface._provisionMandateForUser(
|
||||
userId=userId,
|
||||
mandateLabel=homeMandateLabel,
|
||||
planKey="TRIAL_14D",
|
||||
)
|
||||
logger.info(f"Created Home mandate '{homeMandateLabel}' for user {user.username}")
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
JWT Service
|
||||
Centralizes local JWT creation and cookie helpers.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
from typing import Optional, Tuple
|
||||
from fastapi import Response
|
||||
from jose import jwt
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
MFA (Multi-Factor Authentication) Service.
|
||||
|
|
@ -27,7 +27,7 @@ _MFA_INTERVAL = 30
|
|||
_MFA_VALID_WINDOW = 1
|
||||
|
||||
|
||||
def getMfaIssuer() -> str:
|
||||
def _getMfaIssuer() -> str:
|
||||
"""Build the TOTP issuer name, e.g. 'PowerOn' or 'PowerOn (Dev)'."""
|
||||
envType = (APP_CONFIG.get("APP_ENV_TYPE") or "").strip().lower()
|
||||
if envType in ("prod", ""):
|
||||
|
|
@ -44,11 +44,11 @@ def _encryptSecret(plainSecret: str, userId: str = "system") -> str:
|
|||
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")
|
||||
|
||||
|
||||
def buildTotp(plainSecret: str) -> pyotp.TOTP:
|
||||
def _buildTotp(plainSecret: str) -> pyotp.TOTP:
|
||||
return pyotp.TOTP(plainSecret, digits=_MFA_DIGITS, interval=_MFA_INTERVAL)
|
||||
|
||||
|
||||
|
|
@ -61,8 +61,8 @@ def generateSetup(userId: str, username: str) -> dict:
|
|||
"""
|
||||
plain = _generateSecret()
|
||||
encrypted = _encryptSecret(plain, userId=userId)
|
||||
totp = buildTotp(plain)
|
||||
uri = totp.provisioning_uri(name=username, issuer_name=getMfaIssuer())
|
||||
totp = _buildTotp(plain)
|
||||
uri = totp.provisioning_uri(name=username, issuer_name=_getMfaIssuer())
|
||||
return {
|
||||
"encryptedSecret": encrypted,
|
||||
"provisioningUri": uri,
|
||||
|
|
@ -72,8 +72,8 @@ def generateSetup(userId: str, username: str) -> dict:
|
|||
def confirmSetup(encryptedSecret: str, code: str, userId: str = "system") -> bool:
|
||||
"""Verify a TOTP code against an encrypted secret (enrolment confirmation)."""
|
||||
try:
|
||||
plain = decryptSecret(encryptedSecret, userId=userId)
|
||||
totp = buildTotp(plain)
|
||||
plain = _decryptSecret(encryptedSecret, userId=userId)
|
||||
totp = _buildTotp(plain)
|
||||
return totp.verify(code, valid_window=_MFA_VALID_WINDOW)
|
||||
except Exception:
|
||||
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:
|
||||
"""Verify a TOTP code during login."""
|
||||
try:
|
||||
plain = decryptSecret(encryptedSecret, userId=userId)
|
||||
totp = buildTotp(plain)
|
||||
plain = _decryptSecret(encryptedSecret, userId=userId)
|
||||
totp = _buildTotp(plain)
|
||||
return totp.verify(code, valid_window=_MFA_VALID_WINDOW)
|
||||
except Exception:
|
||||
logger.exception("MFA verifyCode failed for userId=%s", userId)
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Short-lived signed tickets for OAuth data-connection popups.
|
||||
|
||||
The UI authenticates API calls with a Bearer token in localStorage, but
|
||||
``window.open(authUrl)`` cannot send that header. Cross-origin httpOnly cookies
|
||||
are unreliable in cross-origin setups (UI and API on different subdomains).
|
||||
are unreliable in int/prod (UI on poweron-center.net, API on poweron.swiss).
|
||||
Login popups work without a session because ``/auth/login`` is public; connect
|
||||
popups hit ``/auth/connect``, which used to require ``getCurrentUser``.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""OAuth scope sets for split Auth- vs Data-apps (Google / Microsoft)."""
|
||||
|
||||
|
|
@ -46,21 +46,6 @@ def msftDataScopesForRefresh() -> str:
|
|||
return " ".join(msftDataScopes)
|
||||
|
||||
|
||||
# Microsoft — Resource ".default": pulls exactly the permissions already
|
||||
# admin-consented for the app in the user's tenant. Triggers NO interactive /
|
||||
# admin consent (errors AADSTS65001 only if consent is truly missing), which is
|
||||
# what we want for tenants that have disabled user consent but granted tenant-wide
|
||||
# admin consent. msftAuthScopes / msftDataScopes stay as documentation of the
|
||||
# expected permission set.
|
||||
MSFT_GRAPH_RESOURCE = "https://graph.microsoft.com"
|
||||
|
||||
|
||||
def msftGraphDefaultScopes() -> list:
|
||||
"""Single resource ``.default`` scope for Microsoft Graph (must not be mixed
|
||||
with individual scopes or reserved scopes — MSAL adds openid/profile/offline_access)."""
|
||||
return [f"{MSFT_GRAPH_RESOURCE}/.default"]
|
||||
|
||||
|
||||
# Infomaniak intentionally has no OAuth scope set: the kDrive + Mail data APIs
|
||||
# are only reachable with manually issued Personal Access Tokens (see
|
||||
# wiki/d-guides/infomaniak-token-setup.md). The OAuth /authorize endpoint at
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Token Manager Service
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Token Refresh Middleware for PowerOn Gateway
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Token Refresh Service for PowerOn Gateway
|
||||
|
|
@ -12,7 +12,7 @@ import logging
|
|||
from typing import Dict, Any
|
||||
from modules.datamodels.datamodelUam import UserConnection, AuthAuthority
|
||||
from modules.shared.timeUtils import getUtcTimestamp
|
||||
from modules.dbHelpers.auditLogger import audit_logger
|
||||
from modules.shared.auditLogger import audit_logger
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -21,9 +21,7 @@ class TokenRefreshService:
|
|||
|
||||
def __init__(self):
|
||||
self.rate_limit_map = {} # Track refresh attempts per connection
|
||||
# Allow enough proactive refreshes per hour so the wider pre-expiry window
|
||||
# (see proactive_refresh) is never blocked for an actively used connection.
|
||||
self.max_attempts_per_hour = 6
|
||||
self.max_attempts_per_hour = 3
|
||||
self.refresh_window_minutes = 60
|
||||
|
||||
def _is_rate_limited(self, connection_id: str) -> bool:
|
||||
|
|
@ -217,12 +215,7 @@ class TokenRefreshService:
|
|||
|
||||
async def proactive_refresh(self, user_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Proactively refresh tokens that expire within the refresh window (30 min).
|
||||
|
||||
A wide window means any request during the last 30 minutes of a token's
|
||||
lifetime renews it via the refresh token, so an actively used connection
|
||||
effectively never lapses (the stored expiresAt always reflects the real
|
||||
Microsoft/Google token lifetime — it is never faked).
|
||||
Proactively refresh tokens that expire within 5 minutes
|
||||
|
||||
Args:
|
||||
user_id: User ID to check tokens for
|
||||
|
|
@ -248,7 +241,7 @@ class TokenRefreshService:
|
|||
failed_count = 0
|
||||
rate_limited_count = 0
|
||||
current_time = getUtcTimestamp()
|
||||
refresh_window = 30 * 60 # 30 minutes in seconds (matches TokenManager.getFreshToken)
|
||||
five_minutes = 5 * 60 # 5 minutes in seconds
|
||||
|
||||
# Process each connection
|
||||
for connection in connections:
|
||||
|
|
@ -257,9 +250,9 @@ class TokenRefreshService:
|
|||
connection.tokenExpiresAt and
|
||||
connection.authority in [AuthAuthority.GOOGLE, AuthAuthority.MSFT]):
|
||||
|
||||
# Check if token expires within the refresh window
|
||||
# Check if token expires within 5 minutes
|
||||
time_until_expiry = connection.tokenExpiresAt - current_time
|
||||
if 0 < time_until_expiry <= refresh_window:
|
||||
if 0 < time_until_expiry <= five_minutes:
|
||||
|
||||
# Check rate limiting
|
||||
if self._is_rate_limited(connection.id):
|
||||
|
|
|
|||
|
|
@ -1,219 +0,0 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# All rights reserved.
|
||||
"""
|
||||
Trusted Device Service.
|
||||
|
||||
After successful MFA verification a device can be marked as trusted for a
|
||||
configurable duration (default 60 days). On subsequent logins from the same
|
||||
device the MFA step is skipped.
|
||||
|
||||
Cookie: ``mfa_trusted`` (httpOnly, Secure, SameSite policy from jwtService).
|
||||
DB: ``TrustedDevice`` table in poweron_app.
|
||||
|
||||
Regulatory basis:
|
||||
- NIST SP 800-63B Section 5.2.8: Verifier MAY re-authenticate only after a
|
||||
configurable period when a device is bound to the subscriber.
|
||||
- Microsoft, Google, AWS implement identical patterns.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Request, Response
|
||||
|
||||
from modules.shared.configuration import APP_CONFIG
|
||||
from modules.shared.timeUtils import getUtcNow, getUtcTimestamp
|
||||
from modules.datamodels.datamodelSecurity import TrustedDevice, Token, TokenPurpose
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_COOKIE_NAME = "mfa_trusted"
|
||||
_DEFAULT_TRUST_DAYS = 60
|
||||
_TOKEN_BYTES = 32
|
||||
|
||||
|
||||
def _getTrustDurationDays() -> int:
|
||||
raw = (APP_CONFIG.get("MFA_TRUST_DURATION_DAYS") or "").strip()
|
||||
if raw.isdigit() and int(raw) > 0:
|
||||
return int(raw)
|
||||
return _DEFAULT_TRUST_DAYS
|
||||
|
||||
|
||||
def createTrustedDevice(userId: str, request: Request, response: Response, db) -> str:
|
||||
"""Create a TrustedDevice entry and set the cookie on the response.
|
||||
|
||||
Returns the device token (cookie value).
|
||||
"""
|
||||
from modules.auth.jwtService import _cookiePolicy
|
||||
|
||||
trustDays = _getTrustDurationDays()
|
||||
deviceToken = secrets.token_urlsafe(_TOKEN_BYTES)
|
||||
|
||||
now = getUtcTimestamp()
|
||||
trustedUntil = now + (trustDays * 86400)
|
||||
|
||||
device = TrustedDevice(
|
||||
id=deviceToken,
|
||||
userId=userId,
|
||||
trustedUntil=trustedUntil,
|
||||
userAgent=(request.headers.get("user-agent") or "")[:512],
|
||||
ipAddress=_getClientIp(request),
|
||||
createdAt=now,
|
||||
)
|
||||
|
||||
try:
|
||||
db.recordCreate(TrustedDevice, device.model_dump())
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to persist TrustedDevice for userId={userId}: {e}")
|
||||
return ""
|
||||
|
||||
useSecure, samesite, _ = _cookiePolicy()
|
||||
response.set_cookie(
|
||||
key=_COOKIE_NAME,
|
||||
value=deviceToken,
|
||||
httponly=True,
|
||||
secure=useSecure,
|
||||
samesite=samesite,
|
||||
path="/",
|
||||
max_age=trustDays * 86400,
|
||||
)
|
||||
|
||||
logger.info(f"Trusted device created for userId={userId}, valid {trustDays}d")
|
||||
return deviceToken
|
||||
|
||||
|
||||
def isTrustedDevice(request: Request, userId: str, db) -> bool:
|
||||
"""Check if the current request comes from a trusted device for the given user."""
|
||||
deviceToken = request.cookies.get(_COOKIE_NAME)
|
||||
if not deviceToken:
|
||||
return False
|
||||
|
||||
try:
|
||||
records = db.getRecordset(
|
||||
TrustedDevice,
|
||||
recordFilter={"id": deviceToken, "userId": userId},
|
||||
)
|
||||
if not records:
|
||||
return False
|
||||
|
||||
device = records[0]
|
||||
trustedUntil = device.get("trustedUntil", 0)
|
||||
if isinstance(trustedUntil, (int, float)) and trustedUntil > getUtcTimestamp():
|
||||
return True
|
||||
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning(f"Error checking trusted device for userId={userId}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def revokeTrustedDevices(userId: str, db) -> int:
|
||||
"""Revoke all trusted devices for a user. Returns count of deleted entries."""
|
||||
try:
|
||||
records = db.getRecordset(TrustedDevice, recordFilter={"userId": userId})
|
||||
count = 0
|
||||
for rec in records:
|
||||
db.recordDelete(TrustedDevice, rec["id"])
|
||||
count += 1
|
||||
if count:
|
||||
logger.info(f"Revoked {count} trusted device(s) for userId={userId}")
|
||||
return count
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to revoke trusted devices for userId={userId}: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
def clearTrustedDeviceCookie(response: Response) -> None:
|
||||
"""Clear the mfa_trusted cookie."""
|
||||
from modules.auth.jwtService import _cookiePolicy
|
||||
|
||||
useSecure, samesite, samesiteHeader = _cookiePolicy()
|
||||
secure_flag = "; Secure" if useSecure else ""
|
||||
response.headers.append(
|
||||
"Set-Cookie",
|
||||
f"{_COOKIE_NAME}=deleted; Path=/; Max-Age=0; Expires=Thu, 01 Jan 1970 00:00:00 GMT; HttpOnly{secure_flag}; SameSite={samesiteHeader}"
|
||||
)
|
||||
response.delete_cookie(
|
||||
key=_COOKIE_NAME,
|
||||
path="/",
|
||||
secure=useSecure,
|
||||
httponly=True,
|
||||
samesite=samesite,
|
||||
)
|
||||
|
||||
|
||||
def cleanupExpiredDevices(db) -> int:
|
||||
"""Remove TrustedDevice entries past their trustedUntil. Returns deleted count."""
|
||||
try:
|
||||
records = db.getRecordset(TrustedDevice, recordFilter={})
|
||||
now = getUtcTimestamp()
|
||||
count = 0
|
||||
for rec in records:
|
||||
if rec.get("trustedUntil", 0) < now:
|
||||
db.recordDelete(TrustedDevice, rec["id"])
|
||||
count += 1
|
||||
if count:
|
||||
logger.info(f"Cleaned up {count} expired trusted device(s)")
|
||||
return count
|
||||
except Exception as e:
|
||||
logger.error(f"Error cleaning up expired trusted devices: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
def _getClientIp(request: Request) -> Optional[str]:
|
||||
"""Extract client IP from request (respects X-Forwarded-For)."""
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
if request.client:
|
||||
return request.client.host
|
||||
return None
|
||||
|
||||
|
||||
# --- Scheduler Integration ---
|
||||
|
||||
async def _runTokenAndDeviceCleanup() -> None:
|
||||
"""Scheduled task: remove expired tokens and trusted devices."""
|
||||
try:
|
||||
from modules.connectors.connectorDbPostgre import ConnectorPostgre
|
||||
|
||||
db = ConnectorPostgre("poweron_app")
|
||||
now = getUtcTimestamp()
|
||||
|
||||
# Expired auth-session tokens
|
||||
tokens = db.getRecordset(
|
||||
Token,
|
||||
recordFilter={"tokenPurpose": TokenPurpose.AUTH_SESSION.value},
|
||||
)
|
||||
expiredCount = 0
|
||||
for t in tokens:
|
||||
if t.get("expiresAt", 0) < now:
|
||||
db.recordDelete(Token, t["id"])
|
||||
expiredCount += 1
|
||||
|
||||
# Expired trusted devices
|
||||
deviceCount = cleanupExpiredDevices(db)
|
||||
|
||||
if expiredCount or deviceCount:
|
||||
logger.info(
|
||||
f"Token cleanup: {expiredCount} expired token(s), "
|
||||
f"{deviceCount} expired trusted device(s) removed"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Token/device cleanup failed: {e}")
|
||||
|
||||
|
||||
def registerTokenCleanupScheduler() -> None:
|
||||
"""Register daily token cleanup job. Call during app startup."""
|
||||
try:
|
||||
from modules.shared.eventManagement import eventManager
|
||||
|
||||
eventManager.registerCron(
|
||||
jobId="token_device_cleanup",
|
||||
func=_runTokenAndDeviceCleanup,
|
||||
cronKwargs={"hour": "4", "minute": "0"},
|
||||
)
|
||||
logger.info("Token/device cleanup scheduler registered (daily 04:00)")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to register token cleanup scheduler: {e}")
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Shared HTTP resilience helpers for provider connectors.
|
||||
|
||||
|
|
@ -1,9 +1,6 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
import contextvars
|
||||
import copy
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
import psycopg2
|
||||
|
|
@ -11,7 +8,6 @@ import psycopg2.extras
|
|||
import psycopg2.pool
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Dict, Any, Optional, Union, get_origin, get_args, Type
|
||||
import uuid
|
||||
from pydantic import BaseModel, Field
|
||||
|
|
@ -20,6 +16,8 @@ import threading
|
|||
from modules.shared.timeUtils import getUtcTimestamp
|
||||
from modules.shared.configuration import APP_CONFIG
|
||||
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__)
|
||||
|
||||
|
|
@ -151,6 +149,7 @@ def getModelFields(model_class) -> Dict[str, str]:
|
|||
|
||||
def parseRecordFields(record: Dict[str, Any], fields: Dict[str, str], context: str = "") -> None:
|
||||
"""Parse record fields in-place: numeric typing, vector parsing, JSONB deserialization."""
|
||||
import json as _json
|
||||
|
||||
for fieldName, fieldType in fields.items():
|
||||
if fieldName not in record:
|
||||
|
|
@ -178,10 +177,10 @@ def parseRecordFields(record: Dict[str, Any], fields: Dict[str, str], context: s
|
|||
elif fieldType == "JSONB" and value is not None:
|
||||
try:
|
||||
if isinstance(value, str):
|
||||
record[fieldName] = json.loads(value)
|
||||
record[fieldName] = _json.loads(value)
|
||||
elif not isinstance(value, (dict, list)):
|
||||
record[fieldName] = json.loads(str(value))
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
record[fieldName] = _json.loads(str(value))
|
||||
except (_json.JSONDecodeError, TypeError, ValueError):
|
||||
logger.warning(f"Could not parse JSONB field {fieldName}, keeping as string ({context})")
|
||||
|
||||
|
||||
|
|
@ -233,31 +232,6 @@ _BORROW_WAIT_TIMEOUT_S = 30.0
|
|||
_BORROW_WAIT_BACKOFF_S = 0.05
|
||||
_shuttingDown = False
|
||||
|
||||
# Tables whose schema has been verified/created in this process. Avoids repeating
|
||||
# two information_schema round-trips on every getRecordset() — under concurrent
|
||||
# load (pool tests: 20×50 reads) that was the dominant latency source.
|
||||
_ensuredTables: set[tuple[str, str, str]] = set()
|
||||
_ensuredTablesLock = threading.Lock()
|
||||
|
||||
|
||||
def _tableEnsureCacheKey(dbHost: str, dbDatabase: str, table: str) -> tuple[str, str, str]:
|
||||
return (dbHost, dbDatabase, table.lower())
|
||||
|
||||
|
||||
def _isTableEnsured(key: tuple[str, str, str]) -> bool:
|
||||
with _ensuredTablesLock:
|
||||
return key in _ensuredTables
|
||||
|
||||
|
||||
def _markTableEnsured(key: tuple[str, str, str]) -> None:
|
||||
with _ensuredTablesLock:
|
||||
_ensuredTables.add(key)
|
||||
|
||||
|
||||
def _clearEnsuredTablesCache() -> None:
|
||||
with _ensuredTablesLock:
|
||||
_ensuredTables.clear()
|
||||
|
||||
|
||||
def _resolvePoolMax() -> int:
|
||||
"""Pool size is configurable via `DB_POOL_MAX_CONN` (default 20)."""
|
||||
|
|
@ -349,7 +323,6 @@ def closeAllPools() -> None:
|
|||
"""
|
||||
global _shuttingDown
|
||||
_shuttingDown = True
|
||||
_clearEnsuredTablesCache()
|
||||
_PoolRegistry.closeAll()
|
||||
|
||||
|
||||
|
|
@ -817,19 +790,10 @@ class DatabaseConnector:
|
|||
def _ensureTableExists(self, model_class: type) -> bool:
|
||||
"""Ensures a table exists, creates it if it doesn't."""
|
||||
table = model_class.__name__
|
||||
cache_key = _tableEnsureCacheKey(
|
||||
self.dbHost,
|
||||
self.dbDatabase,
|
||||
self._systemTableName if table == "SystemTable" else table,
|
||||
)
|
||||
if _isTableEnsured(cache_key):
|
||||
return True
|
||||
|
||||
if table == "SystemTable":
|
||||
ok = self._ensureSystemTableExists()
|
||||
if ok:
|
||||
_markTableEnsured(cache_key)
|
||||
return ok
|
||||
# Handle system table specially - it uses _system as the actual table name
|
||||
return self._ensureSystemTableExists()
|
||||
|
||||
try:
|
||||
with self.borrowConn() as conn:
|
||||
|
|
@ -906,7 +870,6 @@ class DatabaseConnector:
|
|||
("jsonb", "TEXT"): "TEXT USING \"{col}\"::text",
|
||||
("text", "DOUBLE PRECISION"): _TEXT_TO_DOUBLE,
|
||||
("text", "INTEGER"): "INTEGER USING NULLIF(\"{col}\", '')::integer",
|
||||
("text", "BOOLEAN"): "BOOLEAN USING CASE WHEN \"{col}\" IN ('true', '1', 't', 'yes') THEN TRUE ELSE FALSE END",
|
||||
("timestamp without time zone", "DOUBLE PRECISION"): 'DOUBLE PRECISION USING EXTRACT(EPOCH FROM "{col}" AT TIME ZONE \'UTC\')',
|
||||
("timestamp with time zone", "DOUBLE PRECISION"): 'DOUBLE PRECISION USING EXTRACT(EPOCH FROM "{col}")',
|
||||
("date", "DOUBLE PRECISION"): 'DOUBLE PRECISION USING EXTRACT(EPOCH FROM "{col}"::timestamp AT TIME ZONE \'UTC\')',
|
||||
|
|
@ -939,7 +902,6 @@ class DatabaseConnector:
|
|||
logger.warning(
|
||||
f"Could not ensure columns for existing table '{table}': {ensure_err}"
|
||||
)
|
||||
_markTableEnsured(cache_key)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error ensuring table {table} exists: {e}")
|
||||
|
|
@ -1033,6 +995,8 @@ class DatabaseConnector:
|
|||
|
||||
# Handle JSONB fields - ensure proper JSON format for PostgreSQL
|
||||
elif col in fields and fields[col] == "JSONB" and value is not None:
|
||||
import json
|
||||
|
||||
if isinstance(value, (dict, list)):
|
||||
value = json.dumps(value)
|
||||
elif isinstance(value, str):
|
||||
|
|
@ -1209,6 +1173,25 @@ class DatabaseConnector:
|
|||
logger.error(f"Error removing initial ID for table {table}: {e}")
|
||||
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:
|
||||
"""Updates the context of the database connector.
|
||||
|
|
@ -1429,17 +1412,18 @@ class DatabaseConnector:
|
|||
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)))
|
||||
if isNumericCol and isDateVal:
|
||||
from datetime import datetime as _dt, timezone as _tz
|
||||
if fromVal and toVal:
|
||||
fromTs = datetime.strptime(str(fromVal), '%Y-%m-%d').replace(tzinfo=timezone.utc).timestamp()
|
||||
toTs = datetime.strptime(str(toVal), '%Y-%m-%d').replace(hour=23, minute=59, second=59, tzinfo=timezone.utc).timestamp()
|
||||
fromTs = _dt.strptime(str(fromVal), '%Y-%m-%d').replace(tzinfo=_tz.utc).timestamp()
|
||||
toTs = _dt.strptime(str(toVal), '%Y-%m-%d').replace(hour=23, minute=59, second=59, tzinfo=_tz.utc).timestamp()
|
||||
where_parts.append(f'"{key}" >= %s AND "{key}" <= %s')
|
||||
values.extend([fromTs, toTs])
|
||||
elif fromVal:
|
||||
fromTs = datetime.strptime(str(fromVal), '%Y-%m-%d').replace(tzinfo=timezone.utc).timestamp()
|
||||
fromTs = _dt.strptime(str(fromVal), '%Y-%m-%d').replace(tzinfo=_tz.utc).timestamp()
|
||||
where_parts.append(f'"{key}" >= %s')
|
||||
values.append(fromTs)
|
||||
else:
|
||||
toTs = datetime.strptime(str(toVal), '%Y-%m-%d').replace(hour=23, minute=59, second=59, tzinfo=timezone.utc).timestamp()
|
||||
toTs = _dt.strptime(str(toVal), '%Y-%m-%d').replace(hour=23, minute=59, second=59, tzinfo=_tz.utc).timestamp()
|
||||
where_parts.append(f'"{key}" <= %s')
|
||||
values.append(toTs)
|
||||
elif isNumericCol:
|
||||
|
|
@ -1514,6 +1498,7 @@ class DatabaseConnector:
|
|||
If pagination is None, returns all records (no LIMIT/OFFSET).
|
||||
"""
|
||||
from modules.datamodels.datamodelPagination import PaginationParams
|
||||
import math
|
||||
|
||||
table = model_class.__name__
|
||||
|
||||
|
|
@ -1555,6 +1540,9 @@ class DatabaseConnector:
|
|||
if fieldFilter and isinstance(fieldFilter, list):
|
||||
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)
|
||||
totalPages = math.ceil(totalItems / pageSize) if totalItems > 0 else 0
|
||||
|
||||
|
|
@ -1590,6 +1578,7 @@ class DatabaseConnector:
|
|||
return []
|
||||
|
||||
if pagination:
|
||||
import copy
|
||||
pagination = copy.deepcopy(pagination)
|
||||
if pagination.filters and column in pagination.filters:
|
||||
pagination.filters.pop(column, None)
|
||||
|
|
@ -1823,6 +1812,7 @@ class DatabaseConnector:
|
|||
single inserts produce identical on-disk values (timestamps as floats,
|
||||
enums as strings, vectors as pgvector text, JSONB as JSON strings).
|
||||
"""
|
||||
import json as _json
|
||||
out = []
|
||||
for col in columns:
|
||||
value = record.get(col)
|
||||
|
|
@ -1839,16 +1829,16 @@ class DatabaseConnector:
|
|||
value = f"[{','.join(str(v) for v in value)}]"
|
||||
elif col in fields and fields[col] == "JSONB" and value is not None:
|
||||
if isinstance(value, (dict, list)):
|
||||
value = json.dumps(value)
|
||||
value = _json.dumps(value)
|
||||
elif isinstance(value, str):
|
||||
try:
|
||||
json.loads(value)
|
||||
_json.loads(value)
|
||||
except (ValueError, TypeError):
|
||||
value = json.dumps(value)
|
||||
value = _json.dumps(value)
|
||||
elif hasattr(value, "model_dump"):
|
||||
value = json.dumps(value.model_dump())
|
||||
value = _json.dumps(value.model_dump())
|
||||
else:
|
||||
value = json.dumps(value)
|
||||
value = _json.dumps(value)
|
||||
out.append(value)
|
||||
return tuple(out)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Azure Communication Services Email Connector
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Twilio SMS Connector
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# All rights reserved.
|
||||
"""
|
||||
ÖREB WFS Connector
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Preprocessor connector for executing SQL queries via HTTP API.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Abstract base classes for the Provider-Connector architecture (1:n).
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""ConnectorResolver -- resolves a connectionId to the correct ProviderConnector and ServiceAdapter.
|
||||
|
||||
|
|
@ -44,31 +44,31 @@ class ConnectorResolver:
|
|||
if ConnectorResolver._providerRegistry:
|
||||
return
|
||||
try:
|
||||
from modules.connectors.connectorProviderMsft import MsftConnector
|
||||
from modules.connectors.providerMsft.connectorMsft import MsftConnector
|
||||
ConnectorResolver._providerRegistry["msft"] = MsftConnector
|
||||
except ImportError:
|
||||
logger.warning("MsftConnector not available")
|
||||
|
||||
try:
|
||||
from modules.connectors.connectorProviderGoogle import GoogleConnector
|
||||
from modules.connectors.providerGoogle.connectorGoogle import GoogleConnector
|
||||
ConnectorResolver._providerRegistry["google"] = GoogleConnector
|
||||
except ImportError:
|
||||
logger.debug("GoogleConnector not available (stub)")
|
||||
|
||||
try:
|
||||
from modules.connectors.connectorProviderFtp import FtpConnector
|
||||
from modules.connectors.providerFtp.connectorFtp import FtpConnector
|
||||
ConnectorResolver._providerRegistry["local:ftp"] = FtpConnector
|
||||
except ImportError:
|
||||
logger.debug("FtpConnector not available (stub)")
|
||||
|
||||
try:
|
||||
from modules.connectors.connectorProviderClickup import ClickupConnector
|
||||
from modules.connectors.providerClickup.connectorClickup import ClickupConnector
|
||||
ConnectorResolver._providerRegistry["clickup"] = ClickupConnector
|
||||
except ImportError:
|
||||
logger.warning("ClickupConnector not available")
|
||||
|
||||
try:
|
||||
from modules.connectors.connectorProviderInfomaniak import InfomaniakConnector
|
||||
from modules.connectors.providerInfomaniak.connectorInfomaniak import InfomaniakConnector
|
||||
ConnectorResolver._providerRegistry["infomaniak"] = InfomaniakConnector
|
||||
except ImportError:
|
||||
logger.warning("InfomaniakConnector not available")
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# All rights reserved.
|
||||
"""
|
||||
Swiss Topo MapServer Connector (Simplified)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""ClickUp connector for CRUD operations (compatible with TicketInterface).
|
||||
|
||||
|
|
@ -9,7 +9,7 @@ from typing import Optional
|
|||
import logging
|
||||
import aiohttp
|
||||
from modules.datamodels.datamodelTickets import TicketBase, TicketFieldAttribute
|
||||
from modules.connectors.connectorProviderClickup import clickupAuthorizationHeader
|
||||
from modules.serviceCenter.services.serviceClickup.mainServiceClickup import clickup_authorization_header
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -31,7 +31,7 @@ class ConnectorTicketClickup(TicketBase):
|
|||
|
||||
def _headers(self) -> dict:
|
||||
return {
|
||||
"Authorization": clickupAuthorizationHeader(self.apiToken),
|
||||
"Authorization": clickup_authorization_header(self.apiToken),
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Jira connector for CRUD operations (neutralized to generic ticket interface).
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2026 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Redmine REST connector.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Google Cloud Speech-to-Text and Translation Connector
|
||||
|
|
@ -15,25 +15,10 @@ from google.cloud import speech
|
|||
from google.cloud import translate_v2 as translate
|
||||
from google.cloud import texttospeech
|
||||
from modules.shared.configuration import APP_CONFIG
|
||||
from modules.shared.voiceCatalog import getDefaultVoice
|
||||
from modules.shared.voiceCatalog import getDefaultVoice as _catalogDefaultVoice
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_STT_LANGUAGE_MAP = {
|
||||
"de-CH": "de-DE",
|
||||
"de-AT": "de-DE",
|
||||
"en-GB": "en-US",
|
||||
"en-AU": "en-US",
|
||||
"fr-CH": "fr-FR",
|
||||
"it-CH": "it-IT",
|
||||
"pt-BR": "pt-PT",
|
||||
}
|
||||
|
||||
|
||||
def _normalizeSttLanguage(language: str) -> str:
|
||||
"""Map regional language variants to codes supported by Google STT models."""
|
||||
return _STT_LANGUAGE_MAP.get(language, language)
|
||||
|
||||
|
||||
def _buildPrimarySttRecognitionFields(
|
||||
*,
|
||||
|
|
@ -131,7 +116,6 @@ class ConnectorGoogleSpeech:
|
|||
Returns:
|
||||
Dict containing transcribed text, confidence, and metadata
|
||||
"""
|
||||
language = _normalizeSttLanguage(language)
|
||||
try:
|
||||
# Treat sampleRate=0 as unknown (invalid value from client)
|
||||
if sampleRate is not None and sampleRate <= 0:
|
||||
|
|
@ -496,7 +480,6 @@ class ConnectorGoogleSpeech:
|
|||
Dicts with keys: isFinal, transcript, confidence, stabilityScore, audioDurationSec;
|
||||
optionally endOfSingleUtterance, reconnectRequired
|
||||
"""
|
||||
language = _normalizeSttLanguage(language)
|
||||
STREAM_LIMIT_SEC = 290
|
||||
streamStartTs = time.time()
|
||||
totalAudioBytes = 0
|
||||
|
|
@ -1114,7 +1097,7 @@ class ConnectorGoogleSpeech:
|
|||
voice exists, in which case the caller omits `name` and Google
|
||||
auto-selects based on languageCode + ssml_gender.
|
||||
"""
|
||||
return getDefaultVoice(languageCode)
|
||||
return _catalogDefaultVoice(languageCode)
|
||||
|
||||
async def getAvailableVoices(self, languageCode: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# All rights reserved.
|
||||
"""
|
||||
Swiss Parcel (Liegenschaften) Connector
|
||||
|
||||
|
|
|
|||
7
modules/connectors/providerClickup/__init__.py
Normal file
7
modules/connectors/providerClickup/__init__.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""ClickUp provider connector."""
|
||||
|
||||
from .connectorClickup import ClickupConnector
|
||||
|
||||
__all__ = ["ClickupConnector"]
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""ClickUp ProviderConnector — virtual paths for teams → lists → tasks (table rows).
|
||||
|
||||
|
|
@ -13,13 +13,10 @@ Path convention (leading slash, no trailing slash except root):
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any, Dict, List, Optional, Union
|
||||
|
||||
import aiohttp
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from modules.connectors.connectorProviderBase import (
|
||||
ProviderConnector,
|
||||
|
|
@ -27,11 +24,11 @@ from modules.connectors.connectorProviderBase import (
|
|||
DownloadResult,
|
||||
)
|
||||
from modules.datamodels.datamodelDataSource import ExternalEntry
|
||||
from modules.serviceCenter.services.serviceClickup.mainServiceClickup import ClickupService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CLICKUP_API_BASE = "https://api.clickup.com/api/v2"
|
||||
|
||||
# type metadata for ExternalEntry.metadata["cuType"]
|
||||
_CU_TEAM = "team"
|
||||
_CU_SPACE = "space"
|
||||
_CU_FOLDER = "folder"
|
||||
|
|
@ -48,118 +45,14 @@ def _norm(path: str) -> str:
|
|||
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):
|
||||
"""Maps ClickUp hierarchy + list tasks to browse/download/upload/search."""
|
||||
|
||||
def __init__(self, access_token: str):
|
||||
self._token = access_token
|
||||
self._svc = ClickupApiClient(access_token)
|
||||
# Minimal service instance for API calls (no ServiceCenter context)
|
||||
self._svc = ClickupService(context=None, get_service=lambda _: None)
|
||||
self._svc.setAccessToken(access_token)
|
||||
|
||||
async def browse(
|
||||
self,
|
||||
3
modules/connectors/providerFtp/__init__.py
Normal file
3
modules/connectors/providerFtp/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""FTP/SFTP Provider Connector stub."""
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""FTP/SFTP ProviderConnector stub.
|
||||
|
||||
3
modules/connectors/providerGoogle/__init__.py
Normal file
3
modules/connectors/providerGoogle/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Google Provider Connector -- 1 Connection : n Services (Drive, Gmail)."""
|
||||
|
|
@ -1,19 +1,16 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Google ProviderConnector -- Drive and Gmail via Google OAuth."""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import logging
|
||||
import re
|
||||
import urllib.parse
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
from modules.connectors.connectorProviderBase import ProviderConnector, ServiceAdapter, DownloadResult
|
||||
from modules.shared.httpResilience import ResilientHttp
|
||||
from modules.connectors._httpResilience import ResilientHttp
|
||||
from modules.datamodels.datamodelDataSource import ExternalEntry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -32,6 +29,8 @@ def _parseGoogleDateRange(text: Optional[str]) -> tuple:
|
|||
Supports two ISO dates, a single ISO date (~31 day window) or a YYYY-MM
|
||||
month pattern. Returns RFC3339 UTC strings (timeMin, timeMax) or (None, None).
|
||||
"""
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
if not text:
|
||||
return (None, None)
|
||||
|
||||
|
|
@ -59,7 +58,7 @@ def _parseGoogleDateRange(text: Optional[str]) -> tuple:
|
|||
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}"}
|
||||
return await _http.getJson(url, headers=headers)
|
||||
|
||||
|
|
@ -93,7 +92,7 @@ class DriveAdapter(ServiceAdapter):
|
|||
pageSize = max(1, min(int(limit or 100), 1000))
|
||||
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:
|
||||
_raiseGoogleError(result, "Google Drive browse")
|
||||
|
||||
|
|
@ -185,7 +184,7 @@ class DriveAdapter(ServiceAdapter):
|
|||
if pageToken:
|
||||
params["pageToken"] = pageToken
|
||||
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 not entries:
|
||||
_raiseGoogleError(result, "Google Drive search")
|
||||
|
|
@ -229,7 +228,7 @@ class GmailAdapter(ServiceAdapter):
|
|||
|
||||
if not cleanPath:
|
||||
url = f"{_GMAIL_BASE}/users/me/labels"
|
||||
result = await googleGet(self._token, url)
|
||||
result = await _googleGet(self._token, url)
|
||||
if "error" in result:
|
||||
_raiseGoogleError(result, "Gmail labels")
|
||||
_SYSTEM_LABELS = {"INBOX", "SENT", "DRAFT", "TRASH", "SPAM", "STARRED", "IMPORTANT"}
|
||||
|
|
@ -282,7 +281,7 @@ class GmailAdapter(ServiceAdapter):
|
|||
if not ref:
|
||||
return None
|
||||
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:
|
||||
_raiseGoogleError(result, "Gmail labels")
|
||||
labels = result.get("labels", [])
|
||||
|
|
@ -320,7 +319,7 @@ class GmailAdapter(ServiceAdapter):
|
|||
if pageToken:
|
||||
p["pageToken"] = pageToken
|
||||
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 not msgIds:
|
||||
_raiseGoogleError(result, "Gmail list messages")
|
||||
|
|
@ -351,7 +350,7 @@ class GmailAdapter(ServiceAdapter):
|
|||
f"{_GMAIL_BASE}/users/me/messages/{msgId}"
|
||||
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:
|
||||
return ExternalEntry(name=f"Message {msgId}", path=f"{pathPrefix}/{msgId}", isFolder=False,
|
||||
metadata={"id": msgId})
|
||||
|
|
@ -372,13 +371,15 @@ class GmailAdapter(ServiceAdapter):
|
|||
|
||||
async def download(self, path: str) -> DownloadResult:
|
||||
"""Download a Gmail message as RFC 822 EML via format=raw."""
|
||||
import base64
|
||||
import re
|
||||
cleanPath = (path or "").strip("/")
|
||||
msgId = cleanPath.split("/")[-1] if cleanPath else ""
|
||||
if not msgId:
|
||||
return DownloadResult()
|
||||
|
||||
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:
|
||||
return DownloadResult()
|
||||
|
||||
|
|
@ -389,7 +390,7 @@ class GmailAdapter(ServiceAdapter):
|
|||
emlBytes = base64.urlsafe_b64decode(rawB64)
|
||||
|
||||
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
|
||||
if "error" not in meta:
|
||||
for h in meta.get("payload", {}).get("headers", []):
|
||||
|
|
@ -468,7 +469,7 @@ class CalendarAdapter(ServiceAdapter):
|
|||
cleanPath = (path or "").strip("/")
|
||||
if not cleanPath:
|
||||
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:
|
||||
_raiseGoogleError(result, "Google Calendar list")
|
||||
calendars = result.get("items", [])
|
||||
|
|
@ -503,7 +504,7 @@ class CalendarAdapter(ServiceAdapter):
|
|||
timeMin, timeMax = _parseGoogleDateRange(filter)
|
||||
if timeMin and timeMax:
|
||||
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:
|
||||
_raiseGoogleError(result, "Google Calendar events")
|
||||
events = result.get("items", [])
|
||||
|
|
@ -533,7 +534,7 @@ class CalendarAdapter(ServiceAdapter):
|
|||
return DownloadResult()
|
||||
calendarId, eventId = cleanPath.split("/", 1)
|
||||
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:
|
||||
logger.warning(f"Google Calendar event fetch failed: {ev['error']}")
|
||||
return DownloadResult()
|
||||
|
|
@ -572,7 +573,7 @@ class CalendarAdapter(ServiceAdapter):
|
|||
f"{_CALENDAR_BASE}/calendars/{quote(calendarId, safe='')}/events"
|
||||
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:
|
||||
_raiseGoogleError(result, "Google Calendar search")
|
||||
return [
|
||||
|
|
@ -628,7 +629,7 @@ class ContactsAdapter(ServiceAdapter):
|
|||
),
|
||||
]
|
||||
url = f"{_PEOPLE_BASE}/contactGroups?pageSize=200"
|
||||
result = await googleGet(self._token, url)
|
||||
result = await _googleGet(self._token, url)
|
||||
if "error" not in result:
|
||||
for grp in result.get("contactGroups", []):
|
||||
name = grp.get("formattedName") or grp.get("name") or ""
|
||||
|
|
@ -658,7 +659,7 @@ class ContactsAdapter(ServiceAdapter):
|
|||
f"{_PEOPLE_BASE}/people/me/connections"
|
||||
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:
|
||||
_raiseGoogleError(result, "Google People connections")
|
||||
people = result.get("connections", [])
|
||||
|
|
@ -668,7 +669,7 @@ class ContactsAdapter(ServiceAdapter):
|
|||
f"{_PEOPLE_BASE}/{quote(groupResource, safe='/')}"
|
||||
f"?maxMembers={min(effectiveLimit, 1000)}"
|
||||
)
|
||||
grpResult = await googleGet(self._token, grpUrl)
|
||||
grpResult = await _googleGet(self._token, grpUrl)
|
||||
if "error" in grpResult:
|
||||
_raiseGoogleError(grpResult, "Google contactGroup detail")
|
||||
memberResourceNames = grpResult.get("memberResourceNames") or []
|
||||
|
|
@ -680,7 +681,7 @@ class ContactsAdapter(ServiceAdapter):
|
|||
chunk = memberResourceNames[i : i + chunkSize]
|
||||
params = "&".join(f"resourceNames={quote(rn, safe='/')}" for rn in chunk)
|
||||
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:
|
||||
logger.warning(f"Google People batchGet failed: {batchResult['error']}")
|
||||
continue
|
||||
|
|
@ -716,7 +717,7 @@ class ContactsAdapter(ServiceAdapter):
|
|||
if not personSuffix:
|
||||
return DownloadResult()
|
||||
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:
|
||||
logger.warning(f"Google People fetch failed: {person['error']}")
|
||||
return DownloadResult()
|
||||
|
|
@ -745,7 +746,7 @@ class ContactsAdapter(ServiceAdapter):
|
|||
f"?query={quote(query, safe='')}&pageSize={min(effectiveLimit, 30)}"
|
||||
f"&readMask={self._PERSON_FIELDS}"
|
||||
)
|
||||
result = await googleGet(self._token, url)
|
||||
result = await _googleGet(self._token, url)
|
||||
if "error" in result:
|
||||
_raiseGoogleError(result, "Google Contacts search")
|
||||
entries: List[ExternalEntry] = []
|
||||
|
|
@ -769,6 +770,7 @@ class ContactsAdapter(ServiceAdapter):
|
|||
|
||||
|
||||
def _googleSafeFileName(name: str) -> str:
|
||||
import re
|
||||
return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name or "")[:80].strip(". ")
|
||||
|
||||
|
||||
|
|
@ -788,6 +790,7 @@ def _googleIcsDateTime(value: Optional[str]) -> Optional[str]:
|
|||
"""Convert a Google Calendar dateTime/date string to RFC 5545 format (UTC)."""
|
||||
if not value:
|
||||
return None
|
||||
from datetime import datetime, timezone
|
||||
try:
|
||||
if "T" not in value:
|
||||
dt = datetime.strptime(value, "%Y-%m-%d")
|
||||
|
|
@ -803,6 +806,7 @@ def _googleIcsDateTime(value: Optional[str]) -> Optional[str]:
|
|||
|
||||
def _googleEventToIcs(event: Dict[str, Any]) -> bytes:
|
||||
"""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"
|
||||
summary = _googleIcsEscape(event.get("summary") or "")
|
||||
location = _googleIcsEscape(event.get("location") or "")
|
||||
3
modules/connectors/providerInfomaniak/__init__.py
Normal file
3
modules/connectors/providerInfomaniak/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Infomaniak Provider Connector -- 1 Connection : n Services (kDrive, Mail)."""
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Infomaniak ProviderConnector -- kDrive + Calendar + Contacts via PAT.
|
||||
|
||||
|
|
@ -31,7 +31,6 @@ Path conventions (leading slash, ``ServiceAdapter`` paths always start with
|
|||
/{addressBookId}/{contactId} -- single contact (.vcf download)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
|
@ -45,7 +44,7 @@ from modules.connectors.connectorProviderBase import (
|
|||
ServiceAdapter,
|
||||
DownloadResult,
|
||||
)
|
||||
from modules.shared.httpResilience import ResilientHttp
|
||||
from modules.connectors._httpResilience import ResilientHttp
|
||||
from modules.datamodels.datamodelDataSource import ExternalEntry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -392,115 +391,8 @@ class KdriveAdapter(ServiceAdapter):
|
|||
return DownloadResult()
|
||||
return DownloadResult(data=content, fileName=fileName, mimeType=mimeType)
|
||||
|
||||
async def _createDirectory(self, driveId: str, parentId: str, name: str) -> Optional[str]:
|
||||
"""Create a single directory and return its ID.
|
||||
|
||||
If the directory already exists (409), lists the parent to find
|
||||
the existing folder's ID -- kDrive directory creation is not
|
||||
idempotent.
|
||||
"""
|
||||
url = f"{_API_BASE}/3/drive/{driveId}/files/{parentId}/directory"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self._token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
body = json.dumps({"name": name})
|
||||
result = await _http.request("POST", url, headers=headers, data=body)
|
||||
|
||||
if isinstance(result, dict) and not result.get("error"):
|
||||
data = _unwrapData(result)
|
||||
if isinstance(data, dict) and data.get("id"):
|
||||
return str(data["id"])
|
||||
|
||||
errorStr = str(result.get("error", "")) if isinstance(result, dict) else ""
|
||||
if "already_exists" in errorStr or "409" in errorStr:
|
||||
children = await self._listChildren(driveId, fileId=parentId, limit=1000)
|
||||
for child in children:
|
||||
if child.isFolder and child.name == name:
|
||||
return (child.metadata or {}).get("id") or child.path.strip("/").split("/")[-1]
|
||||
|
||||
logger.warning("kDrive mkdir %s/%s in %s failed: %s", driveId, name, parentId, result)
|
||||
return None
|
||||
|
||||
async def _ensureDirectoryPath(self, driveId: str, parentId: str, pathSegments: List[str]) -> Optional[str]:
|
||||
"""Walk *pathSegments* and create each level that does not exist yet.
|
||||
|
||||
Returns the numeric folder ID of the deepest directory, or
|
||||
``None`` if any step fails.
|
||||
"""
|
||||
currentId = parentId
|
||||
for segment in pathSegments:
|
||||
folderId = await self._createDirectory(driveId, currentId, segment)
|
||||
if not folderId:
|
||||
return None
|
||||
currentId = folderId
|
||||
return currentId
|
||||
|
||||
async def upload(self, path: str, data: bytes, fileName: str) -> dict:
|
||||
"""Upload a file to kDrive.
|
||||
|
||||
Path formats:
|
||||
/{driveId} -> upload to drive root
|
||||
/{driveId}/{folderId} -> upload into folder by numeric ID
|
||||
/{driveId}/{folderId}/Sub/Path -> create Sub/Path under folderId, then upload
|
||||
/{driveId}/Some/Human/Path -> create path from drive root (id 1), then upload
|
||||
|
||||
Directories are created step-by-step via the v3 mkdir endpoint;
|
||||
existing directories are reused (idempotent). File upload uses
|
||||
the v3 upload endpoint (max 1 GB).
|
||||
"""
|
||||
segments = [s for s in (path or "").strip("/").split("/") if s]
|
||||
if not segments:
|
||||
return {"error": "Upload path must include at least a drive ID"}
|
||||
driveId = segments[0]
|
||||
|
||||
targetDirId: Optional[str] = None
|
||||
if len(segments) > 1:
|
||||
subSegments = segments[1:]
|
||||
numericPrefix: List[str] = []
|
||||
nameSegments: List[str] = []
|
||||
for i, seg in enumerate(subSegments):
|
||||
if seg.isdigit() and not nameSegments:
|
||||
numericPrefix.append(seg)
|
||||
else:
|
||||
nameSegments = subSegments[i:]
|
||||
break
|
||||
|
||||
parentId = numericPrefix[-1] if numericPrefix else "1"
|
||||
|
||||
if nameSegments and nameSegments[-1] == fileName:
|
||||
nameSegments = nameSegments[:-1]
|
||||
|
||||
if nameSegments:
|
||||
targetDirId = await self._ensureDirectoryPath(driveId, parentId, nameSegments)
|
||||
if not targetDirId:
|
||||
return {"error": f"Failed to create directory path: {'/'.join(nameSegments)}"}
|
||||
else:
|
||||
targetDirId = parentId
|
||||
|
||||
params = [
|
||||
f"file_name={quote(fileName)}",
|
||||
f"total_size={len(data)}",
|
||||
"conflict=version",
|
||||
]
|
||||
if targetDirId:
|
||||
params.append(f"directory_id={targetDirId}")
|
||||
|
||||
endpoint = f"/3/drive/{driveId}/upload?{'&'.join(params)}"
|
||||
url = f"{_API_BASE.rstrip('/')}/{endpoint.lstrip('/')}"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self._token}",
|
||||
"Content-Type": "application/octet-stream",
|
||||
}
|
||||
|
||||
result = await _http.request(
|
||||
"POST", url, headers=headers, data=data,
|
||||
timeout=aiohttp.ClientTimeout(total=120),
|
||||
)
|
||||
if isinstance(result, dict) and result.get("error"):
|
||||
return result
|
||||
unwrapped = _unwrapData(result) if isinstance(result, dict) else result
|
||||
return unwrapped if isinstance(unwrapped, dict) else {"data": unwrapped}
|
||||
return {"error": "kDrive upload not yet implemented"}
|
||||
|
||||
async def search(
|
||||
self,
|
||||
3
modules/connectors/providerMsft/__init__.py
Normal file
3
modules/connectors/providerMsft/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# 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) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Microsoft ProviderConnector -- one MSFT connection serves SharePoint, Outlook, Teams, OneDrive.
|
||||
|
||||
|
|
@ -6,17 +6,14 @@ All ServiceAdapters share the same OAuth access token obtained from the
|
|||
UserConnection (authority=msft).
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import aiohttp
|
||||
import asyncio
|
||||
import urllib.parse
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from modules.connectors.connectorProviderBase import ProviderConnector, ServiceAdapter, DownloadResult
|
||||
from modules.shared.httpResilience import ResilientHttp
|
||||
from modules.connectors._httpResilience import ResilientHttp
|
||||
from modules.datamodels.datamodelDataSource import ExternalEntry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -82,7 +79,7 @@ async def _handleResponse(resp: aiohttp.ClientResponse) -> Dict[str, Any]:
|
|||
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
|
||||
relative endpoint that ``_makeGraphCall`` expects."""
|
||||
if not url:
|
||||
|
|
@ -179,7 +176,7 @@ class SharepointAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
if effectiveLimit is not None and len(items) >= effectiveLimit:
|
||||
break
|
||||
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]
|
||||
if filter:
|
||||
|
|
@ -260,7 +257,7 @@ class SharepointAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
if effectiveLimit is not None and len(items) >= effectiveLimit:
|
||||
break
|
||||
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]
|
||||
if effectiveLimit is not None:
|
||||
entries = entries[: max(1, effectiveLimit)]
|
||||
|
|
@ -281,6 +278,8 @@ def _parseDateRange(filterStr: Optional[str]) -> tuple:
|
|||
(treated as a ~31 day window), or a YYYY-MM month pattern. Returns
|
||||
(startDateTime, endDateTime) ISO strings, or (None, None) if not parseable.
|
||||
"""
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
if not filterStr:
|
||||
return (None, None)
|
||||
isoMatch = re.findall(r'\d{4}-\d{2}-\d{2}(?:T[\d:]+)?', filterStr)
|
||||
|
|
@ -369,7 +368,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
if not nextLink:
|
||||
endpoint = None
|
||||
else:
|
||||
endpoint = stripGraphBase(nextLink)
|
||||
endpoint = _stripGraphBase(nextLink)
|
||||
|
||||
# Guarantee Inbox is present (well-known name, locale-independent)
|
||||
if not any((f.get("displayName") or "").lower() in ("inbox", "posteingang") for f in folders):
|
||||
|
|
@ -446,7 +445,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
if len(messages) >= effectiveLimit:
|
||||
break
|
||||
nextLink = result.get("@odata.nextLink")
|
||||
endpoint = stripGraphBase(nextLink) if nextLink else None
|
||||
endpoint = _stripGraphBase(nextLink) if nextLink else None
|
||||
entries = [
|
||||
ExternalEntry(
|
||||
name=m.get("subject", "(no subject)"),
|
||||
|
|
@ -471,6 +470,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
|
||||
async def download(self, path: str) -> DownloadResult:
|
||||
"""Download a mail message as RFC 822 EML via Graph API $value endpoint."""
|
||||
import re
|
||||
messageId = path.strip("/").split("/")[-1]
|
||||
|
||||
meta = await self._graphGet(f"me/messages/{messageId}?$select=subject")
|
||||
|
|
@ -572,6 +572,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
attachments: Optional[List[Dict]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Send an email via Microsoft Graph. bodyType: 'Text' or 'HTML'."""
|
||||
import json
|
||||
message = self._buildMessage(to, subject, body, bodyType, cc, attachments)
|
||||
payload = json.dumps({"message": message, "saveToSentItems": True}).encode("utf-8")
|
||||
result = await self._graphPost("me/sendMail", payload)
|
||||
|
|
@ -586,6 +587,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
attachments: Optional[List[Dict]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a draft email in the user's Drafts folder via Microsoft Graph."""
|
||||
import json
|
||||
message = self._buildMessage(to, subject, body, bodyType, cc, attachments)
|
||||
payload = json.dumps(message).encode("utf-8")
|
||||
result = await self._graphPost("me/messages", payload)
|
||||
|
|
@ -615,6 +617,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
Preserves the conversation thread and the ``AW:`` prefix in Outlook --
|
||||
unlike sendMail() which creates a brand-new conversation.
|
||||
"""
|
||||
import json
|
||||
endpointAction = "replyAll" if replyAll else "reply"
|
||||
payload = json.dumps({"comment": comment}).encode("utf-8")
|
||||
result = await self._graphPost(f"me/messages/{messageId}/{endpointAction}", payload)
|
||||
|
|
@ -626,6 +629,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
self, messageId: str, to: List[str], comment: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""Forward an existing message to new recipients."""
|
||||
import json
|
||||
payload = json.dumps({
|
||||
"comment": comment,
|
||||
"toRecipients": [{"emailAddress": {"address": addr}} for addr in to],
|
||||
|
|
@ -640,6 +644,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
replyAll: bool = False,
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a reply-draft (in the Drafts folder) that the user can edit before sending."""
|
||||
import json
|
||||
endpointAction = "createReplyAll" if replyAll else "createReply"
|
||||
payload = json.dumps({"comment": comment}).encode("utf-8") if comment else b"{}"
|
||||
result = await self._graphPost(f"me/messages/{messageId}/{endpointAction}", payload)
|
||||
|
|
@ -651,6 +656,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
self, messageId: str, to: Optional[List[str]] = None, comment: str = "",
|
||||
) -> Dict[str, Any]:
|
||||
"""Create a forward-draft (in the Drafts folder) that the user can edit before sending."""
|
||||
import json
|
||||
body: Dict[str, Any] = {}
|
||||
if comment:
|
||||
body["comment"] = comment
|
||||
|
|
@ -721,7 +727,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
"childFolderCount": f.get("childFolderCount", 0),
|
||||
})
|
||||
nextLink = result.get("@odata.nextLink")
|
||||
endpoint = stripGraphBase(nextLink) if nextLink else None
|
||||
endpoint = _stripGraphBase(nextLink) if nextLink else None
|
||||
return folders
|
||||
|
||||
async def _resolveFolderId(self, folderRef: str) -> Optional[str]:
|
||||
|
|
@ -758,6 +764,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
self, messageId: str, destinationFolder: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Move a message to another folder (well-known name, displayName, or folder id)."""
|
||||
import json
|
||||
destId = await self._resolveFolderId(destinationFolder)
|
||||
if not destId:
|
||||
return {"error": f"Folder not found: '{destinationFolder}'. Use listMailFolders to inspect available folders."}
|
||||
|
|
@ -771,6 +778,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
self, messageId: str, destinationFolder: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Copy a message into another folder (original stays in place)."""
|
||||
import json
|
||||
destId = await self._resolveFolderId(destinationFolder)
|
||||
if not destId:
|
||||
return {"error": f"Folder not found: '{destinationFolder}'. Use listMailFolders to inspect available folders."}
|
||||
|
|
@ -810,6 +818,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
|
||||
async def markMailAsRead(self, messageId: str) -> Dict[str, Any]:
|
||||
"""Mark a message as read (sets ``isRead=true``)."""
|
||||
import json
|
||||
payload = json.dumps({"isRead": True}).encode("utf-8")
|
||||
result = await self._graphPatch(f"me/messages/{messageId}", payload)
|
||||
if "error" in result:
|
||||
|
|
@ -818,6 +827,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
|
||||
async def markMailAsUnread(self, messageId: str) -> Dict[str, Any]:
|
||||
"""Mark a message as unread (sets ``isRead=false``)."""
|
||||
import json
|
||||
payload = json.dumps({"isRead": False}).encode("utf-8")
|
||||
result = await self._graphPatch(f"me/messages/{messageId}", payload)
|
||||
if "error" in result:
|
||||
|
|
@ -835,6 +845,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
``"notFlagged"`` -- the three values Microsoft Graph recognises for
|
||||
``followupFlag.flagStatus``.
|
||||
"""
|
||||
import json
|
||||
if flagStatus not in ("flagged", "complete", "notFlagged"):
|
||||
return {"error": f"Invalid flagStatus '{flagStatus}'. Use one of: flagged, complete, notFlagged."}
|
||||
payload = json.dumps({"flag": {"flagStatus": flagStatus}}).encode("utf-8")
|
||||
|
|
@ -941,7 +952,7 @@ class OneDriveAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
if effectiveLimit is not None and len(items) >= effectiveLimit:
|
||||
break
|
||||
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]
|
||||
if filter:
|
||||
|
|
@ -992,7 +1003,7 @@ class OneDriveAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
if effectiveLimit is not None and len(items) >= effectiveLimit:
|
||||
break
|
||||
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]
|
||||
if effectiveLimit is not None:
|
||||
entries = entries[: max(1, effectiveLimit)]
|
||||
|
|
@ -1088,7 +1099,7 @@ class CalendarAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
if len(events) >= effectiveLimit:
|
||||
break
|
||||
nextLink = result.get("@odata.nextLink")
|
||||
endpoint = stripGraphBase(nextLink) if nextLink else None
|
||||
endpoint = _stripGraphBase(nextLink) if nextLink else None
|
||||
|
||||
return [
|
||||
ExternalEntry(
|
||||
|
|
@ -1285,7 +1296,7 @@ class ContactsAdapter(_GraphApiMixin, ServiceAdapter):
|
|||
if len(contacts) >= effectiveLimit:
|
||||
break
|
||||
nextLink = result.get("@odata.nextLink")
|
||||
endpoint = stripGraphBase(nextLink) if nextLink else None
|
||||
endpoint = _stripGraphBase(nextLink) if nextLink else None
|
||||
|
||||
return [
|
||||
ExternalEntry(
|
||||
|
|
@ -1437,6 +1448,7 @@ def _matchFilter(entry: ExternalEntry, pattern: str) -> bool:
|
|||
|
||||
def _safeFileName(name: str) -> str:
|
||||
"""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(". ")
|
||||
|
||||
|
||||
|
|
@ -1466,6 +1478,7 @@ def _icsDateTime(value: Optional[str]) -> Optional[str]:
|
|||
"""Convert an ISO datetime string to an RFC 5545 DATE-TIME value (UTC)."""
|
||||
if not value:
|
||||
return None
|
||||
from datetime import datetime, timezone
|
||||
try:
|
||||
normalized = value.replace("Z", "+00:00") if value.endswith("Z") else value
|
||||
dt = datetime.fromisoformat(normalized)
|
||||
|
|
@ -1478,6 +1491,7 @@ def _icsDateTime(value: Optional[str]) -> Optional[str]:
|
|||
|
||||
def _eventToIcs(event: Dict[str, Any]) -> bytes:
|
||||
"""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"
|
||||
summary = _icsEscape(event.get("subject") or "")
|
||||
location = _icsEscape((event.get("location") or {}).get("displayName") or "")
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Unified modules.datamodels package.
|
||||
|
|
@ -14,4 +14,3 @@ from . import datamodelChat as chat
|
|||
from . import datamodelFiles as files
|
||||
from . import datamodelVoice as voice
|
||||
from . import datamodelUtils as utils
|
||||
from . import jsonContinuation
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
from typing import Optional, List, Dict, Any, Callable, TYPE_CHECKING, Tuple
|
||||
from pydantic import BaseModel, Field, ConfigDict
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""AI Audit Log data model for Compliance & AI-Datenfluss tracking.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Audit Log Data Model for database-based audit logging.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Background job models: generic, reusable infrastructure for long-running tasks.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Base Pydantic model with system-managed fields (DB + API + UI metadata)."""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Billing models: BillingAccount, BillingTransaction, BillingSettings, UsageStatistics."""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Chat models: ChatWorkflow, ChatMessage, ChatLog, ChatDocument."""
|
||||
|
||||
|
|
@ -131,7 +131,7 @@ class ChatWorkflow(PowerOnModel):
|
|||
None,
|
||||
description=(
|
||||
"Optional foreign key linking this chat to an entity outside the "
|
||||
"ChatWorkflow table (e.g. an Automation2Workflow in WorkflowAutomation "
|
||||
"ChatWorkflow table (e.g. an Automation2Workflow in the GraphicalEditor "
|
||||
"AI editor chat). NULL for the default workspace chats. Combined with "
|
||||
"featureInstanceId this gives a 1:1 relation entity ↔ chat per feature."
|
||||
),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Content Object data models for the container and content extraction pipeline.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""DataSource and ExternalEntry models for external data integration.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Document reference models for typed document references in workflows.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
from typing import Any, Dict, List, Optional, Literal, Union
|
||||
from pydantic import BaseModel, Field, field_serializer
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
from typing import Any, Dict, List, Optional, Literal
|
||||
from pydantic import BaseModel, Field
|
||||
|
|
|
|||
83
modules/datamodels/datamodelFeatureDataSource.py
Normal file
83
modules/datamodels/datamodelFeatureDataSource.py
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""FeatureDataSource model for exposing feature instance data to the AI workspace.
|
||||
|
||||
A FeatureDataSource links a FeatureInstance table (DATA_OBJECT) to a workspace
|
||||
so the agent can query structured feature data (e.g. TrusteePosition rows).
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
from modules.datamodels.datamodelBase import PowerOnModel
|
||||
from modules.shared.i18nRegistry import i18nModel
|
||||
import uuid
|
||||
|
||||
|
||||
@i18nModel("Feature-Datenquelle")
|
||||
class FeatureDataSource(PowerOnModel):
|
||||
"""Feature-Instanz-Tabelle als Datenquelle im AI-Workspace."""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
description="Primary key",
|
||||
json_schema_extra={"label": "ID"},
|
||||
)
|
||||
featureInstanceId: str = Field(
|
||||
description="FK to FeatureInstance",
|
||||
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
|
||||
)
|
||||
featureCode: str = Field(
|
||||
description="Feature code (e.g. trustee, commcoach)",
|
||||
json_schema_extra={"label": "Feature", "fk_target": {"db": "poweron_app", "table": "Feature", "column": "code", "labelField": "code"}},
|
||||
)
|
||||
tableName: str = Field(
|
||||
description="Table name from DATA_OBJECTS meta (e.g. TrusteePosition)",
|
||||
json_schema_extra={"label": "Tabelle"},
|
||||
)
|
||||
objectKey: str = Field(
|
||||
description="RBAC object key (e.g. data.feature.trustee.TrusteePosition)",
|
||||
json_schema_extra={"label": "Objekt-Schluessel"},
|
||||
)
|
||||
label: str = Field(
|
||||
description="User-visible label",
|
||||
json_schema_extra={"label": "Bezeichnung"},
|
||||
)
|
||||
mandateId: str = Field(
|
||||
default="",
|
||||
description="Mandate scope (set automatically from featureInstance.mandateId on create).",
|
||||
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
|
||||
)
|
||||
neutralize: Optional[bool] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Three-state neutralization flag with cascade-inherit semantics. "
|
||||
"None = inherit; True/False = explicit. Cascade-reset on parent toggle."
|
||||
),
|
||||
json_schema_extra={"label": "Neutralisieren", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False},
|
||||
)
|
||||
ragIndexEnabled: Optional[bool] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Three-state RAG-indexing flag with cascade-inherit semantics. "
|
||||
"None = inherit; True/False = explicit. Cascade-reset on parent toggle."
|
||||
),
|
||||
json_schema_extra={"label": "RAG-Indexierung", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False},
|
||||
)
|
||||
neutralizeFields: Optional[List[str]] = Field(
|
||||
default=None,
|
||||
description="Column names whose values are replaced with placeholders before AI processing",
|
||||
json_schema_extra={"label": "Zu neutralisierende Felder", "frontend_type": "multiselect", "frontend_readonly": False, "frontend_required": False},
|
||||
)
|
||||
recordFilter: Optional[Dict[str, str]] = Field(
|
||||
default=None,
|
||||
description="Record-level filter applied when querying this table, e.g. {'sessionId': 'abc-123'}",
|
||||
json_schema_extra={"label": "Datensatzfilter"},
|
||||
)
|
||||
settings: Optional[Dict[str, Any]] = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"FeatureDataSource-scoped settings (JSON). Currently used keys: "
|
||||
"ragLimits.{maxBytes,maxFileSize,maxItems,maxDepth}. "
|
||||
"Mirror of DataSource.settings so the UDB settings modal can target both."
|
||||
),
|
||||
json_schema_extra={"label": "Einstellungen", "frontend_type": "json", "frontend_readonly": True, "frontend_required": False},
|
||||
)
|
||||
|
|
@ -1,19 +1,15 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Feature models: Feature definitions, instances, data sources, and shared feature types."""
|
||||
"""Feature models: Feature, FeatureInstance."""
|
||||
|
||||
import uuid
|
||||
from typing import Optional, Dict, Any, List
|
||||
from typing import Optional, Dict, Any
|
||||
from pydantic import BaseModel, Field
|
||||
from modules.datamodels.datamodelBase import PowerOnModel
|
||||
from modules.shared.i18nRegistry import i18nModel
|
||||
from modules.datamodels.datamodelUtils import TextMultilingual
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Feature & FeatureInstance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@i18nModel("Feature")
|
||||
class Feature(PowerOnModel):
|
||||
"""Feature-Definition (global, z.B. 'trustee', 'commcoach'). Verfuegbare Funktionalitaeten der Plattform."""
|
||||
|
|
@ -75,147 +71,3 @@ class FeatureInstance(PowerOnModel):
|
|||
description="Instance-specific configuration (JSONB). Structure depends on featureCode.",
|
||||
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) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""File-related datamodels: FileItem, FilePreview, FileData."""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Invitation model for self-service onboarding.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Unified JSON document schema and helpers used by both generation prompts and renderers.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Knowledge Store data models: FileContentIndex, ContentChunk, WorkflowMemory.
|
||||
|
||||
|
|
@ -9,7 +9,7 @@ These models support the 3-tier RAG architecture:
|
|||
- Global Layer: scope=global (sysAdmin only)
|
||||
- Workflow Layer: workflowId-scoped (WorkflowMemory)
|
||||
|
||||
Vector fields use json_schema_extra with db_type=vector(KNOWLEDGE_EMBEDDING_DIMENSIONS) for pgvector.
|
||||
Vector fields use json_schema_extra={"db_type": "vector(1536)"} for pgvector.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
|
@ -19,8 +19,6 @@ from modules.shared.i18nRegistry import i18nModel
|
|||
from modules.shared.timeUtils import getUtcTimestamp
|
||||
import uuid
|
||||
|
||||
KNOWLEDGE_EMBEDDING_DIMENSIONS = 1024
|
||||
|
||||
|
||||
@i18nModel("Datei-Inhaltsindex")
|
||||
class FileContentIndex(PowerOnModel):
|
||||
|
|
@ -165,7 +163,7 @@ class ContentChunk(PowerOnModel):
|
|||
embedding: Optional[List[float]] = Field(
|
||||
default=None,
|
||||
description="pgvector embedding (NOT NULL for text chunks)",
|
||||
json_schema_extra={"label": "Embedding", "db_type": f"vector({KNOWLEDGE_EMBEDDING_DIMENSIONS})"},
|
||||
json_schema_extra={"label": "Embedding", "db_type": "vector(1536)"},
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -212,7 +210,7 @@ class RoundMemory(PowerOnModel):
|
|||
embedding: Optional[List[float]] = Field(
|
||||
default=None,
|
||||
description="Embedding of summary for semantic retrieval",
|
||||
json_schema_extra={"label": "Embedding", "db_type": f"vector({KNOWLEDGE_EMBEDDING_DIMENSIONS})"},
|
||||
json_schema_extra={"label": "Embedding", "db_type": "vector(1536)"},
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -253,5 +251,5 @@ class WorkflowMemory(PowerOnModel):
|
|||
embedding: Optional[List[float]] = Field(
|
||||
default=None,
|
||||
description="Optional embedding for semantic lookup",
|
||||
json_schema_extra={"label": "Embedding", "db_type": f"vector({KNOWLEDGE_EMBEDDING_DIMENSIONS})"},
|
||||
json_schema_extra={"label": "Embedding", "db_type": "vector(1536)"},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Membership models: UserMandate, FeatureAccess, and Junction Tables.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Messaging models: MessagingSubscription, MessagingSubscriptionRegistration, MessagingDelivery."""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,367 +0,0 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# All rights reserved.
|
||||
"""
|
||||
Navigation structure data (Layer L1 - datamodels).
|
||||
Single source of truth for UI navigation sections used by RBAC and frontend.
|
||||
"""
|
||||
|
||||
from modules.shared.i18nRegistry import t
|
||||
|
||||
# =============================================================================
|
||||
# Navigation Structure (Single Source of Truth)
|
||||
# =============================================================================
|
||||
#
|
||||
# Block Order (gemaess Navigation-API-Konzept):
|
||||
# - System: 10
|
||||
# - <dynamic/features>: 15 (wird in routeSystem.py eingefuegt)
|
||||
# - Basisdaten: 30
|
||||
# - Administration: 200
|
||||
#
|
||||
# NOTE: Workflows and Migrate sections removed - now handled as features
|
||||
#
|
||||
# Item Order: Default-Abstand 10 pro Item
|
||||
# uiComponent: Abgeleitet von objectKey (ui.system.home -> page.system.home)
|
||||
# icon: Wird intern gehalten aber NICHT in der API Response zurueckgegeben
|
||||
|
||||
NAVIGATION_SECTIONS = [
|
||||
# --- Meine Sicht (with top-level item + subgroups) ---
|
||||
{
|
||||
"id": "system",
|
||||
"title": t("Meine Sicht"),
|
||||
"order": 10,
|
||||
"items": [
|
||||
{
|
||||
"id": "home",
|
||||
"objectKey": "ui.system.home",
|
||||
"label": t("Start"),
|
||||
"icon": "FaHome",
|
||||
"path": "/",
|
||||
"order": 10,
|
||||
"public": True,
|
||||
},
|
||||
],
|
||||
"subgroups": [
|
||||
{
|
||||
"id": "system-overviews",
|
||||
"title": t("Übersichten"),
|
||||
"order": 15,
|
||||
"items": [
|
||||
{
|
||||
"id": "integrations",
|
||||
"objectKey": "ui.system.integrations",
|
||||
"label": t("Integrationen"),
|
||||
"icon": "FaProjectDiagram",
|
||||
"path": "/integrations",
|
||||
"order": 10,
|
||||
"public": True,
|
||||
},
|
||||
{
|
||||
"id": "compliance-audit",
|
||||
"objectKey": "ui.system.complianceAudit",
|
||||
"label": t("Compliance & Audit"),
|
||||
"icon": "FaShieldAlt",
|
||||
"path": "/compliance-audit",
|
||||
"order": 20,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "system-basedata",
|
||||
"title": t("Basisdaten"),
|
||||
"order": 20,
|
||||
"items": [
|
||||
{
|
||||
"id": "connections",
|
||||
"objectKey": "ui.system.connections",
|
||||
"label": t("Verbindungen"),
|
||||
"icon": "FaLink",
|
||||
"path": "/basedata/connections",
|
||||
"order": 10,
|
||||
"public": True,
|
||||
},
|
||||
{
|
||||
"id": "files",
|
||||
"objectKey": "ui.system.files",
|
||||
"label": t("Dateien"),
|
||||
"icon": "FaRegFileAlt",
|
||||
"path": "/basedata/files",
|
||||
"order": 20,
|
||||
"public": True,
|
||||
},
|
||||
{
|
||||
"id": "prompts",
|
||||
"objectKey": "ui.system.prompts",
|
||||
"label": t("Prompts"),
|
||||
"icon": "FaLightbulb",
|
||||
"path": "/basedata/prompts",
|
||||
"order": 30,
|
||||
"public": True,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "system-usage",
|
||||
"title": t("Nutzung"),
|
||||
"order": 30,
|
||||
"items": [
|
||||
{
|
||||
"id": "billing-admin",
|
||||
"objectKey": "ui.system.billingAdmin",
|
||||
"label": t("Abrechnung"),
|
||||
"icon": "FaMoneyBillAlt",
|
||||
"path": "/billing/admin",
|
||||
"order": 10,
|
||||
},
|
||||
{
|
||||
"id": "statistics",
|
||||
"objectKey": "ui.system.statistics",
|
||||
"label": t("Statistiken"),
|
||||
"icon": "FaChartBar",
|
||||
"path": "/billing/transactions",
|
||||
"order": 20,
|
||||
},
|
||||
{
|
||||
"id": "rag-inventory",
|
||||
"objectKey": "ui.system.ragInventory",
|
||||
"label": t("RAG-Inventar"),
|
||||
"icon": "FaDatabase",
|
||||
"path": "/rag-inventory",
|
||||
"order": 35,
|
||||
},
|
||||
{
|
||||
"id": "store",
|
||||
"objectKey": "ui.system.store",
|
||||
"label": t("Store"),
|
||||
"icon": "FaStore",
|
||||
"path": "/store",
|
||||
"order": 40,
|
||||
"public": True,
|
||||
},
|
||||
{
|
||||
"id": "settings",
|
||||
"objectKey": "ui.system.settings",
|
||||
"label": t("Einstellungen"),
|
||||
"icon": "FaCog",
|
||||
"path": "/settings",
|
||||
"order": 50,
|
||||
"public": True,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
# --- Solution Design (System-Komponente, cross-mandate) ---
|
||||
# Single nav entry; tabs are managed internally by WorkflowAutomationHubPage.
|
||||
{
|
||||
"id": "workflowAutomation",
|
||||
"title": t("Lösungsdesign"),
|
||||
"order": 25,
|
||||
"items": [
|
||||
{
|
||||
"id": "wa-hub",
|
||||
"objectKey": "ui.system.workflowAutomation",
|
||||
"label": t("Workflow-Automation"),
|
||||
"icon": "FaSitemap",
|
||||
"path": "/workflow-automation",
|
||||
"order": 10,
|
||||
},
|
||||
],
|
||||
},
|
||||
# --- Administration (with subgroups) ---
|
||||
{
|
||||
"id": "admin",
|
||||
"title": t("Administration"),
|
||||
"order": 200,
|
||||
"subgroups": [
|
||||
{
|
||||
"id": "admin-wizards",
|
||||
"title": t("Wizards"),
|
||||
"order": 10,
|
||||
"items": [
|
||||
{
|
||||
"id": "admin-mandate-wizard",
|
||||
"objectKey": "ui.admin.mandateWizard",
|
||||
"label": t("Mandanten-Wizard"),
|
||||
"icon": "FaMagic",
|
||||
"path": "/admin/mandate-wizard",
|
||||
"order": 10,
|
||||
"adminOnly": True,
|
||||
},
|
||||
{
|
||||
"id": "admin-invitation-wizard",
|
||||
"objectKey": "ui.admin.invitationWizard",
|
||||
"label": t("Einladungs-Wizard"),
|
||||
"icon": "FaEnvelopeOpenText",
|
||||
"path": "/admin/invitation-wizard",
|
||||
"order": 20,
|
||||
"adminOnly": True,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "admin-users-group",
|
||||
"title": t("Benutzer"),
|
||||
"order": 20,
|
||||
"items": [
|
||||
{
|
||||
"id": "admin-users",
|
||||
"objectKey": "ui.admin.users",
|
||||
"label": t("Übersicht"),
|
||||
"icon": "FaUsers",
|
||||
"path": "/admin/users",
|
||||
"order": 10,
|
||||
"adminOnly": True,
|
||||
},
|
||||
{
|
||||
"id": "admin-invitations",
|
||||
"objectKey": "ui.admin.invitations",
|
||||
"label": t("Einladungen"),
|
||||
"icon": "FaEnvelopeOpenText",
|
||||
"path": "/admin/invitations",
|
||||
"order": 20,
|
||||
"adminOnly": True,
|
||||
},
|
||||
{
|
||||
"id": "admin-user-access-overview",
|
||||
"objectKey": "ui.admin.userAccessOverview",
|
||||
"label": t("Zugriffe"),
|
||||
"icon": "FaClipboardList",
|
||||
"path": "/admin/user-access-overview",
|
||||
"order": 30,
|
||||
"adminOnly": True,
|
||||
},
|
||||
{
|
||||
"id": "admin-subscriptions",
|
||||
"objectKey": "ui.admin.subscriptions",
|
||||
"label": t("Abonnements"),
|
||||
"icon": "FaFileContract",
|
||||
"path": "/admin/subscriptions",
|
||||
"order": 40,
|
||||
"adminOnly": True,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "admin-system-group",
|
||||
"title": t("System"),
|
||||
"order": 30,
|
||||
"items": [
|
||||
{
|
||||
"id": "admin-roles",
|
||||
"objectKey": "ui.admin.roles",
|
||||
"label": t("Rollen"),
|
||||
"icon": "FaUserTag",
|
||||
"path": "/admin/mandate-roles",
|
||||
"order": 10,
|
||||
"adminOnly": True,
|
||||
},
|
||||
{
|
||||
"id": "admin-mandate-role-permissions",
|
||||
"objectKey": "ui.admin.mandateRolePermissions",
|
||||
"label": t("Rollen-Berechtigungen"),
|
||||
"icon": "FaKey",
|
||||
"path": "/admin/mandate-role-permissions",
|
||||
"order": 20,
|
||||
"adminOnly": True,
|
||||
},
|
||||
{
|
||||
"id": "admin-mandates",
|
||||
"objectKey": "ui.admin.mandates",
|
||||
"label": t("Mandanten"),
|
||||
"icon": "FaBuilding",
|
||||
"path": "/admin/mandates",
|
||||
"order": 30,
|
||||
"adminOnly": True,
|
||||
},
|
||||
{
|
||||
"id": "admin-user-mandates",
|
||||
"objectKey": "ui.admin.userMandates",
|
||||
"label": t("Mandanten-Mitglieder"),
|
||||
"icon": "FaUserFriends",
|
||||
"path": "/admin/user-mandates",
|
||||
"order": 40,
|
||||
"adminOnly": True,
|
||||
},
|
||||
{
|
||||
"id": "admin-access",
|
||||
"objectKey": "ui.admin.access",
|
||||
"label": t("Zugriffsverwaltung"),
|
||||
"icon": "FaBuilding",
|
||||
"path": "/admin/access",
|
||||
"order": 50,
|
||||
"adminOnly": True,
|
||||
},
|
||||
{
|
||||
"id": "admin-feature-instances",
|
||||
"objectKey": "ui.admin.featureInstances",
|
||||
"label": t("Feature-Instanzen"),
|
||||
"icon": "FaCubes",
|
||||
"path": "/admin/feature-instances",
|
||||
"order": 60,
|
||||
"adminOnly": True,
|
||||
},
|
||||
{
|
||||
"id": "admin-feature-roles",
|
||||
"objectKey": "ui.admin.featureRoles",
|
||||
"label": t("Features Rollen-Vorlagen"),
|
||||
"icon": "FaShieldAlt",
|
||||
"path": "/admin/feature-roles",
|
||||
"order": 70,
|
||||
"adminOnly": True,
|
||||
"sysAdminOnly": True,
|
||||
},
|
||||
{
|
||||
"id": "admin-logs",
|
||||
"objectKey": "ui.admin.logs",
|
||||
"label": t("Logs"),
|
||||
"icon": "FaFileAlt",
|
||||
"path": "/admin/logs",
|
||||
"order": 90,
|
||||
"adminOnly": True,
|
||||
"sysAdminOnly": True,
|
||||
},
|
||||
{
|
||||
"id": "admin-sessions",
|
||||
"objectKey": "ui.admin.sessions",
|
||||
"label": t("Sessions & Geräte"),
|
||||
"icon": "FaDesktop",
|
||||
"path": "/admin/sessions",
|
||||
"order": 92,
|
||||
"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) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Notification model for in-app notifications.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Pagination models for server-side pagination, sorting, and filtering.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
RBAC models: AccessRule, AccessRuleContext, Role.
|
||||
|
|
@ -10,13 +10,13 @@ Multi-Tenant Design:
|
|||
"""
|
||||
|
||||
import uuid
|
||||
from typing import Optional, Dict, List, Protocol, runtime_checkable
|
||||
from typing import Optional
|
||||
from enum import Enum
|
||||
from pydantic import BaseModel, Field
|
||||
from modules.datamodels.datamodelBase import PowerOnModel
|
||||
from modules.shared.i18nRegistry import i18nModel
|
||||
from modules.datamodels.datamodelUtils import TextMultilingual
|
||||
from modules.datamodels.datamodelUam import AccessLevel, User
|
||||
from modules.datamodels.datamodelUam import AccessLevel
|
||||
|
||||
|
||||
class AccessRuleContext(str, Enum):
|
||||
|
|
@ -174,20 +174,6 @@ class AccessRule(PowerOnModel):
|
|||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RbacProtocol(Protocol):
|
||||
"""Structural type for RBAC checkers — allows aicore (L3) to reference
|
||||
the RBAC contract without importing from security (L4)."""
|
||||
|
||||
def checkResourceAccessBulk(
|
||||
self,
|
||||
user: "User",
|
||||
resourcePaths: List[str],
|
||||
mandateId: Optional[str] = None,
|
||||
featureInstanceId: Optional[str] = None,
|
||||
) -> Dict[str, bool]: ...
|
||||
|
||||
|
||||
# IMMUTABLE Fields Definition - für Enforcement auf Application-Level
|
||||
IMMUTABLE_FIELDS = {
|
||||
"Role": ["mandateId", "featureInstanceId", "featureCode"],
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Security models: Token and AuthEvent.
|
||||
|
|
@ -124,43 +124,6 @@ class Token(PowerOnModel):
|
|||
return data
|
||||
|
||||
|
||||
@i18nModel("Vertrauenswuerdiges Geraet")
|
||||
class TrustedDevice(PowerOnModel):
|
||||
"""A device trusted after successful MFA verification (skips MFA for configured duration)."""
|
||||
id: str = Field(
|
||||
default_factory=lambda: str(uuid.uuid4()),
|
||||
description="Random token stored as httpOnly cookie value",
|
||||
json_schema_extra={"label": "ID"},
|
||||
)
|
||||
userId: str = Field(
|
||||
...,
|
||||
description="User this trusted device belongs to",
|
||||
json_schema_extra={"label": "Benutzer-ID", "fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"}},
|
||||
)
|
||||
trustedUntil: float = Field(
|
||||
...,
|
||||
description="UTC timestamp until which the device is trusted",
|
||||
json_schema_extra={"label": "Vertrauenswuerdig bis", "frontend_type": "timestamp"},
|
||||
)
|
||||
userAgent: Optional[str] = Field(
|
||||
default=None,
|
||||
description="Browser user agent at time of trust grant",
|
||||
json_schema_extra={"label": "User-Agent"},
|
||||
)
|
||||
ipAddress: Optional[str] = Field(
|
||||
default=None,
|
||||
description="IP address at time of trust grant",
|
||||
json_schema_extra={"label": "IP-Adresse"},
|
||||
)
|
||||
createdAt: float = Field(
|
||||
default_factory=getUtcTimestamp,
|
||||
description="When the device was trusted",
|
||||
json_schema_extra={"label": "Erstellt am", "frontend_type": "timestamp"},
|
||||
)
|
||||
|
||||
model_config = ConfigDict(use_enum_values=True)
|
||||
|
||||
|
||||
@i18nModel("Authentifizierungsereignis")
|
||||
class AuthEvent(PowerOnModel):
|
||||
"""Authentication event for audit logging."""
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Subscription models: SubscriptionPlan (catalog), MandateSubscription (instance per mandate),
|
||||
StripePlanPrice (persisted Stripe IDs per plan).
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Ticket datamodels used across Jira/ClickUp connectors."""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Utility data models and classes for common tools and mappings.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
UAM models: User, Mandate, UserConnection.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Unified Document Model (UDM) — hierarchical document tree and ContentPart bridge."""
|
||||
from __future__ import annotations
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""UI language sets: structured i18n entries (context, key, value)."""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Utility datamodels: Prompt, TextMultilingual."""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
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.datamodelUiLanguage import UiLanguageSet
|
||||
from modules.datamodels.datamodelRbac import Role
|
||||
from modules.datamodels.datamodelFeatures import DataNeutralizerAttributes
|
||||
from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutralizerAttributes
|
||||
from modules.shared.i18nRegistry import i18nModel
|
||||
|
||||
|
||||
|
|
@ -243,11 +243,11 @@ class RoleView(Role):
|
|||
# Automation Workflow — dashboard view with synthesized fields
|
||||
# ============================================================================
|
||||
|
||||
from modules.datamodels.datamodelFeatures import AutoWorkflow
|
||||
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow
|
||||
|
||||
|
||||
@i18nModel("Workflow (Ansicht)")
|
||||
class AutoWorkflowView(AutoWorkflow):
|
||||
class Automation2WorkflowView(AutoWorkflow):
|
||||
"""AutoWorkflow extended with computed dashboard fields.
|
||||
|
||||
Used exclusively for /api/attributes/ so the frontend can resolve column
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Voice settings datamodel — re-exported from UAM for central voice preferences."""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
Workflow execution models for action definitions, AI responses, and workflow-level structures.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""Workflow Action models: WorkflowActionParameter, WorkflowActionDefinition."""
|
||||
|
||||
|
|
|
|||
|
|
@ -1,171 +0,0 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# All rights reserved.
|
||||
"""
|
||||
Shared service exception classes.
|
||||
|
||||
Centralises the three cross-layer exception types so that both the
|
||||
serviceCenter layer and the workflows/interfaces layers can import them
|
||||
from one place without creating circular dependencies.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
from modules.shared.i18nRegistry import t
|
||||
from modules.datamodels.datamodelSubscription import SubscriptionStatusEnum
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Subscription action / reason constants
|
||||
# ============================================================================
|
||||
|
||||
SUBSCRIPTION_USER_ACTION_UPGRADE = "UPGRADE_SUBSCRIPTION"
|
||||
SUBSCRIPTION_USER_ACTION_REACTIVATE = "REACTIVATE_SUBSCRIPTION"
|
||||
SUBSCRIPTION_USER_ACTION_ADD_PAYMENT = "ADD_PAYMENT_METHOD"
|
||||
SUBSCRIPTION_USER_ACTION_CONTACT_ADMIN = "CONTACT_ADMIN"
|
||||
|
||||
SUBSCRIPTION_REASONS = {
|
||||
"SUBSCRIPTION_INACTIVE",
|
||||
"SUBSCRIPTION_PAYMENT_REQUIRED",
|
||||
"SUBSCRIPTION_PAYMENT_PENDING",
|
||||
"SUBSCRIPTION_EXPIRED",
|
||||
}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Subscription helper functions
|
||||
# ============================================================================
|
||||
|
||||
def _subscriptionReasonForStatus(status: SubscriptionStatusEnum) -> str:
|
||||
if status == SubscriptionStatusEnum.PENDING:
|
||||
return "SUBSCRIPTION_PAYMENT_PENDING"
|
||||
if status == SubscriptionStatusEnum.PAST_DUE:
|
||||
return "SUBSCRIPTION_PAYMENT_REQUIRED"
|
||||
if status == SubscriptionStatusEnum.EXPIRED:
|
||||
return "SUBSCRIPTION_EXPIRED"
|
||||
return "SUBSCRIPTION_INACTIVE"
|
||||
|
||||
|
||||
def _subscriptionUserActionForStatus(status: SubscriptionStatusEnum) -> str:
|
||||
if status in (SubscriptionStatusEnum.PAST_DUE, SubscriptionStatusEnum.PENDING):
|
||||
return SUBSCRIPTION_USER_ACTION_ADD_PAYMENT
|
||||
return SUBSCRIPTION_USER_ACTION_UPGRADE
|
||||
|
||||
|
||||
def _subscriptionLimitsHint() -> str:
|
||||
return " " + t(
|
||||
"Details zu Ihrem Abonnement, den enthaltenen Limits und Upgrade-Optionen: "
|
||||
"Menü «Administration» → «Billing» → Registerkarte «Abonnement»."
|
||||
)
|
||||
|
||||
|
||||
def _enterpriseLimitsHint() -> str:
|
||||
return " " + t(
|
||||
"Ihr Enterprise-Abonnement wird vom Plattform-Administrator verwaltet. "
|
||||
"Bitte kontaktieren Sie den Administrator für eine Anpassung der Limiten."
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Exception classes
|
||||
# ============================================================================
|
||||
|
||||
class SubscriptionInactiveException(Exception):
|
||||
def __init__(self, status: SubscriptionStatusEnum, mandateId: str = "", message: Optional[str] = None):
|
||||
self.status = status
|
||||
self.mandateId = mandateId
|
||||
self.reason = _subscriptionReasonForStatus(status)
|
||||
self.userAction = _subscriptionUserActionForStatus(status)
|
||||
self.message = message or t(
|
||||
"Kein aktives Abonnement für diesen Mandanten. Bitte wählen Sie einen Plan unter Billing."
|
||||
)
|
||||
super().__init__(self.message)
|
||||
|
||||
def toClientDict(self) -> Dict[str, Any]:
|
||||
out: Dict[str, Any] = {
|
||||
"error": self.reason, "message": self.message,
|
||||
"userAction": self.userAction, "subscriptionUiPath": "/admin/billing?tab=subscription",
|
||||
}
|
||||
if self.mandateId:
|
||||
out["mandateId"] = self.mandateId
|
||||
return out
|
||||
|
||||
|
||||
class SubscriptionCapacityException(Exception):
|
||||
def __init__(self, resourceType: str, currentCount: int, maxAllowed: int,
|
||||
message: Optional[str] = None, isEnterprise: bool = False):
|
||||
self.resourceType = resourceType
|
||||
self.currentCount = currentCount
|
||||
self.maxAllowed = maxAllowed
|
||||
self.isEnterprise = isEnterprise
|
||||
hint = _enterpriseLimitsHint() if isEnterprise else _subscriptionLimitsHint()
|
||||
if message is not None:
|
||||
self.message = message
|
||||
elif resourceType == "users":
|
||||
self.message = t(
|
||||
"Mit dem aktuellen Abonnement sind für diesen Mandanten höchstens {maxAllowed} "
|
||||
"Benutzer zulässig (derzeit {currentCount}). "
|
||||
"Ohne Planwechsel können keine weiteren Benutzer hinzugefügt werden."
|
||||
).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint
|
||||
elif resourceType == "featureInstances":
|
||||
self.message = t(
|
||||
"Es sind höchstens {maxAllowed} aktive Module erlaubt (derzeit {currentCount}). "
|
||||
"Bitte Abonnement erweitern oder ein Modul entfernen."
|
||||
).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint
|
||||
elif resourceType == "dataVolumeMB":
|
||||
self.message = t(
|
||||
"Das im Abonnement enthaltene Datenvolumen ({maxAllowed} MB) reicht nicht "
|
||||
"(aktuell ca. {currentCount} MB). Bitte Speicher-Limit oder Plan anpassen."
|
||||
).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint
|
||||
else:
|
||||
self.message = t(
|
||||
"Abonnement-Limit überschritten (Ressource «{resourceType}»: "
|
||||
"aktuell {currentCount}, erlaubt {maxAllowed})."
|
||||
).format(resourceType=resourceType, currentCount=currentCount, maxAllowed=maxAllowed) + hint
|
||||
super().__init__(self.message)
|
||||
|
||||
def toClientDict(self) -> Dict[str, Any]:
|
||||
action = SUBSCRIPTION_USER_ACTION_CONTACT_ADMIN if self.isEnterprise else SUBSCRIPTION_USER_ACTION_UPGRADE
|
||||
return {
|
||||
"error": f"SUBSCRIPTION_{self.resourceType.upper()}_LIMIT",
|
||||
"currentCount": self.currentCount, "maxAllowed": self.maxAllowed,
|
||||
"message": self.message, "userAction": action,
|
||||
"subscriptionUiPath": "/admin/billing?tab=subscription",
|
||||
}
|
||||
|
||||
|
||||
class BillingContextError(Exception):
|
||||
"""Raised when billing context is incomplete (missing mandateId, user, etc.).
|
||||
|
||||
This is a FAIL-SAFE error: AI calls MUST NOT proceed without valid billing context.
|
||||
Acts like a 0 CHF credit card pre-authorization check - validates that billing
|
||||
CAN be recorded before any expensive AI operation starts.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str = None):
|
||||
self.message = message or "Billing context incomplete - AI call blocked"
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Workflow execution pause exceptions
|
||||
# (Canonical location — formerly in automation2/executors/inputExecutor.py)
|
||||
# ============================================================================
|
||||
|
||||
class PauseForHumanTaskError(Exception):
|
||||
"""Raised when execution must pause for a human task. Contains runId, taskId."""
|
||||
|
||||
def __init__(self, runId: str, taskId: str, nodeId: str):
|
||||
self.runId = runId
|
||||
self.taskId = taskId
|
||||
self.nodeId = nodeId
|
||||
super().__init__(f"Pause for human task {taskId} (run {runId}, node {nodeId})")
|
||||
|
||||
|
||||
class PauseForEmailWaitError(Exception):
|
||||
"""Raised when execution must pause waiting for a new email. Background poller will resume."""
|
||||
|
||||
def __init__(self, runId: str, nodeId: str, waitConfig: Dict[str, Any]):
|
||||
self.runId = runId
|
||||
self.nodeId = nodeId
|
||||
self.waitConfig = waitConfig
|
||||
super().__init__(f"Pause for email wait (run {runId}, node {nodeId})")
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# All rights reserved.
|
||||
|
|
@ -1,214 +0,0 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# All rights reserved.
|
||||
"""
|
||||
FK label resolution: resolve foreign-key IDs to human-readable labels.
|
||||
|
||||
Works with the fk_target annotations on Pydantic models (see fkRegistry.py)
|
||||
to auto-build label resolvers for paginated record sets.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from functools import partial
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Individual FK label resolvers (db, ids) -> {id: label}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def resolveMandateLabels(db, ids: List[str]) -> Dict[str, Optional[str]]:
|
||||
"""Resolve mandate IDs to labels. Returns None (not the ID!) for
|
||||
unresolvable entries so the caller can distinguish "resolved" from "missing".
|
||||
"""
|
||||
from modules.datamodels.datamodelUam import Mandate
|
||||
uniqueIds = list(set(ids))
|
||||
records = db.getRecordset(Mandate, recordFilter={"id": uniqueIds}) or []
|
||||
found: Dict[str, dict] = {}
|
||||
for rec in records:
|
||||
mid = rec.get("id", "")
|
||||
found[mid] = rec
|
||||
result: Dict[str, Optional[str]] = {}
|
||||
for mid in ids:
|
||||
m = found.get(mid)
|
||||
label = (m.get("label") or m.get("name")) if m else None
|
||||
if not label:
|
||||
logger.debug("resolveMandateLabels: no label for id=%s (found=%s)", mid, m is not None)
|
||||
result[mid] = label or None
|
||||
return result
|
||||
|
||||
|
||||
def resolveInstanceLabels(db, ids: List[str]) -> Dict[str, Optional[str]]:
|
||||
"""Resolve feature-instance IDs to labels. Returns None for unresolvable."""
|
||||
from modules.datamodels.datamodelFeatures import FeatureInstance
|
||||
result: Dict[str, Optional[str]] = {}
|
||||
for iid in ids:
|
||||
records = db.getRecordset(FeatureInstance, recordFilter={"id": iid})
|
||||
if records:
|
||||
label = records[0].get("label") or None
|
||||
result[iid] = label
|
||||
else:
|
||||
logger.debug("resolveInstanceLabels: no label for id=%s", iid)
|
||||
result[iid] = None
|
||||
return result
|
||||
|
||||
|
||||
def resolveUserLabels(db, ids: List[str]) -> Dict[str, Optional[str]]:
|
||||
"""Resolve user IDs to display names. Returns None for unresolvable."""
|
||||
from modules.datamodels.datamodelUam import UserInDB as _UserInDB
|
||||
uniqueIds = list(set(ids))
|
||||
users = db.getRecordset(
|
||||
_UserInDB,
|
||||
recordFilter={"id": uniqueIds},
|
||||
)
|
||||
result: Dict[str, Optional[str]] = {}
|
||||
found: Dict[str, dict] = {}
|
||||
for u in (users or []):
|
||||
uid = u.get("id", "")
|
||||
found[uid] = u
|
||||
for uid in ids:
|
||||
u = found.get(uid)
|
||||
if u:
|
||||
result[uid] = u.get("displayName") or u.get("username") or u.get("email") or None
|
||||
else:
|
||||
result[uid] = None
|
||||
return result
|
||||
|
||||
|
||||
def resolveRoleLabels(db, ids: List[str]) -> Dict[str, Optional[str]]:
|
||||
"""Resolve Role.id to roleLabel. Returns None for unresolvable."""
|
||||
if not ids:
|
||||
return {}
|
||||
from modules.datamodels.datamodelRbac import Role as _Role
|
||||
recs = db.getRecordset(
|
||||
_Role,
|
||||
recordFilter={"id": list(set(ids))},
|
||||
) or []
|
||||
out: Dict[str, Optional[str]] = {i: None for i in ids}
|
||||
for r in recs:
|
||||
rid = r.get("id")
|
||||
if rid:
|
||||
out[rid] = r.get("roleLabel") or None
|
||||
for rid in ids:
|
||||
if out.get(rid) is None:
|
||||
logger.debug("resolveRoleLabels: no label for id=%s", rid)
|
||||
return out
|
||||
|
||||
|
||||
def resolveFileLabels(db, ids: List[str]) -> Dict[str, Optional[str]]:
|
||||
"""Resolve FileItem IDs to fileName. Returns None for unresolvable."""
|
||||
if not ids:
|
||||
return {}
|
||||
from modules.datamodels.datamodelFiles import FileItem as _FileItem
|
||||
recs = db.getRecordset(
|
||||
_FileItem,
|
||||
recordFilter={"id": list(set(ids))},
|
||||
) or []
|
||||
out: Dict[str, Optional[str]] = {i: None for i in ids}
|
||||
for r in recs:
|
||||
fid = r.get("id")
|
||||
if fid:
|
||||
out[fid] = r.get("fileName") or None
|
||||
return out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resolver registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BUILTIN_FK_RESOLVERS: Dict[str, Callable] = {
|
||||
"Mandate": resolveMandateLabels,
|
||||
"FeatureInstance": resolveInstanceLabels,
|
||||
"UserInDB": resolveUserLabels,
|
||||
"Role": resolveRoleLabels,
|
||||
"FileItem": resolveFileLabels,
|
||||
}
|
||||
|
||||
|
||||
def buildLabelResolversFromModel(
|
||||
modelClass: type,
|
||||
db=None,
|
||||
) -> Dict[str, Callable[[List[str]], Dict[str, str]]]:
|
||||
"""
|
||||
Auto-build labelResolvers dict from ``json_schema_extra.fk_target`` on a Pydantic model.
|
||||
Maps field names to resolver functions when the target table has a registered builtin
|
||||
resolver and ``fk_target.labelField`` is set (non-None).
|
||||
|
||||
When ``db`` is provided, the returned resolvers are pre-bound with partial(resolver, db)
|
||||
so they can be called as resolver(ids).
|
||||
"""
|
||||
resolvers: Dict[str, Callable[[List[str]], Dict[str, str]]] = {}
|
||||
for name, fieldInfo in modelClass.model_fields.items():
|
||||
extra = fieldInfo.json_schema_extra
|
||||
if not extra or not isinstance(extra, dict):
|
||||
continue
|
||||
tgt = extra.get("fk_target")
|
||||
if not isinstance(tgt, dict):
|
||||
continue
|
||||
if tgt.get("labelField") is None:
|
||||
continue
|
||||
fkModel = tgt.get("table")
|
||||
if fkModel and fkModel in _BUILTIN_FK_RESOLVERS:
|
||||
fn = _BUILTIN_FK_RESOLVERS[fkModel]
|
||||
resolvers[name] = partial(fn, db) if db else fn
|
||||
return resolvers
|
||||
|
||||
|
||||
def enrichRowsWithFkLabels(
|
||||
rows: List[Dict[str, Any]],
|
||||
modelClass: type = None,
|
||||
*,
|
||||
db=None,
|
||||
labelResolvers: Optional[Dict[str, Callable[[List[str]], Dict[str, Optional[str]]]]] = None,
|
||||
extraResolvers: Optional[Dict[str, Callable[[List[str]], Dict[str, Optional[str]]]]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Add ``{field}Label`` columns to each row for every FK field that has a
|
||||
registered resolver.
|
||||
|
||||
``modelClass`` — if provided, resolvers are auto-built from ``fk_target``
|
||||
annotations on the Pydantic model (via ``buildLabelResolversFromModel``).
|
||||
Requires ``db`` to be passed.
|
||||
|
||||
``labelResolvers`` — explicit resolver map that overrides auto-built ones.
|
||||
Each resolver has signature ``(ids: List[str]) -> Dict[str, Optional[str]]``.
|
||||
|
||||
``extraResolvers`` — merged on top of auto-built / explicit resolvers. Use
|
||||
for ad-hoc fields that are not FK-annotated on the model (e.g.
|
||||
``createdByUserId`` on billing transactions).
|
||||
|
||||
If a label cannot be resolved the ``{field}Label`` value is ``None``
|
||||
(never the raw ID — that would reintroduce the silent-truncation bug).
|
||||
"""
|
||||
resolvers: Dict[str, Callable] = {}
|
||||
|
||||
if modelClass is not None and labelResolvers is None:
|
||||
resolvers = buildLabelResolversFromModel(modelClass, db)
|
||||
elif labelResolvers is not None:
|
||||
resolvers = dict(labelResolvers)
|
||||
|
||||
if extraResolvers:
|
||||
resolvers.update(extraResolvers)
|
||||
|
||||
if not resolvers or not rows:
|
||||
return rows
|
||||
|
||||
for field, resolver in resolvers.items():
|
||||
ids = list({str(r.get(field)) for r in rows if r.get(field)})
|
||||
if not ids:
|
||||
continue
|
||||
try:
|
||||
labelMap = resolver(ids)
|
||||
except Exception as e:
|
||||
logger.error("enrichRowsWithFkLabels: resolver for '%s' raised: %s", field, e)
|
||||
labelMap = {}
|
||||
|
||||
labelKey = f"{field}Label"
|
||||
for r in rows:
|
||||
fkVal = r.get(field)
|
||||
if fkVal:
|
||||
r[labelKey] = labelMap.get(str(fkVal))
|
||||
else:
|
||||
r[labelKey] = None
|
||||
|
||||
return rows
|
||||
|
|
@ -1,543 +0,0 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# All rights reserved.
|
||||
"""
|
||||
Pagination, filtering and sorting helpers for paginated record sets.
|
||||
|
||||
Provides unified logic for:
|
||||
- mode=filterValues: distinct column values for filter dropdowns (cross-filtered)
|
||||
- mode=ids: all IDs matching current filters (for bulk selection)
|
||||
- In-memory equivalents for enriched/non-SQL routes
|
||||
- FK-label-aware sorting (cross-DB)
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from modules.datamodels.datamodelPagination import (
|
||||
PaginationParams,
|
||||
normalize_pagination_dict,
|
||||
)
|
||||
from modules.shared.i18nRegistry import resolveText
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-filter pagination parsing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def parseCrossFilterPagination(
|
||||
column: str,
|
||||
paginationJson: Optional[str],
|
||||
) -> Optional[PaginationParams]:
|
||||
"""
|
||||
Parse pagination JSON, remove the requested column from filters (cross-filtering),
|
||||
and drop sort — used for filter-values requests.
|
||||
"""
|
||||
if not paginationJson:
|
||||
return None
|
||||
try:
|
||||
paginationDict = json.loads(paginationJson)
|
||||
if not paginationDict:
|
||||
return None
|
||||
paginationDict = normalize_pagination_dict(paginationDict)
|
||||
filters = paginationDict.get("filters", {})
|
||||
filters.pop(column, None)
|
||||
paginationDict["filters"] = filters
|
||||
paginationDict.pop("sort", None)
|
||||
return PaginationParams(**paginationDict)
|
||||
except (json.JSONDecodeError, ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def parsePaginationForIds(
|
||||
paginationJson: Optional[str],
|
||||
) -> Optional[PaginationParams]:
|
||||
"""
|
||||
Parse pagination JSON for mode=ids — keep filters, drop sort and page/pageSize.
|
||||
"""
|
||||
if not paginationJson:
|
||||
return None
|
||||
try:
|
||||
paginationDict = json.loads(paginationJson)
|
||||
if not paginationDict:
|
||||
return None
|
||||
paginationDict = normalize_pagination_dict(paginationDict)
|
||||
paginationDict.pop("sort", None)
|
||||
return PaginationParams(**paginationDict)
|
||||
except (json.JSONDecodeError, ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SQL-based helpers (delegate to DB connector)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def handleFilterValuesMode(
|
||||
db,
|
||||
modelClass: type,
|
||||
column: str,
|
||||
paginationJson: Optional[str] = None,
|
||||
recordFilter: Optional[Dict[str, Any]] = None,
|
||||
enrichFn: Optional[Callable[[str, Optional[PaginationParams], Optional[Dict[str, Any]]], List[str]]] = None,
|
||||
) -> List[str]:
|
||||
"""
|
||||
SQL-based distinct column values with cross-filtering.
|
||||
|
||||
If enrichFn is provided and the column is enriched (computed/joined),
|
||||
enrichFn(column, crossPagination, recordFilter) is called instead of SQL DISTINCT.
|
||||
"""
|
||||
crossPagination = parseCrossFilterPagination(column, paginationJson)
|
||||
|
||||
if enrichFn:
|
||||
try:
|
||||
result = enrichFn(column, crossPagination, recordFilter)
|
||||
if result is not None:
|
||||
return JSONResponse(content=result)
|
||||
except Exception as e:
|
||||
logger.warning(f"handleFilterValuesMode enrichFn failed for {column}: {e}")
|
||||
|
||||
try:
|
||||
values = db.getDistinctColumnValues(
|
||||
modelClass, column,
|
||||
pagination=crossPagination,
|
||||
recordFilter=recordFilter,
|
||||
) or []
|
||||
return JSONResponse(content=values)
|
||||
except Exception as e:
|
||||
logger.error(f"handleFilterValuesMode SQL failed for {modelClass.__name__}.{column}: {e}")
|
||||
return JSONResponse(content=[])
|
||||
|
||||
|
||||
def handleIdsMode(
|
||||
db,
|
||||
modelClass: type,
|
||||
paginationJson: Optional[str] = None,
|
||||
recordFilter: Optional[Dict[str, Any]] = None,
|
||||
idField: str = "id",
|
||||
) -> List[str]:
|
||||
"""
|
||||
Return all IDs matching the current filters (no LIMIT/OFFSET).
|
||||
Uses the same WHERE clause as getRecordsetPaginated.
|
||||
"""
|
||||
pagination = parsePaginationForIds(paginationJson)
|
||||
table = modelClass.__name__
|
||||
|
||||
try:
|
||||
if not db._ensureTableExists(modelClass):
|
||||
return JSONResponse(content=[])
|
||||
|
||||
where_clause, _, _, values, _ = db._buildPaginationClauses(
|
||||
modelClass, pagination, recordFilter,
|
||||
)
|
||||
|
||||
sql = f'SELECT "{idField}"::TEXT AS val FROM "{table}"{where_clause} ORDER BY "{idField}"'
|
||||
|
||||
with db.borrowCursor() as cursor:
|
||||
cursor.execute(sql, values)
|
||||
return JSONResponse(content=[row["val"] for row in cursor.fetchall()])
|
||||
except Exception as e:
|
||||
logger.error(f"handleIdsMode failed for {table}: {e}")
|
||||
return JSONResponse(content=[])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-memory helpers (for enriched / non-SQL routes)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def applyFiltersAndSort(
|
||||
items: List[Dict[str, Any]],
|
||||
paginationParams: Optional[PaginationParams],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Apply filters and sorting to a list of dicts in-memory.
|
||||
Does NOT paginate (no page/pageSize slicing).
|
||||
"""
|
||||
if not paginationParams:
|
||||
return items
|
||||
|
||||
result = list(items)
|
||||
|
||||
if paginationParams.filters:
|
||||
filters = paginationParams.filters
|
||||
searchTerm = filters.get("search", "").lower() if filters.get("search") else None
|
||||
|
||||
if searchTerm:
|
||||
result = [
|
||||
item for item in result
|
||||
if any(
|
||||
searchTerm in str(v).lower()
|
||||
for v in item.values()
|
||||
if v is not None
|
||||
)
|
||||
]
|
||||
|
||||
for field, filterValue in filters.items():
|
||||
if field == "search":
|
||||
continue
|
||||
|
||||
if isinstance(filterValue, dict) and "operator" in filterValue:
|
||||
operator = filterValue.get("operator", "equals")
|
||||
value = filterValue.get("value")
|
||||
else:
|
||||
operator = "equals"
|
||||
value = filterValue
|
||||
|
||||
if value is None:
|
||||
result = [
|
||||
item for item in result
|
||||
if item.get(field) is None or item.get(field) == ""
|
||||
]
|
||||
continue
|
||||
|
||||
if value == "":
|
||||
continue
|
||||
|
||||
result = [
|
||||
item for item in result
|
||||
if _matchesFilter(item, field, operator, value)
|
||||
]
|
||||
|
||||
if paginationParams.sort:
|
||||
for sortField in reversed(paginationParams.sort):
|
||||
fieldName = sortField.field
|
||||
ascending = sortField.direction == "asc"
|
||||
|
||||
noneItems = [item for item in result if item.get(fieldName) is None]
|
||||
nonNoneItems = [item for item in result if item.get(fieldName) is not None]
|
||||
|
||||
def _getSortKey(item: Dict[str, Any], _fn=fieldName):
|
||||
value = item.get(_fn)
|
||||
if isinstance(value, bool):
|
||||
return (0, int(value), "")
|
||||
if isinstance(value, (int, float)):
|
||||
return (0, value, "")
|
||||
return (1, 0, str(value).lower())
|
||||
|
||||
nonNoneItems = sorted(nonNoneItems, key=_getSortKey, reverse=not ascending)
|
||||
result = nonNoneItems + noneItems
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _matchesFilter(item: Dict[str, Any], field: str, operator: str, value: Any) -> bool:
|
||||
"""Single-field filter match for in-memory filtering."""
|
||||
itemValue = item.get(field)
|
||||
if itemValue is None:
|
||||
return False
|
||||
|
||||
itemStr = str(itemValue).lower()
|
||||
valueStr = str(value).lower()
|
||||
|
||||
if operator in ("equals", "eq"):
|
||||
return itemStr == valueStr
|
||||
if operator == "contains":
|
||||
return valueStr in itemStr
|
||||
if operator == "startsWith":
|
||||
return itemStr.startswith(valueStr)
|
||||
if operator == "endsWith":
|
||||
return itemStr.endswith(valueStr)
|
||||
if operator in ("gt", "gte", "lt", "lte"):
|
||||
try:
|
||||
itemNum = float(itemValue)
|
||||
valueNum = float(value)
|
||||
if operator == "gt":
|
||||
return itemNum > valueNum
|
||||
if operator == "gte":
|
||||
return itemNum >= valueNum
|
||||
if operator == "lt":
|
||||
return itemNum < valueNum
|
||||
return itemNum <= valueNum
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
if operator == "between":
|
||||
return _matchesBetween(itemValue, itemStr, value)
|
||||
if operator == "in":
|
||||
if isinstance(value, list):
|
||||
return itemStr in [str(x).lower() for x in value]
|
||||
return False
|
||||
if operator == "notIn":
|
||||
if isinstance(value, list):
|
||||
return itemStr not in [str(x).lower() for x in value]
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
def _matchesBetween(itemValue: Any, itemStr: str, value: Any) -> bool:
|
||||
"""Handle 'between' operator for date ranges and numeric ranges."""
|
||||
if not isinstance(value, dict):
|
||||
return True
|
||||
fromVal = value.get("from", "")
|
||||
toVal = value.get("to", "")
|
||||
if not fromVal and not toVal:
|
||||
return True
|
||||
try:
|
||||
fromTs = None
|
||||
toTs = None
|
||||
if fromVal:
|
||||
fromTs = datetime.strptime(str(fromVal), "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp()
|
||||
if toVal:
|
||||
toTs = datetime.strptime(str(toVal), "%Y-%m-%d").replace(
|
||||
hour=23, minute=59, second=59, tzinfo=timezone.utc
|
||||
).timestamp()
|
||||
itemNum = float(itemValue) if not isinstance(itemValue, (int, float)) else itemValue
|
||||
if itemNum > 10000000000:
|
||||
itemNum = itemNum / 1000
|
||||
if fromTs is not None and toTs is not None:
|
||||
return fromTs <= itemNum <= toTs
|
||||
if fromTs is not None:
|
||||
return itemNum >= fromTs
|
||||
if toTs is not None:
|
||||
return itemNum <= toTs
|
||||
except (ValueError, TypeError):
|
||||
try:
|
||||
itemNum = float(itemValue)
|
||||
fromNum = float(fromVal) if fromVal not in (None, "") else None
|
||||
toNum = float(toVal) if toVal not in (None, "") else None
|
||||
if fromNum is not None and toNum is not None:
|
||||
return fromNum <= itemNum <= toNum
|
||||
if fromNum is not None:
|
||||
return itemNum >= fromNum
|
||||
if toNum is not None:
|
||||
return itemNum <= toNum
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
fromStr = str(fromVal).lower() if fromVal else ""
|
||||
toStr = str(toVal).lower() if toVal else ""
|
||||
if fromStr and toStr:
|
||||
return fromStr <= itemStr <= toStr
|
||||
if fromStr:
|
||||
return itemStr >= fromStr
|
||||
if toStr:
|
||||
return itemStr <= toStr
|
||||
return True
|
||||
|
||||
|
||||
def _extractDistinctValues(
|
||||
items: List[Dict[str, Any]],
|
||||
columnKey: str,
|
||||
requestLang: Optional[str] = None,
|
||||
) -> list:
|
||||
"""Extract sorted distinct display values for a column from enriched items.
|
||||
|
||||
When the items contain a ``{columnKey}Label`` field (FK enrichment convention),
|
||||
returns ``{value, label}`` objects so the frontend shows human-readable
|
||||
labels in filter dropdowns. Otherwise returns plain strings.
|
||||
|
||||
Includes ``None`` as the last entry when at least one row has a null/empty
|
||||
value — this enables the "(Leer)" filter option in the frontend.
|
||||
"""
|
||||
_MISSING = object()
|
||||
labelKey = f"{columnKey}Label"
|
||||
hasFkLabels = any(labelKey in item for item in items[:20])
|
||||
|
||||
if hasFkLabels:
|
||||
byVal: Dict[str, str] = {}
|
||||
hasEmpty = False
|
||||
for item in items:
|
||||
val = item.get(columnKey, _MISSING)
|
||||
if val is _MISSING:
|
||||
continue
|
||||
if val is None or val == "":
|
||||
hasEmpty = True
|
||||
continue
|
||||
strVal = str(val)
|
||||
if strVal not in byVal:
|
||||
label = item.get(labelKey)
|
||||
byVal[strVal] = str(label) if label else f"NA({strVal[:8]})"
|
||||
result: list = sorted(
|
||||
[{"value": v, "label": l} for v, l in byVal.items()],
|
||||
key=lambda x: x["label"].lower(),
|
||||
)
|
||||
if hasEmpty:
|
||||
result.append(None)
|
||||
return result
|
||||
|
||||
values = set()
|
||||
hasEmpty = False
|
||||
for item in items:
|
||||
val = item.get(columnKey, _MISSING)
|
||||
if val is _MISSING:
|
||||
continue
|
||||
if val is None or val == "":
|
||||
hasEmpty = True
|
||||
continue
|
||||
if isinstance(val, bool):
|
||||
values.add("true" if val else "false")
|
||||
elif isinstance(val, (int, float)):
|
||||
values.add(str(val))
|
||||
elif isinstance(val, dict):
|
||||
text = resolveText(val, requestLang)
|
||||
if text:
|
||||
values.add(text)
|
||||
else:
|
||||
values.add(str(val))
|
||||
result = sorted(values, key=lambda v: v.lower())
|
||||
if hasEmpty:
|
||||
result.append(None)
|
||||
return result
|
||||
|
||||
|
||||
def handleFilterValuesInMemory(
|
||||
items: List[Dict[str, Any]],
|
||||
column: str,
|
||||
paginationJson: Optional[str] = None,
|
||||
requestLang: Optional[str] = None,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
In-memory filter-values: apply cross-filters, then extract distinct values.
|
||||
For routes that build enriched in-memory lists.
|
||||
Returns JSONResponse to bypass FastAPI response_model validation.
|
||||
"""
|
||||
crossFilterParams = parseCrossFilterPagination(column, paginationJson)
|
||||
crossFiltered = applyFiltersAndSort(items, crossFilterParams)
|
||||
return JSONResponse(content=_extractDistinctValues(crossFiltered, column, requestLang))
|
||||
|
||||
|
||||
def handleIdsInMemory(
|
||||
items: List[Dict[str, Any]],
|
||||
paginationJson: Optional[str] = None,
|
||||
idField: str = "id",
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
In-memory IDs: apply filters, return all IDs.
|
||||
For routes that build enriched in-memory lists.
|
||||
Returns JSONResponse to bypass FastAPI response_model validation.
|
||||
"""
|
||||
pagination = parsePaginationForIds(paginationJson)
|
||||
filtered = applyFiltersAndSort(items, pagination)
|
||||
ids = []
|
||||
for item in filtered:
|
||||
val = item.get(idField)
|
||||
if val is not None:
|
||||
ids.append(str(val))
|
||||
return JSONResponse(content=ids)
|
||||
|
||||
|
||||
def getRecordsetPaginatedWithFkSort(
|
||||
db,
|
||||
modelClass: type,
|
||||
pagination,
|
||||
recordFilter: Optional[Dict[str, Any]] = None,
|
||||
labelResolvers: Optional[Dict[str, Callable[[List[str]], Dict[str, str]]]] = None,
|
||||
fieldFilter: Optional[List[str]] = None,
|
||||
idField: str = "id",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Wrapper around db.getRecordsetPaginated that handles FK-label sorting.
|
||||
|
||||
If the current sort field is a FK with a registered labelResolver, the
|
||||
function fetches all filtered IDs + FK values, resolves labels cross-DB,
|
||||
sorts in-memory by label, and returns only the requested page.
|
||||
|
||||
If no FK sort is active, delegates directly to db.getRecordsetPaginated.
|
||||
"""
|
||||
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels, buildLabelResolversFromModel
|
||||
|
||||
if not pagination or not pagination.sort:
|
||||
result = db.getRecordsetPaginated(modelClass, pagination, recordFilter, fieldFilter)
|
||||
enrichRowsWithFkLabels(result.get("items", []), modelClass, db=db)
|
||||
return result
|
||||
|
||||
if labelResolvers is None:
|
||||
labelResolvers = buildLabelResolversFromModel(modelClass, db)
|
||||
|
||||
if not labelResolvers:
|
||||
result = db.getRecordsetPaginated(modelClass, pagination, recordFilter, fieldFilter)
|
||||
enrichRowsWithFkLabels(result.get("items", []), modelClass, db=db)
|
||||
return result
|
||||
|
||||
fkSortField = None
|
||||
fkSortDir = "asc"
|
||||
for sf in pagination.sort:
|
||||
sfField = sf.get("field") if isinstance(sf, dict) else getattr(sf, "field", None)
|
||||
sfDir = sf.get("direction", "asc") if isinstance(sf, dict) else getattr(sf, "direction", "asc")
|
||||
if sfField and sfField in labelResolvers:
|
||||
fkSortField = sfField
|
||||
fkSortDir = str(sfDir).lower()
|
||||
break
|
||||
|
||||
if not fkSortField:
|
||||
result = db.getRecordsetPaginated(modelClass, pagination, recordFilter, fieldFilter)
|
||||
enrichRowsWithFkLabels(result.get("items", []), modelClass, db=db)
|
||||
return result
|
||||
|
||||
try:
|
||||
distinctIds = db.getDistinctColumnValues(
|
||||
modelClass, fkSortField, recordFilter=recordFilter,
|
||||
) or []
|
||||
|
||||
labelMap = {}
|
||||
if distinctIds:
|
||||
try:
|
||||
labelMap = labelResolvers[fkSortField](distinctIds)
|
||||
except Exception as e:
|
||||
logger.warning(f"getRecordsetPaginatedWithFkSort: resolver for {fkSortField} failed: {e}")
|
||||
|
||||
filterOnlyPagination = copy.deepcopy(pagination)
|
||||
filterOnlyPagination.sort = []
|
||||
filterOnlyPagination.page = 1
|
||||
filterOnlyPagination.pageSize = 999999
|
||||
|
||||
lightRows = db.getRecordsetPaginated(
|
||||
modelClass, filterOnlyPagination, recordFilter,
|
||||
fieldFilter=[idField, fkSortField],
|
||||
)
|
||||
allRows = lightRows.get("items", [])
|
||||
totalItems = len(allRows)
|
||||
|
||||
if totalItems == 0:
|
||||
return {"items": [], "totalItems": 0, "totalPages": 0}
|
||||
|
||||
def _sortKey(row):
|
||||
fkVal = row.get(fkSortField, "") or ""
|
||||
label = labelMap.get(str(fkVal), str(fkVal)).lower()
|
||||
return label
|
||||
|
||||
reverse = fkSortDir == "desc"
|
||||
allRows.sort(key=_sortKey, reverse=reverse)
|
||||
|
||||
pageSize = pagination.pageSize
|
||||
offset = (pagination.page - 1) * pageSize
|
||||
pageSlice = allRows[offset:offset + pageSize]
|
||||
pageIds = [row[idField] for row in pageSlice if row.get(idField)]
|
||||
|
||||
if not pageIds:
|
||||
return {"items": [], "totalItems": totalItems, "totalPages": math.ceil(totalItems / pageSize)}
|
||||
|
||||
pageItems = db.getRecordset(modelClass, recordFilter={idField: pageIds}, fieldFilter=fieldFilter)
|
||||
|
||||
idOrder = {pid: idx for idx, pid in enumerate(pageIds)}
|
||||
pageItems.sort(key=lambda r: idOrder.get(r.get(idField), 999999))
|
||||
|
||||
enrichRowsWithFkLabels(pageItems, modelClass, db=db)
|
||||
totalPages = math.ceil(totalItems / pageSize) if totalItems > 0 else 0
|
||||
return {"items": pageItems, "totalItems": totalItems, "totalPages": totalPages}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"getRecordsetPaginatedWithFkSort failed for {modelClass.__name__}: {e}")
|
||||
result = db.getRecordsetPaginated(modelClass, pagination, recordFilter, fieldFilter)
|
||||
enrichRowsWithFkLabels(result.get("items", []), modelClass, db=db)
|
||||
return result
|
||||
|
||||
|
||||
def paginateInMemory(
|
||||
items: List[Dict[str, Any]],
|
||||
paginationParams: Optional[PaginationParams],
|
||||
) -> tuple:
|
||||
"""
|
||||
Apply pagination (page/pageSize slicing) to an already-filtered+sorted list.
|
||||
Returns (pageItems, totalItems).
|
||||
"""
|
||||
totalItems = len(items)
|
||||
if not paginationParams:
|
||||
return items, totalItems
|
||||
offset = (paginationParams.page - 1) * paginationParams.pageSize
|
||||
pageItems = items[offset:offset + paginationParams.pageSize]
|
||||
return pageItems, totalItems
|
||||
|
|
@ -1,9 +1,7 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# All rights reserved.
|
||||
"""
|
||||
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().
|
||||
"""
|
||||
|
||||
|
|
@ -13,14 +11,14 @@ import logging
|
|||
import pkgutil
|
||||
from typing import Dict
|
||||
|
||||
from modules.demoConfigs.baseDemoConfig import BaseDemoConfig
|
||||
from modules.demoConfigs._baseDemoConfig import _BaseDemoConfig
|
||||
|
||||
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."""
|
||||
if _configCache:
|
||||
return _configCache
|
||||
|
|
@ -34,7 +32,7 @@ def getAvailableDemoConfigs() -> Dict[str, BaseDemoConfig]:
|
|||
try:
|
||||
module = importlib.import_module(f"{package}.{moduleName}")
|
||||
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()
|
||||
if instance.code:
|
||||
_configCache[instance.code] = instance
|
||||
|
|
@ -45,7 +43,7 @@ def getAvailableDemoConfigs() -> Dict[str, BaseDemoConfig]:
|
|||
return _configCache
|
||||
|
||||
|
||||
def getDemoConfigByCode(code: str) -> BaseDemoConfig | None:
|
||||
def getDemoConfigByCode(code: str) -> _BaseDemoConfig | None:
|
||||
"""Get a specific demo config by its code."""
|
||||
configs = getAvailableDemoConfigs()
|
||||
return configs.get(code)
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# All rights reserved.
|
||||
"""
|
||||
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
|
||||
a complete demo environment (mandates, users, features, test data, etc.).
|
||||
|
||||
|
|
@ -20,7 +18,7 @@ from typing import Any, Dict, List
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseDemoConfig(ABC):
|
||||
class _BaseDemoConfig(ABC):
|
||||
"""Abstract base for demo configurations."""
|
||||
|
||||
code: str = ""
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# All rights reserved.
|
||||
"""
|
||||
Investor Demo April 2026
|
||||
|
||||
|
|
@ -19,7 +17,7 @@ import logging
|
|||
import uuid
|
||||
from typing import Dict, Any, Optional, List
|
||||
|
||||
from modules.demoConfigs.baseDemoConfig import BaseDemoConfig
|
||||
from modules.demoConfigs._baseDemoConfig import _BaseDemoConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -46,6 +44,7 @@ _USER = {
|
|||
_FEATURES_HAPPYLIFE = [
|
||||
{"code": "workspace", "label": "Dokumentenablage"},
|
||||
{"code": "trustee", "label": "Buchhaltung"},
|
||||
{"code": "graphicalEditor", "label": "Automationen"},
|
||||
{"code": "neutralization", "label": "Datenschutz"},
|
||||
]
|
||||
_FEATURES_ALPINA = [
|
||||
|
|
@ -53,16 +52,17 @@ _FEATURES_ALPINA = [
|
|||
{"code": "trustee", "label": "BUHA Müller Immobilien GmbH"},
|
||||
{"code": "trustee", "label": "BUHA Schneider Gastro AG"},
|
||||
{"code": "trustee", "label": "BUHA Weber Consulting"},
|
||||
{"code": "graphicalEditor", "label": "Automationen"},
|
||||
{"code": "neutralization", "label": "Datenschutz"},
|
||||
]
|
||||
|
||||
|
||||
class InvestorDemo2026(BaseDemoConfig):
|
||||
class InvestorDemo2026(_BaseDemoConfig):
|
||||
code = "investor-demo-2026"
|
||||
label = "Investor Demo April 2026"
|
||||
description = (
|
||||
"Two mandates (HappyLife AG + Alpina Treuhand AG), one SysAdmin user, "
|
||||
"trustee with RMA, workspace, workflow automation, and neutralization."
|
||||
"trustee with RMA, workspace, graph editor, and neutralization."
|
||||
)
|
||||
credentials = [
|
||||
{
|
||||
|
|
@ -119,7 +119,7 @@ class InvestorDemo2026(BaseDemoConfig):
|
|||
# remove
|
||||
# ------------------------------------------------------------------
|
||||
def remove(self, db) -> Dict[str, Any]:
|
||||
summary: Dict[str, Any] = {"removed": [], "skipped": [], "errors": []}
|
||||
summary: Dict[str, Any] = {"removed": [], "errors": []}
|
||||
|
||||
from modules.datamodels.datamodelUam import Mandate, UserInDB
|
||||
from modules.datamodels.datamodelMembership import UserMandate
|
||||
|
|
@ -171,7 +171,7 @@ class InvestorDemo2026(BaseDemoConfig):
|
|||
|
||||
def _ensureMandate(self, db, mandateDef: Dict, summary: Dict) -> Optional[str]:
|
||||
from modules.datamodels.datamodelUam import Mandate
|
||||
from modules.interfaces.interfaceRbac import copySystemRolesToMandate
|
||||
from modules.interfaces.interfaceBootstrap import copySystemRolesToMandate
|
||||
|
||||
existing = db.getRecordset(Mandate, recordFilter={"name": mandateDef["name"]})
|
||||
if existing:
|
||||
|
|
@ -395,8 +395,8 @@ class InvestorDemo2026(BaseDemoConfig):
|
|||
apiKey = APP_CONFIG.get("Demo_RMA_ApiKey", "")
|
||||
|
||||
if not apiBaseUrl or not apiKey:
|
||||
summary["skipped"].append(
|
||||
f"RMA credentials not configured (Demo_RMA_ApiBaseUrl, Demo_RMA_ClientName, Demo_RMA_ApiKey) for {mandateLabel} — optional external integration"
|
||||
summary["errors"].append(
|
||||
f"RMA credentials missing in config.ini (Demo_RMA_ApiBaseUrl, Demo_RMA_ClientName, Demo_RMA_ApiKey) for {mandateLabel}"
|
||||
)
|
||||
return
|
||||
|
||||
|
|
@ -492,8 +492,8 @@ class InvestorDemo2026(BaseDemoConfig):
|
|||
if not instId:
|
||||
continue
|
||||
|
||||
if featureCode == "workflowAutomation":
|
||||
self._removeWorkflowAutomationData(instId, mandateId, mandateLabel, summary)
|
||||
if featureCode == "graphicalEditor":
|
||||
self._removeGraphicalEditorData(instId, mandateId, mandateLabel, summary)
|
||||
|
||||
if featureCode == "trustee":
|
||||
self._removeTrusteeData(db, instId, mandateLabel, summary)
|
||||
|
|
@ -551,26 +551,25 @@ class InvestorDemo2026(BaseDemoConfig):
|
|||
except Exception as e:
|
||||
summary["errors"].append(f"Billing cleanup for {mandateLabel}: {e}")
|
||||
|
||||
def _removeWorkflowAutomationData(self, featureInstanceId: str, mandateId: str, mandateLabel: str, summary: Dict):
|
||||
"""Remove all AutoWorkflow data (workflows, runs, versions, logs, tasks) from the WorkflowAutomation DB."""
|
||||
def _removeGraphicalEditorData(self, featureInstanceId: str, mandateId: str, mandateLabel: str, summary: Dict):
|
||||
"""Remove all AutoWorkflow data (workflows, runs, versions, logs, tasks) from the Greenfield DB."""
|
||||
try:
|
||||
from modules.datamodels.datamodelWorkflowAutomation import (
|
||||
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import (
|
||||
AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask,
|
||||
WORKFLOW_AUTOMATION_DATABASE,
|
||||
)
|
||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||
from modules.shared.configuration import APP_CONFIG
|
||||
|
||||
waDb = DatabaseConnector(
|
||||
geDb = DatabaseConnector(
|
||||
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
|
||||
dbDatabase=WORKFLOW_AUTOMATION_DATABASE,
|
||||
dbDatabase="poweron_graphicaleditor",
|
||||
dbUser=APP_CONFIG.get("DB_USER"),
|
||||
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
|
||||
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
|
||||
userId=None,
|
||||
)
|
||||
|
||||
workflows = waDb.getRecordset(AutoWorkflow, recordFilter={
|
||||
workflows = geDb.getRecordset(AutoWorkflow, recordFilter={
|
||||
"mandateId": mandateId,
|
||||
"featureInstanceId": featureInstanceId,
|
||||
}) or []
|
||||
|
|
@ -580,27 +579,27 @@ class InvestorDemo2026(BaseDemoConfig):
|
|||
if not wfId:
|
||||
continue
|
||||
|
||||
for version in waDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []:
|
||||
waDb.recordDelete(AutoVersion, version.get("id"))
|
||||
for version in geDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []:
|
||||
geDb.recordDelete(AutoVersion, version.get("id"))
|
||||
|
||||
runs = waDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or []
|
||||
runs = geDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or []
|
||||
for run in runs:
|
||||
runId = run.get("id")
|
||||
for stepLog in waDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []:
|
||||
waDb.recordDelete(AutoStepLog, stepLog.get("id"))
|
||||
waDb.recordDelete(AutoRun, runId)
|
||||
for stepLog in geDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []:
|
||||
geDb.recordDelete(AutoStepLog, stepLog.get("id"))
|
||||
geDb.recordDelete(AutoRun, runId)
|
||||
|
||||
for task in waDb.getRecordset(AutoTask, recordFilter={"workflowId": wfId}) or []:
|
||||
waDb.recordDelete(AutoTask, task.get("id"))
|
||||
for task in geDb.getRecordset(AutoTask, recordFilter={"workflowId": wfId}) or []:
|
||||
geDb.recordDelete(AutoTask, task.get("id"))
|
||||
|
||||
waDb.recordDelete(AutoWorkflow, wfId)
|
||||
geDb.recordDelete(AutoWorkflow, wfId)
|
||||
|
||||
if workflows:
|
||||
summary["removed"].append(f"{len(workflows)} AutoWorkflows in {mandateLabel}")
|
||||
logger.info(f"Removed {len(workflows)} automation workflows for {mandateLabel}")
|
||||
logger.info(f"Removed {len(workflows)} graphical editor workflows for {mandateLabel}")
|
||||
except Exception as e:
|
||||
summary["errors"].append(f"WorkflowAutomation cleanup for {mandateLabel}: {e}")
|
||||
logger.error(f"Failed to clean up workflow automation data for {mandateLabel}: {e}")
|
||||
summary["errors"].append(f"GraphicalEditor cleanup for {mandateLabel}: {e}")
|
||||
logger.error(f"Failed to clean up graphical editor data for {mandateLabel}: {e}")
|
||||
|
||||
def _removeTrusteeData(self, db, featureInstanceId: str, mandateLabel: str, summary: Dict):
|
||||
"""Remove TrusteeAccountingConfig for a feature instance."""
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# All rights reserved.
|
||||
"""PWG Pilot Demo (April 2026)
|
||||
|
||||
Bootstraps a complete PWG-Pilot demo environment in an empty dev/demo install:
|
||||
|
||||
- 1 mandate "Stiftung PWG"
|
||||
- 1 SysAdmin demo user "pwg.demo"
|
||||
- 3 features: workspace, trustee (BUHA PWG), neutralization (Datenschutz)
|
||||
- 4 features: workspace, trustee (BUHA PWG), graphicalEditor (PWG Automationen),
|
||||
neutralization (Datenschutz)
|
||||
- Trustee seed-data (5 fictitious tenants with monthly rent journal lines for
|
||||
the current year, loaded from ``demoData/pwg/_seedTrusteeData.json``)
|
||||
- Pilot workflow imported from
|
||||
|
|
@ -16,7 +15,7 @@ Bootstraps a complete PWG-Pilot demo environment in an empty dev/demo install:
|
|||
Idempotent: ``load()`` skips anything that already exists; ``remove()`` deletes
|
||||
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
|
||||
implementation we mirror here.
|
||||
"""
|
||||
|
|
@ -28,7 +27,7 @@ from datetime import datetime
|
|||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from modules.demoConfigs.baseDemoConfig import BaseDemoConfig
|
||||
from modules.demoConfigs._baseDemoConfig import _BaseDemoConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -50,18 +49,23 @@ _USER = {
|
|||
_FEATURES_PWG = [
|
||||
{"code": "workspace", "label": "Dokumentenablage PWG"},
|
||||
{"code": "trustee", "label": "Buchhaltung PWG"},
|
||||
{"code": "graphicalEditor", "label": "PWG Automationen"},
|
||||
{"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"
|
||||
|
||||
|
||||
class PwgDemo2026(BaseDemoConfig):
|
||||
class PwgDemo2026(_BaseDemoConfig):
|
||||
code = "pwg-demo-2026"
|
||||
label = "PWG Pilot Demo (Mietzinsbestätigungen)"
|
||||
description = (
|
||||
"Stiftung PWG, ein Demo-Sachbearbeiter, Trustee mit fiktiven Mietern, "
|
||||
"Workflow-Automation (als File importiert, active=false). Idempotent."
|
||||
"Graph-Editor mit dem Pilot-Workflow für Jahresmietzinsbestätigungen "
|
||||
"(als File importiert, active=false). Idempotent."
|
||||
)
|
||||
credentials = [
|
||||
{
|
||||
|
|
@ -94,6 +98,9 @@ class PwgDemo2026(BaseDemoConfig):
|
|||
if trusteeInstanceId:
|
||||
self._ensureTrusteeSeed(mandateId, trusteeInstanceId, summary)
|
||||
|
||||
graphInstanceId = self._getFeatureInstanceId(db, mandateId, "graphicalEditor", "PWG Automationen")
|
||||
if graphInstanceId:
|
||||
self._ensurePilotWorkflow(mandateId, graphInstanceId, summary)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"PWG demo load failed: {e}", exc_info=True)
|
||||
|
|
@ -156,7 +163,7 @@ class PwgDemo2026(BaseDemoConfig):
|
|||
|
||||
def _ensureMandate(self, db, mandateDef: Dict, summary: Dict) -> Optional[str]:
|
||||
from modules.datamodels.datamodelUam import Mandate
|
||||
from modules.interfaces.interfaceRbac import copySystemRolesToMandate
|
||||
from modules.interfaces.interfaceBootstrap import copySystemRolesToMandate
|
||||
|
||||
existing = db.getRecordset(Mandate, recordFilter={"name": mandateDef["name"]})
|
||||
if existing:
|
||||
|
|
@ -534,6 +541,92 @@ class PwgDemo2026(BaseDemoConfig):
|
|||
if skippedTenants:
|
||||
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]:
|
||||
"""Return the first trustee feature-instance id of the given mandate.
|
||||
|
||||
|
|
@ -585,8 +678,8 @@ class PwgDemo2026(BaseDemoConfig):
|
|||
if not instId:
|
||||
continue
|
||||
|
||||
if featureCode == "workflowAutomation":
|
||||
self._removeWorkflowAutomationData(instId, mandateId, mandateLabel, summary)
|
||||
if featureCode == "graphicalEditor":
|
||||
self._removeGraphicalEditorData(instId, mandateId, mandateLabel, summary)
|
||||
if featureCode == "trustee":
|
||||
self._removeTrusteeSeed(instId, mandateLabel, summary)
|
||||
if featureCode == "neutralization":
|
||||
|
|
@ -631,36 +724,36 @@ class PwgDemo2026(BaseDemoConfig):
|
|||
except Exception as e:
|
||||
summary["errors"].append(f"Billing cleanup for {mandateLabel}: {e}")
|
||||
|
||||
def _removeWorkflowAutomationData(self, featureInstanceId: str, mandateId: str, mandateLabel: str, summary: Dict):
|
||||
def _removeGraphicalEditorData(self, featureInstanceId: str, mandateId: str, mandateLabel: str, summary: Dict):
|
||||
try:
|
||||
from modules.datamodels.datamodelWorkflowAutomation import (
|
||||
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import (
|
||||
AutoRun,
|
||||
AutoStepLog,
|
||||
AutoTask,
|
||||
AutoVersion,
|
||||
AutoWorkflow,
|
||||
)
|
||||
waDb = _openWorkflowAutomationDb()
|
||||
workflows = waDb.getRecordset(AutoWorkflow, recordFilter={
|
||||
geDb = _openGraphicalEditorDb()
|
||||
workflows = geDb.getRecordset(AutoWorkflow, recordFilter={
|
||||
"mandateId": mandateId,
|
||||
"featureInstanceId": featureInstanceId,
|
||||
}) or []
|
||||
for wf in workflows:
|
||||
wfId = wf.get("id")
|
||||
for version in waDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []:
|
||||
waDb.recordDelete(AutoVersion, version.get("id"))
|
||||
for run in waDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or []:
|
||||
for version in geDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []:
|
||||
geDb.recordDelete(AutoVersion, version.get("id"))
|
||||
for run in geDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or []:
|
||||
runId = run.get("id")
|
||||
for step in waDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []:
|
||||
waDb.recordDelete(AutoStepLog, step.get("id"))
|
||||
waDb.recordDelete(AutoRun, runId)
|
||||
for task in waDb.getRecordset(AutoTask, recordFilter={"workflowId": wfId}) or []:
|
||||
waDb.recordDelete(AutoTask, task.get("id"))
|
||||
waDb.recordDelete(AutoWorkflow, wfId)
|
||||
for step in geDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []:
|
||||
geDb.recordDelete(AutoStepLog, step.get("id"))
|
||||
geDb.recordDelete(AutoRun, runId)
|
||||
for task in geDb.getRecordset(AutoTask, recordFilter={"workflowId": wfId}) or []:
|
||||
geDb.recordDelete(AutoTask, task.get("id"))
|
||||
geDb.recordDelete(AutoWorkflow, wfId)
|
||||
if workflows:
|
||||
summary["removed"].append(f"{len(workflows)} AutoWorkflows in {mandateLabel}")
|
||||
except Exception as e:
|
||||
summary["errors"].append(f"WorkflowAutomation cleanup for {mandateLabel}: {e}")
|
||||
summary["errors"].append(f"GraphicalEditor cleanup for {mandateLabel}: {e}")
|
||||
|
||||
def _removeTrusteeSeed(self, featureInstanceId: str, mandateLabel: str, summary: Dict):
|
||||
try:
|
||||
|
|
@ -725,14 +818,13 @@ def _openTrusteeDb():
|
|||
)
|
||||
|
||||
|
||||
def _openWorkflowAutomationDb():
|
||||
"""Open a privileged DB connection to the workflow-automation database."""
|
||||
def _openGraphicalEditorDb():
|
||||
"""Open a privileged DB connection to ``poweron_graphicaleditor``."""
|
||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||
from modules.datamodels.datamodelWorkflowAutomation import WORKFLOW_AUTOMATION_DATABASE
|
||||
from modules.shared.configuration import APP_CONFIG
|
||||
return DatabaseConnector(
|
||||
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
|
||||
dbDatabase=WORKFLOW_AUTOMATION_DATABASE,
|
||||
dbDatabase="poweron_graphicaleditor",
|
||||
dbUser=APP_CONFIG.get("DB_USER"),
|
||||
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
|
||||
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
|
||||
|
|
|
|||
178
modules/features/commcoach/CONCEPT.md
Normal file
178
modules/features/commcoach/CONCEPT.md
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
# CommCoach – Communication Coach for Leaders
|
||||
|
||||
## Product Goal
|
||||
|
||||
An AI coaching agent for executives that:
|
||||
- Captures topics, concerns, and questions
|
||||
- Asks active diagnostic follow-up questions
|
||||
- Builds a continuable context per topic (Dossier)
|
||||
- Conducts daily training conversations
|
||||
- Makes progress visible (Gamification)
|
||||
- Supports voice natively (STT/TTS, voice selection)
|
||||
|
||||
## Architecture
|
||||
|
||||
### Layers
|
||||
|
||||
```
|
||||
Transport (REST/SSE) → routeFeatureCommcoach.py
|
||||
Orchestration → serviceCommcoach.py
|
||||
AI Pipeline → serviceCommcoachAi.py
|
||||
Scheduler → serviceCommcoachScheduler.py
|
||||
Domain / Storage → interfaceFeatureCommcoach.py
|
||||
Data Models → datamodelCommcoach.py
|
||||
Feature Registration → mainCommcoach.py
|
||||
```
|
||||
|
||||
### Reuse from Existing Codebase
|
||||
|
||||
| Component | Source | Usage |
|
||||
|-----------|--------|-------|
|
||||
| Feature Plug&Play | `registry.py` | Auto-discovery via `routeFeature*.py` |
|
||||
| RequestContext + RBAC | `authentication.py`, `interfaceRbac.py` | Auth + ownership |
|
||||
| DatabaseConnector | `connectorDbPostgre.py` | New DB `poweron_commcoach` |
|
||||
| VoiceObjects (STT/TTS) | `interfaceVoiceObjects.py` | Voice pipeline |
|
||||
| MessagingInterface | `interfaceMessaging.py` | Email summaries |
|
||||
| SSE Pattern | workspace `routeFeatureWorkspace.py` | Chat streaming |
|
||||
| PDF Renderer | `rendererPdf.py` | Dossier export (Iteration 2) |
|
||||
| EventManagement | `eventManagement.py` | Scheduled reminders |
|
||||
|
||||
## Domain Model
|
||||
|
||||
### Entities
|
||||
|
||||
```
|
||||
User (1) ──── owns ──── (N) CoachingContext
|
||||
│
|
||||
CoachingContext (1) ────── (N) CoachingSession
|
||||
│
|
||||
CoachingSession (1) ───── (N) CoachingMessage
|
||||
│
|
||||
CoachingContext (1) ────── (N) CoachingTask
|
||||
CoachingContext (1) ────── (N) CoachingScore
|
||||
User (1) ──────────────── (1) CoachingUserProfile
|
||||
```
|
||||
|
||||
### Status Models
|
||||
|
||||
```
|
||||
CoachingContext: active → paused → active | archived → active | completed
|
||||
CoachingSession: active → completed | cancelled
|
||||
CoachingTask: open → in_progress → done | skipped
|
||||
```
|
||||
|
||||
## API Design
|
||||
|
||||
```
|
||||
PREFIX: /api/commcoach/{instanceId}
|
||||
|
||||
# Contexts (Dossier)
|
||||
GET /contexts
|
||||
POST /contexts
|
||||
GET /contexts/{contextId}
|
||||
PUT /contexts/{contextId}
|
||||
DELETE /contexts/{contextId}
|
||||
POST /contexts/{contextId}/archive
|
||||
POST /contexts/{contextId}/activate
|
||||
|
||||
# Sessions
|
||||
GET /contexts/{contextId}/sessions
|
||||
POST /contexts/{contextId}/sessions/start
|
||||
GET /sessions/{sessionId}
|
||||
POST /sessions/{sessionId}/complete
|
||||
POST /sessions/{sessionId}/cancel
|
||||
|
||||
# Streaming Chat
|
||||
POST /sessions/{sessionId}/message/stream
|
||||
POST /sessions/{sessionId}/audio/stream
|
||||
GET /sessions/{sessionId}/stream
|
||||
|
||||
# Tasks
|
||||
GET /contexts/{contextId}/tasks
|
||||
POST /contexts/{contextId}/tasks
|
||||
PUT /tasks/{taskId}
|
||||
PUT /tasks/{taskId}/status
|
||||
DELETE /tasks/{taskId}
|
||||
|
||||
# Dashboard
|
||||
GET /dashboard
|
||||
|
||||
# User Profile
|
||||
GET /profile
|
||||
PUT /profile
|
||||
|
||||
# Voice
|
||||
GET /voice/languages
|
||||
GET /voice/voices
|
||||
POST /voice/tts
|
||||
```
|
||||
|
||||
### SSE Event Types
|
||||
|
||||
- `message` – Complete message
|
||||
- `messageChunk` – Streaming token
|
||||
- `sessionState` – Status update
|
||||
- `taskCreated` – New task from coach
|
||||
- `insightGenerated` – New insight
|
||||
- `scoreUpdate` – Score change
|
||||
- `status` – UI status label
|
||||
- `complete` – Stream ended
|
||||
- `error` – Error
|
||||
- `ping` – Keepalive
|
||||
|
||||
## RBAC Model
|
||||
|
||||
### Ownership Rules (Critical)
|
||||
- **Strict MY-only**: User sees only own contexts/sessions/messages/tasks/scores
|
||||
- **SysAdmin**: Only technical monitoring, NO content access
|
||||
- **No admin override** on userId filter
|
||||
|
||||
### Template Roles
|
||||
- `commcoach-user`: DATA=MY on all entities, UI=ALL, RESOURCE=ALL
|
||||
- `commcoach-admin`: DATA=MY (intentionally not ALL), UI=ALL, RESOURCE=ALL
|
||||
|
||||
### Audit Events
|
||||
- `commcoach.context.created/archived`
|
||||
- `commcoach.session.started/completed`
|
||||
- `commcoach.export.requested`
|
||||
|
||||
## Iterations
|
||||
|
||||
### Iteration 1 (MVP)
|
||||
- Context management (create, switch, archive)
|
||||
- Chat + SSE streaming
|
||||
- STT/TTS with language/voice selection
|
||||
- Coaching session with active diagnostic questions
|
||||
- Auto session protocol
|
||||
- Tasks/Checklist per context
|
||||
- Session summary via email
|
||||
- RBAC + strict ownership
|
||||
- Basic dashboard: continuity, competence score, goal progress
|
||||
- Long-session compression: ab 25 Nachrichten wird der aeltere Verlauf per AI zusammengefasst, letzte 15 Nachrichten bleiben vollstaendig (Teamsbot-Pattern)
|
||||
- Context Memory (Phasen 1-7): previousSessionSummaries im Chat, keyTopics bei completeSession, Intent-Erkennung (summarize_all, recall_session, recall_topic), Datums-Lookup, Topic-Suche, Rolling Overview, RAG-Platzhalter
|
||||
|
||||
### Iteration 2
|
||||
- Roleplay personas (critical CFO, difficult employee, etc.)
|
||||
- Document upload + context binding
|
||||
- Exports (Markdown/PDF)
|
||||
- Extended gamification (streaks, levels, badges)
|
||||
- Better scoring/insights
|
||||
|
||||
## Database
|
||||
|
||||
- Database name: `poweron_commcoach`
|
||||
- Tables auto-created from Pydantic models via `DatabaseConnector`
|
||||
|
||||
## Frontend
|
||||
|
||||
### Views
|
||||
- `CommcoachDashboardView` – KPIs, streaks, quick start
|
||||
- `CommcoachCoachingView` – Chat UI with voice + context tabs
|
||||
- `CommcoachDossierView` – Dossier: timeline, tasks, scores
|
||||
- `CommcoachSettingsView` – Voice, reminder, profile settings
|
||||
|
||||
### UX
|
||||
- Multiple active contexts as quick-switch tabs/chips
|
||||
- "Daily Coach" entry point prominent
|
||||
- Voice first, always with text fallback
|
||||
- Dossier view: timeline, learnings, tasks, next exercise
|
||||
|
|
@ -1,3 +1 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# All rights reserved.
|
||||
# CommCoach Feature Container
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Copyright (c) 2026 PowerOn AG
|
||||
# Copyright (c) 2025 Patrick Motsch
|
||||
# All rights reserved.
|
||||
"""
|
||||
CommCoach Feature - Data Models.
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue