elimination of technical issues (imports)
Some checks failed
Deploy Plattform-Core (Int) / test (push) Failing after 13s
Deploy Plattform-Core (Int) / deploy (push) Has been skipped

This commit is contained in:
ValueOn AG 2026-06-06 00:32:45 +02:00
parent 10f172e950
commit bc7c6fe27c
238 changed files with 4940 additions and 18886 deletions

58
app.py
View file

@ -302,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.shared.fkRegistry import validateFkTargets
from modules.dbHelpers.fkRegistry import validateFkTargets
fkErrors = validateFkTargets()
if fkErrors:
for err in fkErrors:
@ -342,7 +342,7 @@ async def lifespan(app: FastAPI):
# Sync gateway i18n registry to DB and load translation cache
try:
from modules.shared.i18nRegistry import syncRegistryToDb, loadCache
from modules.system.i18nBootSync import syncRegistryToDb, loadCache
await syncRegistryToDb()
await loadCache()
logger.info("i18n registry sync + cache load completed")
@ -376,6 +376,34 @@ 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:
@ -400,7 +428,7 @@ async def lifespan(app: FastAPI):
eventManager.start()
# Register audit log cleanup scheduler
from modules.shared.auditLogger import registerAuditLogCleanupScheduler
from modules.dbHelpers.auditLogger import registerAuditLogCleanupScheduler
registerAuditLogCleanupScheduler()
# Register enterprise subscription auto-renewal scheduler
@ -431,6 +459,26 @@ 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) ---
@ -474,7 +522,7 @@ async def lifespan(app: FastAPI):
# 5. Close shared HTTP sessions (ResilientHttp) to avoid TCP keepalive hang
try:
from modules.connectors._httpResilience import closeAllResilientHttp
from modules.shared.httpResilience import closeAllResilientHttp
await closeAllResilientHttp()
except Exception as e:
logger.warning(f"Closing HTTP sessions failed: {e}")
@ -655,8 +703,6 @@ 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)

View file

@ -11,15 +11,15 @@ IMPORTANT: Model Registration Requirements
- If duplicate displayNames are detected during registration, an error will be raised
"""
import re as _re
import 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
)

View file

@ -1,5 +1,6 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
import base64
import json
import logging
import httpx
@ -655,9 +656,8 @@ class AiAnthropic(BaseConnectorAi):
base64Data = parts[1]
_SUPPORTED = {"image/jpeg", "image/png", "image/gif", "image/webp"}
import base64 as _b64
try:
rawHead = _b64.b64decode(base64Data[:32])
rawHead = base64.b64decode(base64Data[:32])
if rawHead[:3] == b"\xff\xd8\xff":
mimeType = "image/jpeg"
elif rawHead[:8] == b"\x89PNG\r\n\x1a\n":

View file

@ -1,7 +1,7 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
import logging
import json as _json
import 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", {})

View file

@ -1,7 +1,7 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
import logging
import json as _json
import json
import httpx
from typing import List, Dict, Any, AsyncGenerator, Union
from fastapi import HTTPException
@ -477,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}")
@ -490,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", {})

View file

@ -437,7 +437,7 @@ def requireSysAdmin(currentUser: User = Depends(getCurrentUser)) -> User:
# Audit for all SysAdmin actions
try:
from modules.shared.auditLogger import audit_logger
from modules.dbHelpers.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.shared.auditLogger import audit_logger
from modules.dbHelpers.auditLogger import audit_logger
audit_logger.logSecurityEvent(
userId=str(currentUser.id),
mandateId="system",

View file

@ -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)

View file

@ -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.shared.auditLogger import audit_logger
from modules.dbHelpers.auditLogger import audit_logger
logger = logging.getLogger(__name__)

View file

@ -1,6 +1,9 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
import contextvars
import copy
import json
import math
import re
import time
import psycopg2
@ -8,6 +11,7 @@ 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
@ -16,8 +20,6 @@ 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__)
@ -149,7 +151,6 @@ 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:
@ -177,10 +178,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})")
@ -995,8 +996,6 @@ 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):
@ -1173,25 +1172,6 @@ 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.
@ -1412,18 +1392,17 @@ 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 = _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()
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()
where_parts.append(f'"{key}" >= %s AND "{key}" <= %s')
values.extend([fromTs, toTs])
elif fromVal:
fromTs = _dt.strptime(str(fromVal), '%Y-%m-%d').replace(tzinfo=_tz.utc).timestamp()
fromTs = datetime.strptime(str(fromVal), '%Y-%m-%d').replace(tzinfo=timezone.utc).timestamp()
where_parts.append(f'"{key}" >= %s')
values.append(fromTs)
else:
toTs = _dt.strptime(str(toVal), '%Y-%m-%d').replace(hour=23, minute=59, second=59, tzinfo=_tz.utc).timestamp()
toTs = datetime.strptime(str(toVal), '%Y-%m-%d').replace(hour=23, minute=59, second=59, tzinfo=timezone.utc).timestamp()
where_parts.append(f'"{key}" <= %s')
values.append(toTs)
elif isNumericCol:
@ -1498,7 +1477,6 @@ class DatabaseConnector:
If pagination is None, returns all records (no LIMIT/OFFSET).
"""
from modules.datamodels.datamodelPagination import PaginationParams
import math
table = model_class.__name__
@ -1540,9 +1518,6 @@ 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
@ -1578,7 +1553,6 @@ class DatabaseConnector:
return []
if pagination:
import copy
pagination = copy.deepcopy(pagination)
if pagination.filters and column in pagination.filters:
pagination.filters.pop(column, None)
@ -1812,7 +1786,6 @@ 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)
@ -1829,16 +1802,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)

View file

@ -13,10 +13,13 @@ 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
from typing import Any, Dict, List, Optional, Union
import aiohttp
from modules.connectors.connectorProviderBase import (
ProviderConnector,
@ -24,11 +27,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__)
# type metadata for ExternalEntry.metadata["cuType"]
_CLICKUP_API_BASE = "https://api.clickup.com/api/v2"
_CU_TEAM = "team"
_CU_SPACE = "space"
_CU_FOLDER = "folder"
@ -45,14 +48,118 @@ 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
# Minimal service instance for API calls (no ServiceCenter context)
self._svc = ClickupService(context=None, get_service=lambda _: None)
self._svc.setAccessToken(access_token)
self._svc = ClickupApiClient(access_token)
async def browse(
self,

View file

@ -3,14 +3,17 @@
"""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.connectors._httpResilience import ResilientHttp
from modules.shared.httpResilience import ResilientHttp
from modules.datamodels.datamodelDataSource import ExternalEntry
logger = logging.getLogger(__name__)
@ -29,8 +32,6 @@ def _parseGoogleDateRange(text: Optional[str]) -> tuple:
Supports two ISO dates, a single ISO date (~31 day window) or a YYYY-MM
month pattern. Returns RFC3339 UTC strings (timeMin, timeMax) or (None, None).
"""
import re
from datetime import datetime, timedelta
if not text:
return (None, None)
@ -58,7 +59,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)
@ -92,7 +93,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")
@ -184,7 +185,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")
@ -228,7 +229,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"}
@ -281,7 +282,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", [])
@ -319,7 +320,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")
@ -350,7 +351,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})
@ -371,15 +372,13 @@ 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()
@ -390,7 +389,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", []):
@ -469,7 +468,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", [])
@ -504,7 +503,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", [])
@ -534,7 +533,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()
@ -573,7 +572,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 [
@ -629,7 +628,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 ""
@ -659,7 +658,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", [])
@ -669,7 +668,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 []
@ -681,7 +680,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
@ -717,7 +716,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()
@ -746,7 +745,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] = []
@ -770,7 +769,6 @@ class ContactsAdapter(ServiceAdapter):
def _googleSafeFileName(name: str) -> str:
import re
return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name or "")[:80].strip(". ")
@ -790,7 +788,6 @@ def _googleIcsDateTime(value: Optional[str]) -> Optional[str]:
"""Convert a Google Calendar dateTime/date string to RFC 5545 format (UTC)."""
if not value:
return None
from datetime import datetime, timezone
try:
if "T" not in value:
dt = datetime.strptime(value, "%Y-%m-%d")
@ -806,7 +803,6 @@ def _googleIcsDateTime(value: Optional[str]) -> Optional[str]:
def _googleEventToIcs(event: Dict[str, Any]) -> bytes:
"""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 "")

View file

@ -45,7 +45,7 @@ from modules.connectors.connectorProviderBase import (
ServiceAdapter,
DownloadResult,
)
from modules.connectors._httpResilience import ResilientHttp
from modules.shared.httpResilience import ResilientHttp
from modules.datamodels.datamodelDataSource import ExternalEntry
logger = logging.getLogger(__name__)

View file

@ -6,14 +6,17 @@ 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.connectors._httpResilience import ResilientHttp
from modules.shared.httpResilience import ResilientHttp
from modules.datamodels.datamodelDataSource import ExternalEntry
logger = logging.getLogger(__name__)
@ -79,7 +82,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:
@ -176,7 +179,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:
@ -257,7 +260,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)]
@ -278,8 +281,6 @@ 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)
@ -368,7 +369,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):
@ -445,7 +446,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)"),
@ -470,7 +471,6 @@ 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,7 +572,6 @@ 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)
@ -587,7 +586,6 @@ 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)
@ -617,7 +615,6 @@ 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)
@ -629,7 +626,6 @@ 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],
@ -644,7 +640,6 @@ 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)
@ -656,7 +651,6 @@ 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
@ -727,7 +721,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]:
@ -764,7 +758,6 @@ 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."}
@ -778,7 +771,6 @@ 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."}
@ -818,7 +810,6 @@ 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:
@ -827,7 +818,6 @@ 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:
@ -845,7 +835,6 @@ 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")
@ -952,7 +941,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:
@ -1003,7 +992,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)]
@ -1099,7 +1088,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(
@ -1296,7 +1285,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(
@ -1448,7 +1437,6 @@ 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(". ")
@ -1478,7 +1466,6 @@ 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)
@ -1491,7 +1478,6 @@ 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 "")

View file

@ -44,31 +44,31 @@ class ConnectorResolver:
if ConnectorResolver._providerRegistry:
return
try:
from modules.connectors.providerMsft.connectorMsft import MsftConnector
from modules.connectors.connectorProviderMsft import MsftConnector
ConnectorResolver._providerRegistry["msft"] = MsftConnector
except ImportError:
logger.warning("MsftConnector not available")
try:
from modules.connectors.providerGoogle.connectorGoogle import GoogleConnector
from modules.connectors.connectorProviderGoogle import GoogleConnector
ConnectorResolver._providerRegistry["google"] = GoogleConnector
except ImportError:
logger.debug("GoogleConnector not available (stub)")
try:
from modules.connectors.providerFtp.connectorFtp import FtpConnector
from modules.connectors.connectorProviderFtp import FtpConnector
ConnectorResolver._providerRegistry["local:ftp"] = FtpConnector
except ImportError:
logger.debug("FtpConnector not available (stub)")
try:
from modules.connectors.providerClickup.connectorClickup import ClickupConnector
from modules.connectors.connectorProviderClickup import ClickupConnector
ConnectorResolver._providerRegistry["clickup"] = ClickupConnector
except ImportError:
logger.warning("ClickupConnector not available")
try:
from modules.connectors.providerInfomaniak.connectorInfomaniak import InfomaniakConnector
from modules.connectors.connectorProviderInfomaniak import InfomaniakConnector
ConnectorResolver._providerRegistry["infomaniak"] = InfomaniakConnector
except ImportError:
logger.warning("InfomaniakConnector not available")

View file

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

View file

@ -15,7 +15,7 @@ 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 as _catalogDefaultVoice
from modules.shared.voiceCatalog import getDefaultVoice
logger = logging.getLogger(__name__)
@ -1097,7 +1097,7 @@ class ConnectorGoogleSpeech:
voice exists, in which case the caller omits `name` and Google
auto-selects based on languageCode + ssml_gender.
"""
return _catalogDefaultVoice(languageCode)
return getDefaultVoice(languageCode)
async def getAvailableVoices(self, languageCode: Optional[str] = None) -> Dict[str, Any]:
"""

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -13,4 +13,5 @@ from . import datamodelSecurity as security
from . import datamodelChat as chat
from . import datamodelFiles as files
from . import datamodelVoice as voice
from . import datamodelUtils as utils
from . import datamodelUtils as utils
from . import jsonContinuation

View file

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

View file

@ -1,15 +1,19 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""Feature models: Feature, FeatureInstance."""
"""Feature models: Feature definitions, instances, data sources, and shared feature types."""
import uuid
from typing import Optional, Dict, Any
from typing import Optional, Dict, Any, List
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."""
@ -71,3 +75,147 @@ 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

View file

@ -0,0 +1,551 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""Port type catalog and primitive types for the Graphical Editor workflow system."""
from typing import Dict, List, Optional
from pydantic import BaseModel, ConfigDict, Field
from modules.shared.i18nRegistry import t
class PortField(BaseModel):
model_config = ConfigDict(populate_by_name=True)
name: str
type: str # str, int, bool, List[str], List[Document], Dict[str,Any], ConnectionRef, …
description: str = ""
required: bool = True
enumValues: Optional[List[str]] = None
# Marks this field as the discriminator for a Ref-Schema (e.g. ConnectionRef.authority,
# FeatureInstanceRef.featureCode). Pickers/validators use it to filter compatible
# producers by sub-type. Type must be "str" when discriminator is True.
discriminator: bool = False
# Surfaces this field at the top of the DataPicker list as the most common pick.
recommended: bool = False
# Human DataPicker title (camelCase JSON for frontend). Omit for technical paths-only.
picker_label: Optional[str] = Field(default=None, serialization_alias="pickerLabel")
# For List[T] fields: segment between parent and inner field (iteration / one list item).
picker_item_label: Optional[str] = Field(default=None, serialization_alias="pickerItemLabel")
class PortSchema(BaseModel):
name: str # e.g. "EmailDraft", "AiResult", "Transit"
fields: List[PortField]
# Declarative flag for the engine: when True, the executor attaches
# connection provenance ({id, authority, label}) onto the output. Replaces
# hard-coded schema lists in actionNodeExecutor._attachConnectionProvenance.
carriesConnectionProvenance: bool = False
# ---------------------------------------------------------------------------
# PORT_TYPE_CATALOG
# ---------------------------------------------------------------------------
PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
# -----------------------------------------------------------------
# Refs (handles to external resources, pickable by user)
# -----------------------------------------------------------------
"ConnectionRef": PortSchema(name="ConnectionRef", fields=[
PortField(name="id", type="str", description="UserConnection.id (UUID)"),
PortField(name="authority", type="str", discriminator=True,
description="Auth-Provider-Code: msft | clickup | google | …"),
PortField(name="label", type="str", required=False, description="Anzeigename"),
]),
"FeatureInstanceRef": PortSchema(name="FeatureInstanceRef", fields=[
PortField(name="id", type="str", description="FeatureInstance.id (UUID)"),
PortField(name="featureCode", type="str", discriminator=True,
description="Feature-Modul-Code: trustee | redmine | clickup | sharepoint | …"),
PortField(name="label", type="str", required=False, description="Anzeigename"),
PortField(name="mandateId", type="str", required=False, description="Zugehöriger Mandant"),
]),
"ClickUpListRef": PortSchema(name="ClickUpListRef", fields=[
PortField(name="listId", type="str", description="ClickUp-Listen-ID"),
PortField(name="name", type="str", required=False, description="Listenname"),
PortField(name="spaceId", type="str", required=False, description="Space-ID"),
PortField(name="groupId", type="str", required=False, description="Gruppen-ID für die Gruppierungszuordnung"),
PortField(name="connection", type="ConnectionRef", required=False,
description="ClickUp-Verbindung"),
]),
"PromptTemplateRef": PortSchema(name="PromptTemplateRef", fields=[
PortField(name="id", type="str", description="Prompt-Template-ID"),
PortField(name="name", type="str", required=False, description="Anzeigename"),
PortField(name="version", type="str", required=False, description="Version / Tag"),
]),
"SharePointFolderRef": PortSchema(name="SharePointFolderRef", fields=[
PortField(name="siteUrl", type="str", required=False, description="SharePoint Site"),
PortField(name="driveId", type="str", required=False, description="Drive ID"),
PortField(name="folderPath", type="str", required=False, description="Ordnerpfad"),
PortField(name="label", type="str", required=False, description="Kurzlabel für Picker"),
]),
"SharePointFileRef": PortSchema(name="SharePointFileRef", fields=[
PortField(name="siteUrl", type="str", required=False, description="SharePoint Site"),
PortField(name="driveId", type="str", required=False, description="Drive ID"),
PortField(name="filePath", type="str", required=False, description="Dateipfad"),
PortField(name="fileName", type="str", required=False, description="Dateiname"),
PortField(name="label", type="str", required=False, description="Kurzlabel"),
]),
"Document": PortSchema(name="Document", fields=[
PortField(name="id", type="str", required=False, description="Dokument-/Datei-ID"),
PortField(name="name", type="str", required=False, description="Anzeigename"),
PortField(name="mimeType", type="str", required=False, description="MIME-Typ"),
PortField(name="sizeBytes", type="int", required=False, description="Grösse"),
PortField(name="downloadUrl", type="str", required=False, description="Download-URL"),
PortField(name="filePath", type="str", required=False, description="Logischer Pfad"),
]),
"FileItem": PortSchema(name="FileItem", fields=[
PortField(name="id", type="str", required=False, description="Datei-ID"),
PortField(name="name", type="str", required=False, description="Name"),
PortField(name="path", type="str", required=False, description="Pfad"),
PortField(name="mimeType", type="str", required=False, description="MIME"),
PortField(name="sizeBytes", type="int", required=False, description="Grösse"),
]),
"EmailItem": PortSchema(name="EmailItem", fields=[
PortField(name="id", type="str", required=False, description="Message-ID"),
PortField(name="subject", type="str", required=False, description="Betreff"),
PortField(name="fromAddress", type="str", required=False, description="Absender"),
PortField(name="toAddresses", type="List[str]", required=False, description="Empfänger"),
PortField(name="receivedAt", type="str", required=False, description="Empfangen am"),
PortField(name="hasAttachments", type="bool", required=False, description="Hat Anhänge"),
PortField(name="bodyPreview", type="str", required=False, description="Vorschau"),
]),
"TaskItem": PortSchema(name="TaskItem", fields=[
PortField(name="id", type="str", required=False, description="Task-ID"),
PortField(name="title", type="str", required=False, description="Titel"),
PortField(name="status", type="str", required=False, description="Status"),
PortField(name="assignee", type="str", required=False, description="Assignee"),
PortField(name="dueDate", type="str", required=False, description="Fälligkeit"),
PortField(name="listId", type="str", required=False, description="ClickUp-Liste"),
]),
"QueryResult": PortSchema(name="QueryResult", fields=[
PortField(name="rows", type="List[Any]", description="Ergebniszeilen"),
PortField(name="columns", type="List[str]", required=False, description="Spaltennamen"),
PortField(name="count", type="int", required=False, description="Zeilenanzahl"),
]),
"UdmPage": PortSchema(name="UdmPage", fields=[
PortField(name="pageNumber", type="int", required=False, description="Seitennummer"),
PortField(name="blocks", type="List[Any]", required=False, description="ContentBlocks"),
]),
"UdmBlock": PortSchema(name="UdmBlock", fields=[
PortField(name="kind", type="str", required=False, description="Block-Typ"),
PortField(name="text", type="str", required=False, description="Textinhalt"),
PortField(name="children", type="List[Any]", required=False, description="Unterblöcke"),
]),
"DocumentList": PortSchema(name="DocumentList", carriesConnectionProvenance=True, fields=[
PortField(name="documents", type="List[Document]",
description="Dokumente aus vorherigen Schritten", recommended=True),
PortField(name="connection", type="ConnectionRef", required=False,
description="Verbindung, mit der die Liste erzeugt wurde"),
PortField(name="source", type="SharePointFolderRef", required=False,
description="Herkunftsordner / Quelle"),
PortField(name="count", type="int", required=False,
description="Anzahl Dokumente"),
]),
"FileList": PortSchema(name="FileList", carriesConnectionProvenance=True, fields=[
PortField(name="files", type="List[FileItem]",
description="Dateiliste"),
PortField(name="connection", type="ConnectionRef", required=False,
description="Verbindung"),
PortField(name="source", type="SharePointFolderRef", required=False,
description="Listen-Kontext"),
PortField(name="count", type="int", required=False,
description="Anzahl Dateien"),
]),
"EmailDraft": PortSchema(name="EmailDraft", carriesConnectionProvenance=True, fields=[
PortField(name="subject", type="str",
description="Betreff"),
PortField(name="body", type="str",
description="Inhalt"),
PortField(name="to", type="List[str]",
description="Empfänger"),
PortField(name="cc", type="List[str]", required=False,
description="CC"),
PortField(name="attachments", type="List[Document]", required=False,
description="Anhänge"),
PortField(name="connection", type="ConnectionRef", required=False,
description="Outlook-/Graph-Verbindung"),
]),
"EmailList": PortSchema(name="EmailList", carriesConnectionProvenance=True, fields=[
PortField(name="emails", type="List[EmailItem]",
description="E-Mails"),
PortField(name="connection", type="ConnectionRef", required=False,
description="Verbindung"),
PortField(name="count", type="int", required=False,
description="Anzahl"),
]),
"TaskList": PortSchema(name="TaskList", carriesConnectionProvenance=True, fields=[
PortField(name="tasks", type="List[TaskItem]",
description="Aufgaben"),
PortField(name="connection", type="ConnectionRef", required=False,
description="Verbindung"),
PortField(name="listId", type="str", required=False,
description="ClickUp-Listen-ID"),
PortField(name="count", type="int", required=False,
description="Anzahl"),
]),
"TaskResult": PortSchema(name="TaskResult", fields=[
PortField(name="success", type="bool",
description="Erfolg"),
PortField(name="taskId", type="str",
description="Aufgaben-ID"),
PortField(name="task", type="Dict",
description="Aufgabendaten"),
]),
"FormPayload": PortSchema(name="FormPayload", fields=[
PortField(name="payload", type="Dict[str,Any]",
description="Formulardaten"),
]),
"AiResult": PortSchema(name="AiResult", fields=[
PortField(name="prompt", type="str",
description="Prompt",
picker_label=t("Eingabe (Prompt des Schritts)"),
),
PortField(name="response", type="str",
description=(
"Antworttext (Modell-Fließtext o. ä.; Bilder liegen in documents, nicht hier)."
),
recommended=True,
picker_label=t("Ausgabetext (Modell)"),
),
PortField(name="responseData", type="Dict", required=False,
description="Strukturierte Antwort (nur bei JSON-Ausgabe)",
picker_label=t("Strukturierte Antwortdaten")),
PortField(name="context", type="str",
description="Kontext",
picker_label=t("Eingabe-Kontext")),
PortField(name="documents", type="List[Document]",
description=(
"Erzeugte oder mitgegebene Dateien (z. B. Bilder); documentData = Nutzlast pro Eintrag."
),
picker_label=t("Alle Ausgabe-Dateien (Liste)"),
picker_item_label=t("je Datei"),
),
PortField(name="data", type="Dict", required=False,
description=(
"Internes Payload-Objekt (entspricht ``ActionResult.data``-Semantik). "
"Wird vom Executor gesetzt und enthält denselben Inhalt wie ``response`` "
"in strukturierter Form; primär für nachgelagerte Kontext-Nodes."
),
picker_label=t("Technische Detaildaten (data)")),
PortField(name="imageDocumentsOnly", type="List[Document]", required=False,
description="Nur Bild-bezogene Einträge aus documents.",
picker_label=t("Nur Bilder (Liste)")),
]),
"BoolResult": PortSchema(name="BoolResult", fields=[
PortField(name="result", type="bool",
description="Ergebnis"),
PortField(name="reason", type="str", required=False,
description="Begründung"),
]),
"TextResult": PortSchema(name="TextResult", fields=[
PortField(name="text", type="str",
description="Text",
picker_label=t("Text (Schrittausgabe)")),
]),
"LoopItem": PortSchema(name="LoopItem", fields=[
PortField(name="currentItem", type="Any",
description="Aktuelles Element"),
PortField(name="currentIndex", type="int",
description="Aktueller Index"),
PortField(name="items", type="List[Any]",
description="Alle Elemente"),
PortField(name="count", type="int",
description="Gesamtanzahl"),
]),
"AggregateResult": PortSchema(name="AggregateResult", fields=[
PortField(name="items", type="List[Any]",
description="Gesammelte Elemente"),
PortField(name="count", type="int",
description="Anzahl"),
]),
"MergeResult": PortSchema(name="MergeResult", fields=[
PortField(name="inputs", type="Dict[int,Any]",
description="Eingaben nach Port"),
PortField(name="first", type="Any",
description="Erstes verfügbares"),
PortField(name="merged", type="Dict",
description="Zusammengeführte Daten"),
]),
"ContextBranch": PortSchema(name="ContextBranch", fields=[
PortField(name="items", type="List[Any]",
description="Schleifen-fertige Elemente aus dem (gefilterten) Kontext",
recommended=True,
picker_label=t("Gefilterte Elemente")),
PortField(name="data", type="Dict", required=False,
description="Gefilterter Presentation-Umschlag oder Eingabe-Spiegel",
picker_label=t("Kontext (data)")),
PortField(name="filterApplied", type="bool", required=False,
description="True wenn ein Kontext-Inhaltsfilter angewendet wurde"),
PortField(name="contentType", type="str", required=False,
description="Angewendeter Inhaltstyp-Filter (z. B. image)"),
PortField(name="match", type="int", required=False,
description="Aktiver Ausgangs-Index (Fall oder Sonst)"),
]),
"ActionDocument": PortSchema(name="ActionDocument", fields=[
PortField(name="documentName", type="str",
description="Dokumentname",
picker_label=t("Dateiname")),
PortField(name="documentData", type="Any",
description="Inhalt / Rohdaten (z.B. JSON-String, Bytes)",
picker_label=t("Dateiinhalt (JSON, Text oder Bild)"),
recommended=True),
PortField(name="mimeType", type="str",
description="MIME-Typ",
picker_label=t("Dateityp (MIME)")),
PortField(name="fileId", type="str", required=False,
description="Persistierte FileItem.id (vom Engine ergänzt)"),
PortField(name="fileName", type="str", required=False,
description="Persistierter Dateiname (vom Engine ergänzt)"),
]),
"ActionResult": PortSchema(name="ActionResult", fields=[
PortField(name="success", type="bool",
description="Erfolg"),
PortField(name="error", type="str", required=False,
description="Fehler"),
# `documents` is populated for every action that returns ActionResult
# (see datamodelChat.ActionResult.documents and actionNodeExecutor.out).
# Without it in the catalog the DataPicker cannot offer downstream
# bindings like `processDocuments → documents → *` for syncToAccounting.
PortField(name="documents", type="List[ActionDocument]", required=False,
description=(
"Dokumentliste für Actions mit echten Artefakt-Dokumenten. "
"Beim Knoten „Inhalt extrahieren“ fehlt dieses Feld in der Knotenausgabe."
),
picker_label=t("Alle Ausgabe-Dokumente"),
picker_item_label=t("je Dokument"),
),
PortField(name="data", type="Dict", required=False,
description=(
"Strukturierter Inhalt. Bei **context.extractContent**: **Presentation**-Root "
"(`schemaVersion`, `kind`, `fileOrder`, `files`) plus **`_meta`** — ohne "
"zusätzliches `response`/`contentExtracted`-Duplikat."
),
picker_label=t("Technische Detaildaten (data)")),
# Mirror AiResult primary text fields so DataPicker / primaryTextRef behave the same
PortField(name="prompt", type="str", required=False,
description="Optional: auslösender Prompt / Schrittname",
picker_label=t("Auslöser / Prompt (falls vorhanden)")),
PortField(name="response", type="str", required=False,
description=(
"Fließtext wo die Action einen liefert. Bei **„Inhalt extrahieren“** absichtlich leer — "
"Inhalt liegt in ``data``.``files``."
),
recommended=True,
picker_label=t("Nur Fließtext (gesamt)")),
PortField(name="context", type="str", required=False,
description="Optional: Eingabe-Kontext",
picker_label=t("Mitgegebener Kontext")),
PortField(name="imageDocumentsOnly", type="List[ActionDocument]", required=False,
description=(
"Nur Bild-bezogene Einträge. Bei „Inhalt extrahieren“: synthetische "
"Einträge mit ``fileId`` aus persistierten Extrakt-Bildern (kein separates JSON-Dokument)."
),
picker_label=t("Nur Bilder (Liste)")),
PortField(name="responseData", type="Dict", required=False,
description="Optional: strukturierte Zusatzdaten",
picker_label=t("Strukturierte Zusatzdaten")),
PortField(name="presentation", type="Dict", required=False,
description=(
"Selten: Top-Level-Spiegel von Präsentationsdaten andere Actions. "
"Bei „Inhalt extrahieren“ liegt alles direkt unter ``data`` (kein zusätzlicher Spiegel)."
),
picker_label=t("Presentation (Top-Level-Spiegel)")),
PortField(name="presentationSummary", type="Dict", required=False,
description=(
"Kompakte Metadaten zu ``presentation`` (Debugging / traces)."
),
picker_label=t("Presentation-Zusammenfassung")),
PortField(name="presentationConfig", type="Dict", required=False,
description=(
"Optional: Debugging-Konfiguration; bei Extract liegt die Primärquelle in ``validationMetadata`` des JSON-Dokuments."
),
picker_label=t("Presentation-Konfiguration")),
]),
"Transit": PortSchema(name="Transit", fields=[]),
"UdmDocument": PortSchema(name="UdmDocument", carriesConnectionProvenance=True, fields=[
PortField(name="id", type="str", description="Dokument-ID"),
PortField(name="sourceType", type="str", description="Quellformat (pdf, docx, …)"),
PortField(name="sourcePath", type="str", description="Quellpfad"),
PortField(name="children", type="List[Any]", description="StructuralNodes / Seiten"),
PortField(name="connection", type="ConnectionRef", required=False,
description="Optionale Verbindungsreferenz"),
PortField(name="source", type="SharePointFileRef", required=False,
description="Optionale Datei-Herkunft"),
]),
"UdmNodeList": PortSchema(name="UdmNodeList", fields=[
PortField(name="nodes", type="List[Any]", description="UDM StructuralNodes oder ContentBlocks"),
PortField(name="count", type="int", description="Anzahl"),
]),
"ConsolidateResult": PortSchema(name="ConsolidateResult", fields=[
PortField(name="result", type="Any", description="Konsolidiertes Ergebnis"),
PortField(name="mode", type="str", description="Konsolidierungsmodus"),
PortField(name="count", type="int", description="Anzahl verarbeiteter Elemente"),
]),
# -----------------------------------------------------------------
# Shared sub-types (used inside Result schemas)
# -----------------------------------------------------------------
"ProcessError": PortSchema(name="ProcessError", fields=[
PortField(name="documentId", type="str", required=False,
description="Betroffenes Dokument (falls zuordbar)"),
PortField(name="stage", type="str",
description="Pipeline-Stufe: extract | parse | sync | validate | …"),
PortField(name="message", type="str", description="Fehlermeldung"),
PortField(name="code", type="str", required=False, description="Fehler-Code"),
]),
"JournalLine": PortSchema(name="JournalLine", fields=[
PortField(name="id", type="str", required=False, description="Buchungszeilen-ID"),
PortField(name="bookingDate", type="str", description="Buchungsdatum (ISO)"),
PortField(name="account", type="str", description="Konto"),
PortField(name="contraAccount", type="str", required=False, description="Gegenkonto"),
PortField(name="amount", type="float", description="Betrag"),
PortField(name="currency", type="str", required=False, description="Währung"),
PortField(name="text", type="str", required=False, description="Buchungstext"),
PortField(name="reference", type="str", required=False, description="Beleg-Referenz"),
]),
# -----------------------------------------------------------------
# Trustee Action Results
# -----------------------------------------------------------------
"TrusteeRefreshResult": PortSchema(name="TrusteeRefreshResult", fields=[
PortField(name="syncCounts", type="Dict[str,int]",
description="Tabellen → Anzahl synchronisierter Datensätze"),
PortField(name="oldestBookingDate", type="str", required=False,
description="Ältestes Buchungsdatum (ISO)"),
PortField(name="newestBookingDate", type="str", required=False,
description="Neuestes Buchungsdatum (ISO)"),
PortField(name="durationMs", type="int", required=False,
description="Dauer in Millisekunden"),
PortField(name="featureInstance", type="FeatureInstanceRef", required=False,
description="Trustee-Instanz"),
PortField(name="errors", type="List[ProcessError]", required=False,
description="Fehler-Liste"),
]),
"TrusteeProcessResult": PortSchema(name="TrusteeProcessResult", fields=[
PortField(name="documents", type="List[Document]",
description="Verarbeitete Dokumente mit angereicherten Daten"),
PortField(name="processedCount", type="int", required=False,
description="Anzahl erfolgreich verarbeiteter Dokumente"),
PortField(name="failedCount", type="int", required=False,
description="Anzahl fehlgeschlagener Dokumente"),
PortField(name="featureInstance", type="FeatureInstanceRef", required=False,
description="Trustee-Instanz"),
PortField(name="errors", type="List[ProcessError]", required=False,
description="Fehler-Liste"),
]),
"TrusteeSyncResult": PortSchema(name="TrusteeSyncResult", fields=[
PortField(name="syncedCount", type="int",
description="Erfolgreich in das Buchhaltungssystem übertragene Datensätze"),
PortField(name="failedCount", type="int", required=False,
description="Fehlgeschlagene Übertragungen"),
PortField(name="journalLines", type="List[JournalLine]", required=False,
description="Erzeugte Buchungszeilen"),
PortField(name="featureInstance", type="FeatureInstanceRef", required=False,
description="Ziel-Trustee-Instanz"),
PortField(name="errors", type="List[ProcessError]", required=False,
description="Fehler-Liste"),
]),
# -----------------------------------------------------------------
# Redmine Action Results
# -----------------------------------------------------------------
"RedmineTicket": PortSchema(name="RedmineTicket", fields=[
PortField(name="id", type="str", description="Ticket-ID"),
PortField(name="subject", type="str", description="Betreff"),
PortField(name="description", type="str", required=False, description="Beschreibung"),
PortField(name="status", type="str", description="Status-Name"),
PortField(name="tracker", type="str", required=False,
description="Tracker (Bug, Feature, Task, …)"),
PortField(name="priority", type="str", required=False, description="Priorität"),
PortField(name="assignee", type="str", required=False, description="Zugewiesen an"),
PortField(name="author", type="str", required=False, description="Autor"),
PortField(name="project", type="str", required=False, description="Projekt"),
PortField(name="createdOn", type="str", required=False, description="Erstellt (ISO)"),
PortField(name="updatedOn", type="str", required=False, description="Aktualisiert (ISO)"),
PortField(name="dueDate", type="str", required=False, description="Fälligkeitsdatum"),
PortField(name="featureInstance", type="FeatureInstanceRef", required=False,
description="Redmine-Instanz"),
]),
"RedmineTicketList": PortSchema(name="RedmineTicketList", fields=[
PortField(name="tickets", type="List[RedmineTicket]", description="Ticket-Liste"),
PortField(name="count", type="int", required=False, description="Anzahl Tickets"),
PortField(name="filters", type="Dict[str,Any]", required=False,
description="Angewendete Filter"),
PortField(name="featureInstance", type="FeatureInstanceRef", required=False,
description="Redmine-Instanz"),
]),
"RedmineRelationList": PortSchema(name="RedmineRelationList", fields=[
PortField(name="relations", type="List[Any]", description="Relationen"),
PortField(name="count", type="int", required=False, description="Anzahl in dieser Seite"),
PortField(name="totalMatched", type="int", required=False,
description="Gesamtanzahl nach Filter"),
PortField(name="offset", type="int", required=False, description="Pagination-Offset"),
PortField(name="hasMore", type="bool", required=False, description="Weitere Seiten verfügbar"),
]),
"RedmineStats": PortSchema(name="RedmineStats", fields=[
PortField(name="kpis", type="Dict[str,Any]",
description="Key Performance Indicators"),
PortField(name="throughput", type="Dict[str,Any]", required=False,
description="Durchsatz pro Zeitraum"),
PortField(name="statusDistribution", type="Dict[str,int]", required=False,
description="Tickets pro Status"),
PortField(name="backlog", type="Dict[str,Any]", required=False,
description="Backlog-Statistik"),
PortField(name="featureInstance", type="FeatureInstanceRef", required=False,
description="Redmine-Instanz"),
]),
# -----------------------------------------------------------------
# ClickUp / SharePoint / Email helper results
# -----------------------------------------------------------------
"TaskAttachmentRef": PortSchema(name="TaskAttachmentRef", fields=[
PortField(name="taskId", type="str", description="Aufgaben-ID"),
PortField(name="attachmentId", type="str", required=False, description="Attachment-ID"),
PortField(name="fileName", type="str", required=False, description="Dateiname"),
PortField(name="url", type="str", required=False, description="Download-URL"),
]),
"AttachmentSpec": PortSchema(name="AttachmentSpec", fields=[
PortField(name="source", type="str",
description="Quellart: path | document | url",
enumValues=["path", "document", "url"]),
PortField(name="ref", type="str",
description="Referenzwert (Pfad / Document.id / URL)"),
PortField(name="fileName", type="str", required=False,
description="Override-Dateiname"),
PortField(name="mimeType", type="str", required=False, description="MIME-Override"),
]),
# -----------------------------------------------------------------
# Expressions (replace string-typed condition / cron params)
# -----------------------------------------------------------------
"CronExpression": PortSchema(name="CronExpression", fields=[
PortField(name="expression", type="str",
description="Cron-Ausdruck (5 oder 6 Felder)"),
PortField(name="timezone", type="str", required=False,
description="IANA Timezone (z.B. Europe/Zurich)"),
]),
"ConditionExpression": PortSchema(name="ConditionExpression", fields=[
PortField(name="expression", type="str", description="Boolescher Ausdruck"),
PortField(name="syntax", type="str", required=False,
description="jmespath | jsonlogic | python | template",
enumValues=["jmespath", "jsonlogic", "python", "template"]),
]),
# -----------------------------------------------------------------
# Semantic primitives (give meaning to scalar str values)
# -----------------------------------------------------------------
"DateTime": PortSchema(name="DateTime", fields=[
PortField(name="iso", type="str", description="ISO-8601 Datum/Zeit"),
PortField(name="timezone", type="str", required=False,
description="IANA Timezone"),
]),
"Url": PortSchema(name="Url", fields=[
PortField(name="url", type="str", description="Vollständige URL"),
PortField(name="label", type="str", required=False, description="Anzeigename"),
]),
}
# Primitives accepted as PortField.type in addition to catalog schema names.
PRIMITIVE_TYPES: frozenset = frozenset({
"str", "int", "bool", "float", "Any", "Dict", "List",
})

View file

@ -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.features.neutralization.datamodelFeatureNeutralizer import DataNeutralizerAttributes
from modules.datamodels.datamodelFeatures import DataNeutralizerAttributes
from modules.shared.i18nRegistry import i18nModel
@ -243,7 +243,7 @@ class RoleView(Role):
# Automation Workflow — dashboard view with synthesized fields
# ============================================================================
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow
from modules.datamodels.datamodelFeatures import AutoWorkflow
@i18nModel("Workflow (Ansicht)")

View file

@ -0,0 +1,579 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""Workflow Automation models: AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask.
Canonical location for all workflow-engine data models used across the platform.
"""
from enum import Enum
from typing import Dict, Any, List, Optional
from pydantic import Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.i18nRegistry import i18nModel
import uuid
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
class AutoWorkflowStatus(str, Enum):
DRAFT = "draft"
PUBLISHED = "published"
ARCHIVED = "archived"
class AutoRunStatus(str, Enum):
RUNNING = "running"
PAUSED = "paused"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
class AutoStepStatus(str, Enum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
SKIPPED = "skipped"
class AutoTaskStatus(str, Enum):
PENDING = "pending"
COMPLETED = "completed"
CANCELLED = "cancelled"
EXPIRED = "expired"
class AutoTemplateScope(str, Enum):
USER = "user"
INSTANCE = "instance"
MANDATE = "mandate"
SYSTEM = "system"
GRAPHICAL_EDITOR_DATABASE = "poweron_graphicaleditor"
# ---------------------------------------------------------------------------
# AutoWorkflow
# ---------------------------------------------------------------------------
@i18nModel("Workflow")
class AutoWorkflow(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"},
)
mandateId: str = Field(
description="Mandate ID",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Mandanten-ID",
"fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"},
},
)
featureInstanceId: str = Field(
description="Feature instance ID (GE owner instance / RBAC scope)",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Feature-Instanz-ID",
"fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"},
},
)
targetFeatureInstanceId: Optional[str] = Field(
default=None,
description="Target feature instance for execution data scope. NULL for templates, mandatory for non-templates.",
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": False,
"label": "Ziel-Instanz",
"fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"},
},
)
label: str = Field(
description="User-friendly workflow name",
json_schema_extra={"frontend_type": "text", "frontend_required": True, "label": "Bezeichnung"},
)
description: Optional[str] = Field(
default=None,
description="Workflow description",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Beschreibung"},
)
tags: List[str] = Field(
default_factory=list,
description="Tags for categorization",
json_schema_extra={"frontend_type": "tags", "frontend_required": False, "label": "Tags"},
)
isTemplate: bool = Field(
default=False,
description="Whether this workflow is a template",
json_schema_extra={
"frontend_type": "checkbox",
"frontend_required": False,
"label": "Ist Vorlage",
"frontend_format_labels": ["Ja", "-", "Nein"],
},
)
templateSourceId: Optional[str] = Field(
default=None,
description="ID of the template this workflow was created from",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Vorlagen-Quelle",
"fk_target": {
"db": "poweron_graphicaleditor",
"table": "AutoWorkflow",
"labelField": "label",
"softFk": True,
},
},
)
templateScope: Optional[str] = Field(
default=None,
description="Template scope: user, instance, mandate, system (AutoTemplateScope)",
json_schema_extra={
"frontend_type": "select",
"frontend_required": False,
"label": "Vorlagen-Bereich",
"frontend_options": [
{"value": "user", "label": "Meine"},
{"value": "instance", "label": "Instanz"},
{"value": "mandate", "label": "Mandant"},
{"value": "system", "label": "System"},
],
},
)
sharedReadOnly: bool = Field(
default=False,
description="If true, shared template is read-only for non-owners",
json_schema_extra={
"frontend_type": "checkbox",
"frontend_required": False,
"label": "Freigabe nur-lesen",
"frontend_format_labels": ["Ja", "-", "Nein"],
},
)
currentVersionId: Optional[str] = Field(
default=None,
description="ID of the currently published AutoVersion",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Aktuelle Version",
"fk_target": {"db": "poweron_graphicaleditor", "table": "AutoVersion", "labelField": "versionNumber"},
},
)
active: bool = Field(
default=True,
description="Whether workflow is active",
json_schema_extra={
"frontend_type": "checkbox",
"frontend_required": False,
"label": "Aktiv",
"frontend_format_labels": ["Ja", "-", "Nein"],
},
)
eventId: Optional[str] = Field(
default=None,
description="Scheduler event ID for incremental sync",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Event-ID"},
)
notifyOnFailure: bool = Field(
default=True,
description="Send notification (in-app + email) when a run fails",
json_schema_extra={
"frontend_type": "checkbox",
"frontend_required": False,
"label": "Bei Fehler benachrichtigen",
"frontend_format_labels": ["Ja", "-", "Nein"],
},
)
graph: Dict[str, Any] = Field(
default_factory=dict,
description="Graph with nodes and connections (legacy; prefer AutoVersion.graph)",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Graph"},
)
invocations: List[Dict[str, Any]] = Field(
default_factory=list,
description="Entry points / starts (manual, form, schedule, webhook, ...)",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Starts / Einstiegspunkte"},
)
# ---------------------------------------------------------------------------
# AutoVersion
# ---------------------------------------------------------------------------
@i18nModel("Workflow-Version")
class AutoVersion(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"},
)
workflowId: str = Field(
description="FK -> AutoWorkflow",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": True,
"label": "Workflow-ID",
"fk_target": {"db": "poweron_graphicaleditor", "table": "AutoWorkflow", "labelField": "label"},
},
)
versionNumber: int = Field(
default=1,
description="Incrementing version number",
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Version"},
)
status: str = Field(
default=AutoWorkflowStatus.DRAFT.value,
description="Version status: draft, published, archived",
json_schema_extra={
"frontend_type": "select",
"frontend_required": False,
"label": "Status",
"frontend_options": [
{"value": "draft", "label": "Entwurf"},
{"value": "published", "label": "Veröffentlicht"},
{"value": "archived", "label": "Archiviert"},
],
},
)
graph: Dict[str, Any] = Field(
default_factory=dict,
description="Graph with nodes and connections (incl. node parameters)",
json_schema_extra={"frontend_type": "textarea", "frontend_required": True, "label": "Graph"},
)
invocations: List[Dict[str, Any]] = Field(
default_factory=list,
description="Entry points / starts for this version",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Einstiegspunkte"},
)
publishedAt: Optional[float] = Field(
default=None,
description="Timestamp when version was published",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Veröffentlicht am"},
)
publishedBy: Optional[str] = Field(
default=None,
description="User ID who published this version",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Veröffentlicht von",
"fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"},
},
)
# ---------------------------------------------------------------------------
# AutoRun
# ---------------------------------------------------------------------------
@i18nModel("Workflow-Ausführung")
class AutoRun(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"},
)
workflowId: str = Field(
description="Workflow ID",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": True,
"label": "Workflow-ID",
"fk_target": {"db": "poweron_graphicaleditor", "table": "AutoWorkflow", "labelField": "label"},
},
)
label: Optional[str] = Field(
default=None,
description="Human-readable run label, set at creation from workflow name or caller",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Bezeichnung"},
)
mandateId: Optional[str] = Field(
default=None,
description="Mandate ID for cross-feature querying",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Mandanten-ID",
"fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"},
},
)
ownerId: Optional[str] = Field(
default=None,
description="User ID who triggered this run",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Auslöser",
"fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"},
},
)
versionId: Optional[str] = Field(
default=None,
description="AutoVersion ID used for this run",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Versions-ID",
"fk_target": {"db": "poweron_graphicaleditor", "table": "AutoVersion", "labelField": "versionNumber"},
},
)
status: str = Field(
default=AutoRunStatus.RUNNING.value,
description="Status: running, paused, completed, failed, cancelled",
json_schema_extra={
"frontend_type": "select",
"frontend_required": False,
"label": "Status",
"frontend_options": [
{"value": "running", "label": "Läuft"},
{"value": "paused", "label": "Pausiert"},
{"value": "completed", "label": "Abgeschlossen"},
{"value": "failed", "label": "Fehlgeschlagen"},
{"value": "cancelled", "label": "Abgebrochen"},
],
},
)
trigger: Dict[str, Any] = Field(
default_factory=dict,
description="Trigger info (type, entryPointId, payload, etc.)",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Auslöser"},
)
startedAt: Optional[float] = Field(
default=None,
description="Run start timestamp",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Gestartet am"},
)
completedAt: Optional[float] = Field(
default=None,
description="Run completion timestamp",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Abgeschlossen am"},
)
nodeOutputs: Dict[str, Any] = Field(
default_factory=dict,
description="Outputs from executed nodes",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Node-Ausgaben"},
)
currentNodeId: Optional[str] = Field(
default=None,
description="Node ID when paused (human task / email wait)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Aktueller Knoten"},
)
resumeContext: Dict[str, Any] = Field(
default_factory=dict,
description="Context for resume (connectionMap, inputSources, etc.)",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Wiederaufnahme-Kontext"},
)
error: Optional[str] = Field(
default=None,
description="Error message if failed",
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False, "label": "Fehler"},
)
costTokens: int = Field(
default=0,
description="Total tokens consumed by AI nodes",
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Verbrauchte Tokens"},
)
costCredits: float = Field(
default=0.0,
description="Total credits consumed",
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Verbrauchte Credits"},
)
# ---------------------------------------------------------------------------
# AutoStepLog
# ---------------------------------------------------------------------------
@i18nModel("Schritt-Protokoll")
class AutoStepLog(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"},
)
runId: str = Field(
description="FK -> AutoRun",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": True,
"label": "Lauf-ID",
"fk_target": {"db": "poweron_graphicaleditor", "table": "AutoRun", "labelField": "label"},
},
)
nodeId: str = Field(
description="Node ID in the graph",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Knoten-ID"},
)
nodeType: str = Field(
description="Node type (e.g. ai.chat, email.send)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Knotentyp"},
)
status: str = Field(
default=AutoStepStatus.PENDING.value,
description="Step status: pending, running, completed, failed, skipped",
json_schema_extra={
"frontend_type": "select",
"frontend_required": False,
"label": "Status",
"frontend_options": [
{"value": "pending", "label": "Wartend"},
{"value": "running", "label": "Läuft"},
{"value": "completed", "label": "Abgeschlossen"},
{"value": "failed", "label": "Fehlgeschlagen"},
{"value": "skipped", "label": "Übersprungen"},
],
},
)
inputSnapshot: Dict[str, Any] = Field(
default_factory=dict,
description="Snapshot of inputs at execution time",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Eingabe-Snapshot"},
)
output: Dict[str, Any] = Field(
default_factory=dict,
description="Node output",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Ausgabe"},
)
error: Optional[str] = Field(
default=None,
description="Error message if step failed",
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False, "label": "Fehler"},
)
startedAt: Optional[float] = Field(
default=None,
description="Step start timestamp",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Gestartet am"},
)
completedAt: Optional[float] = Field(
default=None,
description="Step completion timestamp",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Abgeschlossen am"},
)
durationMs: Optional[int] = Field(
default=None,
description="Execution duration in milliseconds",
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Dauer (ms)"},
)
tokensUsed: int = Field(
default=0,
description="Tokens consumed by this step",
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Verbrauchte Tokens"},
)
retryCount: int = Field(
default=0,
description="Number of retries executed",
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Wiederholungen"},
)
# ---------------------------------------------------------------------------
# AutoTask
# ---------------------------------------------------------------------------
@i18nModel("Aufgabe")
class AutoTask(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"},
)
runId: str = Field(
description="FK -> AutoRun",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": True,
"label": "Lauf-ID",
"fk_target": {"db": "poweron_graphicaleditor", "table": "AutoRun", "labelField": "label"},
},
)
workflowId: str = Field(
description="Workflow ID",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": True,
"label": "Workflow-ID",
"fk_target": {"db": "poweron_graphicaleditor", "table": "AutoWorkflow", "labelField": "label"},
},
)
nodeId: str = Field(
description="Node ID in the graph",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Knoten-ID"},
)
nodeType: str = Field(
description="Node type: form, approval, upload, comment, review, selection, confirmation",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Knotentyp"},
)
config: Dict[str, Any] = Field(
default_factory=dict,
description="Node config (form schema, approval text, etc.)",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Konfiguration"},
)
assigneeId: Optional[str] = Field(
default=None,
description="User ID assigned to complete the task",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": False,
"frontend_required": False,
"label": "Zugewiesen an",
"fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"},
},
)
status: str = Field(
default=AutoTaskStatus.PENDING.value,
description="Status: pending, completed, cancelled, expired",
json_schema_extra={
"frontend_type": "select",
"frontend_required": False,
"label": "Status",
"frontend_options": [
{"value": "pending", "label": "Wartend"},
{"value": "completed", "label": "Abgeschlossen"},
{"value": "cancelled", "label": "Abgebrochen"},
{"value": "expired", "label": "Abgelaufen"},
],
},
)
result: Optional[Dict[str, Any]] = Field(
default=None,
description="Task result (form data, approval decision, etc.)",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Ergebnis"},
)
expiresAt: Optional[float] = Field(
default=None,
description="Expiration timestamp for the task",
json_schema_extra={"frontend_type": "timestamp", "frontend_required": False, "label": "Läuft ab am"},
)
# ---------------------------------------------------------------------------
# Backward-compatible aliases
# ---------------------------------------------------------------------------
Automation2Workflow = AutoWorkflow
Automation2WorkflowRun = AutoRun
Automation2HumanTask = AutoTask

View file

@ -21,7 +21,7 @@ Modulkonstanten:
Maximale Zeichen für den Overlap Context
Verwendung:
>>> from modules.shared.jsonContinuation import getContexts
>>> from modules.datamodels.jsonContinuation import getContexts
>>> jsonStr = '{"users": [{"name": "John", "bio": "Hello Wor'
>>> contexts = getContexts(jsonStr)
>>> print(contexts.overlapContext)

View file

@ -3,7 +3,7 @@
"""AI Audit Logger — records every AI provider call for compliance reporting.
Usage:
from modules.shared.aiAuditLogger import aiAuditLogger
from modules.dbHelpers.aiAuditLogger import aiAuditLogger
aiAuditLogger.logAiCall(userId=..., mandateId=..., ...)
"""

View file

@ -14,6 +14,7 @@ GDPR Requirements Addressed:
"""
import logging
import time
from datetime import datetime
from typing import Optional, Dict, Any
@ -395,7 +396,6 @@ class AuditLogger:
try:
from modules.datamodels.datamodelAudit import AuditLogEntry
import time
# Calculate cutoff timestamp
cutoffTimestamp = time.time() - (retentionDays * 24 * 60 * 60)

View file

@ -7,7 +7,7 @@ Applies indexes, immutable triggers, and foreign key constraints
for the junction tables used in the multi-tenant mandate model.
Usage:
from modules.shared.dbMultiTenantOptimizations import applyMultiTenantOptimizations
from modules.dbHelpers.dbMultiTenantOptimizations import applyMultiTenantOptimizations
# Call after database tables are created
applyMultiTenantOptimizations(dbConnector)

View file

@ -4,7 +4,7 @@
Dynamic database registry each interface self-registers its DB on import.
Usage in any interfaceDb*.py / interfaceFeature*.py:
from modules.shared.dbRegistry import registerDatabase
from modules.dbHelpers.dbRegistry import registerDatabase
registerDatabase("poweron_xyz")
"""

View file

@ -0,0 +1,196 @@
# Copyright (c) 2025 Patrick Motsch
# 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
# ---------------------------------------------------------------------------
# Resolver registry
# ---------------------------------------------------------------------------
_BUILTIN_FK_RESOLVERS: Dict[str, Callable] = {
"Mandate": resolveMandateLabels,
"FeatureInstance": resolveInstanceLabels,
"UserInDB": resolveUserLabels,
"Role": resolveRoleLabels,
}
def buildLabelResolversFromModel(
modelClass: type,
db=None,
) -> Dict[str, Callable[[List[str]], Dict[str, str]]]:
"""
Auto-build labelResolvers dict from ``json_schema_extra.fk_target`` on a Pydantic model.
Maps field names to resolver functions when the target table has a registered builtin
resolver and ``fk_target.labelField`` is set (non-None).
When ``db`` is provided, the returned resolvers are pre-bound with partial(resolver, db)
so they can be called as resolver(ids).
"""
resolvers: Dict[str, Callable[[List[str]], Dict[str, str]]] = {}
for name, fieldInfo in modelClass.model_fields.items():
extra = fieldInfo.json_schema_extra
if not extra or not isinstance(extra, dict):
continue
tgt = extra.get("fk_target")
if not isinstance(tgt, dict):
continue
if tgt.get("labelField") is None:
continue
fkModel = tgt.get("table")
if fkModel and fkModel in _BUILTIN_FK_RESOLVERS:
fn = _BUILTIN_FK_RESOLVERS[fkModel]
resolvers[name] = partial(fn, db) if db else fn
return resolvers
def enrichRowsWithFkLabels(
rows: List[Dict[str, Any]],
modelClass: type = None,
*,
db=None,
labelResolvers: Optional[Dict[str, Callable[[List[str]], Dict[str, Optional[str]]]]] = None,
extraResolvers: Optional[Dict[str, Callable[[List[str]], Dict[str, Optional[str]]]]] = None,
) -> List[Dict[str, Any]]:
"""Add ``{field}Label`` columns to each row for every FK field that has a
registered resolver.
``modelClass`` if provided, resolvers are auto-built from ``fk_target``
annotations on the Pydantic model (via ``buildLabelResolversFromModel``).
Requires ``db`` to be passed.
``labelResolvers`` explicit resolver map that overrides auto-built ones.
Each resolver has signature ``(ids: List[str]) -> Dict[str, Optional[str]]``.
``extraResolvers`` merged on top of auto-built / explicit resolvers. Use
for ad-hoc fields that are not FK-annotated on the model (e.g.
``createdByUserId`` on billing transactions).
If a label cannot be resolved the ``{field}Label`` value is ``None``
(never the raw ID that would reintroduce the silent-truncation bug).
"""
resolvers: Dict[str, Callable] = {}
if modelClass is not None and labelResolvers is None:
resolvers = buildLabelResolversFromModel(modelClass, db)
elif labelResolvers is not None:
resolvers = dict(labelResolvers)
if extraResolvers:
resolvers.update(extraResolvers)
if not resolvers or not rows:
return rows
for field, resolver in resolvers.items():
ids = list({str(r.get(field)) for r in rows if r.get(field)})
if not ids:
continue
try:
labelMap = resolver(ids)
except Exception as e:
logger.error("enrichRowsWithFkLabels: resolver for '%s' raised: %s", field, e)
labelMap = {}
labelKey = f"{field}Label"
for r in rows:
fkVal = r.get(field)
if fkVal:
r[labelKey] = labelMap.get(str(fkVal))
else:
r[labelKey] = None
return rows

View file

@ -14,7 +14,7 @@ for the *target* side. By collecting all such declarations we know which DB
each table lives in no extra registration step needed.
Usage:
from modules.shared.fkRegistry import getFkRelationships
from modules.dbHelpers.fkRegistry import getFkRelationships
rels = getFkRelationships()
"""
@ -32,7 +32,7 @@ logger = logging.getLogger(__name__)
_modelsLoaded = False
def _ensureModelsLoaded() -> None:
def ensureModelsLoaded() -> None:
"""Import all datamodel modules so that __init_subclass__ fills MODEL_REGISTRY.
In a running server the interfaces import the datamodels automatically.
@ -98,7 +98,7 @@ def _buildTableToDbMap() -> Dict[str, str]:
2. For models still unmapped, query each registered database's
catalog (information_schema) to find the table there.
"""
_ensureModelsLoaded()
ensureModelsLoaded()
mapping: Dict[str, str] = {}
for modelCls in MODEL_REGISTRY.values():
@ -117,7 +117,7 @@ def _buildTableToDbMap() -> Dict[str, str]:
unmapped = [name for name in MODEL_REGISTRY if name not in mapping]
if unmapped:
try:
from modules.shared.dbRegistry import getRegisteredDatabases
from modules.dbHelpers.dbRegistry import getRegisteredDatabases
_resolveUnmappedTablesFromCatalog(mapping, unmapped, getRegisteredDatabases())
except Exception as e:
logger.warning(f"Could not resolve unmapped tables from catalog: {e}")
@ -260,7 +260,7 @@ def validateFkTargets() -> List[str]:
Each ``fk_target`` must contain exactly ``db``, ``table``, and ``labelField``
(``labelField`` may be ``None``).
"""
_ensureModelsLoaded()
ensureModelsLoaded()
errors: List[str] = []
for tableName, modelCls in MODEL_REGISTRY.items():
for fieldName, fieldInfo in modelCls.model_fields.items():

View file

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

View file

@ -1,7 +1,7 @@
"""
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().
"""
@ -11,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
@ -32,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
@ -43,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)

View file

@ -1,7 +1,7 @@
"""
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.).
@ -18,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 = ""

View file

@ -17,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__)
@ -57,7 +57,7 @@ _FEATURES_ALPINA = [
]
class InvestorDemo2026(_BaseDemoConfig):
class InvestorDemo2026(BaseDemoConfig):
code = "investor-demo-2026"
label = "Investor Demo April 2026"
description = (

View file

@ -15,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.
"""
@ -27,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__)
@ -59,7 +59,7 @@ _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 = (

View file

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

View file

@ -11,7 +11,7 @@ from typing import Dict, Any, List, Optional
from modules.datamodels.datamodelUam import User
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.dbRegistry import registerDatabase
from modules.dbHelpers.dbRegistry import registerDatabase
from modules.shared.timeUtils import getIsoTimestamp, getUtcTimestamp
from modules.shared.configuration import APP_CONFIG
from modules.shared.i18nRegistry import resolveText, t

View file

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

View file

@ -7,6 +7,7 @@ Implements training module management, session streaming, tasks, and dashboard.
import logging
import json
import math
import asyncio
import base64
import uuid
@ -43,7 +44,7 @@ _activeProcessTasks: dict = {}
def _audit(context: RequestContext, action: str, resourceType: str = None, resourceId: str = None, details: str = ""):
"""Log an audit event for CommCoach. Non-blocking, best-effort."""
try:
from modules.shared.auditLogger import audit_logger
from modules.dbHelpers.auditLogger import audit_logger
audit_logger.logEvent(
userId=str(context.user.id),
mandateId=str(context.mandateId) if context.mandateId else None,
@ -941,24 +942,22 @@ async def listPersonas(
allPersonas = interface.getAllPersonas(instanceId)
if mode == "filterValues":
from modules.routes.routeHelpers import handleFilterValuesInMemory
from modules.dbHelpers.paginationHelpers import handleFilterValuesInMemory
if not column:
raise HTTPException(status_code=400, detail=routeApiMsg("column parameter required"))
return handleFilterValuesInMemory(allPersonas, column, pagination)
if mode == "ids":
from modules.routes.routeHelpers import handleIdsInMemory
from modules.dbHelpers.paginationHelpers import handleIdsInMemory
return handleIdsInMemory(allPersonas, pagination)
if pagination:
import json as _json
from modules.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict
from modules.routes.routeHelpers import applyFiltersAndSort, paginateInMemory
paginationDict = _json.loads(pagination)
from modules.dbHelpers.paginationHelpers import applyFiltersAndSort, paginateInMemory
paginationDict = json.loads(pagination)
paginationDict = normalize_pagination_dict(paginationDict)
paginationParams = PaginationParams(**paginationDict)
filtered = applyFiltersAndSort(allPersonas, paginationParams)
pageItems, totalItems = paginateInMemory(filtered, paginationParams)
import math
return {
"items": pageItems,
"pagination": PaginationMetadata(

View file

@ -5,11 +5,14 @@ CommCoach Service - Coaching Orchestration.
Manages the coaching pipeline: message processing, AI calls, scoring, task extraction.
"""
import base64
import os
import re
import html
import logging
import json
import asyncio
from datetime import datetime, timezone
from typing import Optional, Dict, Any, List
from modules.datamodels.datamodelUam import User
@ -344,7 +347,6 @@ async def _generateAndEmitTts(sessionId: str, speechText: str, currentUser, mand
return
try:
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
import base64
voiceInterface = getVoiceInterface(currentUser, mandateId)
language, voiceName = getUserVoicePrefs(str(currentUser.id), mandateId)
ttsResult = await voiceInterface.textToSpeech(
@ -377,7 +379,6 @@ async def _generateAndEmitTts(sessionId: str, speechText: str, currentUser, mand
def _resolveFileNameAndMime(title: str) -> tuple:
"""Derive fileName and mimeType from a document title. Only appends .md if no known extension present."""
import os
knownExtensions = {
".md": "text/markdown", ".txt": "text/plain", ".html": "text/html",
".htm": "text/html", ".pdf": "application/pdf", ".json": "application/json",
@ -1269,7 +1270,6 @@ class CommcoachService:
startedAt = session.get("startedAt")
durationSeconds = 0
if startedAt:
from datetime import datetime, timezone
start = datetime.fromtimestamp(startedAt, tz=timezone.utc)
end = datetime.now(timezone.utc)
durationSeconds = int((end - start).total_seconds())
@ -1335,8 +1335,6 @@ class CommcoachService:
if not profile:
profile = interface.getOrCreateProfile(self.userId, self.mandateId, self.instanceId)
from datetime import datetime, timezone
lastSessionAt = profile.get("lastSessionAt")
currentStreak = profile.get("streakDays", 0)
longestStreak = profile.get("longestStreak", 0)
@ -1381,7 +1379,7 @@ class CommcoachService:
from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.shared.notifyMandateAdmins import renderHtmlEmail, resolveMandateName
from modules.system.notifyMandateAdmins import renderHtmlEmail, resolveMandateName
rootInterface = getRootInterface()
user = rootInterface.getUser(self.userId)
@ -1428,7 +1426,6 @@ class CommcoachService:
for s in completedSessions:
startedAt = s.get("startedAt")
if startedAt:
from datetime import datetime, timezone
dt = datetime.fromtimestamp(startedAt, tz=timezone.utc)
s["date"] = dt.strftime("%d.%m.%Y")
else:

View file

@ -7,6 +7,7 @@ Handles system prompts, diagnostic question generation, session summarization, a
import logging
import json
from datetime import datetime, timezone
from typing import Optional, Dict, Any, List, Tuple
logger = logging.getLogger(__name__)
@ -208,7 +209,6 @@ Tool-Nutzung:
dateStr = ""
startedAt = retrievedSession.get("startedAt")
if startedAt:
from datetime import datetime, timezone
dt = datetime.fromtimestamp(startedAt, tz=timezone.utc)
dateStr = dt.strftime("%d.%m.%Y")
prompt += f"\n\nVom Benutzer angefragte Session ({dateStr}):"

View file

@ -5,6 +5,7 @@ CommCoach Context Retrieval.
Intent detection, retrieval strategies, and context assembly for intelligent session continuity.
"""
import json
import re
import logging
from datetime import datetime, timezone
@ -146,7 +147,6 @@ def searchSessionsByTopic(
keyTopics = []
if keyTopicsRaw:
try:
import json
parsed = json.loads(keyTopicsRaw) if isinstance(keyTopicsRaw, str) else keyTopicsRaw
keyTopics = [t.lower() if isinstance(t, str) else str(t).lower() for t in parsed] if isinstance(parsed, list) else []
except Exception:

View file

@ -5,8 +5,10 @@ CommCoach Export Service.
Generates Markdown and PDF exports for dossiers and sessions.
"""
import io
import logging
import json
import re
from typing import Dict, Any, List, Optional
from datetime import datetime, timezone
@ -161,8 +163,6 @@ async def renderSessionPdf(session: Dict[str, Any], messages: List[Dict[str, Any
def _markdownToPdf(markdownText: str, title: str) -> bytes:
"""Convert markdown text to a styled PDF using reportlab. Raises on failure."""
import re as _re
import io
from reportlab.lib.pagesizes import A4
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
@ -217,12 +217,11 @@ def _escXml(text: str) -> str:
def _mdToXml(text: str) -> str:
"""Convert markdown inline formatting to reportlab XML. Bold, italic, escape the rest."""
import re as _re
text = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
text = _re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text)
text = _re.sub(r'__(.+?)__', r'<b>\1</b>', text)
text = _re.sub(r'\*(.+?)\*', r'<i>\1</i>', text)
text = _re.sub(r'_(.+?)_', r'<i>\1</i>', text)
text = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text)
text = re.sub(r'__(.+?)__', r'<b>\1</b>', text)
text = re.sub(r'\*(.+?)\*', r'<i>\1</i>', text)
text = re.sub(r'_(.+?)_', r'<i>\1</i>', text)
return text

View file

@ -64,7 +64,7 @@ async def _runDailyReminders():
from modules.connectors.connectorDbPostgre import DatabaseConnector
from .datamodelCommcoach import CoachingUserProfile, TrainingModuleStatus
from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface
from modules.shared.notifyMandateAdmins import renderHtmlEmail, resolveMandateName
from modules.system.notifyMandateAdmins import renderHtmlEmail, resolveMandateName
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
db = DatabaseConnector(

View file

@ -31,7 +31,7 @@ from modules.features.graphicalEditor.nodeAdapter import (
_adapterFromLegacyNode,
_isMethodBoundNode,
)
from modules.workflows.methods._actionSignatureValidator import _validateTypeRef
from modules.workflows.methods._actionSignatureValidator import validateTypeRef
@dataclass
@ -91,14 +91,14 @@ def _validateAdapterAgainstAction(
f"action '{adapter.bindsAction}.{paramName}': missing 'type' on parameter"
)
continue
for err in _validateTypeRef(typeRef):
for err in validateTypeRef(typeRef):
report.errors.append(
f"action '{adapter.bindsAction}.{paramName}': {err}"
)
# Rule 4: Action outputType exists in catalog (or is a generic fire-and-forget type)
if outputType not in {"ActionResult", "Transit"}:
for err in _validateTypeRef(outputType):
for err in validateTypeRef(outputType):
report.errors.append(
f"action '{adapter.bindsAction}'.outputType: {err}"
)

View file

@ -1,579 +1,25 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""GraphicalEditor models with Auto-prefix: AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask."""
"""GraphicalEditor models — re-exports from canonical datamodels.datamodelWorkflowAutomation."""
from enum import Enum
from typing import Dict, Any, List, Optional
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
from modules.shared.i18nRegistry import i18nModel
import uuid
# All models and enums re-exported for backward compatibility.
# Canonical location: modules.datamodels.datamodelWorkflowAutomation
from modules.datamodels.datamodelWorkflowAutomation import ( # noqa: F401
AutoWorkflowStatus,
AutoRunStatus,
AutoStepStatus,
AutoTaskStatus,
AutoTemplateScope,
GRAPHICAL_EDITOR_DATABASE,
AutoWorkflow,
AutoVersion,
AutoRun,
AutoStepLog,
AutoTask,
Automation2Workflow,
Automation2WorkflowRun,
Automation2HumanTask,
)
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
class AutoWorkflowStatus(str, Enum):
DRAFT = "draft"
PUBLISHED = "published"
ARCHIVED = "archived"
class AutoRunStatus(str, Enum):
RUNNING = "running"
PAUSED = "paused"
COMPLETED = "completed"
FAILED = "failed"
CANCELLED = "cancelled"
class AutoStepStatus(str, Enum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
SKIPPED = "skipped"
class AutoTaskStatus(str, Enum):
PENDING = "pending"
COMPLETED = "completed"
CANCELLED = "cancelled"
EXPIRED = "expired"
class AutoTemplateScope(str, Enum):
USER = "user"
INSTANCE = "instance"
MANDATE = "mandate"
SYSTEM = "system"
# ---------------------------------------------------------------------------
# AutoWorkflow
# ---------------------------------------------------------------------------
@i18nModel("Workflow")
class AutoWorkflow(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"},
)
mandateId: str = Field(
description="Mandate ID",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Mandanten-ID",
"fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"},
},
)
featureInstanceId: str = Field(
description="Feature instance ID (GE owner instance / RBAC scope)",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Feature-Instanz-ID",
"fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"},
},
)
targetFeatureInstanceId: Optional[str] = Field(
default=None,
description="Target feature instance for execution data scope. NULL for templates, mandatory for non-templates.",
json_schema_extra={
"frontend_type": "select",
"frontend_readonly": False,
"frontend_required": False,
"label": "Ziel-Instanz",
"fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"},
},
)
label: str = Field(
description="User-friendly workflow name",
json_schema_extra={"frontend_type": "text", "frontend_required": True, "label": "Bezeichnung"},
)
description: Optional[str] = Field(
default=None,
description="Workflow description",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Beschreibung"},
)
tags: List[str] = Field(
default_factory=list,
description="Tags for categorization",
json_schema_extra={"frontend_type": "tags", "frontend_required": False, "label": "Tags"},
)
isTemplate: bool = Field(
default=False,
description="Whether this workflow is a template",
json_schema_extra={
"frontend_type": "checkbox",
"frontend_required": False,
"label": "Ist Vorlage",
"frontend_format_labels": ["Ja", "-", "Nein"],
},
)
templateSourceId: Optional[str] = Field(
default=None,
description="ID of the template this workflow was created from",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Vorlagen-Quelle",
# Soft FK: holds either a real AutoWorkflow.id (UUID, when copied
# from a stored template) OR an in-code sentinel like
# "trustee-receipt-import" (when bootstrapped from
# featureModule.getTemplateWorkflows()). Sentinel values do not
# exist as DB rows by design — orphan cleanup MUST skip this column.
"fk_target": {
"db": "poweron_graphicaleditor",
"table": "AutoWorkflow",
"labelField": "label",
"softFk": True,
},
},
)
templateScope: Optional[str] = Field(
default=None,
description="Template scope: user, instance, mandate, system (AutoTemplateScope)",
json_schema_extra={
"frontend_type": "select",
"frontend_required": False,
"label": "Vorlagen-Bereich",
"frontend_options": [
{"value": "user", "label": "Meine"},
{"value": "instance", "label": "Instanz"},
{"value": "mandate", "label": "Mandant"},
{"value": "system", "label": "System"},
],
},
)
sharedReadOnly: bool = Field(
default=False,
description="If true, shared template is read-only for non-owners",
json_schema_extra={
"frontend_type": "checkbox",
"frontend_required": False,
"label": "Freigabe nur-lesen",
"frontend_format_labels": ["Ja", "-", "Nein"],
},
)
currentVersionId: Optional[str] = Field(
default=None,
description="ID of the currently published AutoVersion",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Aktuelle Version",
"fk_target": {"db": "poweron_graphicaleditor", "table": "AutoVersion", "labelField": "versionNumber"},
},
)
active: bool = Field(
default=True,
description="Whether workflow is active",
json_schema_extra={
"frontend_type": "checkbox",
"frontend_required": False,
"label": "Aktiv",
"frontend_format_labels": ["Ja", "-", "Nein"],
},
)
eventId: Optional[str] = Field(
default=None,
description="Scheduler event ID for incremental sync",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Event-ID"},
)
notifyOnFailure: bool = Field(
default=True,
description="Send notification (in-app + email) when a run fails",
json_schema_extra={
"frontend_type": "checkbox",
"frontend_required": False,
"label": "Bei Fehler benachrichtigen",
"frontend_format_labels": ["Ja", "-", "Nein"],
},
)
# Legacy fields kept for backward compatibility during transition
graph: Dict[str, Any] = Field(
default_factory=dict,
description="Graph with nodes and connections (legacy; prefer AutoVersion.graph)",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Graph"},
)
invocations: List[Dict[str, Any]] = Field(
default_factory=list,
description="Entry points / starts (manual, form, schedule, webhook, ...)",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Starts / Einstiegspunkte"},
)
# ---------------------------------------------------------------------------
# AutoVersion
# ---------------------------------------------------------------------------
@i18nModel("Workflow-Version")
class AutoVersion(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"},
)
workflowId: str = Field(
description="FK -> AutoWorkflow",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": True,
"label": "Workflow-ID",
"fk_target": {"db": "poweron_graphicaleditor", "table": "AutoWorkflow", "labelField": "label"},
},
)
versionNumber: int = Field(
default=1,
description="Incrementing version number",
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Version"},
)
status: str = Field(
default=AutoWorkflowStatus.DRAFT.value,
description="Version status: draft, published, archived",
json_schema_extra={
"frontend_type": "select",
"frontend_required": False,
"label": "Status",
"frontend_options": [
{"value": "draft", "label": "Entwurf"},
{"value": "published", "label": "Veröffentlicht"},
{"value": "archived", "label": "Archiviert"},
],
},
)
graph: Dict[str, Any] = Field(
default_factory=dict,
description="Graph with nodes and connections (incl. node parameters)",
json_schema_extra={"frontend_type": "textarea", "frontend_required": True, "label": "Graph"},
)
invocations: List[Dict[str, Any]] = Field(
default_factory=list,
description="Entry points / starts for this version",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Einstiegspunkte"},
)
publishedAt: Optional[float] = Field(
default=None,
description="Timestamp when version was published",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Veröffentlicht am"},
)
publishedBy: Optional[str] = Field(
default=None,
description="User ID who published this version",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Veröffentlicht von",
"fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"},
},
)
# ---------------------------------------------------------------------------
# AutoRun
# ---------------------------------------------------------------------------
@i18nModel("Workflow-Ausführung")
class AutoRun(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"},
)
workflowId: str = Field(
description="Workflow ID",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": True,
"label": "Workflow-ID",
"fk_target": {"db": "poweron_graphicaleditor", "table": "AutoWorkflow", "labelField": "label"},
},
)
label: Optional[str] = Field(
default=None,
description="Human-readable run label, set at creation from workflow name or caller",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Bezeichnung"},
)
mandateId: Optional[str] = Field(
default=None,
description="Mandate ID for cross-feature querying",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Mandanten-ID",
"fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"},
},
)
ownerId: Optional[str] = Field(
default=None,
description="User ID who triggered this run",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Auslöser",
"fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"},
},
)
versionId: Optional[str] = Field(
default=None,
description="AutoVersion ID used for this run",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": False,
"label": "Versions-ID",
"fk_target": {"db": "poweron_graphicaleditor", "table": "AutoVersion", "labelField": "versionNumber"},
},
)
status: str = Field(
default=AutoRunStatus.RUNNING.value,
description="Status: running, paused, completed, failed, cancelled",
json_schema_extra={
"frontend_type": "select",
"frontend_required": False,
"label": "Status",
"frontend_options": [
{"value": "running", "label": "Läuft"},
{"value": "paused", "label": "Pausiert"},
{"value": "completed", "label": "Abgeschlossen"},
{"value": "failed", "label": "Fehlgeschlagen"},
{"value": "cancelled", "label": "Abgebrochen"},
],
},
)
trigger: Dict[str, Any] = Field(
default_factory=dict,
description="Trigger info (type, entryPointId, payload, etc.)",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Auslöser"},
)
startedAt: Optional[float] = Field(
default=None,
description="Run start timestamp",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Gestartet am"},
)
completedAt: Optional[float] = Field(
default=None,
description="Run completion timestamp",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Abgeschlossen am"},
)
nodeOutputs: Dict[str, Any] = Field(
default_factory=dict,
description="Outputs from executed nodes",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Node-Ausgaben"},
)
currentNodeId: Optional[str] = Field(
default=None,
description="Node ID when paused (human task / email wait)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Aktueller Knoten"},
)
resumeContext: Dict[str, Any] = Field(
default_factory=dict,
description="Context for resume (connectionMap, inputSources, etc.)",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Wiederaufnahme-Kontext"},
)
error: Optional[str] = Field(
default=None,
description="Error message if failed",
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False, "label": "Fehler"},
)
costTokens: int = Field(
default=0,
description="Total tokens consumed by AI nodes",
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Verbrauchte Tokens"},
)
costCredits: float = Field(
default=0.0,
description="Total credits consumed",
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Verbrauchte Credits"},
)
# ---------------------------------------------------------------------------
# AutoStepLog
# ---------------------------------------------------------------------------
@i18nModel("Schritt-Protokoll")
class AutoStepLog(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"},
)
runId: str = Field(
description="FK -> AutoRun",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": True,
"label": "Lauf-ID",
"fk_target": {"db": "poweron_graphicaleditor", "table": "AutoRun", "labelField": "label"},
},
)
nodeId: str = Field(
description="Node ID in the graph",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Knoten-ID"},
)
nodeType: str = Field(
description="Node type (e.g. ai.chat, email.send)",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Knotentyp"},
)
status: str = Field(
default=AutoStepStatus.PENDING.value,
description="Step status: pending, running, completed, failed, skipped",
json_schema_extra={
"frontend_type": "select",
"frontend_required": False,
"label": "Status",
"frontend_options": [
{"value": "pending", "label": "Wartend"},
{"value": "running", "label": "Läuft"},
{"value": "completed", "label": "Abgeschlossen"},
{"value": "failed", "label": "Fehlgeschlagen"},
{"value": "skipped", "label": "Übersprungen"},
],
},
)
inputSnapshot: Dict[str, Any] = Field(
default_factory=dict,
description="Snapshot of inputs at execution time",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Eingabe-Snapshot"},
)
output: Dict[str, Any] = Field(
default_factory=dict,
description="Node output",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Ausgabe"},
)
error: Optional[str] = Field(
default=None,
description="Error message if step failed",
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False, "label": "Fehler"},
)
startedAt: Optional[float] = Field(
default=None,
description="Step start timestamp",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Gestartet am"},
)
completedAt: Optional[float] = Field(
default=None,
description="Step completion timestamp",
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Abgeschlossen am"},
)
durationMs: Optional[int] = Field(
default=None,
description="Execution duration in milliseconds",
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Dauer (ms)"},
)
tokensUsed: int = Field(
default=0,
description="Tokens consumed by this step",
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Verbrauchte Tokens"},
)
retryCount: int = Field(
default=0,
description="Number of retries executed",
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Wiederholungen"},
)
# ---------------------------------------------------------------------------
# AutoTask
# ---------------------------------------------------------------------------
@i18nModel("Aufgabe")
class AutoTask(PowerOnModel):
id: str = Field(
default_factory=lambda: str(uuid.uuid4()),
description="Primary key",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"},
)
runId: str = Field(
description="FK -> AutoRun",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": True,
"label": "Lauf-ID",
"fk_target": {"db": "poweron_graphicaleditor", "table": "AutoRun", "labelField": "label"},
},
)
workflowId: str = Field(
description="Workflow ID",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": True,
"frontend_required": True,
"label": "Workflow-ID",
"fk_target": {"db": "poweron_graphicaleditor", "table": "AutoWorkflow", "labelField": "label"},
},
)
nodeId: str = Field(
description="Node ID in the graph",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Knoten-ID"},
)
nodeType: str = Field(
description="Node type: form, approval, upload, comment, review, selection, confirmation",
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Knotentyp"},
)
config: Dict[str, Any] = Field(
default_factory=dict,
description="Node config (form schema, approval text, etc.)",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Konfiguration"},
)
assigneeId: Optional[str] = Field(
default=None,
description="User ID assigned to complete the task",
json_schema_extra={
"frontend_type": "text",
"frontend_readonly": False,
"frontend_required": False,
"label": "Zugewiesen an",
"fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"},
},
)
status: str = Field(
default=AutoTaskStatus.PENDING.value,
description="Status: pending, completed, cancelled, expired",
json_schema_extra={
"frontend_type": "select",
"frontend_required": False,
"label": "Status",
"frontend_options": [
{"value": "pending", "label": "Wartend"},
{"value": "completed", "label": "Abgeschlossen"},
{"value": "cancelled", "label": "Abgebrochen"},
{"value": "expired", "label": "Abgelaufen"},
],
},
)
result: Optional[Dict[str, Any]] = Field(
default=None,
description="Task result (form data, approval decision, etc.)",
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Ergebnis"},
)
expiresAt: Optional[float] = Field(
default=None,
description="Expiration timestamp for the task",
json_schema_extra={"frontend_type": "timestamp", "frontend_required": False, "label": "Läuft ab am"},
)
# ---------------------------------------------------------------------------
# Backward-compatible aliases for transition period
# ---------------------------------------------------------------------------
Automation2Workflow = AutoWorkflow
Automation2WorkflowRun = AutoRun
Automation2HumanTask = AutoTask
# Legacy alias
graphicalEditorDatabase = GRAPHICAL_EDITOR_DATABASE

View file

@ -39,24 +39,22 @@ def _make_json_serializable(obj: Any, _depth: int = 0) -> Any:
return obj
from modules.datamodels.datamodelUam import User
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import (
from modules.datamodels.datamodelWorkflowAutomation import (
GRAPHICAL_EDITOR_DATABASE,
AutoWorkflow,
AutoVersion,
AutoRun,
AutoStepLog,
AutoTask,
AutoWorkflow as Automation2Workflow,
AutoRun as Automation2WorkflowRun,
AutoTask as Automation2HumanTask,
)
from modules.features.graphicalEditor.entryPoints import invocations_synced_with_graph
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG
from modules.shared.dbRegistry import registerDatabase
from modules.dbHelpers.dbRegistry import registerDatabase
logger = logging.getLogger(__name__)
graphicalEditorDatabase = "poweron_graphicaleditor"
graphicalEditorDatabase = GRAPHICAL_EDITOR_DATABASE
registerDatabase(graphicalEditorDatabase)
_CALLBACK_WORKFLOW_CHANGED = "graphicalEditor.workflow.changed"
@ -524,7 +522,6 @@ class GraphicalEditorObjects:
return None
existing = self.getVersions(workflowId)
nextNumber = max((v.get("versionNumber", 0) for v in existing), default=0) + 1
import time
data = {
"id": str(uuid.uuid4()),
"workflowId": workflowId,
@ -546,7 +543,6 @@ class GraphicalEditorObjects:
for v in existing:
if v.get("status") == "published" and v.get("id") != versionId:
self.db.recordModify(AutoVersion, v["id"], {"status": "archived"})
import time
updated = self.db.recordModify(AutoVersion, versionId, {
"status": "published",
"publishedAt": time.time(),

View file

@ -5,7 +5,9 @@ GraphicalEditor Feature - n8n-style flow automation.
Minimal bootstrap for feature instance creation. Build from here.
"""
import json
import logging
import uuid
from typing import Dict, List, Any, Optional
from modules.shared.i18nRegistry import t
@ -119,11 +121,10 @@ def getGraphicalEditorServices(
_workflow = workflow
if _workflow is None:
import uuid as _uuid
_workflow = type(
"_Placeholder",
(),
{"featureCode": FEATURE_CODE, "id": f"transient-{_uuid.uuid4().hex[:12]}", "workflowMode": None, "messages": []},
{"featureCode": FEATURE_CODE, "id": f"transient-{uuid.uuid4().hex[:12]}", "workflowMode": None, "messages": []},
)()
ctx = ServiceCenterContext(
@ -209,6 +210,269 @@ def getFeatureDefinition() -> Dict[str, Any]:
}
# ---------------------------------------------------------------------------
# Feature Lifecycle Hooks (called dynamically by core via loadFeatureMainModules)
# ---------------------------------------------------------------------------
def onMandateDelete(mandateId: str, instances: list) -> None:
"""Cascade-delete all AutoWorkflow data in the Greenfield DB for this mandate."""
from modules.datamodels.datamodelWorkflowAutomation import (
GRAPHICAL_EDITOR_DATABASE, AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask,
)
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG
try:
geDb = DatabaseConnector(
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
dbDatabase=GRAPHICAL_EDITOR_DATABASE,
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,
)
if not geDb._ensureTableExists(AutoWorkflow):
return
geInstances = [
inst for inst in instances
if (inst.get("featureCode") if isinstance(inst, dict) else getattr(inst, "featureCode", "")) == "graphicalEditor"
]
totalDeleted = 0
for inst in geInstances:
instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None)
if not instId:
continue
workflows = geDb.getRecordset(AutoWorkflow, recordFilter={
"mandateId": mandateId,
"featureInstanceId": instId,
}) or []
for wf in workflows:
wfId = wf.get("id")
if not wfId:
continue
for v in geDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []:
geDb.recordDelete(AutoVersion, v.get("id"))
for run in geDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or []:
runId = run.get("id")
for sl in geDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []:
geDb.recordDelete(AutoStepLog, sl.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)
totalDeleted += 1
if totalDeleted:
logger.info(f"Cascade: deleted {totalDeleted} AutoWorkflow(s) in Greenfield DB for mandate {mandateId}")
geDb.close()
except Exception as e:
logger.warning(f"Failed to cascade-delete graphical editor data for mandate {mandateId}: {e}")
def onBootstrap() -> None:
"""Seed system workflow templates and sync feature template workflows on boot."""
from modules.datamodels.datamodelWorkflowAutomation import GRAPHICAL_EDITOR_DATABASE, AutoWorkflow
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG
try:
greenfieldDb = DatabaseConnector(
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
dbDatabase=GRAPHICAL_EDITOR_DATABASE,
dbUser=APP_CONFIG.get("DB_USER"),
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
)
greenfieldDb._ensureTableExists(AutoWorkflow)
# --- Seed system templates ---
existing = greenfieldDb.getRecordset(AutoWorkflow, recordFilter={
"isTemplate": True,
"templateScope": "system",
})
existingLabels = {r.get("label") if isinstance(r, dict) else getattr(r, "label", "") for r in (existing or [])}
templates = _buildSystemTemplates()
created = 0
for tpl in templates:
if tpl["label"] in existingLabels:
continue
tpl["id"] = str(uuid.uuid4())
greenfieldDb.recordCreate(AutoWorkflow, tpl)
created += 1
if created:
logger.info(f"Bootstrapped {created} system workflow template(s)")
# --- Sync feature template workflows ---
from modules.system.registry import loadFeatureMainModules
mainModules = loadFeatureMainModules()
templatesBySourceId: dict = {}
for featureCode, mod in mainModules.items():
getTemplateWorkflowsFn = getattr(mod, "getTemplateWorkflows", None)
if not getTemplateWorkflowsFn:
continue
try:
featureTemplates = getTemplateWorkflowsFn() or []
except Exception:
continue
for tpl in featureTemplates:
tplId = tpl.get("id")
if tplId:
templatesBySourceId[tplId] = tpl
if templatesBySourceId:
updated = 0
for sourceId, tpl in templatesBySourceId.items():
instances = greenfieldDb.getRecordset(AutoWorkflow, recordFilter={
"templateSourceId": sourceId,
"isTemplate": False,
})
if not instances:
continue
canonicalGraph = tpl.get("graph", {})
for inst in instances:
instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None)
targetInstanceId = (
inst.get("targetFeatureInstanceId") if isinstance(inst, dict)
else getattr(inst, "targetFeatureInstanceId", None)
) or ""
graphJson = json.dumps(canonicalGraph)
graphJson = graphJson.replace("{{featureInstanceId}}", targetInstanceId)
newGraph = json.loads(graphJson)
existingGraph = inst.get("graph") if isinstance(inst, dict) else getattr(inst, "graph", None)
if isinstance(existingGraph, str):
try:
existingGraph = json.loads(existingGraph)
except Exception:
existingGraph = None
if existingGraph == newGraph:
continue
greenfieldDb.recordModify(AutoWorkflow, instId, {"graph": newGraph})
updated += 1
if updated:
logger.info(f"Synced {updated} workflow(s) with current feature templates")
greenfieldDb.close()
except Exception as e:
logger.warning(f"GraphicalEditor bootstrap failed: {e}")
def onInstanceCreate(mandateId: str, instanceId: str, featureCode: str, templateWorkflows: list) -> int:
"""Create workflow instances from template definitions when a feature instance is created."""
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
from modules.security.rootAccess import getRootUser
from modules.shared.i18nRegistry import resolveText
rootUser = getRootUser()
geInterface = getGraphicalEditorInterface(rootUser, mandateId, instanceId)
copied = 0
for template in templateWorkflows:
templateId = template.get("id", "<no-id>")
try:
graphJson = json.dumps(template.get("graph", {}))
graphJson = graphJson.replace("{{featureInstanceId}}", instanceId)
graph = json.loads(graphJson)
label = resolveText(template.get("label"))
geInterface.createWorkflow({
"label": label,
"graph": graph,
"tags": template.get("tags", [f"feature:{featureCode}"]),
"isTemplate": False,
"templateSourceId": templateId,
"templateScope": "instance",
"active": True,
"targetFeatureInstanceId": instanceId,
"invocations": template.get("invocations", []),
})
copied += 1
except Exception as e:
logger.error(f"onInstanceCreate: failed to copy template '{templateId}': {e}")
return copied
def _buildSystemTemplates():
"""Build the graph definitions for platform system templates."""
return [
{
"label": "Personal Assistant: E-Mail-Antwort-Drafting",
"mandateId": None,
"featureInstanceId": None,
"isTemplate": True,
"templateScope": "system",
"sharedReadOnly": True,
"active": False,
"graph": {
"nodes": [
{"id": "n1", "type": "trigger.schedule", "x": 50, "y": 200, "title": "Täglicher Check", "parameters": {}},
{"id": "n2", "type": "email.checkEmail", "x": 300, "y": 200, "title": "Mailbox prüfen", "parameters": {}},
{"id": "n3", "type": "flow.loop", "x": 550, "y": 200, "title": "Pro E-Mail", "parameters": {"items": {"type": "ref", "nodeId": "n2", "path": ["emails"]}, "concurrency": 1}},
{"id": "n4", "type": "ai.prompt", "x": 800, "y": 200, "title": "Analyse: Antwort nötig?", "parameters": {}},
{"id": "n5", "type": "flow.ifElse", "x": 1050, "y": 200, "title": "Antwort nötig?", "parameters": {}},
{"id": "n6", "type": "ai.prompt", "x": 1300, "y": 100, "title": "Kontext abrufen & Antwort formulieren", "parameters": {}},
{"id": "n7", "type": "email.draftEmail", "x": 1550, "y": 100, "title": "Draft erstellen", "parameters": {}},
],
"connections": [
{"source": "n1", "target": "n2", "sourceOutput": 0, "targetInput": 0},
{"source": "n2", "target": "n3", "sourceOutput": 0, "targetInput": 0},
{"source": "n3", "target": "n4", "sourceOutput": 0, "targetInput": 0},
{"source": "n4", "target": "n5", "sourceOutput": 0, "targetInput": 0},
{"source": "n5", "target": "n6", "sourceOutput": 0, "targetInput": 0},
{"source": "n6", "target": "n7", "sourceOutput": 0, "targetInput": 0},
],
},
"invocations": [{"type": "schedule", "cronExpression": "0 8 * * 1-5"}],
},
{
"label": "Treuhand: PDF-Klassifizierung & Trustee-Import",
"mandateId": None,
"featureInstanceId": None,
"isTemplate": True,
"templateScope": "system",
"sharedReadOnly": True,
"active": False,
"graph": {
"nodes": [
{"id": "n1", "type": "trigger.schedule", "x": 50, "y": 200, "title": "Geplanter Import", "parameters": {}},
{"id": "n2", "type": "sharepoint.listFiles", "x": 300, "y": 200, "title": "SharePoint Ordner lesen", "parameters": {}},
{"id": "n3", "type": "flow.loop", "x": 550, "y": 200, "title": "Pro Dokument", "parameters": {"items": {"type": "ref", "nodeId": "n2", "path": ["files"]}, "concurrency": 1}},
{"id": "n4", "type": "sharepoint.readFile", "x": 800, "y": 200, "title": "PDF-Inhalt lesen", "parameters": {}},
{"id": "n5", "type": "ai.prompt", "x": 1050, "y": 200, "title": "Typ klassifizieren (Rechnung, Beleg, Bankauszug, Vertrag, etc.)", "parameters": {}},
{"id": "n6", "type": "trustee.extractFromFiles", "x": 1300, "y": 200, "title": "Dokument extrahieren", "parameters": {}},
{"id": "n7", "type": "trustee.processDocuments", "x": 1550, "y": 200, "title": "In Trustee einlesen", "parameters": {}},
],
"connections": [
{"source": "n1", "target": "n2", "sourceOutput": 0, "targetInput": 0},
{"source": "n2", "target": "n3", "sourceOutput": 0, "targetInput": 0},
{"source": "n3", "target": "n4", "sourceOutput": 0, "targetInput": 0},
{"source": "n4", "target": "n5", "sourceOutput": 0, "targetInput": 0},
{"source": "n5", "target": "n6", "sourceOutput": 0, "targetInput": 0},
{"source": "n6", "target": "n7", "sourceOutput": 0, "targetInput": 0},
],
},
"invocations": [{"type": "schedule", "cronExpression": "0 7 * * 1-5"}],
},
]
def getUiObjects() -> List[Dict[str, Any]]:
"""Return UI objects for RBAC catalog registration."""
return UI_OBJECTS

View file

@ -11,11 +11,18 @@ output normalizers, and Transit helpers.
import logging
import time
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, ConfigDict, Field
from modules.shared.i18nRegistry import resolveText, t
from modules.datamodels.datamodelPortTypes import (
PortField,
PortSchema,
PORT_TYPE_CATALOG,
PRIMITIVE_TYPES,
)
logger = logging.getLogger(__name__)
@ -24,35 +31,6 @@ logger = logging.getLogger(__name__)
# Pydantic models
# ---------------------------------------------------------------------------
class PortField(BaseModel):
model_config = ConfigDict(populate_by_name=True)
name: str
type: str # str, int, bool, List[str], List[Document], Dict[str,Any], ConnectionRef, …
description: str = ""
required: bool = True
enumValues: Optional[List[str]] = None
# Marks this field as the discriminator for a Ref-Schema (e.g. ConnectionRef.authority,
# FeatureInstanceRef.featureCode). Pickers/validators use it to filter compatible
# producers by sub-type. Type must be "str" when discriminator is True.
discriminator: bool = False
# Surfaces this field at the top of the DataPicker list as the most common pick.
recommended: bool = False
# Human DataPicker title (camelCase JSON for frontend). Omit for technical paths-only.
picker_label: Optional[str] = Field(default=None, serialization_alias="pickerLabel")
# For List[T] fields: segment between parent and inner field (iteration / one list item).
picker_item_label: Optional[str] = Field(default=None, serialization_alias="pickerItemLabel")
class PortSchema(BaseModel):
name: str # e.g. "EmailDraft", "AiResult", "Transit"
fields: List[PortField]
# Declarative flag for the engine: when True, the executor attaches
# connection provenance ({id, authority, label}) onto the output. Replaces
# hard-coded schema lists in actionNodeExecutor._attachConnectionProvenance.
carriesConnectionProvenance: bool = False
class InputPortDef(BaseModel):
accepts: List[str] # list of accepted schema names
@ -70,523 +48,10 @@ class OutputPortDef(BaseModel):
return d
# ---------------------------------------------------------------------------
# PORT_TYPE_CATALOG
# ---------------------------------------------------------------------------
PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
# -----------------------------------------------------------------
# Refs (handles to external resources, pickable by user)
# -----------------------------------------------------------------
"ConnectionRef": PortSchema(name="ConnectionRef", fields=[
PortField(name="id", type="str", description="UserConnection.id (UUID)"),
PortField(name="authority", type="str", discriminator=True,
description="Auth-Provider-Code: msft | clickup | google | …"),
PortField(name="label", type="str", required=False, description="Anzeigename"),
]),
"FeatureInstanceRef": PortSchema(name="FeatureInstanceRef", fields=[
PortField(name="id", type="str", description="FeatureInstance.id (UUID)"),
PortField(name="featureCode", type="str", discriminator=True,
description="Feature-Modul-Code: trustee | redmine | clickup | sharepoint | …"),
PortField(name="label", type="str", required=False, description="Anzeigename"),
PortField(name="mandateId", type="str", required=False, description="Zugehöriger Mandant"),
]),
"ClickUpListRef": PortSchema(name="ClickUpListRef", fields=[
PortField(name="listId", type="str", description="ClickUp-Listen-ID"),
PortField(name="name", type="str", required=False, description="Listenname"),
PortField(name="spaceId", type="str", required=False, description="Space-ID"),
PortField(name="groupId", type="str", required=False, description="Gruppen-ID für die Gruppierungszuordnung"),
PortField(name="connection", type="ConnectionRef", required=False,
description="ClickUp-Verbindung"),
]),
"PromptTemplateRef": PortSchema(name="PromptTemplateRef", fields=[
PortField(name="id", type="str", description="Prompt-Template-ID"),
PortField(name="name", type="str", required=False, description="Anzeigename"),
PortField(name="version", type="str", required=False, description="Version / Tag"),
]),
"SharePointFolderRef": PortSchema(name="SharePointFolderRef", fields=[
PortField(name="siteUrl", type="str", required=False, description="SharePoint Site"),
PortField(name="driveId", type="str", required=False, description="Drive ID"),
PortField(name="folderPath", type="str", required=False, description="Ordnerpfad"),
PortField(name="label", type="str", required=False, description="Kurzlabel für Picker"),
]),
"SharePointFileRef": PortSchema(name="SharePointFileRef", fields=[
PortField(name="siteUrl", type="str", required=False, description="SharePoint Site"),
PortField(name="driveId", type="str", required=False, description="Drive ID"),
PortField(name="filePath", type="str", required=False, description="Dateipfad"),
PortField(name="fileName", type="str", required=False, description="Dateiname"),
PortField(name="label", type="str", required=False, description="Kurzlabel"),
]),
"Document": PortSchema(name="Document", fields=[
PortField(name="id", type="str", required=False, description="Dokument-/Datei-ID"),
PortField(name="name", type="str", required=False, description="Anzeigename"),
PortField(name="mimeType", type="str", required=False, description="MIME-Typ"),
PortField(name="sizeBytes", type="int", required=False, description="Grösse"),
PortField(name="downloadUrl", type="str", required=False, description="Download-URL"),
PortField(name="filePath", type="str", required=False, description="Logischer Pfad"),
]),
"FileItem": PortSchema(name="FileItem", fields=[
PortField(name="id", type="str", required=False, description="Datei-ID"),
PortField(name="name", type="str", required=False, description="Name"),
PortField(name="path", type="str", required=False, description="Pfad"),
PortField(name="mimeType", type="str", required=False, description="MIME"),
PortField(name="sizeBytes", type="int", required=False, description="Grösse"),
]),
"EmailItem": PortSchema(name="EmailItem", fields=[
PortField(name="id", type="str", required=False, description="Message-ID"),
PortField(name="subject", type="str", required=False, description="Betreff"),
PortField(name="fromAddress", type="str", required=False, description="Absender"),
PortField(name="toAddresses", type="List[str]", required=False, description="Empfänger"),
PortField(name="receivedAt", type="str", required=False, description="Empfangen am"),
PortField(name="hasAttachments", type="bool", required=False, description="Hat Anhänge"),
PortField(name="bodyPreview", type="str", required=False, description="Vorschau"),
]),
"TaskItem": PortSchema(name="TaskItem", fields=[
PortField(name="id", type="str", required=False, description="Task-ID"),
PortField(name="title", type="str", required=False, description="Titel"),
PortField(name="status", type="str", required=False, description="Status"),
PortField(name="assignee", type="str", required=False, description="Assignee"),
PortField(name="dueDate", type="str", required=False, description="Fälligkeit"),
PortField(name="listId", type="str", required=False, description="ClickUp-Liste"),
]),
"QueryResult": PortSchema(name="QueryResult", fields=[
PortField(name="rows", type="List[Any]", description="Ergebniszeilen"),
PortField(name="columns", type="List[str]", required=False, description="Spaltennamen"),
PortField(name="count", type="int", required=False, description="Zeilenanzahl"),
]),
"UdmPage": PortSchema(name="UdmPage", fields=[
PortField(name="pageNumber", type="int", required=False, description="Seitennummer"),
PortField(name="blocks", type="List[Any]", required=False, description="ContentBlocks"),
]),
"UdmBlock": PortSchema(name="UdmBlock", fields=[
PortField(name="kind", type="str", required=False, description="Block-Typ"),
PortField(name="text", type="str", required=False, description="Textinhalt"),
PortField(name="children", type="List[Any]", required=False, description="Unterblöcke"),
]),
"DocumentList": PortSchema(name="DocumentList", carriesConnectionProvenance=True, fields=[
PortField(name="documents", type="List[Document]",
description="Dokumente aus vorherigen Schritten", recommended=True),
PortField(name="connection", type="ConnectionRef", required=False,
description="Verbindung, mit der die Liste erzeugt wurde"),
PortField(name="source", type="SharePointFolderRef", required=False,
description="Herkunftsordner / Quelle"),
PortField(name="count", type="int", required=False,
description="Anzahl Dokumente"),
]),
"FileList": PortSchema(name="FileList", carriesConnectionProvenance=True, fields=[
PortField(name="files", type="List[FileItem]",
description="Dateiliste"),
PortField(name="connection", type="ConnectionRef", required=False,
description="Verbindung"),
PortField(name="source", type="SharePointFolderRef", required=False,
description="Listen-Kontext"),
PortField(name="count", type="int", required=False,
description="Anzahl Dateien"),
]),
"EmailDraft": PortSchema(name="EmailDraft", carriesConnectionProvenance=True, fields=[
PortField(name="subject", type="str",
description="Betreff"),
PortField(name="body", type="str",
description="Inhalt"),
PortField(name="to", type="List[str]",
description="Empfänger"),
PortField(name="cc", type="List[str]", required=False,
description="CC"),
PortField(name="attachments", type="List[Document]", required=False,
description="Anhänge"),
PortField(name="connection", type="ConnectionRef", required=False,
description="Outlook-/Graph-Verbindung"),
]),
"EmailList": PortSchema(name="EmailList", carriesConnectionProvenance=True, fields=[
PortField(name="emails", type="List[EmailItem]",
description="E-Mails"),
PortField(name="connection", type="ConnectionRef", required=False,
description="Verbindung"),
PortField(name="count", type="int", required=False,
description="Anzahl"),
]),
"TaskList": PortSchema(name="TaskList", carriesConnectionProvenance=True, fields=[
PortField(name="tasks", type="List[TaskItem]",
description="Aufgaben"),
PortField(name="connection", type="ConnectionRef", required=False,
description="Verbindung"),
PortField(name="listId", type="str", required=False,
description="ClickUp-Listen-ID"),
PortField(name="count", type="int", required=False,
description="Anzahl"),
]),
"TaskResult": PortSchema(name="TaskResult", fields=[
PortField(name="success", type="bool",
description="Erfolg"),
PortField(name="taskId", type="str",
description="Aufgaben-ID"),
PortField(name="task", type="Dict",
description="Aufgabendaten"),
]),
"FormPayload": PortSchema(name="FormPayload", fields=[
PortField(name="payload", type="Dict[str,Any]",
description="Formulardaten"),
]),
"AiResult": PortSchema(name="AiResult", fields=[
PortField(name="prompt", type="str",
description="Prompt",
picker_label=t("Eingabe (Prompt des Schritts)"),
),
PortField(name="response", type="str",
description=(
"Antworttext (Modell-Fließtext o. ä.; Bilder liegen in documents, nicht hier)."
),
recommended=True,
picker_label=t("Ausgabetext (Modell)"),
),
PortField(name="responseData", type="Dict", required=False,
description="Strukturierte Antwort (nur bei JSON-Ausgabe)",
picker_label=t("Strukturierte Antwortdaten")),
PortField(name="context", type="str",
description="Kontext",
picker_label=t("Eingabe-Kontext")),
PortField(name="documents", type="List[Document]",
description=(
"Erzeugte oder mitgegebene Dateien (z. B. Bilder); documentData = Nutzlast pro Eintrag."
),
picker_label=t("Alle Ausgabe-Dateien (Liste)"),
picker_item_label=t("je Datei"),
),
PortField(name="data", type="Dict", required=False,
description=(
"Internes Payload-Objekt (entspricht ``ActionResult.data``-Semantik). "
"Wird vom Executor gesetzt und enthält denselben Inhalt wie ``response`` "
"in strukturierter Form; primär für nachgelagerte Kontext-Nodes."
),
picker_label=t("Technische Detaildaten (data)")),
PortField(name="imageDocumentsOnly", type="List[Document]", required=False,
description="Nur Bild-bezogene Einträge aus documents.",
picker_label=t("Nur Bilder (Liste)")),
]),
"BoolResult": PortSchema(name="BoolResult", fields=[
PortField(name="result", type="bool",
description="Ergebnis"),
PortField(name="reason", type="str", required=False,
description="Begründung"),
]),
"TextResult": PortSchema(name="TextResult", fields=[
PortField(name="text", type="str",
description="Text",
picker_label=t("Text (Schrittausgabe)")),
]),
"LoopItem": PortSchema(name="LoopItem", fields=[
PortField(name="currentItem", type="Any",
description="Aktuelles Element"),
PortField(name="currentIndex", type="int",
description="Aktueller Index"),
PortField(name="items", type="List[Any]",
description="Alle Elemente"),
PortField(name="count", type="int",
description="Gesamtanzahl"),
]),
"AggregateResult": PortSchema(name="AggregateResult", fields=[
PortField(name="items", type="List[Any]",
description="Gesammelte Elemente"),
PortField(name="count", type="int",
description="Anzahl"),
]),
"MergeResult": PortSchema(name="MergeResult", fields=[
PortField(name="inputs", type="Dict[int,Any]",
description="Eingaben nach Port"),
PortField(name="first", type="Any",
description="Erstes verfügbares"),
PortField(name="merged", type="Dict",
description="Zusammengeführte Daten"),
]),
"ContextBranch": PortSchema(name="ContextBranch", fields=[
PortField(name="items", type="List[Any]",
description="Schleifen-fertige Elemente aus dem (gefilterten) Kontext",
recommended=True,
picker_label=t("Gefilterte Elemente")),
PortField(name="data", type="Dict", required=False,
description="Gefilterter Presentation-Umschlag oder Eingabe-Spiegel",
picker_label=t("Kontext (data)")),
PortField(name="filterApplied", type="bool", required=False,
description="True wenn ein Kontext-Inhaltsfilter angewendet wurde"),
PortField(name="contentType", type="str", required=False,
description="Angewendeter Inhaltstyp-Filter (z. B. image)"),
PortField(name="match", type="int", required=False,
description="Aktiver Ausgangs-Index (Fall oder Sonst)"),
]),
"ActionDocument": PortSchema(name="ActionDocument", fields=[
PortField(name="documentName", type="str",
description="Dokumentname",
picker_label=t("Dateiname")),
PortField(name="documentData", type="Any",
description="Inhalt / Rohdaten (z.B. JSON-String, Bytes)",
picker_label=t("Dateiinhalt (JSON, Text oder Bild)"),
recommended=True),
PortField(name="mimeType", type="str",
description="MIME-Typ",
picker_label=t("Dateityp (MIME)")),
PortField(name="fileId", type="str", required=False,
description="Persistierte FileItem.id (vom Engine ergänzt)"),
PortField(name="fileName", type="str", required=False,
description="Persistierter Dateiname (vom Engine ergänzt)"),
]),
"ActionResult": PortSchema(name="ActionResult", fields=[
PortField(name="success", type="bool",
description="Erfolg"),
PortField(name="error", type="str", required=False,
description="Fehler"),
# `documents` is populated for every action that returns ActionResult
# (see datamodelChat.ActionResult.documents and actionNodeExecutor.out).
# Without it in the catalog the DataPicker cannot offer downstream
# bindings like `processDocuments → documents → *` for syncToAccounting.
PortField(name="documents", type="List[ActionDocument]", required=False,
description=(
"Dokumentliste für Actions mit echten Artefakt-Dokumenten. "
"Beim Knoten „Inhalt extrahieren“ fehlt dieses Feld in der Knotenausgabe."
),
picker_label=t("Alle Ausgabe-Dokumente"),
picker_item_label=t("je Dokument"),
),
PortField(name="data", type="Dict", required=False,
description=(
"Strukturierter Inhalt. Bei **context.extractContent**: **Presentation**-Root "
"(`schemaVersion`, `kind`, `fileOrder`, `files`) plus **`_meta`** — ohne "
"zusätzliches `response`/`contentExtracted`-Duplikat."
),
picker_label=t("Technische Detaildaten (data)")),
# Mirror AiResult primary text fields so DataPicker / primaryTextRef behave the same
PortField(name="prompt", type="str", required=False,
description="Optional: auslösender Prompt / Schrittname",
picker_label=t("Auslöser / Prompt (falls vorhanden)")),
PortField(name="response", type="str", required=False,
description=(
"Fließtext wo die Action einen liefert. Bei **„Inhalt extrahieren“** absichtlich leer — "
"Inhalt liegt in ``data``.``files``."
),
recommended=True,
picker_label=t("Nur Fließtext (gesamt)")),
PortField(name="context", type="str", required=False,
description="Optional: Eingabe-Kontext",
picker_label=t("Mitgegebener Kontext")),
PortField(name="imageDocumentsOnly", type="List[ActionDocument]", required=False,
description=(
"Nur Bild-bezogene Einträge. Bei „Inhalt extrahieren“: synthetische "
"Einträge mit ``fileId`` aus persistierten Extrakt-Bildern (kein separates JSON-Dokument)."
),
picker_label=t("Nur Bilder (Liste)")),
PortField(name="responseData", type="Dict", required=False,
description="Optional: strukturierte Zusatzdaten",
picker_label=t("Strukturierte Zusatzdaten")),
PortField(name="presentation", type="Dict", required=False,
description=(
"Selten: Top-Level-Spiegel von Präsentationsdaten andere Actions. "
"Bei „Inhalt extrahieren“ liegt alles direkt unter ``data`` (kein zusätzlicher Spiegel)."
),
picker_label=t("Presentation (Top-Level-Spiegel)")),
PortField(name="presentationSummary", type="Dict", required=False,
description=(
"Kompakte Metadaten zu ``presentation`` (Debugging / traces)."
),
picker_label=t("Presentation-Zusammenfassung")),
PortField(name="presentationConfig", type="Dict", required=False,
description=(
"Optional: Debugging-Konfiguration; bei Extract liegt die Primärquelle in ``validationMetadata`` des JSON-Dokuments."
),
picker_label=t("Presentation-Konfiguration")),
]),
"Transit": PortSchema(name="Transit", fields=[]),
"UdmDocument": PortSchema(name="UdmDocument", carriesConnectionProvenance=True, fields=[
PortField(name="id", type="str", description="Dokument-ID"),
PortField(name="sourceType", type="str", description="Quellformat (pdf, docx, …)"),
PortField(name="sourcePath", type="str", description="Quellpfad"),
PortField(name="children", type="List[Any]", description="StructuralNodes / Seiten"),
PortField(name="connection", type="ConnectionRef", required=False,
description="Optionale Verbindungsreferenz"),
PortField(name="source", type="SharePointFileRef", required=False,
description="Optionale Datei-Herkunft"),
]),
"UdmNodeList": PortSchema(name="UdmNodeList", fields=[
PortField(name="nodes", type="List[Any]", description="UDM StructuralNodes oder ContentBlocks"),
PortField(name="count", type="int", description="Anzahl"),
]),
"ConsolidateResult": PortSchema(name="ConsolidateResult", fields=[
PortField(name="result", type="Any", description="Konsolidiertes Ergebnis"),
PortField(name="mode", type="str", description="Konsolidierungsmodus"),
PortField(name="count", type="int", description="Anzahl verarbeiteter Elemente"),
]),
# -----------------------------------------------------------------
# Shared sub-types (used inside Result schemas)
# -----------------------------------------------------------------
"ProcessError": PortSchema(name="ProcessError", fields=[
PortField(name="documentId", type="str", required=False,
description="Betroffenes Dokument (falls zuordbar)"),
PortField(name="stage", type="str",
description="Pipeline-Stufe: extract | parse | sync | validate | …"),
PortField(name="message", type="str", description="Fehlermeldung"),
PortField(name="code", type="str", required=False, description="Fehler-Code"),
]),
"JournalLine": PortSchema(name="JournalLine", fields=[
PortField(name="id", type="str", required=False, description="Buchungszeilen-ID"),
PortField(name="bookingDate", type="str", description="Buchungsdatum (ISO)"),
PortField(name="account", type="str", description="Konto"),
PortField(name="contraAccount", type="str", required=False, description="Gegenkonto"),
PortField(name="amount", type="float", description="Betrag"),
PortField(name="currency", type="str", required=False, description="Währung"),
PortField(name="text", type="str", required=False, description="Buchungstext"),
PortField(name="reference", type="str", required=False, description="Beleg-Referenz"),
]),
# -----------------------------------------------------------------
# Trustee Action Results
# -----------------------------------------------------------------
"TrusteeRefreshResult": PortSchema(name="TrusteeRefreshResult", fields=[
PortField(name="syncCounts", type="Dict[str,int]",
description="Tabellen → Anzahl synchronisierter Datensätze"),
PortField(name="oldestBookingDate", type="str", required=False,
description="Ältestes Buchungsdatum (ISO)"),
PortField(name="newestBookingDate", type="str", required=False,
description="Neuestes Buchungsdatum (ISO)"),
PortField(name="durationMs", type="int", required=False,
description="Dauer in Millisekunden"),
PortField(name="featureInstance", type="FeatureInstanceRef", required=False,
description="Trustee-Instanz"),
PortField(name="errors", type="List[ProcessError]", required=False,
description="Fehler-Liste"),
]),
"TrusteeProcessResult": PortSchema(name="TrusteeProcessResult", fields=[
PortField(name="documents", type="List[Document]",
description="Verarbeitete Dokumente mit angereicherten Daten"),
PortField(name="processedCount", type="int", required=False,
description="Anzahl erfolgreich verarbeiteter Dokumente"),
PortField(name="failedCount", type="int", required=False,
description="Anzahl fehlgeschlagener Dokumente"),
PortField(name="featureInstance", type="FeatureInstanceRef", required=False,
description="Trustee-Instanz"),
PortField(name="errors", type="List[ProcessError]", required=False,
description="Fehler-Liste"),
]),
"TrusteeSyncResult": PortSchema(name="TrusteeSyncResult", fields=[
PortField(name="syncedCount", type="int",
description="Erfolgreich in das Buchhaltungssystem übertragene Datensätze"),
PortField(name="failedCount", type="int", required=False,
description="Fehlgeschlagene Übertragungen"),
PortField(name="journalLines", type="List[JournalLine]", required=False,
description="Erzeugte Buchungszeilen"),
PortField(name="featureInstance", type="FeatureInstanceRef", required=False,
description="Ziel-Trustee-Instanz"),
PortField(name="errors", type="List[ProcessError]", required=False,
description="Fehler-Liste"),
]),
# -----------------------------------------------------------------
# Redmine Action Results
# -----------------------------------------------------------------
"RedmineTicket": PortSchema(name="RedmineTicket", fields=[
PortField(name="id", type="str", description="Ticket-ID"),
PortField(name="subject", type="str", description="Betreff"),
PortField(name="description", type="str", required=False, description="Beschreibung"),
PortField(name="status", type="str", description="Status-Name"),
PortField(name="tracker", type="str", required=False,
description="Tracker (Bug, Feature, Task, …)"),
PortField(name="priority", type="str", required=False, description="Priorität"),
PortField(name="assignee", type="str", required=False, description="Zugewiesen an"),
PortField(name="author", type="str", required=False, description="Autor"),
PortField(name="project", type="str", required=False, description="Projekt"),
PortField(name="createdOn", type="str", required=False, description="Erstellt (ISO)"),
PortField(name="updatedOn", type="str", required=False, description="Aktualisiert (ISO)"),
PortField(name="dueDate", type="str", required=False, description="Fälligkeitsdatum"),
PortField(name="featureInstance", type="FeatureInstanceRef", required=False,
description="Redmine-Instanz"),
]),
"RedmineTicketList": PortSchema(name="RedmineTicketList", fields=[
PortField(name="tickets", type="List[RedmineTicket]", description="Ticket-Liste"),
PortField(name="count", type="int", required=False, description="Anzahl Tickets"),
PortField(name="filters", type="Dict[str,Any]", required=False,
description="Angewendete Filter"),
PortField(name="featureInstance", type="FeatureInstanceRef", required=False,
description="Redmine-Instanz"),
]),
"RedmineRelationList": PortSchema(name="RedmineRelationList", fields=[
PortField(name="relations", type="List[Any]", description="Relationen"),
PortField(name="count", type="int", required=False, description="Anzahl in dieser Seite"),
PortField(name="totalMatched", type="int", required=False,
description="Gesamtanzahl nach Filter"),
PortField(name="offset", type="int", required=False, description="Pagination-Offset"),
PortField(name="hasMore", type="bool", required=False, description="Weitere Seiten verfügbar"),
]),
"RedmineStats": PortSchema(name="RedmineStats", fields=[
PortField(name="kpis", type="Dict[str,Any]",
description="Key Performance Indicators"),
PortField(name="throughput", type="Dict[str,Any]", required=False,
description="Durchsatz pro Zeitraum"),
PortField(name="statusDistribution", type="Dict[str,int]", required=False,
description="Tickets pro Status"),
PortField(name="backlog", type="Dict[str,Any]", required=False,
description="Backlog-Statistik"),
PortField(name="featureInstance", type="FeatureInstanceRef", required=False,
description="Redmine-Instanz"),
]),
# -----------------------------------------------------------------
# ClickUp / SharePoint / Email helper results
# -----------------------------------------------------------------
"TaskAttachmentRef": PortSchema(name="TaskAttachmentRef", fields=[
PortField(name="taskId", type="str", description="Aufgaben-ID"),
PortField(name="attachmentId", type="str", required=False, description="Attachment-ID"),
PortField(name="fileName", type="str", required=False, description="Dateiname"),
PortField(name="url", type="str", required=False, description="Download-URL"),
]),
"AttachmentSpec": PortSchema(name="AttachmentSpec", fields=[
PortField(name="source", type="str",
description="Quellart: path | document | url",
enumValues=["path", "document", "url"]),
PortField(name="ref", type="str",
description="Referenzwert (Pfad / Document.id / URL)"),
PortField(name="fileName", type="str", required=False,
description="Override-Dateiname"),
PortField(name="mimeType", type="str", required=False, description="MIME-Override"),
]),
# -----------------------------------------------------------------
# Expressions (replace string-typed condition / cron params)
# -----------------------------------------------------------------
"CronExpression": PortSchema(name="CronExpression", fields=[
PortField(name="expression", type="str",
description="Cron-Ausdruck (5 oder 6 Felder)"),
PortField(name="timezone", type="str", required=False,
description="IANA Timezone (z.B. Europe/Zurich)"),
]),
"ConditionExpression": PortSchema(name="ConditionExpression", fields=[
PortField(name="expression", type="str", description="Boolescher Ausdruck"),
PortField(name="syntax", type="str", required=False,
description="jmespath | jsonlogic | python | template",
enumValues=["jmespath", "jsonlogic", "python", "template"]),
]),
# -----------------------------------------------------------------
# Semantic primitives (give meaning to scalar str values)
# -----------------------------------------------------------------
"DateTime": PortSchema(name="DateTime", fields=[
PortField(name="iso", type="str", description="ISO-8601 Datum/Zeit"),
PortField(name="timezone", type="str", required=False,
description="IANA Timezone"),
]),
"Url": PortSchema(name="Url", fields=[
PortField(name="url", type="str", description="Vollständige URL"),
PortField(name="label", type="str", required=False, description="Anzeigename"),
]),
}
# ---------------------------------------------------------------------------
# Catalog validator
# ---------------------------------------------------------------------------
# Primitives accepted as PortField.type in addition to catalog schema names.
PRIMITIVE_TYPES: frozenset = frozenset({
"str", "int", "bool", "float", "Any", "Dict", "List",
})
def _stripContainer(typeStr: str) -> List[str]:
"""
@ -744,8 +209,6 @@ PRIMARY_TEXT_HANDOVER_REF_PATH: Dict[str, List[Any]] = {
def resolveSystemVariable(variable: str, context: Dict[str, Any]) -> Any:
"""Resolve a system variable name to its runtime value."""
from datetime import datetime, timezone
now = datetime.now(timezone.utc)
mapping = {
"system.timestamp": lambda: int(now.timestamp() * 1000),

View file

@ -8,13 +8,14 @@ import asyncio
import json
import logging
import math
import uuid
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, Path, Query, Body, Request, HTTPException
from fastapi.responses import JSONResponse, StreamingResponse, Response
from modules.auth import limiter, getRequestContext, RequestContext
from modules.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict
from modules.routes.routeHelpers import applyFiltersAndSort
from modules.dbHelpers.paginationHelpers import applyFiltersAndSort
from modules.features.graphicalEditor.mainGraphicalEditor import getGraphicalEditorServices
from modules.features.graphicalEditor.nodeRegistry import getNodeTypesForApi
@ -422,7 +423,6 @@ async def post_execute(
workflow_for_envelope = wf
targetFeatureInstanceId = wf.get("targetFeatureInstanceId")
if not workflowId:
import uuid
workflowId = f"transient-{uuid.uuid4().hex[:12]}"
logger.info("graphicalEditor execute: using transient workflowId=%s", workflowId)
@ -642,18 +642,18 @@ def get_templates(
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
templates = iface.getTemplates(scope=scope)
from modules.routes.routeHelpers import enrichRowsWithFkLabels
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow
enrichRowsWithFkLabels(templates, AutoWorkflow)
enrichRowsWithFkLabels(templates, AutoWorkflow, db=iface.db)
if mode == "filterValues":
if not column:
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
from modules.routes.routeHelpers import handleFilterValuesInMemory
from modules.dbHelpers.paginationHelpers import handleFilterValuesInMemory
return handleFilterValuesInMemory(templates, column, pagination)
if mode == "ids":
from modules.routes.routeHelpers import handleIdsInMemory
from modules.dbHelpers.paginationHelpers import handleIdsInMemory
return handleIdsInMemory(templates, pagination)
paginationParams = None
@ -1411,11 +1411,11 @@ def get_workflows(
if mode == "filterValues":
if not column:
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
from modules.routes.routeHelpers import handleFilterValuesInMemory
from modules.dbHelpers.paginationHelpers import handleFilterValuesInMemory
return handleFilterValuesInMemory(enriched, column, pagination)
if mode == "ids":
from modules.routes.routeHelpers import handleIdsInMemory
from modules.dbHelpers.paginationHelpers import handleIdsInMemory
return handleIdsInMemory(enriched, pagination)
paginationParams = None

View file

@ -92,63 +92,8 @@ class DataNeutraliserConfig(PowerOnModel):
)
@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},
)
# Re-exported from canonical location (moved to datamodels layer)
from modules.datamodels.datamodelFeatures import DataNeutralizerAttributes # noqa: F401
@i18nModel("Neutralisierungs-Snapshot")

View file

@ -14,7 +14,7 @@ from modules.features.neutralization.datamodelFeatureNeutralizer import (
DataNeutralizationSnapshot,
)
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.dbRegistry import registerDatabase
from modules.dbHelpers.dbRegistry import registerDatabase
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
from modules.shared.configuration import APP_CONFIG
from modules.shared.timeUtils import getUtcTimestamp

View file

@ -231,3 +231,51 @@ def _ensureAccessRulesForRole(rootInterface, roleId: str, ruleTemplates: List[Di
createdCount += 1
return createdCount
# ---------------------------------------------------------------------------
# Feature Lifecycle Hooks
# ---------------------------------------------------------------------------
def onMandateDelete(mandateId: str, instances: list) -> None:
"""Cascade-delete all neutralization data for deleted mandate."""
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG
from modules.features.neutralization.datamodelFeatureNeutralizer import (
DataNeutraliserConfig, DataNeutralizationSnapshot,
)
try:
featureInstances = [
inst for inst in instances
if (inst.get("featureCode") if isinstance(inst, dict) else getattr(inst, "featureCode", "")) == FEATURE_CODE
]
if not featureInstances:
return
db = DatabaseConnector(
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
dbDatabase="poweron_neutralization",
dbUser=APP_CONFIG.get("DB_USER"),
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
userId=None,
)
totalDeleted = 0
for inst in featureInstances:
instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None)
if not instId:
continue
for ModelClass in [DataNeutraliserConfig, DataNeutralizationSnapshot]:
records = db.getRecordset(ModelClass, recordFilter={"featureInstanceId": instId}) or []
for rec in records:
db.recordDelete(ModelClass, rec.get("id"))
totalDeleted += len(records)
if totalDeleted:
logger.info(f"Cascade: deleted {totalDeleted} neutralization record(s) for mandate {mandateId}")
db.close()
except Exception as e:
logger.warning(f"Failed to cascade-delete neutralization data for mandate {mandateId}: {e}")

View file

@ -1,5 +1,6 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
import base64
import logging
import asyncio
from typing import Any, Dict, List, Optional
@ -28,7 +29,6 @@ class NeutralizationPlayground:
async def processUploadedFileAsync(self, file_bytes: bytes, filename: str) -> Dict[str, Any]:
"""Process an uploaded file (bytes + filename). Returns neutralized result for text or binary.
Saves both original and neutralized files to user files (component storage) when available."""
import base64
name_lower = (filename or '').lower()
mime_map = {
'.pdf': 'application/pdf',

View file

@ -9,9 +9,11 @@ Mehrsprachig: DE, EN, FR, IT
"""
import asyncio
import base64
import logging
import re
import json
import uuid
from typing import Dict, List, Any, Optional
from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutraliserConfig, DataNeutralizerAttributes
@ -120,12 +122,12 @@ class NeutralizationService:
Returns the model object (with contextLength etc.) or None."""
try:
from modules.aicore.aicoreModelRegistry import modelRegistry
from modules.aicore.aicoreModelSelector import modelSelector as _modSel
from modules.aicore.aicoreModelSelector import modelSelector
from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum
_models = modelRegistry.getAvailableModels()
_opts = AiCallOptions(operationType=OperationTypeEnum.NEUTRALIZATION_TEXT)
_failover = _modSel.getFailoverModelList("x", "", _opts, _models)
_failover = modelSelector.getFailoverModelList("x", "", _opts, _models)
return _failover[0] if _failover else None
except Exception as _e:
logger.warning(f"_resolveNeutModel failed: {_e}")
@ -219,8 +221,6 @@ class NeutralizationService:
Regex patterns run as a supplementary pass to catch anything the
model missed.
"""
import uuid as _uuid
aiService = None
if self._getService:
try:
@ -262,7 +262,7 @@ class NeutralizationService:
continue
if _origText in aiMapping:
continue
_uid = str(_uuid.uuid4())
_uid = str(uuid.uuid4())
_placeholder = f"[{_patType}.{_uid}]"
aiMapping[_origText] = _placeholder
@ -430,7 +430,6 @@ class NeutralizationService:
Uses NEUTRALIZATION_IMAGE operation type only internal Private-LLM models.
If no internal model available returns 'blocked'.
"""
import base64
try:
aiService = None
if self._getService:
@ -494,7 +493,6 @@ class NeutralizationService:
def processImage(self, imageBytes: bytes, fileName: str, mimeType: str = "image/png") -> Dict[str, Any]:
"""Sync wrapper for processImageAsync. Uses asyncio.run when no event loop is running."""
import asyncio
try:
return asyncio.run(self.processImageAsync(imageBytes, fileName, mimeType))
except RuntimeError:
@ -554,7 +552,6 @@ class NeutralizationService:
"""Persist mapping to DB for resolve to work. mapping: originalText -> placeholder e.g. '[email.uuid]'"""
if not self.interfaceNeutralizer or not mapping:
return
import re
placeholder_re = re.compile(r'^\[([a-z]+)\.([a-f0-9-]{36})\]$')
for original_text, placeholder in mapping.items():
m = placeholder_re.match(placeholder)
@ -615,9 +612,8 @@ class NeutralizationService:
neutralized_parts.append(part)
continue
if type_group == 'image':
import base64 as _b64img
try:
_imgBytes = _b64img.b64decode(str(data))
_imgBytes = base64.b64decode(str(data))
_imgResult = await self.processImageAsync(_imgBytes, fileName)
if _imgResult.get("status") == "ok":
neutralized_parts.append(part)

View file

@ -6,6 +6,7 @@ Handles structured data with headers (CSV, JSON, XML)
"""
import json
import uuid
import pandas as pd
import xml.etree.ElementTree as ET
from typing import Dict, List, Any, Union
@ -58,7 +59,6 @@ class ListProcessor:
original = str(row[i])
if original not in self.string_parser.mapping:
# Generate a UUID for the placeholder
import uuid
placeholderId = str(uuid.uuid4())
self.string_parser.mapping[original] = pattern.replacement_template.format(len(self.string_parser.mapping) + 1)
row[i] = self.string_parser.mapping[original]
@ -143,7 +143,6 @@ class ListProcessor:
if pattern:
if attrValue not in self.string_parser.mapping:
# Generate a UUID for the placeholder
import uuid
placeholderId = str(uuid.uuid4())
# Create placeholder in format [type.uuid]
typeMapping = {
@ -166,7 +165,6 @@ class ListProcessor:
if pattern:
if attrValue not in self.string_parser.mapping:
# Generate a UUID for the placeholder
import uuid
placeholderId = str(uuid.uuid4())
# Create placeholder in format [type.uuid]
typeMapping = {
@ -202,7 +200,6 @@ class ListProcessor:
if pattern:
if text not in self.string_parser.mapping:
# Generate a UUID for the placeholder
import uuid
placeholder_id = str(uuid.uuid4())
# Create placeholder in format [type.uuid]
type_mapping = {
@ -223,7 +220,6 @@ class ListProcessor:
if text.lower().strip() == name.lower().strip():
if text not in self.string_parser.mapping:
# Generate a UUID for the placeholder
import uuid
placeholder_id = str(uuid.uuid4())
self.string_parser.mapping[text] = f"[name.{placeholder_id}]"
text = self.string_parser.mapping[text]

View file

@ -4,6 +4,7 @@ Queries Dokument table and retrieves PDF content from ComponentObjects.
"""
import logging
import re
from typing import List, Dict, Any, Optional
from .datamodelFeatureRealEstate import Dokument, DokumentTyp, Gemeinde
from .interfaceFeatureRealEstate import RealEstateObjects
@ -182,8 +183,6 @@ class BZODocumentRetriever:
Returns:
Year as integer if found, None otherwise
"""
import re
# Try to extract year from label
if dokument.label:
year_match = re.search(r'\b(19|20)\d{2}\b', dokument.label)

View file

@ -8,6 +8,7 @@ directly (no external workflow-orchestration framework).
import logging
import re
import uuid
from typing import TypedDict, List, Dict, Any, Optional
from dataclasses import dataclass
@ -1310,8 +1311,6 @@ def run_extraction(pdf_bytes: bytes, pdf_id: str = None, dokument_id: str = None
"warnings": [...]
}
"""
import uuid
if not pdf_id:
pdf_id = f"pdf_{uuid.uuid4().hex[:8]}"

View file

@ -5,6 +5,8 @@ Handles CRUD operations on Real Estate entities (Projekt, Parzelle, etc.).
"""
import logging
import re
import time
from typing import Dict, Any, List, Optional, Union
from .datamodelFeatureRealEstate import (
Projekt,
@ -21,7 +23,7 @@ from .datamodelFeatureRealEstate import (
from modules.datamodels.datamodelUam import User
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG
from modules.shared.dbRegistry import registerDatabase
from modules.dbHelpers.dbRegistry import registerDatabase
from modules.security.rbac import RbacClass
from modules.datamodels.datamodelRbac import AccessRuleContext
from modules.datamodels.datamodelUam import AccessLevel
@ -322,7 +324,6 @@ class RealEstateObjects:
def _isUUID(self, value: str) -> bool:
"""Check if a string looks like a UUID."""
import re
uuid_pattern = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.IGNORECASE)
return bool(uuid_pattern.match(value))
@ -832,8 +833,6 @@ class RealEstateObjects:
Dictionary with 'rows' (list of dicts), 'columns' (list of column names),
'rowCount' (int), and 'executionTime' (float)
"""
import time
try:
start_time = time.time()

View file

@ -7,6 +7,7 @@ This module also handles feature initialization and RBAC catalog registration.
"""
import logging
import re
from modules.shared.i18nRegistry import t
@ -1351,7 +1352,6 @@ async def executeIntentBasedOperation(
location_id = None
try:
# Check if it's already a UUID
import re
uuid_pattern = re.compile(r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', re.IGNORECASE)
if not uuid_pattern.match(location_filter):
# Try to resolve name to ID
@ -3071,3 +3071,56 @@ CRITICAL: You MUST include the actual numeric values from the tables in your sum
# Return a basic summary if AI fails
return f"Summary generation failed: {str(e)}. Found {len(relevant_rules)} relevant rules and {len(relevant_zones)} zones for Bauzone {bauzone}."
# ---------------------------------------------------------------------------
# Feature Lifecycle Hooks
# ---------------------------------------------------------------------------
def onMandateDelete(mandateId: str, instances: list) -> None:
"""Cascade-delete all realEstate data for deleted mandate."""
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG
from modules.features.realEstate.datamodelFeatureRealEstate import (
Dokument, Kontext, Land, Kanton, Gemeinde, Parzelle, Projekt,
)
try:
featureInstances = [
inst for inst in instances
if (inst.get("featureCode") if isinstance(inst, dict) else getattr(inst, "featureCode", "")) == FEATURE_CODE
]
if not featureInstances:
return
db = DatabaseConnector(
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
dbDatabase="poweron_realestate",
dbUser=APP_CONFIG.get("DB_USER"),
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
userId=None,
)
totalDeleted = 0
for inst in featureInstances:
instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None)
if not instId:
continue
for ModelClass in [Dokument, Kontext, Land, Kanton, Gemeinde, Parzelle, Projekt]:
try:
records = db.getRecordset(ModelClass, recordFilter={"featureInstanceId": instId}) or []
if not records:
records = db.getRecordset(ModelClass, recordFilter={"mandateId": mandateId}) or []
for rec in records:
db.recordDelete(ModelClass, rec.get("id"))
totalDeleted += len(records)
except Exception:
pass
if totalDeleted:
logger.info(f"Cascade: deleted {totalDeleted} realEstate record(s) for mandate {mandateId}")
db.close()
except Exception as e:
logger.warning(f"Failed to cascade-delete realEstate data for mandate {mandateId}: {e}")

View file

@ -228,20 +228,21 @@ def get_projects(
recordFilter = {"featureInstanceId": instanceId}
if mode in ("filterValues", "ids"):
from modules.routes.routeHelpers import handleFilterValuesInMemory, handleIdsInMemory, enrichRowsWithFkLabels
from modules.dbHelpers.paginationHelpers import handleFilterValuesInMemory, handleIdsInMemory
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
items = interface.getProjekte(recordFilter=recordFilter)
itemDicts = [i.model_dump() if hasattr(i, 'model_dump') else i for i in items]
if mode == "filterValues":
if not column:
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
enrichRowsWithFkLabels(itemDicts, Projekt)
enrichRowsWithFkLabels(itemDicts, Projekt, db=interface.db)
return handleFilterValuesInMemory(itemDicts, column, pagination)
return handleIdsInMemory(itemDicts, pagination)
items = interface.getProjekte(recordFilter=recordFilter)
paginationParams = _parsePagination(pagination)
if paginationParams:
from modules.routes.routeHelpers import applyFiltersAndSort
from modules.dbHelpers.paginationHelpers import applyFiltersAndSort
itemDicts = [i.model_dump() if hasattr(i, 'model_dump') else i for i in items]
filtered = applyFiltersAndSort(itemDicts, paginationParams)
total_items = len(filtered)
@ -369,20 +370,21 @@ def get_parcels(
recordFilter = {"featureInstanceId": instanceId}
if mode in ("filterValues", "ids"):
from modules.routes.routeHelpers import handleFilterValuesInMemory, handleIdsInMemory, enrichRowsWithFkLabels
from modules.dbHelpers.paginationHelpers import handleFilterValuesInMemory, handleIdsInMemory
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
items = interface.getParzellen(recordFilter=recordFilter)
itemDicts = [i.model_dump() if hasattr(i, 'model_dump') else i for i in items]
if mode == "filterValues":
if not column:
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
enrichRowsWithFkLabels(itemDicts, Parzelle)
enrichRowsWithFkLabels(itemDicts, Parzelle, db=interface.db)
return handleFilterValuesInMemory(itemDicts, column, pagination)
return handleIdsInMemory(itemDicts, pagination)
items = interface.getParzellen(recordFilter=recordFilter)
paginationParams = _parsePagination(pagination)
if paginationParams:
from modules.routes.routeHelpers import applyFiltersAndSort
from modules.dbHelpers.paginationHelpers import applyFiltersAndSort
itemDicts = [i.model_dump() if hasattr(i, 'model_dump') else i for i in items]
filtered = applyFiltersAndSort(itemDicts, paginationParams)
total_items = len(filtered)

View file

@ -27,7 +27,7 @@ from modules.features.redmine.datamodelRedmine import (
)
from modules.security.rbac import RbacClass
from modules.shared.configuration import APP_CONFIG, decryptValue, encryptValue
from modules.shared.dbRegistry import registerDatabase
from modules.dbHelpers.dbRegistry import registerDatabase
logger = logging.getLogger(__name__)

View file

@ -333,3 +333,51 @@ def _ensureAccessRulesForRole(
rootInterface.db.recordCreate(AccessRule, newRule.model_dump())
createdCount += 1
return createdCount
# ---------------------------------------------------------------------------
# Feature Lifecycle Hooks
# ---------------------------------------------------------------------------
def onMandateDelete(mandateId: str, instances: list) -> None:
"""Cascade-delete all redmine data for deleted mandate."""
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG
from modules.features.redmine.datamodelRedmine import (
RedmineInstanceConfig, RedmineTicketMirror, RedmineRelationMirror,
)
try:
featureInstances = [
inst for inst in instances
if (inst.get("featureCode") if isinstance(inst, dict) else getattr(inst, "featureCode", "")) == FEATURE_CODE
]
if not featureInstances:
return
db = DatabaseConnector(
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
dbDatabase="poweron_redmine",
dbUser=APP_CONFIG.get("DB_USER"),
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
userId=None,
)
totalDeleted = 0
for inst in featureInstances:
instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None)
if not instId:
continue
for ModelClass in [RedmineInstanceConfig, RedmineTicketMirror, RedmineRelationMirror]:
records = db.getRecordset(ModelClass, recordFilter={"featureInstanceId": instId}) or []
for rec in records:
db.recordDelete(ModelClass, rec.get("id"))
totalDeleted += len(records)
if totalDeleted:
logger.info(f"Cascade: deleted {totalDeleted} redmine record(s) for mandate {mandateId}")
db.close()
except Exception as e:
logger.warning(f"Failed to cascade-delete redmine data for mandate {mandateId}: {e}")

View file

@ -63,7 +63,7 @@ def _audit(
errorMessage: Optional[str] = None,
) -> None:
try:
from modules.shared.auditLogger import audit_logger
from modules.dbHelpers.auditLogger import audit_logger
audit_logger.logEvent(
userId=str(context.user.id),
mandateId=str(context.mandateId) if context.mandateId else None,

View file

@ -26,6 +26,7 @@ from __future__ import annotations
import logging
import time
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple
from modules.connectors.connectorTicketsRedmine import (
@ -253,7 +254,6 @@ def _isoToEpoch(value: Optional[str]) -> Optional[float]:
if not value:
return None
try:
from datetime import datetime
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
except Exception:
return None

View file

@ -21,7 +21,7 @@ The whole result is cached in :mod:`serviceRedmineStatsCache` keyed by
from __future__ import annotations
import bisect
import datetime as _dt
import datetime
import logging
from collections import Counter, defaultdict
from typing import Any, Dict, Iterable, List, Optional, Tuple
@ -170,8 +170,8 @@ def _aggregate(
def _kpis(
tickets: List[RedmineTicketDto],
rootTrackerId: Optional[int],
periodFrom: Optional[_dt.datetime],
periodTo: Optional[_dt.datetime],
periodFrom: Optional[datetime.datetime],
periodTo: Optional[datetime.datetime],
) -> RedmineStatsKpis:
total = len(tickets)
open_count = sum(1 for t in tickets if not t.isClosed)
@ -260,8 +260,8 @@ def _statusByTracker(
def _throughput(
tickets: List[RedmineTicketDto],
periodFrom: Optional[_dt.datetime],
periodTo: Optional[_dt.datetime],
periodFrom: Optional[datetime.datetime],
periodTo: Optional[datetime.datetime],
bucket: str,
) -> List[RedmineThroughputBucket]:
"""Build per-bucket snapshots: how many tickets exist at the END of
@ -276,7 +276,7 @@ def _throughput(
# If no period is set, span the lifetime of the data.
if periodFrom is None or periodTo is None:
all_dates: List[_dt.datetime] = []
all_dates: List[datetime.datetime] = []
for t in tickets:
for s in (t.createdOn, t.updatedOn):
d = _parseIsoDate(s)
@ -309,8 +309,8 @@ def _throughput(
# open = total - #closed with closedTs <= bucket end. We compute
# against ALL tickets (not just the period-windowed counters) so
# pre-period tickets are correctly counted in the snapshot.
created_dates: List[_dt.datetime] = []
closed_dates: List[_dt.datetime] = []
created_dates: List[datetime.datetime] = []
closed_dates: List[datetime.datetime] = []
for t in tickets:
c = _parseIsoDate(t.createdOn)
if c:
@ -341,13 +341,13 @@ def _throughput(
return out
def _countLE(sortedDates: List[_dt.datetime], edge: _dt.datetime) -> int:
def _countLE(sortedDates: List[datetime.datetime], edge: datetime.datetime) -> int:
"""Binary search: how many entries in ``sortedDates`` are <= ``edge``."""
return bisect.bisect_right(sortedDates, edge)
def _bucketKeysBetween(
fromD: _dt.datetime, toD: _dt.datetime, bucket: str
fromD: datetime.datetime, toD: datetime.datetime, bucket: str
) -> List[str]:
"""Inclusive list of bucket keys covering ``[fromD, toD]``."""
if toD < fromD:
@ -357,9 +357,9 @@ def _bucketKeysBetween(
cursor = fromD
safety = 0
step = (
_dt.timedelta(days=1) if bucket == "day"
else _dt.timedelta(days=7) if bucket == "week"
else _dt.timedelta(days=27) # month: walk in <31d steps so we never skip
datetime.timedelta(days=1) if bucket == "day"
else datetime.timedelta(days=7) if bucket == "week"
else datetime.timedelta(days=27) # month: walk in <31d steps so we never skip
)
while cursor <= toD and safety < 5000:
k = _bucketKey(cursor, bucket)
@ -377,27 +377,27 @@ def _bucketKeysBetween(
return keys
def _bucketEnd(key: str, bucket: str) -> _dt.datetime:
def _bucketEnd(key: str, bucket: str) -> datetime.datetime:
"""Last-instant timestamp covered by the given bucket key."""
if bucket == "day":
d = _dt.datetime.strptime(key, "%Y-%m-%d")
d = datetime.datetime.strptime(key, "%Y-%m-%d")
return d.replace(hour=23, minute=59, second=59)
if bucket == "month":
d = _dt.datetime.strptime(key, "%Y-%m")
d = datetime.datetime.strptime(key, "%Y-%m")
# First of next month minus one second.
if d.month == 12:
nxt = d.replace(year=d.year + 1, month=1)
else:
nxt = d.replace(month=d.month + 1)
return nxt - _dt.timedelta(seconds=1)
return nxt - datetime.timedelta(seconds=1)
# week: ISO format ``YYYY-Www``. End = Sunday 23:59:59 of that week.
try:
year_str, week_str = key.split("-W")
year = int(year_str)
week = int(week_str)
# ``%G-%V-%u`` parses ISO year/week/day; %u=1 is Monday.
monday = _dt.datetime.strptime(f"{year}-{week:02d}-1", "%G-%V-%u")
return monday + _dt.timedelta(days=6, hours=23, minutes=59, seconds=59)
monday = datetime.datetime.strptime(f"{year}-{week:02d}-1", "%G-%V-%u")
return monday + datetime.timedelta(days=6, hours=23, minutes=59, seconds=59)
except Exception:
return _utcNow()
@ -436,7 +436,7 @@ def _relationDistribution(
def _backlogAging(
tickets: List[RedmineTicketDto], *, now: Optional[_dt.datetime] = None
tickets: List[RedmineTicketDto], *, now: Optional[datetime.datetime] = None
) -> List[RedmineAgingBucket]:
if now is None:
now = _utcNow()
@ -467,40 +467,40 @@ def _backlogAging(
# Date helpers (no external deps)
# ---------------------------------------------------------------------------
def _utcNow() -> _dt.datetime:
def _utcNow() -> datetime.datetime:
"""Naive UTC ``datetime`` -- the rest of the helpers compare naive
objects, so we strip tz info on purpose."""
return _dt.datetime.now(_dt.timezone.utc).replace(tzinfo=None)
return datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
def _parseIsoDate(value: Optional[str]) -> Optional[_dt.datetime]:
def _parseIsoDate(value: Optional[str]) -> Optional[datetime.datetime]:
if not value:
return None
try:
s = value.replace("Z", "+00:00") if isinstance(value, str) else value
if isinstance(s, str) and "T" not in s and len(s) == 10:
return _dt.datetime.strptime(s, "%Y-%m-%d")
return _dt.datetime.fromisoformat(s).replace(tzinfo=None)
return datetime.datetime.strptime(s, "%Y-%m-%d")
return datetime.datetime.fromisoformat(s).replace(tzinfo=None)
except Exception:
try:
return _dt.datetime.strptime(str(value)[:10], "%Y-%m-%d")
return datetime.datetime.strptime(str(value)[:10], "%Y-%m-%d")
except Exception:
return None
def _inPeriod(
when: _dt.datetime,
fromDate: Optional[_dt.datetime],
toDate: Optional[_dt.datetime],
when: datetime.datetime,
fromDate: Optional[datetime.datetime],
toDate: Optional[datetime.datetime],
) -> bool:
if fromDate and when < fromDate:
return False
if toDate and when > toDate + _dt.timedelta(days=1):
if toDate and when > toDate + datetime.timedelta(days=1):
return False
return True
def _bucketKey(when: _dt.datetime, bucket: str) -> str:
def _bucketKey(when: datetime.datetime, bucket: str) -> str:
if bucket == "day":
return when.strftime("%Y-%m-%d")
if bucket == "month":
@ -514,7 +514,7 @@ def _bucketLabel(key: str, bucket: str) -> str:
return key
if bucket == "month":
try:
d = _dt.datetime.strptime(key, "%Y-%m")
d = datetime.datetime.strptime(key, "%Y-%m")
return d.strftime("%b %Y")
except Exception:
return key

View file

@ -26,6 +26,7 @@ from __future__ import annotations
import asyncio
import logging
import time
from datetime import datetime
from typing import Any, Dict, List, Optional
from modules.connectors.connectorTicketsRedmine import RedmineApiError
@ -354,7 +355,6 @@ def _parseRedmineDateToEpoch(value: Optional[str]) -> Optional[float]:
if not value:
return None
try:
from datetime import datetime
s = value.replace("Z", "+00:00")
return datetime.fromisoformat(s).timestamp()
except Exception:

View file

@ -11,7 +11,7 @@ from typing import Dict, Any, List, Optional
from modules.datamodels.datamodelUam import User
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG
from modules.shared.dbRegistry import registerDatabase
from modules.dbHelpers.dbRegistry import registerDatabase
from .datamodelTeamsbot import (
TeamsbotSession,

View file

@ -6,6 +6,8 @@ Handles feature initialization and RBAC catalog registration.
"""
import logging
import time
import uuid
from typing import Dict, List, Any
from modules.shared.i18nRegistry import t
@ -261,7 +263,6 @@ def _runMigrations():
from modules.shared.configuration import APP_CONFIG
import psycopg2
from psycopg2.extras import RealDictCursor
import uuid
conn = psycopg2.connect(
host=APP_CONFIG.get("DB_HOST", "localhost"),
@ -320,8 +321,7 @@ def _runMigrations():
continue
adhocId = str(uuid.uuid4())
import time as _time
now = _time.time()
now = time.time()
cur.execute("""
INSERT INTO "TeamsbotMeetingModule" (id, "instanceId", "mandateId", "ownerUserId", title, "seriesType", status, "sysCreatedAt")
VALUES (%s, %s, %s, 'system', 'Adhoc', 'adhoc', 'active', %s)
@ -439,3 +439,68 @@ def _ensureAccessRulesForRole(rootInterface, roleId: str, ruleTemplates: List[Di
logger.debug(f"Created {createdCount} AccessRules for role {roleId}")
return createdCount
# ---------------------------------------------------------------------------
# Feature Lifecycle Hooks
# ---------------------------------------------------------------------------
def onMandateDelete(mandateId: str, instances: list) -> None:
"""Cascade-delete all teamsbot data for deleted mandate."""
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG
from modules.features.teamsbot.datamodelTeamsbot import (
TeamsbotMeetingModule, TeamsbotSession, TeamsbotTranscript,
TeamsbotBotResponse, TeamsbotSystemBot, TeamsbotUserAccount,
TeamsbotUserSettings, TeamsbotDirectorPrompt,
)
try:
featureInstances = [
inst for inst in instances
if (inst.get("featureCode") if isinstance(inst, dict) else getattr(inst, "featureCode", "")) == FEATURE_CODE
]
if not featureInstances:
return
db = DatabaseConnector(
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
dbDatabase="poweron_teamsbot",
dbUser=APP_CONFIG.get("DB_USER"),
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
userId=None,
)
totalDeleted = 0
for inst in featureInstances:
instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None)
if not instId:
continue
# Models scoped by instanceId
for ModelClass in [
TeamsbotMeetingModule, TeamsbotSession,
TeamsbotUserSettings, TeamsbotDirectorPrompt,
]:
records = db.getRecordset(ModelClass, recordFilter={"instanceId": instId}) or []
for rec in records:
db.recordDelete(ModelClass, rec.get("id"))
totalDeleted += len(records)
# Models scoped by mandateId only (no instanceId)
for ModelClass in [TeamsbotSystemBot, TeamsbotUserAccount]:
records = db.getRecordset(ModelClass, recordFilter={"mandateId": mandateId}) or []
for rec in records:
db.recordDelete(ModelClass, rec.get("id"))
totalDeleted += len(records)
# TeamsbotTranscript + TeamsbotBotResponse: scoped via sessionId
# (orphans cleaned up when sessions are deleted above)
if totalDeleted:
logger.info(f"Cascade: deleted {totalDeleted} teamsbot record(s) for mandate {mandateId}")
db.close()
except Exception as e:
logger.warning(f"Failed to cascade-delete teamsbot data for mandate {mandateId}: {e}")

View file

@ -5,6 +5,7 @@ Teamsbot routes for the backend API.
Implements Teams Bot session management, live streaming, and configuration endpoints.
"""
import base64
import logging
import json
import re
@ -1170,7 +1171,6 @@ async def testVoice(
)
if result and isinstance(result, dict):
import base64
audioContent = result.get("audioContent")
if audioContent:
audioB64 = base64.b64encode(

View file

@ -795,7 +795,6 @@ class TeamsbotService:
def _loadAvatarFileData(self, fileId, _teamsbotInterface):
"""Load avatar file as base64 data + mime type. Returns (data, mimeType) or (None, None)."""
import base64
from modules.interfaces import interfaceDbManagement
try:
mgmt = interfaceDbManagement.getInterface(self.currentUser, self.mandateId, featureInstanceId=self.instanceId)
@ -1239,7 +1238,6 @@ class TeamsbotService:
voiced chunks. Here we apply a minimum-duration safety net: very short
chunks (<1s) are buffered until they reach 1s; everything else goes
straight to STT. A wall-clock timeout flushes stale buffers."""
import base64
_MIN_CHUNK_SEC = 1.0
_STALE_TIMEOUT_SEC = 3.0

View file

@ -8,7 +8,8 @@ Encapsulates: config loading -> connector resolution -> duplicate check -> push
import json
import logging
import time
from datetime import datetime as _dt, timezone as _tz
import uuid
from datetime import datetime, timezone
from typing import List, Dict, Any, Optional
from .accountingConnectorBase import (
@ -18,6 +19,7 @@ from .accountingConnectorBase import (
SyncResult,
)
from .accountingRegistry import getAccountingRegistry
from modules.features.trustee.datamodelFeatureTrustee import TrusteeDocument
logger = logging.getLogger(__name__)
@ -44,7 +46,6 @@ class AccountingBridge:
def _decryptConfig(self, encryptedConfig: str) -> Dict[str, Any]:
"""Decrypt the stored connector config JSON."""
from modules.shared.configuration import decryptValue
import json
try:
if not encryptedConfig:
logger.error("Accounting config encryptedConfig is empty")
@ -105,7 +106,7 @@ class AccountingBridge:
))
valutaTs = position.get("valuta")
bookingDateStr = _dt.fromtimestamp(valutaTs, tz=_tz.utc).strftime("%Y-%m-%d") if valutaTs else ""
bookingDateStr = datetime.fromtimestamp(valutaTs, tz=timezone.utc).strftime("%Y-%m-%d") if valutaTs else ""
return AccountingBooking(
reference=position.get("bookingReference") or position.get("id", ""),
@ -163,7 +164,6 @@ class AccountingBridge:
# 1) Pre-booking document upload (RMA-style: upload first, link via belegId)
if documentIds and not postBookingAttach:
from modules.features.trustee.datamodelFeatureTrustee import TrusteeDocument as TrusteeDocumentModel
logger.info("Accounting sync: positionId=%s, uploading %s document(s) pre-booking ...", positionId, len(documentIds))
belegIds = []
belegLabels = []
@ -197,7 +197,7 @@ class AccountingBridge:
return SyncResult(success=False, errorMessage=f"Dokument-Upload fehlgeschlagen: {uploadResult.errorMessage}")
belegId = uploadResult.externalId
if belegId:
self._trusteeInterface.db.recordModify(TrusteeDocumentModel, documentId, {"externalBelegId": belegId})
self._trusteeInterface.db.recordModify(TrusteeDocument, documentId, {"externalBelegId": belegId})
logger.info("Accounting sync: document uploaded & belegId=%s stored on document %s", belegId, documentId)
belegIds.append(belegId)
belegLabels.append(fileName)
@ -208,7 +208,6 @@ class AccountingBridge:
# 1b) Post-booking flow: collect raw doc data now, attach after pushBooking
if documentIds and postBookingAttach:
from modules.features.trustee.datamodelFeatureTrustee import TrusteeDocument as TrusteeDocumentModel
for documentId in documentIds:
doc = self._trusteeInterface.getDocument(documentId)
if not doc:
@ -263,7 +262,6 @@ class AccountingBridge:
# 3) Post-booking document attach (Abacus-style: entry must exist before attaching docs)
if result.success and pendingDocs and result.externalId:
from modules.features.trustee.datamodelFeatureTrustee import TrusteeDocument as TrusteeDocumentModel
logger.info("Accounting sync: positionId=%s, attaching %s document(s) to entry %s ...", positionId, len(pendingDocs), result.externalId)
for documentId, fileName, docData, mimeType in pendingDocs:
attachResult = await connector.attachDocumentToEntry(
@ -280,11 +278,10 @@ class AccountingBridge:
)
continue
if attachResult.externalId:
self._trusteeInterface.db.recordModify(TrusteeDocumentModel, documentId, {"externalBelegId": attachResult.externalId})
self._trusteeInterface.db.recordModify(TrusteeDocument, documentId, {"externalBelegId": attachResult.externalId})
logger.info("Accounting sync: document attached, externalId=%s stored on document %s", attachResult.externalId, documentId)
# Save sync record
import uuid
syncRecord = {
"id": str(uuid.uuid4()),
"positionId": positionId,

View file

@ -16,12 +16,13 @@ froze every other request (chat, health-check, etc.) for minutes. See
"""
import asyncio
import json as _json
import json
import logging
import os
import time
import uuid
from collections import defaultdict
from datetime import datetime as _dt, timezone as _tz
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable, Dict, Any, List, Optional, Type
@ -46,7 +47,7 @@ def _isoDateToTimestamp(raw: Any) -> Optional[float]:
if not s:
return None
try:
return _dt.strptime(s, "%Y-%m-%d").replace(tzinfo=_tz.utc).timestamp()
return datetime.strptime(s, "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp()
except ValueError:
raise ValueError(f"Cannot parse bookingDate '{raw}' as YYYY-MM-DD")
@ -174,7 +175,7 @@ def _dumpSyncData(tag: str, rows: list) -> None:
else:
serializable.append(str(r))
with open(path, "w", encoding="utf-8") as f:
_json.dump({"count": len(serializable), "rows": serializable}, f, ensure_ascii=False, indent=2, default=str)
json.dump({"count": len(serializable), "rows": serializable}, f, ensure_ascii=False, indent=2, default=str)
logger.info(f"Debug sync dump: {path.name} ({len(serializable)} rows)")
except Exception as e:
logger.warning(f"Failed to write debug sync dump for {tag}: {e}")
@ -253,7 +254,7 @@ class AccountingDataSync:
try:
plainJson = decryptValue(encryptedConfig)
connConfig = _json.loads(plainJson) if plainJson else {}
connConfig = json.loads(plainJson) if plainJson else {}
except Exception as e:
summary["errors"].append(f"Failed to decrypt config: {e}")
return summary
@ -444,7 +445,6 @@ class AccountingDataSync:
Returns ``(entriesCount, linesCount, oldestBookingDate, newestBookingDate)``
where the date strings are ISO ``YYYY-MM-DD`` (or ``None`` if no entries).
"""
import uuid as _uuid
t0 = time.time()
self._bulkClear(modelEntry, featureInstanceId)
self._bulkClear(modelLine, featureInstanceId)
@ -454,7 +454,7 @@ class AccountingDataSync:
oldestDate: Optional[str] = None
newestDate: Optional[str] = None
for raw in rawEntries:
entryId = str(_uuid.uuid4())
entryId = str(uuid.uuid4())
rawDate = raw.get("bookingDate")
bookingTs = _isoDateToTimestamp(rawDate)
if rawDate:
@ -603,7 +603,7 @@ class AccountingDataSync:
if not accNo or not bdate:
continue
try:
dt = _dt.fromtimestamp(float(bdate), tz=_tz.utc)
dt = datetime.fromtimestamp(float(bdate), tz=timezone.utc)
year = dt.year
month = dt.month
except (ValueError, TypeError, OSError):

View file

@ -7,6 +7,7 @@ Manages trustee organisations, roles, access, contracts, documents, and position
import logging
import math
import re
import uuid
from datetime import datetime, timezone
from typing import Dict, Any, List, Optional, Union
@ -14,7 +15,7 @@ from pydantic import ValidationError
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG
from modules.shared.dbRegistry import registerDatabase
from modules.dbHelpers.dbRegistry import registerDatabase
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC, getRecordsetPaginatedWithRBAC, getDistinctColumnValuesWithRBAC
from modules.security.rbac import RbacClass
from modules.datamodels.datamodelUam import User, AccessLevel
@ -562,7 +563,6 @@ class TrusteeObjects:
logger.error(f"Invalid organisation ID length: {len(orgId)}")
return None
import re
if not re.match(r'^[a-zA-Z0-9_-]+$', orgId):
logger.error(f"Invalid organisation ID format: {orgId}")
return None
@ -739,7 +739,6 @@ class TrusteeObjects:
if "featureInstanceId" not in data:
data["featureInstanceId"] = self.featureInstanceId
import uuid
accessId = data.get("id") or str(uuid.uuid4())
data["id"] = accessId
@ -936,7 +935,6 @@ class TrusteeObjects:
if "featureInstanceId" not in data:
data["featureInstanceId"] = self.featureInstanceId
import uuid
contractId = data.get("id") or str(uuid.uuid4())
data["id"] = contractId
@ -1047,7 +1045,6 @@ class TrusteeObjects:
data["mandateId"] = self.mandateId
data["featureInstanceId"] = self.featureInstanceId
import uuid
documentId = data.get("id") or str(uuid.uuid4())
data["id"] = documentId
@ -1263,7 +1260,6 @@ class TrusteeObjects:
vatPercentage = data.get("vatPercentage", 0)
data["vatAmount"] = bookingAmount * vatPercentage / 100
import uuid
positionId = data.get("id") or str(uuid.uuid4())
data["id"] = positionId

View file

@ -1041,3 +1041,59 @@ def _ensureAccessRulesForRole(rootInterface, roleId: str, ruleTemplates: List[Di
logger.debug(f"Created {createdCount} AccessRules for role {roleId}")
return createdCount
# ---------------------------------------------------------------------------
# Feature Lifecycle Hooks
# ---------------------------------------------------------------------------
def onMandateDelete(mandateId: str, instances: list) -> None:
"""Cascade-delete all trustee data for deleted mandate."""
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG
from modules.features.trustee.datamodelFeatureTrustee import (
TrusteeOrganisation, TrusteeRole, TrusteeAccess, TrusteeContract,
TrusteeDocument, TrusteePosition, TrusteeDataAccount,
TrusteeDataJournalEntry, TrusteeDataJournalLine, TrusteeDataContact,
TrusteeDataAccountBalance, TrusteeAccountingConfig, TrusteeAccountingSync,
)
try:
featureInstances = [
inst for inst in instances
if (inst.get("featureCode") if isinstance(inst, dict) else getattr(inst, "featureCode", "")) == FEATURE_CODE
]
if not featureInstances:
return
db = DatabaseConnector(
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
dbDatabase="poweron_trustee",
dbUser=APP_CONFIG.get("DB_USER"),
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
userId=None,
)
totalDeleted = 0
for inst in featureInstances:
instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None)
if not instId:
continue
for ModelClass in [
TrusteeOrganisation, TrusteeRole, TrusteeAccess, TrusteeContract,
TrusteeDocument, TrusteePosition, TrusteeDataAccount,
TrusteeDataJournalEntry, TrusteeDataJournalLine, TrusteeDataContact,
TrusteeDataAccountBalance, TrusteeAccountingConfig, TrusteeAccountingSync,
]:
records = db.getRecordset(ModelClass, recordFilter={"featureInstanceId": instId}) or []
for rec in records:
db.recordDelete(ModelClass, rec.get("id"))
totalDeleted += len(records)
if totalDeleted:
logger.info(f"Cascade: deleted {totalDeleted} trustee record(s) for mandate {mandateId}")
db.close()
except Exception as e:
logger.warning(f"Failed to cascade-delete trustee data for mandate {mandateId}: {e}")

View file

@ -18,6 +18,9 @@ import logging
import json
import io
import base64
import time
import uuid
from datetime import datetime, timezone
from modules.auth import limiter, getRequestContext, RequestContext
from .interfaceFeatureTrustee import getInterface
@ -395,10 +398,9 @@ def get_position_options(
items = result.items if hasattr(result, 'items') else result
def _makePositionLabel(p: TrusteePosition) -> str:
from datetime import datetime as _dt, timezone as _tz
parts = []
if p.valuta:
parts.append(_dt.fromtimestamp(p.valuta, tz=_tz.utc).strftime("%Y-%m-%d"))
parts.append(datetime.fromtimestamp(p.valuta, tz=timezone.utc).strftime("%Y-%m-%d"))
if p.company:
parts.append(p.company[:30])
if p.desc:
@ -424,7 +426,7 @@ def get_organisations(
context: RequestContext = Depends(getRequestContext)
):
"""Get all organisations for a feature instance with optional pagination."""
from modules.routes.routeHelpers import enrichRowsWithFkLabels
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
mandateId = _validateInstanceAccess(instanceId, context)
paginationParams = _parsePagination(pagination)
@ -435,7 +437,7 @@ def get_organisations(
return [r.model_dump() if hasattr(r, "model_dump") else r for r in items]
if paginationParams and hasattr(result, 'items'):
enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeOrganisation)
enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeOrganisation, db=interface.db)
return {
"items": enriched,
"pagination": PaginationMetadata(
@ -448,7 +450,7 @@ def get_organisations(
).model_dump(),
}
items = result if isinstance(result, list) else result.items
enriched = enrichRowsWithFkLabels(_toDicts(items), TrusteeOrganisation)
enriched = enrichRowsWithFkLabels(_toDicts(items), TrusteeOrganisation, db=interface.db)
return {"items": enriched, "pagination": None}
@ -544,7 +546,7 @@ def get_roles(
context: RequestContext = Depends(getRequestContext)
):
"""Get all roles with optional pagination."""
from modules.routes.routeHelpers import enrichRowsWithFkLabels
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
mandateId = _validateInstanceAccess(instanceId, context)
paginationParams = _parsePagination(pagination)
@ -555,7 +557,7 @@ def get_roles(
return [r.model_dump() if hasattr(r, "model_dump") else r for r in items]
if paginationParams and hasattr(result, 'items'):
enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeRole)
enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeRole, db=interface.db)
return {
"items": enriched,
"pagination": PaginationMetadata(
@ -568,7 +570,7 @@ def get_roles(
).model_dump(),
}
items = result if isinstance(result, list) else result.items
enriched = enrichRowsWithFkLabels(_toDicts(items), TrusteeRole)
enriched = enrichRowsWithFkLabels(_toDicts(items), TrusteeRole, db=interface.db)
return {"items": enriched, "pagination": None}
@ -664,7 +666,7 @@ def get_all_access(
context: RequestContext = Depends(getRequestContext)
):
"""Get all access records with optional pagination."""
from modules.routes.routeHelpers import enrichRowsWithFkLabels
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
mandateId = _validateInstanceAccess(instanceId, context)
paginationParams = _parsePagination(pagination)
@ -675,7 +677,7 @@ def get_all_access(
return [r.model_dump() if hasattr(r, "model_dump") else r for r in items]
if paginationParams and hasattr(result, 'items'):
enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeAccess)
enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeAccess, db=interface.db)
return {
"items": enriched,
"pagination": PaginationMetadata(
@ -688,7 +690,7 @@ def get_all_access(
).model_dump(),
}
items = result if isinstance(result, list) else result.items
enriched = enrichRowsWithFkLabels(_toDicts(items), TrusteeAccess)
enriched = enrichRowsWithFkLabels(_toDicts(items), TrusteeAccess, db=interface.db)
return {"items": enriched, "pagination": None}
@ -814,7 +816,7 @@ def get_contracts(
context: RequestContext = Depends(getRequestContext)
):
"""Get all contracts with optional pagination."""
from modules.routes.routeHelpers import enrichRowsWithFkLabels
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
mandateId = _validateInstanceAccess(instanceId, context)
paginationParams = _parsePagination(pagination)
@ -825,7 +827,7 @@ def get_contracts(
return [r.model_dump() if hasattr(r, "model_dump") else r for r in items]
if paginationParams and hasattr(result, 'items'):
enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeContract)
enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeContract, db=interface.db)
return {
"items": enriched,
"pagination": PaginationMetadata(
@ -838,7 +840,7 @@ def get_contracts(
).model_dump(),
}
items = result if isinstance(result, list) else result.items
enriched = enrichRowsWithFkLabels(_toDicts(items), TrusteeContract)
enriched = enrichRowsWithFkLabels(_toDicts(items), TrusteeContract, db=interface.db)
return {"items": enriched, "pagination": None}
@ -981,14 +983,15 @@ def get_documents(
def _handleDocumentMode(instanceId, mandateId, mode, column, pagination, context):
"""Handle mode=filterValues and mode=ids for trustee documents."""
from modules.routes.routeHelpers import handleIdsInMemory, handleFilterValuesInMemory, enrichRowsWithFkLabels
from modules.dbHelpers.paginationHelpers import handleIdsInMemory, handleFilterValuesInMemory
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
if mode == "filterValues":
if not column:
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
result = interface.getAllDocuments(None)
items = [r.model_dump() if hasattr(r, 'model_dump') else r for r in (result.items if hasattr(result, 'items') else result)]
enrichRowsWithFkLabels(items, TrusteeDocument)
enrichRowsWithFkLabels(items, TrusteeDocument, db=interface.db)
return handleFilterValuesInMemory(items, column, pagination)
if mode == "ids":
result = interface.getAllDocuments(None)
@ -1260,7 +1263,8 @@ def get_positions(
def _handlePositionMode(instanceId, mandateId, mode, column, pagination, context):
"""Handle mode=filterValues and mode=ids for trustee positions."""
from modules.routes.routeHelpers import handleIdsInMemory, handleFilterValuesInMemory, enrichRowsWithFkLabels
from modules.dbHelpers.paginationHelpers import handleIdsInMemory, handleFilterValuesInMemory
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
from .datamodelFeatureTrustee import TrusteePositionView
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
if mode == "filterValues":
@ -1269,8 +1273,7 @@ def _handlePositionMode(instanceId, mandateId, mode, column, pagination, context
result = interface.getAllPositions(None)
items = [r.model_dump() if hasattr(r, 'model_dump') else r for r in (result.items if hasattr(result, 'items') else result)]
_enrichPositionsWithSyncStatus(items, interface, instanceId)
# Use the view model so FK labels for the synthetic columns also resolve.
enrichRowsWithFkLabels(items, TrusteePositionView)
enrichRowsWithFkLabels(items, TrusteePositionView, db=interface.db)
return handleFilterValuesInMemory(items, column, pagination)
if mode == "ids":
result = interface.getAllPositions(None)
@ -1442,7 +1445,6 @@ def get_accounting_config(
record["configured"] = True
if encryptedConfig:
try:
import json
plain = json.loads(decryptValue(encryptedConfig, keyName="accountingConfig"))
record["configMasked"] = _getConfigMasked(record.get("connectorType", ""), plain)
except Exception:
@ -1477,7 +1479,6 @@ async def save_accounting_config(
from .datamodelFeatureTrustee import TrusteeAccountingConfig
from modules.shared.configuration import encryptValue
import uuid as _uuid
plainConfig = body.config if isinstance(body.config, dict) else {}
# When updating, empty config is normal (frontend never receives credentials from GET).
@ -1524,7 +1525,7 @@ async def save_accounting_config(
encryptedConfig = encryptValue(json.dumps(plainConfig), keyName="accountingConfig")
configRecord = {
"id": str(_uuid.uuid4()),
"id": str(uuid.uuid4()),
"featureInstanceId": instanceId,
"connectorType": body.connectorType or "",
"displayLabel": body.displayLabel or "",
@ -2027,8 +2028,6 @@ def export_accounting_data(
TrusteeDataAccountBalance,
TrusteeAccountingConfig,
)
import time as _time
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
_filter = {"featureInstanceId": instanceId}
@ -2057,7 +2056,7 @@ def export_accounting_data(
}
payload = {
"exportedAt": _time.time(),
"exportedAt": time.time(),
"featureInstanceId": instanceId,
"mandateId": mandateId,
"syncInfo": syncInfo,
@ -2404,7 +2403,6 @@ def _buildFeatureInternalResolvers(modelClass, db) -> Dict[str, Any]:
val = row.get(col)
if val is not None and val != "":
if col == "bookingDate" and isinstance(val, (int, float)):
from datetime import datetime, timezone
try:
parts.append(datetime.fromtimestamp(val, tz=timezone.utc).strftime("%Y-%m-%d"))
except Exception:
@ -2440,11 +2438,8 @@ def _paginatedReadEndpoint(
from modules.interfaces.interfaceRbac import (
getRecordsetPaginatedWithRBAC,
)
from modules.routes.routeHelpers import (
handleIdsInMemory,
handleFilterValuesInMemory,
enrichRowsWithFkLabels,
)
from modules.dbHelpers.paginationHelpers import handleIdsInMemory, handleFilterValuesInMemory
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
mandateId = _validateInstanceAccess(instanceId, context)
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
@ -2465,7 +2460,7 @@ def _paginatedReadEndpoint(
rawItems = result.items if hasattr(result, "items") else result
items = [r.model_dump() if hasattr(r, "model_dump") else r for r in rawItems]
featureResolvers = _buildFeatureInternalResolvers(modelClass, interface.db)
enrichRowsWithFkLabels(items, modelClass, extraResolvers=featureResolvers or None)
enrichRowsWithFkLabels(items, modelClass, db=interface.db, extraResolvers=featureResolvers or None)
return handleFilterValuesInMemory(items, column, pagination)
if mode == "ids":
@ -2503,7 +2498,7 @@ def _paginatedReadEndpoint(
if paginationParams and hasattr(result, "items"):
enriched = enrichRowsWithFkLabels(
_itemsToDicts(result.items), modelClass,
extraResolvers=featureResolvers or None,
db=interface.db, extraResolvers=featureResolvers or None,
)
return {
"items": enriched,
@ -2519,7 +2514,7 @@ def _paginatedReadEndpoint(
items = result.items if hasattr(result, "items") else result
enriched = enrichRowsWithFkLabels(
_itemsToDicts(items), modelClass,
extraResolvers=featureResolvers or None,
db=interface.db, extraResolvers=featureResolvers or None,
)
return {"items": enriched, "pagination": None}

View file

@ -9,7 +9,7 @@ import logging
from typing import Dict, Any, Optional
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.dbRegistry import registerDatabase
from modules.dbHelpers.dbRegistry import registerDatabase
from modules.datamodels.datamodelUam import User
from modules.features.workspace.datamodelFeatureWorkspace import WorkspaceUserSettings
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC

View file

@ -311,3 +311,51 @@ def _ensureAccessRulesForRole(rootInterface, roleId: str, ruleTemplates: List[Di
logger.debug(f"Created {createdCount} AccessRules for role {roleId}")
return createdCount
# ---------------------------------------------------------------------------
# Feature Lifecycle Hooks
# ---------------------------------------------------------------------------
def onMandateDelete(mandateId: str, instances: list) -> None:
"""Cascade-delete all workspace data for deleted mandate."""
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG
from modules.features.workspace.datamodelFeatureWorkspace import (
WorkspaceUserSettings,
)
try:
featureInstances = [
inst for inst in instances
if (inst.get("featureCode") if isinstance(inst, dict) else getattr(inst, "featureCode", "")) == FEATURE_CODE
]
if not featureInstances:
return
db = DatabaseConnector(
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
dbDatabase="poweron_workspace",
dbUser=APP_CONFIG.get("DB_USER"),
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
userId=None,
)
totalDeleted = 0
for inst in featureInstances:
instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None)
if not instId:
continue
for ModelClass in [WorkspaceUserSettings]:
records = db.getRecordset(ModelClass, recordFilter={"featureInstanceId": instId}) or []
for rec in records:
db.recordDelete(ModelClass, rec.get("id"))
totalDeleted += len(records)
if totalDeleted:
logger.info(f"Cascade: deleted {totalDeleted} workspace record(s) for mandate {mandateId}")
db.close()
except Exception as e:
logger.warning(f"Failed to cascade-delete workspace data for mandate {mandateId}: {e}")

View file

@ -8,6 +8,7 @@ SSE-based endpoints for the agent-driven AI Workspace.
import logging
import json
import asyncio
import os
import uuid
from typing import Any, Dict, Optional, List
@ -145,25 +146,6 @@ def _getChatInterface(context: RequestContext, featureInstanceId: str = None, ma
)
def _buildResolverDbInterface(chatService):
"""Build a DB adapter that ConnectorResolver can use to load UserConnections.
ConnectorResolver calls db.getUserConnection(connectionId).
interfaceDbApp provides getUserConnectionById(connectionId).
This adapter bridges the method name difference.
"""
class _ResolverDbAdapter:
def __init__(self, appInterface):
self._app = appInterface
def getUserConnection(self, connectionId: str):
if hasattr(self._app, "getUserConnectionById"):
return self._app.getUserConnectionById(connectionId)
return None
appIf = getattr(chatService, "interfaceDbApp", None)
if appIf:
return _ResolverDbAdapter(appIf)
return getattr(chatService, "interfaceDbComponent", None)
def _getDbManagement(context: RequestContext, featureInstanceId: str = None):
return interfaceDbManagement.getInterface(
@ -236,7 +218,7 @@ def buildDataSourceContext(chatService, dataSourceIds: List[str]) -> str:
def buildFeatureDataSourceContext(featureDataSourceIds: List[str]) -> str:
"""Build a description of attached feature data sources for the agent prompt."""
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
from modules.datamodels.datamodelFeatures import FeatureDataSource
from modules.security.rbacCatalog import getCatalogService
from modules.interfaces.interfaceDbApp import getRootInterface
@ -343,7 +325,7 @@ def _buildWorkspaceAttachmentLabel(chatService: Any, dataSourceIds: List[str], f
fdsLabels: List[str] = []
for fdsId in featureDataSourceIds or []:
try:
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
from modules.datamodels.datamodelFeatures import FeatureDataSource
from modules.interfaces.interfaceDbApp import getRootInterface
rootIf = getRootInterface()
records = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"id": fdsId})
@ -1170,10 +1152,10 @@ async def getWorkspaceMessages(
from modules.interfaces.interfaceDbApp import getRootInterface
rootIf = getRootInterface()
if attachedDsIds:
from modules.datamodels.datamodelDataSource import DataSource as _DS
from modules.datamodels.datamodelDataSource import DataSource
for dsId in attachedDsIds:
try:
records = rootIf.db.getRecordset(_DS, recordFilter={"id": dsId})
records = rootIf.db.getRecordset(DataSource, recordFilter={"id": dsId})
if records:
lbl = records[0].get("label") or records[0].get("path") or ""
if lbl:
@ -1181,10 +1163,10 @@ async def getWorkspaceMessages(
except Exception:
pass
if attachedFdsIds:
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource as _FDS
from modules.datamodels.datamodelFeatures import FeatureDataSource
for fdsId in attachedFdsIds:
try:
records = rootIf.db.getRecordset(_FDS, recordFilter={"id": fdsId})
records = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"id": fdsId})
if records:
tbl = records[0].get("tableName") or ""
lbl = records[0].get("label") or tbl
@ -1298,7 +1280,6 @@ async def getFileContent(
filePath = fileData.get("filePath")
if not filePath:
raise HTTPException(status_code=404, detail=routeApiMsg("File has no stored path"))
import os
if not os.path.isfile(filePath):
raise HTTPException(status_code=404, detail=routeApiMsg("File not found on disk"))
mimeType = fileData.get("mimeType", "application/octet-stream")
@ -1438,7 +1419,7 @@ async def createFeatureDataSource(
"""
_validateInstanceAccess(instanceId, context)
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
from modules.datamodels.datamodelFeatures import FeatureDataSource
rootIf = getRootInterface()
if not rootIf.getFeatureAccess(str(context.user.id), body.featureInstanceId):
@ -1482,7 +1463,7 @@ async def listFeatureDataSources(
the mandate."""
wsMandateId, _ = _validateInstanceAccess(instanceId, context)
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
from modules.datamodels.datamodelFeatures import FeatureDataSource
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import buildEffectiveByWorkspaceFds
rootIf = getRootInterface()
@ -1514,7 +1495,7 @@ async def deleteFeatureDataSource(
"""Delete a FeatureDataSource."""
_mandateId, _ = _validateInstanceAccess(instanceId, context)
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
from modules.datamodels.datamodelFeatures import FeatureDataSource
rootIf = getRootInterface()
rootIf.db.recordDelete(FeatureDataSource, featureDataSourceId)

View file

@ -158,7 +158,7 @@ def _backfillTargetFeatureInstanceId() -> None:
"""
def _do() -> None:
from modules.shared.configuration import APP_CONFIG
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow
from modules.datamodels.datamodelWorkflowAutomation import AutoWorkflow
dbHost = APP_CONFIG.get("DB_HOST", "localhost")
dbUser = APP_CONFIG.get("DB_USER")

View file

@ -4,6 +4,7 @@ import logging
import asyncio
import uuid
import base64
import json
from typing import Dict, Any, List, Union, Tuple, Optional, Callable, AsyncGenerator
from dataclasses import dataclass, field
import time
@ -316,8 +317,6 @@ class AiObjects:
tools: List[Dict[str, Any]] = None,
toolChoice: Any = None) -> AiCallResponse:
"""Call a model with pre-built messages (agent mode). Supports tools for native function calling."""
import json as _json
inputBytes = sum(len(str(m.get("content", "")).encode("utf-8")) for m in messages)
startTime = time.time()
@ -536,7 +535,7 @@ class AiObjects:
Returns:
AiCallResponse with metadata["embeddings"] containing the vectors.
"""
from modules.aicore.aicoreBase import ContextLengthExceededException as _CtxExc
from modules.aicore.aicoreBase import ContextLengthExceededException
if options is None:
options = AiCallOptions(operationType=OperationTypeEnum.EMBEDDING)
@ -622,7 +621,7 @@ class AiObjects:
return response
except _CtxExc as e:
except ContextLengthExceededException as e:
logger.error(f"ContextLengthExceeded for {model.name} despite batching aborting failover: {e}")
return AiCallResponse(
content=str(e), modelName=model.name, priceCHF=0.0,

View file

@ -11,6 +11,7 @@ Multi-Tenant Design:
"""
import logging
import uuid
from typing import Optional, Dict
from passlib.context import CryptContext
from modules.connectors.connectorDbPostgre import DatabaseConnector
@ -101,9 +102,6 @@ def initBootstrap(db: DatabaseConnector) -> None:
if mandateId:
_initRootMandateSubscription(mandateId)
# Auto-provision Stripe Products/Prices for paid plans (idempotent)
_bootstrapStripePrices()
# Purge soft-deleted mandates past 30-day retention
try:
from modules.interfaces.interfaceDbApp import getRootInterface
@ -112,12 +110,15 @@ def initBootstrap(db: DatabaseConnector) -> None:
except Exception as e:
logger.warning(f"Mandate retention purge failed: {e}")
# Bootstrap system workflow templates for graphical editor
_bootstrapSystemTemplates(db)
# Sync feature template workflows (update graph of existing instance workflows
# whose templateSourceId matches a current code-defined template)
_syncFeatureTemplateWorkflows()
# Let features run their own bootstrap logic via lifecycle hooks
from modules.system.registry import loadFeatureMainModules
for _fCode, _fMod in loadFeatureMainModules().items():
_bootHook = getattr(_fMod, "onBootstrap", None)
if _bootHook:
try:
_bootHook()
except Exception as _bootErr:
logger.warning(f"onBootstrap hook for '{_fCode}' failed: {_bootErr}")
# Ensure billing settings and accounts exist for all mandates
_bootstrapBilling()
@ -154,219 +155,10 @@ def _bootstrapBilling() -> None:
logger.warning(f"Billing bootstrap failed (non-critical): {e}")
def _bootstrapSystemTemplates(db: DatabaseConnector) -> None:
"""
Seed platform-wide workflow templates (templateScope='system', mandateId=None).
Idempotent: skips if templates with the same label already exist.
"""
try:
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import graphicalEditorDatabase
import uuid
greenfieldDb = DatabaseConnector(
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
dbDatabase=graphicalEditorDatabase,
dbUser=APP_CONFIG.get("DB_USER"),
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
)
greenfieldDb._ensureTableExists(AutoWorkflow)
existing = greenfieldDb.getRecordset(AutoWorkflow, recordFilter={
"isTemplate": True,
"templateScope": "system",
})
existingLabels = {r.get("label") if isinstance(r, dict) else getattr(r, "label", "") for r in (existing or [])}
templates = _buildSystemTemplates()
created = 0
for tpl in templates:
if tpl["label"] in existingLabels:
continue
tpl["id"] = str(uuid.uuid4())
greenfieldDb.recordCreate(AutoWorkflow, tpl)
created += 1
if created:
logger.info(f"Bootstrapped {created} system workflow template(s)")
greenfieldDb.close()
except Exception as e:
logger.warning(f"System workflow template bootstrap failed: {e}")
def _syncFeatureTemplateWorkflows() -> None:
"""Sync existing instance-scoped workflows with current code-defined templates.
For each feature that exposes getTemplateWorkflows(), find all AutoWorkflow
rows whose templateSourceId matches a template ID and update their graph
if the code-defined version has changed. Preserves instance-specific
fields (label, tags, targetFeatureInstanceId, invocations, active).
Idempotent, runs on every boot.
"""
import json
try:
from modules.system.registry import loadFeatureMainModules
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import graphicalEditorDatabase
mainModules = loadFeatureMainModules()
templatesBySourceId: dict = {}
for featureCode, mod in mainModules.items():
getTemplateWorkflows = getattr(mod, "getTemplateWorkflows", None)
if not getTemplateWorkflows:
continue
try:
templates = getTemplateWorkflows() or []
except Exception:
continue
for tpl in templates:
tplId = tpl.get("id")
if tplId:
templatesBySourceId[tplId] = tpl
if not templatesBySourceId:
logger.info("_syncFeatureTemplateWorkflows: no templates found, skipping")
return
logger.info(f"_syncFeatureTemplateWorkflows: found {len(templatesBySourceId)} template(s): {list(templatesBySourceId.keys())}")
greenfieldDb = DatabaseConnector(
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
dbDatabase=graphicalEditorDatabase,
dbUser=APP_CONFIG.get("DB_USER"),
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
)
updated = 0
for sourceId, tpl in templatesBySourceId.items():
instances = greenfieldDb.getRecordset(AutoWorkflow, recordFilter={
"templateSourceId": sourceId,
"isTemplate": False,
})
if not instances:
continue
canonicalGraph = tpl.get("graph", {})
for inst in instances:
instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None)
targetInstanceId = (
inst.get("targetFeatureInstanceId") if isinstance(inst, dict)
else getattr(inst, "targetFeatureInstanceId", None)
) or ""
graphJson = json.dumps(canonicalGraph)
graphJson = graphJson.replace("{{featureInstanceId}}", targetInstanceId)
newGraph = json.loads(graphJson)
existingGraph = inst.get("graph") if isinstance(inst, dict) else getattr(inst, "graph", None)
if isinstance(existingGraph, str):
try:
existingGraph = json.loads(existingGraph)
except Exception:
existingGraph = None
if existingGraph == newGraph:
logger.debug(f"_syncFeatureTemplateWorkflows: graph unchanged for workflow {instId} (template={sourceId})")
continue
logger.debug(f"_syncFeatureTemplateWorkflows: graph DIFFERS for workflow {instId} (template={sourceId}), updating")
greenfieldDb.recordModify(AutoWorkflow, instId, {"graph": newGraph})
updated += 1
logger.info(f"_syncFeatureTemplateWorkflows: updated graph for workflow {instId} (template={sourceId})")
if updated:
logger.info(f"_syncFeatureTemplateWorkflows: synced {updated} workflow(s) with current templates")
else:
logger.info("_syncFeatureTemplateWorkflows: all instance graphs already match current templates")
greenfieldDb.close()
except Exception as e:
logger.warning(f"Feature template workflow sync failed: {e}")
def _buildSystemTemplates():
"""Build the graph definitions for platform system templates."""
return [
{
"label": "Personal Assistant: E-Mail-Antwort-Drafting",
"mandateId": None,
"featureInstanceId": None,
"isTemplate": True,
"templateScope": "system",
"sharedReadOnly": True,
"active": False,
"graph": {
"nodes": [
{"id": "n1", "type": "trigger.schedule", "x": 50, "y": 200, "title": "Täglicher Check", "parameters": {}},
{"id": "n2", "type": "email.checkEmail", "x": 300, "y": 200, "title": "Mailbox prüfen", "parameters": {}},
{
"id": "n3",
"type": "flow.loop",
"x": 550,
"y": 200,
"title": "Pro E-Mail",
"parameters": {
"items": {"type": "ref", "nodeId": "n2", "path": ["emails"]},
"concurrency": 1,
},
},
{"id": "n4", "type": "ai.prompt", "x": 800, "y": 200, "title": "Analyse: Antwort nötig?", "parameters": {}},
{"id": "n5", "type": "flow.ifElse", "x": 1050, "y": 200, "title": "Antwort nötig?", "parameters": {}},
{"id": "n6", "type": "ai.prompt", "x": 1300, "y": 100, "title": "Kontext abrufen & Antwort formulieren", "parameters": {}},
{"id": "n7", "type": "email.draftEmail", "x": 1550, "y": 100, "title": "Draft erstellen", "parameters": {}},
],
"connections": [
{"source": "n1", "target": "n2", "sourceOutput": 0, "targetInput": 0},
{"source": "n2", "target": "n3", "sourceOutput": 0, "targetInput": 0},
{"source": "n3", "target": "n4", "sourceOutput": 0, "targetInput": 0},
{"source": "n4", "target": "n5", "sourceOutput": 0, "targetInput": 0},
{"source": "n5", "target": "n6", "sourceOutput": 0, "targetInput": 0},
{"source": "n6", "target": "n7", "sourceOutput": 0, "targetInput": 0},
],
},
"invocations": [{"type": "schedule", "cronExpression": "0 8 * * 1-5"}],
},
{
"label": "Treuhand: PDF-Klassifizierung & Trustee-Import",
"mandateId": None,
"featureInstanceId": None,
"isTemplate": True,
"templateScope": "system",
"sharedReadOnly": True,
"active": False,
"graph": {
"nodes": [
{"id": "n1", "type": "trigger.schedule", "x": 50, "y": 200, "title": "Geplanter Import", "parameters": {}},
{"id": "n2", "type": "sharepoint.listFiles", "x": 300, "y": 200, "title": "SharePoint Ordner lesen", "parameters": {}},
{
"id": "n3",
"type": "flow.loop",
"x": 550,
"y": 200,
"title": "Pro Dokument",
"parameters": {
"items": {"type": "ref", "nodeId": "n2", "path": ["files"]},
"concurrency": 1,
},
},
{"id": "n4", "type": "sharepoint.readFile", "x": 800, "y": 200, "title": "PDF-Inhalt lesen", "parameters": {}},
{"id": "n5", "type": "ai.prompt", "x": 1050, "y": 200, "title": "Typ klassifizieren (Rechnung, Beleg, Bankauszug, Vertrag, etc.)", "parameters": {}},
{"id": "n6", "type": "trustee.extractFromFiles", "x": 1300, "y": 200, "title": "Dokument extrahieren", "parameters": {}},
{"id": "n7", "type": "trustee.processDocuments", "x": 1550, "y": 200, "title": "In Trustee einlesen", "parameters": {}},
],
"connections": [
{"source": "n1", "target": "n2", "sourceOutput": 0, "targetInput": 0},
{"source": "n2", "target": "n3", "sourceOutput": 0, "targetInput": 0},
{"source": "n3", "target": "n4", "sourceOutput": 0, "targetInput": 0},
{"source": "n4", "target": "n5", "sourceOutput": 0, "targetInput": 0},
{"source": "n5", "target": "n6", "sourceOutput": 0, "targetInput": 0},
{"source": "n6", "target": "n7", "sourceOutput": 0, "targetInput": 0},
],
},
"invocations": [{"type": "schedule", "cronExpression": "0 7 * * 1-5"}],
},
]
def initRootMandateFeatures(db: DatabaseConnector, mandateId: str) -> None:
@ -749,8 +541,6 @@ def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int:
Returns:
Number of roles copied
"""
import uuid as _uuid
# Find system template roles (global: mandateId=NULL, isSystemRole=True)
templateRoles = db.getRecordset(
Role,
@ -785,7 +575,7 @@ def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int:
logger.debug(f"Mandate {mandateId} already has role '{roleLabel}', skipping")
continue
newRoleId = str(_uuid.uuid4())
newRoleId = str(uuid.uuid4())
# Create mandate-instance role
newRole = Role(
@ -803,7 +593,7 @@ def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int:
templateRules = rulesByRoleId.get(templateRole.get("id"), [])
for rule in templateRules:
newRule = AccessRule(
id=str(_uuid.uuid4()),
id=str(uuid.uuid4()),
roleId=newRoleId,
context=rule.get("context"),
item=rule.get("item"),
@ -1929,16 +1719,6 @@ def _initRootMandateSubscription(mandateId: str) -> None:
logger.warning(f"Failed to initialize root mandate subscription (non-critical): {e}")
def _bootstrapStripePrices() -> None:
"""Auto-create Stripe Products and Prices for all paid plans.
Idempotent safe on every startup. IDs are persisted in the StripePlanPrice table."""
try:
from modules.serviceCenter.services.serviceSubscription.stripeBootstrap import bootstrapStripePrices
bootstrapStripePrices()
except Exception as e:
logger.error(f"Stripe price bootstrap failed (subscriptions will not work for paid plans): {e}")
def assignInitialUserMemberships(
db: DatabaseConnector,
mandateId: str,
@ -2034,7 +1814,7 @@ def _applyDatabaseOptimizations(db: DatabaseConnector) -> None:
db: Database connector instance
"""
try:
from modules.shared.dbMultiTenantOptimizations import applyMultiTenantOptimizations
from modules.dbHelpers.dbMultiTenantOptimizations import applyMultiTenantOptimizations
result = applyMultiTenantOptimizations(db)

View file

@ -11,13 +11,15 @@ Multi-Tenant Design:
import logging
import math
import time
from datetime import datetime, timezone, timedelta
from typing import Dict, Any, List, Optional, Union
from passlib.context import CryptContext
import uuid
from modules.connectors.connectorDbPostgre import DatabaseConnector, getCachedConnector
from modules.shared.configuration import APP_CONFIG
from modules.shared.dbRegistry import registerDatabase
from modules.dbHelpers.dbRegistry import registerDatabase
from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp
from modules.shared.i18nRegistry import resolveText
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
@ -498,6 +500,9 @@ class AppObjects:
recordFilter={"id": userIds}
)
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
enrichRowsWithFkLabels(result.get("items", []), UserInDB, db=self.db)
items = []
for record in result["items"]:
cleanedUser = dict(record)
@ -1611,7 +1616,6 @@ class AppObjects:
from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot
from modules.interfaces.interfaceDbBilling import getRootInterface as _getBillingRoot
from datetime import datetime, timezone, timedelta
now = datetime.now(timezone.utc)
nowTs = now.timestamp()
@ -1710,7 +1714,6 @@ class AppObjects:
SubscriptionStatusEnum, BUILTIN_PLANS,
)
from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot
from datetime import datetime, timezone, timedelta
activated = 0
subInterface = _getSubRoot()
@ -1861,14 +1864,21 @@ class AppObjects:
from modules.datamodels.datamodelFiles import FileItem
from modules.datamodels.datamodelDataSource import DataSource
from modules.datamodels.datamodelKnowledge import FileContentIndex, ContentChunk
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
from modules.datamodels.datamodelFeatures import FeatureDataSource
from modules.datamodels.datamodelBilling import BillingSettings, BillingAccount, BillingTransaction
from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutralizerAttributes
from modules.datamodels.datamodelFeatures import DataNeutralizerAttributes
instances = self.db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
# 0-pre. Delete AutoWorkflow data in Greenfield DB (poweron_graphicaleditor)
self._cascadeDeleteGraphicalEditorData(mandateId, instances)
# 0-pre. Let features cascade-delete their own data via lifecycle hooks
from modules.system.registry import loadFeatureMainModules
for _fCode, _fMod in loadFeatureMainModules().items():
_hook = getattr(_fMod, "onMandateDelete", None)
if _hook:
try:
_hook(mandateId, instances)
except Exception as _hookErr:
logger.warning(f"onMandateDelete hook for '{_fCode}' failed: {_hookErr}")
# 0. Delete instance-scoped data for each FeatureInstance
for inst in instances:
@ -2011,67 +2021,6 @@ class AppObjects:
logger.error(f"Error deleting mandate: {str(e)}")
raise ValueError(f"Failed to delete mandate: {str(e)}")
def _cascadeDeleteGraphicalEditorData(self, mandateId: str, instances) -> None:
"""Delete AutoWorkflow + related data in the Greenfield DB for all graphicalEditor instances."""
try:
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import (
AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask,
)
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import graphicalEditorDatabase
from modules.connectors.connectorDbPostgre import DatabaseConnector
geDb = DatabaseConnector(
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
dbDatabase=graphicalEditorDatabase,
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,
)
if not geDb._ensureTableExists(AutoWorkflow):
return
geInstances = [
inst for inst in instances
if (inst.get("featureCode") if isinstance(inst, dict) else getattr(inst, "featureCode", "")) == "graphicalEditor"
]
totalDeleted = 0
for inst in geInstances:
instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None)
if not instId:
continue
workflows = geDb.getRecordset(AutoWorkflow, recordFilter={
"mandateId": mandateId,
"featureInstanceId": instId,
}) or []
for wf in workflows:
wfId = wf.get("id")
if not wfId:
continue
for v in geDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []:
geDb.recordDelete(AutoVersion, v.get("id"))
for run in geDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or []:
runId = run.get("id")
for sl in geDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []:
geDb.recordDelete(AutoStepLog, sl.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)
totalDeleted += 1
if totalDeleted:
logger.info(f"Cascade: deleted {totalDeleted} AutoWorkflow(s) in Greenfield DB for mandate {mandateId}")
except Exception as e:
logger.warning(f"Failed to cascade-delete graphical editor data for mandate {mandateId}: {e}")
def restoreMandate(self, mandateId: str) -> bool:
"""Restore a soft-deleted mandate (undo soft-delete within the 30-day retention window)."""
@ -2084,7 +2033,6 @@ class AppObjects:
def purgeExpiredMandates(self, retentionDays: int = 30) -> int:
"""Hard-delete all mandates whose soft-delete timestamp exceeds the retention period."""
import time
cutoff = time.time() - (retentionDays * 86400)
allMandates = self.db.getRecordset(Mandate)
purged = 0
@ -2914,6 +2862,9 @@ class AppObjects:
try:
result = self.db.getRecordsetPaginated(UserInDB, pagination=pagination)
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
enrichRowsWithFkLabels(result.get("items", []), UserInDB, db=self.db)
items = []
for record in result["items"]:
user = User.model_validate(record)
@ -3919,6 +3870,9 @@ class AppObjects:
try:
result = self.db.getRecordsetPaginated(Role, pagination=pagination)
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
enrichRowsWithFkLabels(result.get("items", []), Role, db=self.db)
items = []
for record in result["items"]:
cleanedRole = dict(record)

View file

@ -8,13 +8,15 @@ All billing data is stored in the poweron_billing database.
"""
import logging
import copy
import math
from typing import Dict, Any, List, Optional, Union
from datetime import date, datetime, timedelta, timezone
import uuid
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG
from modules.shared.dbRegistry import registerDatabase
from modules.dbHelpers.dbRegistry import registerDatabase
from modules.shared.timeUtils import getUtcTimestamp
from modules.datamodels.datamodelUam import User, Mandate
from modules.datamodels.datamodelMembership import UserMandate
@ -632,6 +634,8 @@ class BillingObjects:
pagination=pagination,
recordFilter=recordFilter
)
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
enrichRowsWithFkLabels(result.get("items", []), BillingTransaction, db=self.db)
_logBillingTransactionsMissingSysCreatedAt(
result["items"],
"getTransactions(accountId) paginated",
@ -702,6 +706,8 @@ class BillingObjects:
pagination=pagination,
recordFilter={"accountId": accountIds}
)
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
enrichRowsWithFkLabels(result.get("items", []), BillingTransaction, db=self.db)
return PaginatedResult(
items=result["items"],
totalItems=result["totalItems"],
@ -1499,7 +1505,6 @@ class BillingObjects:
"""Remap frontend column names to DB column names in filters and sort."""
_COL_MAP: dict = {}
_ENRICHED_COLS = {"mandateName", "userName", "mandateId", "userId"}
import copy
p = copy.deepcopy(pagination)
if p.filters:
mapped = {}
@ -1578,6 +1583,12 @@ class BillingObjects:
pagination=mappedPagination,
recordFilter=recordFilter,
)
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
enrichRowsWithFkLabels(
result.get("items", []) if isinstance(result, dict) else result.items,
BillingTransaction,
db=self.db,
)
pageItems = result.get("items", []) if isinstance(result, dict) else result.items
totalItems = result.get("totalItems", 0) if isinstance(result, dict) else result.totalItems
totalPages = result.get("totalPages", 0) if isinstance(result, dict) else result.totalPages
@ -1643,7 +1654,6 @@ class BillingObjects:
`amount` column. Resolves matching mandate/user IDs via the app DB
first, then builds a single SQL query with OR-combined conditions.
"""
import math
from modules.connectors.connectorDbPostgre import getModelFields, parseRecordFields
from modules.datamodels.datamodelUam import UserInDB
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
@ -1701,7 +1711,6 @@ class BillingObjects:
# Apply non-search filters from pagination (reuse existing builder for
# everything except the `search` key which we handle explicitly).
import copy
paginationWithoutSearch = copy.deepcopy(pagination) if pagination else None
if paginationWithoutSearch and paginationWithoutSearch.filters:
paginationWithoutSearch.filters = {

View file

@ -6,8 +6,10 @@ Uses the JSON connector for data access with added language support.
"""
import logging
import os
import uuid
import math
from datetime import datetime, UTC
from typing import Dict, Any, List, Optional, Union
import asyncio
@ -29,7 +31,7 @@ from modules.datamodels.datamodelUam import User
# DYNAMIC PART: Connectors to the Interface
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.dbRegistry import registerDatabase
from modules.dbHelpers.dbRegistry import registerDatabase
from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
@ -60,8 +62,6 @@ def storeDebugMessageAndDocuments(message, currentUser, mandateId=None, featureI
featureInstanceId: Feature instance ID for RBAC context
"""
try:
import os
from datetime import datetime, UTC
from modules.shared.debugLogger import getBaseDebugDir, ensureDir
from modules.interfaces.interfaceDbManagement import getInterface

View file

@ -12,7 +12,7 @@ from datetime import datetime, timezone, timedelta
from typing import Dict, Any, List, Optional
from modules.connectors.connectorDbPostgre import getCachedConnector
from modules.shared.dbRegistry import registerDatabase
from modules.dbHelpers.dbRegistry import registerDatabase
from modules.datamodels.datamodelKnowledge import FileContentIndex, ContentChunk, RoundMemory, WorkflowMemory
from modules.datamodels.datamodelUam import User
from modules.shared.configuration import APP_CONFIG
@ -125,9 +125,9 @@ class KnowledgeObjects:
for mid in mandateIds:
try:
from modules.interfaces.interfaceDbBilling import _getRootInterface
from modules.interfaces.interfaceDbBilling import getRootInterface as getBillingRoot
_getRootInterface().reconcileMandateStorageBilling(mid)
getBillingRoot().reconcileMandateStorageBilling(mid)
except Exception as ex:
logger.warning("reconcileMandateStorageBilling after connection purge failed: %s", ex)
@ -168,8 +168,8 @@ class KnowledgeObjects:
for mid in mandateIds:
try:
from modules.interfaces.interfaceDbBilling import _getRootInterface
_getRootInterface().reconcileMandateStorageBilling(mid)
from modules.interfaces.interfaceDbBilling import getRootInterface as getBillingRoot
getBillingRoot().reconcileMandateStorageBilling(mid)
except Exception as ex:
logger.warning("reconcileMandateStorageBilling after datasource purge failed: %s", ex)

View file

@ -11,10 +11,11 @@ import base64
import hashlib
import math
import mimetypes
import re
from typing import Dict, Any, List, Optional, Union
from modules.connectors.connectorDbPostgre import DatabaseConnector, getCachedConnector
from modules.shared.dbRegistry import registerDatabase
from modules.dbHelpers.dbRegistry import registerDatabase
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC, getRecordsetPaginatedWithRBAC
from modules.security.rbac import RbacClass
from modules.datamodels.datamodelRbac import AccessRuleContext
@ -194,7 +195,6 @@ class ComponentObjects:
try:
# Initialize standard prompts
self._initializeStandardPrompts()
self._seedUiLanguageSetsIfEmpty()
# Add other record initializations here
@ -204,47 +204,6 @@ class ComponentObjects:
# Don't raise the error, just log it
# This allows the interface to be created even if initialization fails
def _seedUiLanguageSetsIfEmpty(self) -> None:
try:
import json
from pathlib import Path
from modules.datamodels.datamodelUiLanguage import UiLanguageSet
existing = self.db.getRecordset(UiLanguageSet)
if existing:
return
seedPath = (
Path(__file__).resolve().parent.parent
/ "migration"
/ "seedData"
/ "ui_language_seed.json"
)
if not seedPath.is_file():
logger.warning("ui_language_seed.json not found, skipping UI i18n seed")
return
payload = json.loads(seedPath.read_text(encoding="utf-8"))
now = getUtcTimestamp()
for row in payload:
entries = row.get("entries")
if not isinstance(entries, list):
keys = row.get("keys") or {}
entries = [{"context": "ui", "key": k, "value": v} for k, v in keys.items()]
rec = {
"id": row["id"],
"label": row["label"],
"entries": entries,
"status": row.get("status") or "complete",
"isDefault": bool(row.get("isDefault", False)),
"sysCreatedAt": now,
"sysModifiedBy": None,
"sysCreatedBy": None,
"sysModifiedAt": now,
}
self.db.recordCreate(UiLanguageSet, rec)
logger.info("Seeded UiLanguageSet rows from ui_language_seed.json")
except Exception as e:
logger.error(f"UI i18n seed failed: {e}")
def _initializeStandardPrompts(self):
"""Initializes standard prompts if they don't exist yet."""
@ -606,7 +565,6 @@ class ComponentObjects:
size_str = size_str.replace(",", "").replace(" ", "")
# Extract number and unit - handle both "MB" and "M" formats
import re
# Match: number (with optional decimal) followed by optional unit (K/M/G/T with optional B)
match = re.match(r"^([\d.]+)([KMGT]?B?)$", size_str)
if not match:
@ -900,36 +858,21 @@ class ComponentObjects:
_extensionToMime: Optional[Dict[str, str]] = None
_textMimeTypes: Optional[set] = None
@classmethod
def setMimeMap(cls, extensionToMime: dict, textMimeTypes: set):
"""Set MIME maps from external bootstrap (avoids upward import to serviceCenter)."""
cls._extensionToMime = extensionToMime
cls._textMimeTypes = textMimeTypes
@classmethod
def _ensureMimeMaps(cls):
"""Lazily build extension→MIME and text-MIME-set from the ExtractorRegistry."""
"""Use MIME maps previously injected via setMimeMap (called from app.py at startup).
Falls back to empty maps if bootstrap has not run yet."""
if cls._extensionToMime is not None:
return
try:
from modules.serviceCenter.services.serviceExtraction.subRegistry import ExtractorRegistry
registry = ExtractorRegistry()
cls._extensionToMime = registry.getExtensionToMimeMap()
# Collect all MIME types declared by the TextExtractor (and other text-ish extractors)
textMimes: set = set()
seen: set = set()
for ext in registry._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)
# Always include common text types
textMimes.update({
"application/json", "application/xml", "application/javascript",
"application/sql", "application/x-yaml", "application/x-toml",
})
cls._textMimeTypes = textMimes
except Exception:
cls._extensionToMime = {}
cls._textMimeTypes = set()
# Fallback: maps not yet injected from bootstrap
cls._extensionToMime = {}
cls._textMimeTypes = set()
def getMimeType(self, fileName: str) -> str:
"""Determines the MIME type based on the file extension.
@ -2314,4 +2257,24 @@ def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] =
else:
logger.info("Returning interface without user context")
return interface
return interface
def buildResolverDbInterface(chatService):
"""Build a DB adapter that ConnectorResolver can use to load UserConnections.
ConnectorResolver calls db.getUserConnection(connectionId).
interfaceDbApp provides getUserConnectionById(connectionId).
This adapter bridges the method name difference.
"""
class _ResolverDbAdapter:
def __init__(self, appInterface):
self._app = appInterface
def getUserConnection(self, connectionId: str):
if hasattr(self._app, "getUserConnectionById"):
return self._app.getUserConnectionById(connectionId)
return None
appIf = getattr(chatService, "interfaceDbApp", None)
if appIf:
return _ResolverDbAdapter(appIf)
return getattr(chatService, "interfaceDbComponent", None)

View file

@ -13,7 +13,7 @@ from datetime import datetime, timezone
from modules.connectors.connectorDbPostgre import DatabaseConnector
from modules.shared.configuration import APP_CONFIG
from modules.shared.dbRegistry import registerDatabase
from modules.dbHelpers.dbRegistry import registerDatabase
from modules.datamodels.datamodelUam import User
from modules.datamodels.datamodelMembership import UserMandate
from modules.datamodels.datamodelSubscription import (
@ -30,6 +30,8 @@ from modules.datamodels.datamodelSubscription import (
getEffectiveLimits,
)
from modules.shared.serviceExceptions import SubscriptionCapacityException
logger = logging.getLogger(__name__)
SUBSCRIPTION_DATABASE = "poweron_billing"
@ -270,7 +272,6 @@ class SubscriptionObjects:
def assertCapacity(self, mandateId: str, resourceType: str, delta: int = 1) -> bool:
sub = self.getOperativeForMandate(mandateId)
if not sub:
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
raise SubscriptionCapacityException(
resourceType=resourceType, currentCount=0, maxAllowed=0,
message="No active subscription for this mandate.",
@ -286,7 +287,6 @@ class SubscriptionObjects:
return True
current = self.countActiveUsers(mandateId)
if current + delta > cap:
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
raise SubscriptionCapacityException(
resourceType=resourceType, currentCount=current, maxAllowed=cap,
isEnterprise=isEnterprise,
@ -297,7 +297,6 @@ class SubscriptionObjects:
return True
current = self.countActiveFeatureInstances(mandateId)
if current + delta > cap:
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
raise SubscriptionCapacityException(
resourceType=resourceType, currentCount=current, maxAllowed=cap,
isEnterprise=isEnterprise,
@ -308,7 +307,6 @@ class SubscriptionObjects:
return True
currentMB = self.getMandateDataVolumeMB(mandateId)
if currentMB + delta > cap:
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
raise SubscriptionCapacityException(
resourceType=resourceType, currentCount=int(currentMB), maxAllowed=cap,
isEnterprise=isEnterprise,

View file

@ -287,8 +287,6 @@ class FeatureInterface:
RuntimeError: If templates exist but cannot be copied.
Caller decides whether to swallow or re-raise.
"""
import json
from modules.system.registry import loadFeatureMainModules
mainModules = loadFeatureMainModules()
featureModule = mainModules.get(featureCode)
@ -323,49 +321,26 @@ class FeatureInterface:
f"for feature '{featureCode}' to instance {instanceId} (mandate={mandateId})"
)
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
from modules.security.rootAccess import getRootUser
rootUser = getRootUser()
geInterface = getGraphicalEditorInterface(rootUser, mandateId, instanceId)
geMod = mainModules.get("graphicalEditor")
onInstanceCreateHook = getattr(geMod, "onInstanceCreate", None) if geMod else None
if not onInstanceCreateHook:
logger.warning("_copyTemplateWorkflows: graphicalEditor.onInstanceCreate hook not available")
return 0
copied = 0
failed = 0
for template in templateWorkflows:
templateId = template.get("id", "<no-id>")
try:
graphJson = json.dumps(template.get("graph", {}))
graphJson = graphJson.replace("{{featureInstanceId}}", instanceId)
graph = json.loads(graphJson)
label = resolveText(template.get("label"))
geInterface.createWorkflow({
"label": label,
"graph": graph,
"tags": template.get("tags", [f"feature:{featureCode}"]),
"isTemplate": False,
"templateSourceId": templateId,
"templateScope": "instance",
"active": True,
"targetFeatureInstanceId": instanceId,
})
copied += 1
except Exception as e:
failed += 1
logger.error(
f"_copyTemplateWorkflows: failed to create workflow '{templateId}' for "
f"feature '{featureCode}' instance {instanceId}: {e}",
exc_info=True,
)
try:
copied = onInstanceCreateHook(mandateId, instanceId, featureCode, templateWorkflows)
except Exception as e:
logger.error(
f"_copyTemplateWorkflows: onInstanceCreate hook failed for '{featureCode}': {e}",
exc_info=True,
)
raise RuntimeError(
f"_copyTemplateWorkflows: onInstanceCreate failed for feature '{featureCode}': {e}"
)
if copied:
logger.info(
f"_copyTemplateWorkflows: copied {copied}/{len(templateWorkflows)} workflow(s) "
f"for feature '{featureCode}' instance {instanceId} (failed={failed})"
)
if failed:
raise RuntimeError(
f"_copyTemplateWorkflows: {failed}/{len(templateWorkflows)} workflow(s) failed "
f"for feature '{featureCode}' instance {instanceId}"
)
return copied

View file

@ -26,6 +26,8 @@ import logging
import json
import math
import re
import copy
from datetime import datetime, timezone
from typing import List, Dict, Any, Optional, Type, Union
from pydantic import BaseModel
from modules.datamodels.datamodelRbac import AccessRuleContext
@ -107,21 +109,20 @@ def _rbacAppendPaginationDictFilter(
toVal and _ISO_DATE_RE.match(str(toVal))
)
if isNumericCol and isDateVal:
from datetime import datetime as _dt, timezone as _tz
if fromVal and toVal:
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
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()
whereConditions.append(f'"{key}" >= %s AND "{key}" <= %s')
whereValues.extend([fromTs, toTs])
elif fromVal:
fromTs = _dt.strptime(str(fromVal), "%Y-%m-%d").replace(tzinfo=_tz.utc).timestamp()
fromTs = datetime.strptime(str(fromVal), "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp()
whereConditions.append(f'"{key}" >= %s')
whereValues.append(fromTs)
else:
toTs = _dt.strptime(str(toVal), "%Y-%m-%d").replace(
hour=23, minute=59, second=59, tzinfo=_tz.utc
toTs = datetime.strptime(str(toVal), "%Y-%m-%d").replace(
hour=23, minute=59, second=59, tzinfo=timezone.utc
).timestamp()
whereConditions.append(f'"{key}" <= %s')
whereValues.append(toTs)
@ -585,8 +586,8 @@ def getRecordsetPaginatedWithRBAC(
if enrichPermissions:
records = _enrichRecordsWithPermissions(records, permissions, currentUser)
from modules.routes.routeHelpers import enrichRowsWithFkLabels
enrichRowsWithFkLabels(records, modelClass)
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
enrichRowsWithFkLabels(records, modelClass, db=connector)
if pagination:
pageSize = pagination.pageSize
@ -614,7 +615,6 @@ def getDistinctColumnValuesWithRBAC(
Get sorted distinct values for a column with RBAC filtering at SQL level.
Cross-filtering: removes the requested column from active filters.
"""
import copy
table = modelClass.__name__
objectKey = buildDataObjectKey(table, featureCode)

View file

@ -0,0 +1,330 @@
# Copyright (c) 2025 Patrick Motsch
# All rights reserved.
"""
Table/list presentation helpers: view resolution, grouping, Strategy B.
These helpers orchestrate how paginated table data is grouped, filtered
and sorted according to saved TableListView configurations.
"""
import logging
from collections import defaultdict
from functools import cmp_to_key
from typing import Any, Dict, List, Optional
from modules.datamodels.datamodelPagination import PaginationParams
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# View resolution
# ---------------------------------------------------------------------------
def resolveView(interface, contextKey: str, viewKey: Optional[str]):
"""
Load a TableListView for the current user and contextKey.
Returns (config_dict, display_name):
- (None, None) when viewKey is None / empty
- (config, str | None) otherwise config may be {}; display_name from the row
Raises HTTPException(404) when viewKey is explicitly set but the view
does not exist (prevents silent fallback to ungrouped behaviour).
"""
from fastapi import HTTPException
if not viewKey:
return None, None
try:
view = interface.getTableListView(contextKey=contextKey, viewKey=viewKey)
except Exception as e:
logger.warning(f"resolveView: store lookup failed for key={viewKey!r} context={contextKey!r}: {e}")
view = None
if view is None:
raise HTTPException(status_code=404, detail=f"View '{viewKey}' not found for context '{contextKey}'")
cfg = view.config or {}
dname = getattr(view, "displayName", None) or None
return cfg, dname
def effective_group_by_levels(
pagination_params: Optional["PaginationParams"],
view_config: Optional[dict],
) -> List[Dict[str, Any]]:
"""
Choose grouping levels for this request.
If the client sends ``groupByLevels`` (including ``[]``), it wins over the
saved view. If the key is omitted (``None``), use the view's levels.
"""
if pagination_params is not None:
req = getattr(pagination_params, "groupByLevels", None)
if req is not None:
out: List[Dict[str, Any]] = []
for lvl in req:
if hasattr(lvl, "model_dump"):
out.append(lvl.model_dump())
elif isinstance(lvl, dict):
out.append(dict(lvl))
else:
out.append(dict(lvl)) # type: ignore[arg-type]
return out
vc = (view_config or {}).get("groupByLevels") if view_config else None
return list(vc or [])
def applyViewToParams(params: Optional["PaginationParams"], viewConfig: Optional[dict]) -> Optional["PaginationParams"]:
"""
Merge a view's saved configuration into PaginationParams.
Priority: explicit request fields win over view defaults.
- sort: use request sort if non-empty, otherwise view sort
- filters: deep-merge (request filters win per-key)
- pageSize: use request value (already set by normalize_pagination_dict)
Returns the (mutated) params, or a new minimal PaginationParams when
params is None (so callers always get a valid object).
"""
from modules.datamodels.datamodelPagination import SortField
if not viewConfig:
return params
if params is None:
params = PaginationParams(page=1, pageSize=25)
if not params.sort and viewConfig.get("sort"):
try:
params.sort = [
SortField(**s) if isinstance(s, dict) else s
for s in viewConfig["sort"]
]
except Exception as e:
logger.warning(f"applyViewToParams: could not parse view sort: {e}")
viewFilters = viewConfig.get("filters") or {}
if viewFilters:
merged = dict(viewFilters)
if params.filters:
merged.update(params.filters)
params.filters = merged
return params
def apply_strategy_b_filters_and_sort(
items: List[Dict[str, Any]],
pagination_params: Optional[PaginationParams],
current_user: Any,
) -> List[Dict[str, Any]]:
"""
Shared in-memory filter + sort pass for Strategy B (files/prompts/connections lists).
"""
if not pagination_params:
return list(items)
from modules.interfaces.interfaceDbManagement import ComponentObjects
comp = ComponentObjects()
comp.setUserContext(current_user)
out = list(items)
if pagination_params.filters:
out = comp._applyFilters(out, pagination_params.filters)
if pagination_params.sort:
out = comp._applySorting(out, pagination_params.sort)
return out
def build_group_summary_groups(
items: List[Dict[str, Any]],
field: str,
null_label: str = "\u2014",
groupByLevels: List[Dict[str, Any]] | None = None,
) -> List[Dict[str, Any]]:
"""
Build {"value", "label", "totalCount"} summaries for mode=groupSummary.
When *groupByLevels* contains more than one level the function produces one
entry per unique combination of all level values (flat permutations).
``value`` becomes a ``///``-joined composite key and ``label`` the ``/``-joined
human-readable label so the frontend can split them back.
"""
fields: list[dict] = []
if groupByLevels and len(groupByLevels) > 1:
for lvl in groupByLevels:
f = lvl.get("field", "")
nl = str(lvl.get("nullLabel") or null_label)
if f:
fields.append({"field": f, "nullLabel": nl})
if not fields:
fields = [{"field": field, "nullLabel": null_label}]
nullKey = "\x00NULL"
if len(fields) == 1:
f = fields[0]["field"]
nl = fields[0]["nullLabel"]
counts: Dict[str, int] = defaultdict(int)
displayByKey: Dict[str, str] = {}
labelAttr = f"{f}Label"
for item in items:
raw = item.get(f)
if raw is None or raw == "":
nk = nullKey
display = nl
else:
nk = str(raw)
display = None
lbl = item.get(labelAttr)
if lbl is not None and lbl != "":
display = str(lbl)
if display is None:
display = nk
counts[nk] += 1
if nk not in displayByKey:
displayByKey[nk] = display
orderedKeys = sorted(
counts.keys(),
key=lambda x: (x == nullKey, str(displayByKey.get(x, x)).lower()),
)
return [
{
"value": None if nk == nullKey else nk,
"label": displayByKey.get(nk, nk),
"totalCount": counts[nk],
}
for nk in orderedKeys
]
counts = defaultdict(int)
displayByComposite: Dict[str, list] = {}
filtersByComposite: Dict[str, dict] = {}
for item in items:
parts: list[str] = []
labels: list[str] = []
filterMap: dict = {}
for fd in fields:
f = fd["field"]
nl = fd["nullLabel"]
labelAttr = f"{f}Label"
raw = item.get(f)
if raw is None or raw == "":
parts.append(nullKey)
labels.append(nl)
filterMap[f] = None
else:
parts.append(str(raw))
lbl = item.get(labelAttr)
labels.append(str(lbl) if lbl not in (None, "") else str(raw))
filterMap[f] = str(raw)
compositeKey = "///".join(parts)
counts[compositeKey] += 1
if compositeKey not in displayByComposite:
displayByComposite[compositeKey] = labels
filtersByComposite[compositeKey] = filterMap
orderedKeys = sorted(
counts.keys(),
key=lambda x: tuple(
(seg == nullKey, seg.lower()) for seg in x.split("///")
),
)
return [
{
"value": ck.replace(nullKey, "__null__") if nullKey in ck else ck,
"label": " / ".join(displayByComposite[ck]),
"totalCount": counts[ck],
"filters": filtersByComposite[ck],
}
for ck in orderedKeys
]
def buildGroupLayout(
all_items: List[Dict[str, Any]],
groupByLevels: List[Dict[str, Any]],
page: int,
pageSize: int,
) -> tuple:
"""
Apply multi-level grouping to all_items, slice to the requested page,
and return (page_items, GroupLayout | None).
Strategy B: grouping operates on the full filtered+sorted candidate list.
Items are stably re-sorted by the group path so that members of the same
group are always contiguous (preserving the existing per-group sort order
from the caller).
Parameters
----------
all_items: fully filtered and user-sorted list of row dicts.
groupByLevels: list of {"field": str, "nullLabel": str, "direction": "asc"|"desc"} dicts.
page, pageSize: 1-based page index and page size.
Returns
-------
(page_items, GroupLayout | None)
"""
from modules.datamodels.datamodelPagination import GroupBand, GroupLayout
if not groupByLevels:
offset = (page - 1) * pageSize
return all_items[offset:offset + pageSize], None
levels = [lvl.get("field", "") for lvl in groupByLevels if lvl.get("field")]
if not levels:
offset = (page - 1) * pageSize
return all_items[offset:offset + pageSize], None
nullLabels = {lvl.get("field", ""): lvl.get("nullLabel", "\u2014") for lvl in groupByLevels}
def _path_key(item: dict) -> tuple:
return tuple(
str(item.get(f) or "") if item.get(f) is not None else nullLabels.get(f, "\u2014")
for f in levels
)
def _item_cmp(a: dict, b: dict) -> int:
pa, pb = _path_key(a), _path_key(b)
for i in range(len(levels)):
if pa[i] != pb[i]:
asc = (groupByLevels[i].get("direction") or "asc").lower() != "desc"
if pa[i] < pb[i]:
return -1 if asc else 1
return 1 if asc else -1
return 0
all_items.sort(key=cmp_to_key(_item_cmp))
bands_global: List[dict] = []
current_path: Optional[tuple] = None
current_start = 0
for i, item in enumerate(all_items):
path = _path_key(item)
if path != current_path:
if current_path is not None:
bands_global.append({"path": list(current_path), "startIdx": current_start, "endIdx": i})
current_path = path
current_start = i
if current_path is not None:
bands_global.append({"path": list(current_path), "startIdx": current_start, "endIdx": len(all_items)})
page_start = (page - 1) * pageSize
page_end = page_start + pageSize
page_items = all_items[page_start:page_end]
bands_on_page: List[GroupBand] = []
for band in bands_global:
inter_start = max(band["startIdx"], page_start)
inter_end = min(band["endIdx"], page_end)
if inter_start >= inter_end:
continue
path_list = band["path"]
bands_on_page.append(GroupBand(
path=path_list,
label=path_list[-1] if path_list else "\u2014",
startRowIndex=inter_start - page_start,
rowCount=inter_end - inter_start,
))
group_layout = GroupLayout(levels=levels, bands=bands_on_page) if bands_on_page else GroupLayout(levels=levels, bands=[])
return page_items, group_layout

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,11 +0,0 @@
# Archived one-off migrations
`migrate_folders_to_groups.py` copies `FileFolder` + `FileItem.folderId` into `TableGrouping` (`files/list`). It was used during an experimental UI path; **product choice** is to keep physical folders (`FileFolder`, `folderId`) and recover `FormGeneratorTree` (see `wiki/c-work/1-plan/2026-05-formgenerator-tree-and-folder-recovery.md`).
Run only if you need a historical data rescue:
```bash
cd gateway
python -m modules.migrations._archive.migrate_folders_to_groups --verbose
python -m modules.migrations._archive.migrate_folders_to_groups --execute --verbose
```

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