diff --git a/app.py b/app.py index 11068505..f43035c1 100644 --- a/app.py +++ b/app.py @@ -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) diff --git a/modules/aicore/aicoreBase.py b/modules/aicore/aicoreBase.py index e107beb3..0908c40d 100644 --- a/modules/aicore/aicoreBase.py +++ b/modules/aicore/aicoreBase.py @@ -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 ) diff --git a/modules/aicore/aicorePluginAnthropic.py b/modules/aicore/aicorePluginAnthropic.py index ce6349f0..4e873511 100644 --- a/modules/aicore/aicorePluginAnthropic.py +++ b/modules/aicore/aicorePluginAnthropic.py @@ -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": diff --git a/modules/aicore/aicorePluginMistral.py b/modules/aicore/aicorePluginMistral.py index d2ad0694..a9805195 100644 --- a/modules/aicore/aicorePluginMistral.py +++ b/modules/aicore/aicorePluginMistral.py @@ -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", {}) diff --git a/modules/aicore/aicorePluginOpenai.py b/modules/aicore/aicorePluginOpenai.py index bfea82f7..78f8ba26 100644 --- a/modules/aicore/aicorePluginOpenai.py +++ b/modules/aicore/aicorePluginOpenai.py @@ -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", {}) diff --git a/modules/auth/authentication.py b/modules/auth/authentication.py index 27cf1a31..d641d659 100644 --- a/modules/auth/authentication.py +++ b/modules/auth/authentication.py @@ -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", diff --git a/modules/auth/mfaService.py b/modules/auth/mfaService.py index 7ecd7889..3987eab9 100644 --- a/modules/auth/mfaService.py +++ b/modules/auth/mfaService.py @@ -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) diff --git a/modules/auth/tokenRefreshService.py b/modules/auth/tokenRefreshService.py index 5f243b3f..bc471e6f 100644 --- a/modules/auth/tokenRefreshService.py +++ b/modules/auth/tokenRefreshService.py @@ -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__) diff --git a/modules/connectors/connectorDbPostgre.py b/modules/connectors/connectorDbPostgre.py index 1f37e24a..493a3862 100644 --- a/modules/connectors/connectorDbPostgre.py +++ b/modules/connectors/connectorDbPostgre.py @@ -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) diff --git a/modules/connectors/providerClickup/connectorClickup.py b/modules/connectors/connectorProviderClickup.py similarity index 68% rename from modules/connectors/providerClickup/connectorClickup.py rename to modules/connectors/connectorProviderClickup.py index 10517db2..2a2f2ba1 100644 --- a/modules/connectors/providerClickup/connectorClickup.py +++ b/modules/connectors/connectorProviderClickup.py @@ -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, diff --git a/modules/connectors/providerFtp/connectorFtp.py b/modules/connectors/connectorProviderFtp.py similarity index 100% rename from modules/connectors/providerFtp/connectorFtp.py rename to modules/connectors/connectorProviderFtp.py diff --git a/modules/connectors/providerGoogle/connectorGoogle.py b/modules/connectors/connectorProviderGoogle.py similarity index 96% rename from modules/connectors/providerGoogle/connectorGoogle.py rename to modules/connectors/connectorProviderGoogle.py index a1f02a03..acce4935 100644 --- a/modules/connectors/providerGoogle/connectorGoogle.py +++ b/modules/connectors/connectorProviderGoogle.py @@ -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 "") diff --git a/modules/connectors/providerInfomaniak/connectorInfomaniak.py b/modules/connectors/connectorProviderInfomaniak.py similarity index 99% rename from modules/connectors/providerInfomaniak/connectorInfomaniak.py rename to modules/connectors/connectorProviderInfomaniak.py index 9aa3ea9c..661fdb64 100644 --- a/modules/connectors/providerInfomaniak/connectorInfomaniak.py +++ b/modules/connectors/connectorProviderInfomaniak.py @@ -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__) diff --git a/modules/connectors/providerMsft/connectorMsft.py b/modules/connectors/connectorProviderMsft.py similarity index 98% rename from modules/connectors/providerMsft/connectorMsft.py rename to modules/connectors/connectorProviderMsft.py index 0830e6ed..266f9deb 100644 --- a/modules/connectors/providerMsft/connectorMsft.py +++ b/modules/connectors/connectorProviderMsft.py @@ -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 "") diff --git a/modules/connectors/connectorResolver.py b/modules/connectors/connectorResolver.py index a8b9fd23..a6b559a0 100644 --- a/modules/connectors/connectorResolver.py +++ b/modules/connectors/connectorResolver.py @@ -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") diff --git a/modules/connectors/connectorTicketsClickup.py b/modules/connectors/connectorTicketsClickup.py index af02b44a..bb43ceac 100644 --- a/modules/connectors/connectorTicketsClickup.py +++ b/modules/connectors/connectorTicketsClickup.py @@ -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", } diff --git a/modules/connectors/connectorVoiceGoogle.py b/modules/connectors/connectorVoiceGoogle.py index 3dd3221d..7ae8e54b 100644 --- a/modules/connectors/connectorVoiceGoogle.py +++ b/modules/connectors/connectorVoiceGoogle.py @@ -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]: """ diff --git a/modules/connectors/providerClickup/__init__.py b/modules/connectors/providerClickup/__init__.py deleted file mode 100644 index 12439593..00000000 --- a/modules/connectors/providerClickup/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -"""ClickUp provider connector.""" - -from .connectorClickup import ClickupConnector - -__all__ = ["ClickupConnector"] diff --git a/modules/connectors/providerFtp/__init__.py b/modules/connectors/providerFtp/__init__.py deleted file mode 100644 index ee198298..00000000 --- a/modules/connectors/providerFtp/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -"""FTP/SFTP Provider Connector stub.""" diff --git a/modules/connectors/providerGoogle/__init__.py b/modules/connectors/providerGoogle/__init__.py deleted file mode 100644 index 0e09a79e..00000000 --- a/modules/connectors/providerGoogle/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -"""Google Provider Connector -- 1 Connection : n Services (Drive, Gmail).""" diff --git a/modules/connectors/providerInfomaniak/__init__.py b/modules/connectors/providerInfomaniak/__init__.py deleted file mode 100644 index 87482425..00000000 --- a/modules/connectors/providerInfomaniak/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -"""Infomaniak Provider Connector -- 1 Connection : n Services (kDrive, Mail).""" diff --git a/modules/connectors/providerMsft/__init__.py b/modules/connectors/providerMsft/__init__.py deleted file mode 100644 index 2229ecb3..00000000 --- a/modules/connectors/providerMsft/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -"""Microsoft Provider Connector -- 1 Connection : n Services (SharePoint, Outlook, Teams, OneDrive).""" diff --git a/modules/datamodels/__init__.py b/modules/datamodels/__init__.py index 4bc577ee..40adbebb 100644 --- a/modules/datamodels/__init__.py +++ b/modules/datamodels/__init__.py @@ -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 \ No newline at end of file +from . import datamodelUtils as utils +from . import jsonContinuation \ No newline at end of file diff --git a/modules/datamodels/datamodelFeatureDataSource.py b/modules/datamodels/datamodelFeatureDataSource.py deleted file mode 100644 index 2f234742..00000000 --- a/modules/datamodels/datamodelFeatureDataSource.py +++ /dev/null @@ -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}, - ) diff --git a/modules/datamodels/datamodelFeatures.py b/modules/datamodels/datamodelFeatures.py index 67532fe3..e43569b1 100644 --- a/modules/datamodels/datamodelFeatures.py +++ b/modules/datamodels/datamodelFeatures.py @@ -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 diff --git a/modules/datamodels/datamodelPortTypes.py b/modules/datamodels/datamodelPortTypes.py new file mode 100644 index 00000000..6d87c25e --- /dev/null +++ b/modules/datamodels/datamodelPortTypes.py @@ -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", +}) diff --git a/modules/datamodels/datamodelViews.py b/modules/datamodels/datamodelViews.py index 7a327fd8..03a5a27f 100644 --- a/modules/datamodels/datamodelViews.py +++ b/modules/datamodels/datamodelViews.py @@ -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)") diff --git a/modules/datamodels/datamodelWorkflowAutomation.py b/modules/datamodels/datamodelWorkflowAutomation.py new file mode 100644 index 00000000..5f9cb7b2 --- /dev/null +++ b/modules/datamodels/datamodelWorkflowAutomation.py @@ -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 diff --git a/modules/shared/jsonContinuation.py b/modules/datamodels/jsonContinuation.py similarity index 99% rename from modules/shared/jsonContinuation.py rename to modules/datamodels/jsonContinuation.py index 9d282c62..d4ee81f9 100644 --- a/modules/shared/jsonContinuation.py +++ b/modules/datamodels/jsonContinuation.py @@ -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) diff --git a/modules/migrations/__init__.py b/modules/dbHelpers/__init__.py similarity index 100% rename from modules/migrations/__init__.py rename to modules/dbHelpers/__init__.py diff --git a/modules/shared/aiAuditLogger.py b/modules/dbHelpers/aiAuditLogger.py similarity index 99% rename from modules/shared/aiAuditLogger.py rename to modules/dbHelpers/aiAuditLogger.py index 5da105a8..060ace33 100644 --- a/modules/shared/aiAuditLogger.py +++ b/modules/dbHelpers/aiAuditLogger.py @@ -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=..., ...) """ diff --git a/modules/shared/auditLogger.py b/modules/dbHelpers/auditLogger.py similarity index 99% rename from modules/shared/auditLogger.py rename to modules/dbHelpers/auditLogger.py index 0f9c2b39..a5b0ec9e 100644 --- a/modules/shared/auditLogger.py +++ b/modules/dbHelpers/auditLogger.py @@ -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) diff --git a/modules/shared/dbMultiTenantOptimizations.py b/modules/dbHelpers/dbMultiTenantOptimizations.py similarity index 99% rename from modules/shared/dbMultiTenantOptimizations.py rename to modules/dbHelpers/dbMultiTenantOptimizations.py index 9b5a15b4..4b8a5e78 100644 --- a/modules/shared/dbMultiTenantOptimizations.py +++ b/modules/dbHelpers/dbMultiTenantOptimizations.py @@ -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) diff --git a/modules/shared/dbRegistry.py b/modules/dbHelpers/dbRegistry.py similarity index 97% rename from modules/shared/dbRegistry.py rename to modules/dbHelpers/dbRegistry.py index 4626a100..8c24d664 100644 --- a/modules/shared/dbRegistry.py +++ b/modules/dbHelpers/dbRegistry.py @@ -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") """ diff --git a/modules/dbHelpers/fkLabelResolver.py b/modules/dbHelpers/fkLabelResolver.py new file mode 100644 index 00000000..940866d5 --- /dev/null +++ b/modules/dbHelpers/fkLabelResolver.py @@ -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 diff --git a/modules/shared/fkRegistry.py b/modules/dbHelpers/fkRegistry.py similarity index 97% rename from modules/shared/fkRegistry.py rename to modules/dbHelpers/fkRegistry.py index 9f3d63c4..9ca5b1ec 100644 --- a/modules/shared/fkRegistry.py +++ b/modules/dbHelpers/fkRegistry.py @@ -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(): diff --git a/modules/dbHelpers/paginationHelpers.py b/modules/dbHelpers/paginationHelpers.py new file mode 100644 index 00000000..981cd411 --- /dev/null +++ b/modules/dbHelpers/paginationHelpers.py @@ -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 diff --git a/modules/demoConfigs/__init__.py b/modules/demoConfigs/__init__.py index 5395f71b..6ac5054f 100644 --- a/modules/demoConfigs/__init__.py +++ b/modules/demoConfigs/__init__.py @@ -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) diff --git a/modules/demoConfigs/_baseDemoConfig.py b/modules/demoConfigs/baseDemoConfig.py similarity index 94% rename from modules/demoConfigs/_baseDemoConfig.py rename to modules/demoConfigs/baseDemoConfig.py index d20d4315..604c7a78 100644 --- a/modules/demoConfigs/_baseDemoConfig.py +++ b/modules/demoConfigs/baseDemoConfig.py @@ -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 = "" diff --git a/modules/demoConfigs/investorDemo2026.py b/modules/demoConfigs/investorDemo2026.py index d807921d..6855a63c 100644 --- a/modules/demoConfigs/investorDemo2026.py +++ b/modules/demoConfigs/investorDemo2026.py @@ -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 = ( diff --git a/modules/demoConfigs/pwgDemo2026.py b/modules/demoConfigs/pwgDemo2026.py index 4a6491a3..968aabf8 100644 --- a/modules/demoConfigs/pwgDemo2026.py +++ b/modules/demoConfigs/pwgDemo2026.py @@ -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 = ( diff --git a/modules/features/commcoach/CONCEPT.md b/modules/features/commcoach/CONCEPT.md deleted file mode 100644 index 7a8fcd00..00000000 --- a/modules/features/commcoach/CONCEPT.md +++ /dev/null @@ -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 diff --git a/modules/features/commcoach/interfaceFeatureCommcoach.py b/modules/features/commcoach/interfaceFeatureCommcoach.py index a6fd41ec..8341ec1b 100644 --- a/modules/features/commcoach/interfaceFeatureCommcoach.py +++ b/modules/features/commcoach/interfaceFeatureCommcoach.py @@ -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 diff --git a/modules/features/commcoach/mainCommcoach.py b/modules/features/commcoach/mainCommcoach.py index 999f940c..7050a078 100644 --- a/modules/features/commcoach/mainCommcoach.py +++ b/modules/features/commcoach/mainCommcoach.py @@ -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}") + diff --git a/modules/features/commcoach/routeFeatureCommcoach.py b/modules/features/commcoach/routeFeatureCommcoach.py index 45075ae9..a60db504 100644 --- a/modules/features/commcoach/routeFeatureCommcoach.py +++ b/modules/features/commcoach/routeFeatureCommcoach.py @@ -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( diff --git a/modules/features/commcoach/serviceCommcoach.py b/modules/features/commcoach/serviceCommcoach.py index 5ac3af23..d7a79d1f 100644 --- a/modules/features/commcoach/serviceCommcoach.py +++ b/modules/features/commcoach/serviceCommcoach.py @@ -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: diff --git a/modules/features/commcoach/serviceCommcoachAi.py b/modules/features/commcoach/serviceCommcoachAi.py index e3394125..1b9baca8 100644 --- a/modules/features/commcoach/serviceCommcoachAi.py +++ b/modules/features/commcoach/serviceCommcoachAi.py @@ -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}):" diff --git a/modules/features/commcoach/serviceCommcoachContextRetrieval.py b/modules/features/commcoach/serviceCommcoachContextRetrieval.py index e841dec4..98673cc6 100644 --- a/modules/features/commcoach/serviceCommcoachContextRetrieval.py +++ b/modules/features/commcoach/serviceCommcoachContextRetrieval.py @@ -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: diff --git a/modules/features/commcoach/serviceCommcoachExport.py b/modules/features/commcoach/serviceCommcoachExport.py index 614a3fe6..5f8e9356 100644 --- a/modules/features/commcoach/serviceCommcoachExport.py +++ b/modules/features/commcoach/serviceCommcoachExport.py @@ -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("&", "&").replace("<", "<").replace(">", ">") - text = _re.sub(r'\*\*(.+?)\*\*', r'\1', text) - text = _re.sub(r'__(.+?)__', r'\1', text) - text = _re.sub(r'\*(.+?)\*', r'\1', text) - text = _re.sub(r'_(.+?)_', r'\1', text) + text = re.sub(r'\*\*(.+?)\*\*', r'\1', text) + text = re.sub(r'__(.+?)__', r'\1', text) + text = re.sub(r'\*(.+?)\*', r'\1', text) + text = re.sub(r'_(.+?)_', r'\1', text) return text diff --git a/modules/features/commcoach/serviceCommcoachScheduler.py b/modules/features/commcoach/serviceCommcoachScheduler.py index 72e253d6..51a3491d 100644 --- a/modules/features/commcoach/serviceCommcoachScheduler.py +++ b/modules/features/commcoach/serviceCommcoachScheduler.py @@ -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( diff --git a/modules/features/graphicalEditor/adapterValidator.py b/modules/features/graphicalEditor/adapterValidator.py index 7f760896..08e25232 100644 --- a/modules/features/graphicalEditor/adapterValidator.py +++ b/modules/features/graphicalEditor/adapterValidator.py @@ -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}" ) diff --git a/modules/features/graphicalEditor/datamodelFeatureGraphicalEditor.py b/modules/features/graphicalEditor/datamodelFeatureGraphicalEditor.py index 10d1f47f..1e701716 100644 --- a/modules/features/graphicalEditor/datamodelFeatureGraphicalEditor.py +++ b/modules/features/graphicalEditor/datamodelFeatureGraphicalEditor.py @@ -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 diff --git a/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py b/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py index 09192d2e..e58c7b18 100644 --- a/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py +++ b/modules/features/graphicalEditor/interfaceFeatureGraphicalEditor.py @@ -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(), diff --git a/modules/features/graphicalEditor/mainGraphicalEditor.py b/modules/features/graphicalEditor/mainGraphicalEditor.py index d3d70381..44cb890e 100644 --- a/modules/features/graphicalEditor/mainGraphicalEditor.py +++ b/modules/features/graphicalEditor/mainGraphicalEditor.py @@ -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", "") + 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 diff --git a/modules/features/graphicalEditor/portTypes.py b/modules/features/graphicalEditor/portTypes.py index 12c2d90f..a7eb0f3f 100644 --- a/modules/features/graphicalEditor/portTypes.py +++ b/modules/features/graphicalEditor/portTypes.py @@ -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), diff --git a/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py b/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py index 663f87e4..20a2708b 100644 --- a/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py +++ b/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py @@ -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 diff --git a/modules/features/neutralization/datamodelFeatureNeutralizer.py b/modules/features/neutralization/datamodelFeatureNeutralizer.py index 9465667c..a308faa3 100644 --- a/modules/features/neutralization/datamodelFeatureNeutralizer.py +++ b/modules/features/neutralization/datamodelFeatureNeutralizer.py @@ -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") diff --git a/modules/features/neutralization/interfaceFeatureNeutralizer.py b/modules/features/neutralization/interfaceFeatureNeutralizer.py index 1575ed85..3d5c9129 100644 --- a/modules/features/neutralization/interfaceFeatureNeutralizer.py +++ b/modules/features/neutralization/interfaceFeatureNeutralizer.py @@ -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 diff --git a/modules/features/neutralization/mainNeutralization.py b/modules/features/neutralization/mainNeutralization.py index 0fe11aea..42f74cf3 100644 --- a/modules/features/neutralization/mainNeutralization.py +++ b/modules/features/neutralization/mainNeutralization.py @@ -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}") + diff --git a/modules/features/neutralization/neutralizePlayground.py b/modules/features/neutralization/neutralizePlayground.py index 500cc1ba..eab0bdeb 100644 --- a/modules/features/neutralization/neutralizePlayground.py +++ b/modules/features/neutralization/neutralizePlayground.py @@ -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', diff --git a/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py b/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py index 74509118..809d6be5 100644 --- a/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py +++ b/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py @@ -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) diff --git a/modules/features/neutralization/serviceNeutralization/subProcessList.py b/modules/features/neutralization/serviceNeutralization/subProcessList.py index 8f815e1e..021cec2b 100644 --- a/modules/features/neutralization/serviceNeutralization/subProcessList.py +++ b/modules/features/neutralization/serviceNeutralization/subProcessList.py @@ -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] diff --git a/modules/features/realEstate/bzoDocumentRetriever.py b/modules/features/realEstate/bzoDocumentRetriever.py index a9356301..9b271cda 100644 --- a/modules/features/realEstate/bzoDocumentRetriever.py +++ b/modules/features/realEstate/bzoDocumentRetriever.py @@ -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) diff --git a/modules/features/realEstate/bzoExtraction.py b/modules/features/realEstate/bzoExtraction.py index f56405ed..3eace0f2 100644 --- a/modules/features/realEstate/bzoExtraction.py +++ b/modules/features/realEstate/bzoExtraction.py @@ -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]}" diff --git a/modules/features/realEstate/interfaceFeatureRealEstate.py b/modules/features/realEstate/interfaceFeatureRealEstate.py index 0637d0e9..9219a842 100644 --- a/modules/features/realEstate/interfaceFeatureRealEstate.py +++ b/modules/features/realEstate/interfaceFeatureRealEstate.py @@ -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() diff --git a/modules/features/realEstate/mainRealEstate.py b/modules/features/realEstate/mainRealEstate.py index d825ec50..7ab7d8d5 100644 --- a/modules/features/realEstate/mainRealEstate.py +++ b/modules/features/realEstate/mainRealEstate.py @@ -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}") + + diff --git a/modules/features/realEstate/routeFeatureRealEstate.py b/modules/features/realEstate/routeFeatureRealEstate.py index 230c5d80..a1cfdb8b 100644 --- a/modules/features/realEstate/routeFeatureRealEstate.py +++ b/modules/features/realEstate/routeFeatureRealEstate.py @@ -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) diff --git a/modules/features/redmine/interfaceFeatureRedmine.py b/modules/features/redmine/interfaceFeatureRedmine.py index 0f3991a2..88855501 100644 --- a/modules/features/redmine/interfaceFeatureRedmine.py +++ b/modules/features/redmine/interfaceFeatureRedmine.py @@ -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__) diff --git a/modules/features/redmine/mainRedmine.py b/modules/features/redmine/mainRedmine.py index 919361c1..fe893cef 100644 --- a/modules/features/redmine/mainRedmine.py +++ b/modules/features/redmine/mainRedmine.py @@ -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}") + diff --git a/modules/features/redmine/routeFeatureRedmine.py b/modules/features/redmine/routeFeatureRedmine.py index ff4f1391..d973e690 100644 --- a/modules/features/redmine/routeFeatureRedmine.py +++ b/modules/features/redmine/routeFeatureRedmine.py @@ -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, diff --git a/modules/features/redmine/serviceRedmine.py b/modules/features/redmine/serviceRedmine.py index 2aea0918..b4a3d137 100644 --- a/modules/features/redmine/serviceRedmine.py +++ b/modules/features/redmine/serviceRedmine.py @@ -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 diff --git a/modules/features/redmine/serviceRedmineStats.py b/modules/features/redmine/serviceRedmineStats.py index 33a83aa7..1c289181 100644 --- a/modules/features/redmine/serviceRedmineStats.py +++ b/modules/features/redmine/serviceRedmineStats.py @@ -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 diff --git a/modules/features/redmine/serviceRedmineSync.py b/modules/features/redmine/serviceRedmineSync.py index 32cd5a09..37507973 100644 --- a/modules/features/redmine/serviceRedmineSync.py +++ b/modules/features/redmine/serviceRedmineSync.py @@ -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: diff --git a/modules/features/teamsbot/interfaceFeatureTeamsbot.py b/modules/features/teamsbot/interfaceFeatureTeamsbot.py index 2bfe77ff..5afeea69 100644 --- a/modules/features/teamsbot/interfaceFeatureTeamsbot.py +++ b/modules/features/teamsbot/interfaceFeatureTeamsbot.py @@ -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, diff --git a/modules/features/teamsbot/mainTeamsbot.py b/modules/features/teamsbot/mainTeamsbot.py index 850135d6..5a003182 100644 --- a/modules/features/teamsbot/mainTeamsbot.py +++ b/modules/features/teamsbot/mainTeamsbot.py @@ -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}") + diff --git a/modules/features/teamsbot/routeFeatureTeamsbot.py b/modules/features/teamsbot/routeFeatureTeamsbot.py index f07c98c5..c0862ba1 100644 --- a/modules/features/teamsbot/routeFeatureTeamsbot.py +++ b/modules/features/teamsbot/routeFeatureTeamsbot.py @@ -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( diff --git a/modules/features/teamsbot/service.py b/modules/features/teamsbot/service.py index bba2bab1..edf5af94 100644 --- a/modules/features/teamsbot/service.py +++ b/modules/features/teamsbot/service.py @@ -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 diff --git a/modules/features/trustee/accounting/accountingBridge.py b/modules/features/trustee/accounting/accountingBridge.py index 4e0a4d59..7fb26b3a 100644 --- a/modules/features/trustee/accounting/accountingBridge.py +++ b/modules/features/trustee/accounting/accountingBridge.py @@ -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, diff --git a/modules/features/trustee/accounting/accountingDataSync.py b/modules/features/trustee/accounting/accountingDataSync.py index db50d657..8ee3b431 100644 --- a/modules/features/trustee/accounting/accountingDataSync.py +++ b/modules/features/trustee/accounting/accountingDataSync.py @@ -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): diff --git a/modules/features/trustee/interfaceFeatureTrustee.py b/modules/features/trustee/interfaceFeatureTrustee.py index 2f8aabf6..4efaaaef 100644 --- a/modules/features/trustee/interfaceFeatureTrustee.py +++ b/modules/features/trustee/interfaceFeatureTrustee.py @@ -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 diff --git a/modules/features/trustee/mainTrustee.py b/modules/features/trustee/mainTrustee.py index b33aaf74..7c686f3e 100644 --- a/modules/features/trustee/mainTrustee.py +++ b/modules/features/trustee/mainTrustee.py @@ -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}") + diff --git a/modules/features/trustee/routeFeatureTrustee.py b/modules/features/trustee/routeFeatureTrustee.py index a71b508f..45a37ca3 100644 --- a/modules/features/trustee/routeFeatureTrustee.py +++ b/modules/features/trustee/routeFeatureTrustee.py @@ -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} diff --git a/modules/features/workspace/interfaceFeatureWorkspace.py b/modules/features/workspace/interfaceFeatureWorkspace.py index 984bf942..e2d16521 100644 --- a/modules/features/workspace/interfaceFeatureWorkspace.py +++ b/modules/features/workspace/interfaceFeatureWorkspace.py @@ -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 diff --git a/modules/features/workspace/mainWorkspace.py b/modules/features/workspace/mainWorkspace.py index 77f5b290..1a96a852 100644 --- a/modules/features/workspace/mainWorkspace.py +++ b/modules/features/workspace/mainWorkspace.py @@ -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}") + diff --git a/modules/features/workspace/routeFeatureWorkspace.py b/modules/features/workspace/routeFeatureWorkspace.py index 55526c55..d9fc3f4d 100644 --- a/modules/features/workspace/routeFeatureWorkspace.py +++ b/modules/features/workspace/routeFeatureWorkspace.py @@ -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) diff --git a/modules/interfaces/_legacyMigrationTelemetry.py b/modules/interfaces/_legacyMigrationTelemetry.py index 4a0db04c..02c2c184 100644 --- a/modules/interfaces/_legacyMigrationTelemetry.py +++ b/modules/interfaces/_legacyMigrationTelemetry.py @@ -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") diff --git a/modules/interfaces/interfaceAiObjects.py b/modules/interfaces/interfaceAiObjects.py index 2d13439c..13f5d8a7 100644 --- a/modules/interfaces/interfaceAiObjects.py +++ b/modules/interfaces/interfaceAiObjects.py @@ -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, diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py index 1f450d0c..287e60df 100644 --- a/modules/interfaces/interfaceBootstrap.py +++ b/modules/interfaces/interfaceBootstrap.py @@ -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) diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py index 76bdf14d..60aac0e8 100644 --- a/modules/interfaces/interfaceDbApp.py +++ b/modules/interfaces/interfaceDbApp.py @@ -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) diff --git a/modules/interfaces/interfaceDbBilling.py b/modules/interfaces/interfaceDbBilling.py index 273583d9..d51813d8 100644 --- a/modules/interfaces/interfaceDbBilling.py +++ b/modules/interfaces/interfaceDbBilling.py @@ -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 = { diff --git a/modules/interfaces/interfaceDbChat.py b/modules/interfaces/interfaceDbChat.py index 1b7ec59a..432769bd 100644 --- a/modules/interfaces/interfaceDbChat.py +++ b/modules/interfaces/interfaceDbChat.py @@ -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 diff --git a/modules/interfaces/interfaceDbKnowledge.py b/modules/interfaces/interfaceDbKnowledge.py index d7a445bd..e979bbd3 100644 --- a/modules/interfaces/interfaceDbKnowledge.py +++ b/modules/interfaces/interfaceDbKnowledge.py @@ -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) diff --git a/modules/interfaces/interfaceDbManagement.py b/modules/interfaces/interfaceDbManagement.py index 3b87611d..46289b7e 100644 --- a/modules/interfaces/interfaceDbManagement.py +++ b/modules/interfaces/interfaceDbManagement.py @@ -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 \ No newline at end of file + 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) \ No newline at end of file diff --git a/modules/interfaces/interfaceDbSubscription.py b/modules/interfaces/interfaceDbSubscription.py index a0a69315..bdcaeb2b 100644 --- a/modules/interfaces/interfaceDbSubscription.py +++ b/modules/interfaces/interfaceDbSubscription.py @@ -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, diff --git a/modules/interfaces/interfaceFeatures.py b/modules/interfaces/interfaceFeatures.py index 0f7d20e1..4c1b29b8 100644 --- a/modules/interfaces/interfaceFeatures.py +++ b/modules/interfaces/interfaceFeatures.py @@ -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", "") - 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 diff --git a/modules/interfaces/interfaceRbac.py b/modules/interfaces/interfaceRbac.py index 29182ae2..8d886cfd 100644 --- a/modules/interfaces/interfaceRbac.py +++ b/modules/interfaces/interfaceRbac.py @@ -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) diff --git a/modules/interfaces/interfaceTableHelpers.py b/modules/interfaces/interfaceTableHelpers.py new file mode 100644 index 00000000..81336fed --- /dev/null +++ b/modules/interfaces/interfaceTableHelpers.py @@ -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 diff --git a/modules/migration/__init__.py b/modules/migration/__init__.py deleted file mode 100644 index 7639be60..00000000 --- a/modules/migration/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Migration modules diff --git a/modules/migration/seedData/ui_language_seed.json b/modules/migration/seedData/ui_language_seed.json deleted file mode 100644 index 060e6c51..00000000 --- a/modules/migration/seedData/ui_language_seed.json +++ /dev/null @@ -1,13554 +0,0 @@ -[ - { - "id": "xx", - "label": "Basisset (Meta)", - "entries": [ - { - "context": "ui", - "key": "+41 123 456 789", - "value": "" - }, - { - "context": "ui", - "key": "1 Benutzer ausgewählt", - "value": "" - }, - { - "context": "ui", - "key": "ABGEBROCHEN", - "value": "" - }, - { - "context": "ui", - "key": "ABGESCHLOSSEN", - "value": "" - }, - { - "context": "ui", - "key": "Abbrechen", - "value": "" - }, - { - "context": "ui", - "key": "Abgeschlossen", - "value": "" - }, - { - "context": "ui", - "key": "Abmelden", - "value": "" - }, - { - "context": "ui", - "key": "Admin-Einstellungen", - "value": "" - }, - { - "context": "ui", - "key": "Administrative Einstellungen", - "value": "" - }, - { - "context": "ui", - "key": "Administrator", - "value": "" - }, - { - "context": "ui", - "key": "Adresse", - "value": "" - }, - { - "context": "ui", - "key": "Agent Assist (AA)", - "value": "" - }, - { - "context": "ui", - "key": "Aktionen", - "value": "" - }, - { - "context": "ui", - "key": "Aktiv", - "value": "" - }, - { - "context": "ui", - "key": "Aktiviert", - "value": "" - }, - { - "context": "ui", - "key": "Aktualisieren", - "value": "" - }, - { - "context": "ui", - "key": "Aktuelle Transkripte", - "value": "" - }, - { - "context": "ui", - "key": "Alle", - "value": "" - }, - { - "context": "ui", - "key": "Alle Dateien", - "value": "" - }, - { - "context": "ui", - "key": "Alle Daten als CSV exportieren", - "value": "" - }, - { - "context": "ui", - "key": "Alle Elemente auswählen", - "value": "" - }, - { - "context": "ui", - "key": "Alle Nicht-Standard-Sprachsets jetzt mit dem deutschen Master synchronisieren?", - "value": "" - }, - { - "context": "ui", - "key": "Alle abwählen", - "value": "" - }, - { - "context": "ui", - "key": "Alle aktualisieren", - "value": "" - }, - { - "context": "ui", - "key": "Alle auswählen", - "value": "" - }, - { - "context": "ui", - "key": "Alle {count} Elemente löschen", - "value": "" - }, - { - "context": "ui", - "key": "Analysiere Workflow...", - "value": "" - }, - { - "context": "ui", - "key": "Anmelden", - "value": "" - }, - { - "context": "ui", - "key": "Anrufer", - "value": "" - }, - { - "context": "ui", - "key": "Anzeigen", - "value": "" - }, - { - "context": "ui", - "key": "Anzeigename", - "value": "" - }, - { - "context": "ui", - "key": "Audio", - "value": "" - }, - { - "context": "ui", - "key": "Auf Standard zurücksetzen", - "value": "" - }, - { - "context": "ui", - "key": "Aufgaben", - "value": "" - }, - { - "context": "ui", - "key": "Ausführen", - "value": "" - }, - { - "context": "ui", - "key": "Ausgewählte Datei:", - "value": "" - }, - { - "context": "ui", - "key": "Auth-Anbieter", - "value": "" - }, - { - "context": "ui", - "key": "Authentifizierungsanbieter", - "value": "" - }, - { - "context": "ui", - "key": "Authentifizierungstoken abgelaufen oder ungültig. Bitte verbinden Sie Ihr Microsoft-Konto erneut.", - "value": "" - }, - { - "context": "ui", - "key": "Automatisierung erfolgreich erstellt", - "value": "" - }, - { - "context": "ui", - "key": "Automatisierungen", - "value": "" - }, - { - "context": "ui", - "key": "Basisdaten", - "value": "" - }, - { - "context": "ui", - "key": "Bearbeiten", - "value": "" - }, - { - "context": "ui", - "key": "Befehl eingeben (z.B., \"Erstelle ein neues Projekt namens 'Hauptstrasse 42'\")", - "value": "" - }, - { - "context": "ui", - "key": "Beginne ein Gespräch, indem du eine Nachricht eingibst, eine Vorlage auswählst oder einen vorherigen Workflow fortsetzt …", - "value": "" - }, - { - "context": "ui", - "key": "Beginnen Sie mit:", - "value": "" - }, - { - "context": "ui", - "key": "Bei Genehmigung planen wir einen Einrichtungsanruf zur Konfiguration Ihrer Integration.", - "value": "" - }, - { - "context": "ui", - "key": "Beim Hochladen ist ein Fehler aufgetreten.", - "value": "" - }, - { - "context": "ui", - "key": "Beim Hochladen ist ein unerwarteter Fehler aufgetreten.", - "value": "" - }, - { - "context": "ui", - "key": "Belege verwalten", - "value": "" - }, - { - "context": "ui", - "key": "Benutzer", - "value": "" - }, - { - "context": "ui", - "key": "Benutzer auswählen", - "value": "" - }, - { - "context": "ui", - "key": "Benutzer bearbeiten", - "value": "" - }, - { - "context": "ui", - "key": "Benutzer erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Benutzer hinzufügen", - "value": "" - }, - { - "context": "ui", - "key": "Benutzer löschen", - "value": "" - }, - { - "context": "ui", - "key": "Benutzer werden geladen...", - "value": "" - }, - { - "context": "ui", - "key": "Benutzer-Zugriff verwalten", - "value": "" - }, - { - "context": "ui", - "key": "Benutzerdefinierter Titel (optional)", - "value": "" - }, - { - "context": "ui", - "key": "Benutzerinformationen", - "value": "" - }, - { - "context": "ui", - "key": "Benutzerinformationen erfolgreich aktualisiert", - "value": "" - }, - { - "context": "ui", - "key": "Benutzerinformationen werden geladen...", - "value": "" - }, - { - "context": "ui", - "key": "Benutzername", - "value": "" - }, - { - "context": "ui", - "key": "Benutzerverwaltung - Teammitglieder und Berechtigungen verwalten", - "value": "" - }, - { - "context": "ui", - "key": "Berechtigung", - "value": "" - }, - { - "context": "ui", - "key": "Berechtigungsstufe", - "value": "" - }, - { - "context": "ui", - "key": "Beschreibung", - "value": "" - }, - { - "context": "ui", - "key": "Beschreibung der Rolle", - "value": "" - }, - { - "context": "ui", - "key": "Betrachter", - "value": "" - }, - { - "context": "ui", - "key": "Betreff", - "value": "" - }, - { - "context": "ui", - "key": "Bezeichnung", - "value": "" - }, - { - "context": "ui", - "key": "Bieten Sie Unterstützung im Live-Chat und setzen Sie intelligente Chatbots in allen Kanälen ein.", - "value": "" - }, - { - "context": "ui", - "key": "Bild", - "value": "" - }, - { - "context": "ui", - "key": "Bis", - "value": "" - }, - { - "context": "ui", - "key": "Bitte geben Sie eine gültige E-Mail-Adresse ein", - "value": "" - }, - { - "context": "ui", - "key": "Bitte wählen Sie mindestens einen Benutzer aus", - "value": "" - }, - { - "context": "ui", - "key": "Branche", - "value": "" - }, - { - "context": "ui", - "key": "Branche ist erforderlich", - "value": "" - }, - { - "context": "ui", - "key": "Buchungsbetrag", - "value": "" - }, - { - "context": "ui", - "key": "Buchungspositionen verwalten", - "value": "" - }, - { - "context": "ui", - "key": "Buchungswährung", - "value": "" - }, - { - "context": "ui", - "key": "Chat Platform (CP)", - "value": "" - }, - { - "context": "ui", - "key": "Chat leeren...", - "value": "" - }, - { - "context": "ui", - "key": "Chatbereich", - "value": "" - }, - { - "context": "ui", - "key": "Darstellung", - "value": "" - }, - { - "context": "ui", - "key": "Datei", - "value": "" - }, - { - "context": "ui", - "key": "Datei anhängen", - "value": "" - }, - { - "context": "ui", - "key": "Datei bearbeiten", - "value": "" - }, - { - "context": "ui", - "key": "Datei bereits vorhanden", - "value": "" - }, - { - "context": "ui", - "key": "Datei entfernen", - "value": "" - }, - { - "context": "ui", - "key": "Datei erfolgreich hochgeladen!", - "value": "" - }, - { - "context": "ui", - "key": "Datei herunterladen", - "value": "" - }, - { - "context": "ui", - "key": "Datei hier ablegen...", - "value": "" - }, - { - "context": "ui", - "key": "Datei hinzufügen", - "value": "" - }, - { - "context": "ui", - "key": "Datei hochladen", - "value": "" - }, - { - "context": "ui", - "key": "Datei löschen", - "value": "" - }, - { - "context": "ui", - "key": "Datei vorschauen", - "value": "" - }, - { - "context": "ui", - "key": "Datei-Ablage während Workflow deaktiviert", - "value": "" - }, - { - "context": "ui", - "key": "Dateien", - "value": "" - }, - { - "context": "ui", - "key": "Dateien anhängen", - "value": "" - }, - { - "context": "ui", - "key": "Dateien auswählen", - "value": "" - }, - { - "context": "ui", - "key": "Dateien hier ablegen", - "value": "" - }, - { - "context": "ui", - "key": "Dateien hier ablegen zum Anhängen", - "value": "" - }, - { - "context": "ui", - "key": "Dateien hierher ziehen", - "value": "" - }, - { - "context": "ui", - "key": "Dateien hochladen", - "value": "" - }, - { - "context": "ui", - "key": "Dateien werden geladen...", - "value": "" - }, - { - "context": "ui", - "key": "Dateien werden verarbeitet...", - "value": "" - }, - { - "context": "ui", - "key": "Dateigröße", - "value": "" - }, - { - "context": "ui", - "key": "Dateiname", - "value": "" - }, - { - "context": "ui", - "key": "Dateityp", - "value": "" - }, - { - "context": "ui", - "key": "Dateiverwaltung - Dokumente hochladen und organisieren", - "value": "" - }, - { - "context": "ui", - "key": "Dateivorschau", - "value": "" - }, - { - "context": "ui", - "key": "Daten aktualisieren", - "value": "" - }, - { - "context": "ui", - "key": "Daten empfangen", - "value": "" - }, - { - "context": "ui", - "key": "Daten gesendet", - "value": "" - }, - { - "context": "ui", - "key": "Datenverwaltung", - "value": "" - }, - { - "context": "ui", - "key": "Datenverwaltung - Datenimporte und -exporte verwalten", - "value": "" - }, - { - "context": "ui", - "key": "Datenverwaltung mit Tabellen", - "value": "" - }, - { - "context": "ui", - "key": "Datum", - "value": "" - }, - { - "context": "ui", - "key": "Dauer", - "value": "" - }, - { - "context": "ui", - "key": "Details", - "value": "" - }, - { - "context": "ui", - "key": "Deutsch", - "value": "" - }, - { - "context": "ui", - "key": "Die Datei \"{fileName}\" existiert bereits mit identischem Inhalt. Die vorhandene Datei wird wiederverwendet.", - "value": "" - }, - { - "context": "ui", - "key": "Die Erstellung einer neuen Sprache kann AI-Guthaben auf Ihrem Mandats-Pool belasten. Fortfahren?", - "value": "" - }, - { - "context": "ui", - "key": "Dies ist Ihr Ausgangspunkt für den Zugriff auf alle Arbeitsbereich-Features und -Tools.", - "value": "" - }, - { - "context": "ui", - "key": "Diese Aktion kann nicht rückgängig gemacht werden.", - "value": "" - }, - { - "context": "ui", - "key": "Diese Datei scheint beschädigt zu sein. Sie hat eine PDF-Erweiterung, enthält aber Textinhalte. Bitte laden Sie die Datei erneut hoch, falls möglich.", - "value": "" - }, - { - "context": "ui", - "key": "Dieser Bereich enthält alle Verwaltungs- und Management-Tools für Ihren Arbeitsbereich.", - "value": "" - }, - { - "context": "ui", - "key": "Dieses Element auswählen", - "value": "" - }, - { - "context": "ui", - "key": "Dieses Element kann nicht ausgewählt werden", - "value": "" - }, - { - "context": "ui", - "key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden", - "value": "" - }, - { - "context": "ui", - "key": "Dossiers", - "value": "UDB tab label for chat workflows / cases" - }, - { - "context": "ui", - "key": "Dokument", - "value": "" - }, - { - "context": "ui", - "key": "Dokument erfolgreich erstellt", - "value": "" - }, - { - "context": "ui", - "key": "Dokument herunterladen", - "value": "" - }, - { - "context": "ui", - "key": "Dokument vorschauen", - "value": "" - }, - { - "context": "ui", - "key": "Dokumente", - "value": "" - }, - { - "context": "ui", - "key": "Dokumente auflisten", - "value": "" - }, - { - "context": "ui", - "key": "Dokumentname", - "value": "" - }, - { - "context": "ui", - "key": "Dunkel", - "value": "" - }, - { - "context": "ui", - "key": "Durchsuchen", - "value": "" - }, - { - "context": "ui", - "key": "E-Mail", - "value": "" - }, - { - "context": "ui", - "key": "E-Mail-Adresse", - "value": "" - }, - { - "context": "ui", - "key": "E-Mail-Adresse ist erforderlich", - "value": "" - }, - { - "context": "ui", - "key": "E-Mail-Bestätigung", - "value": "" - }, - { - "context": "ui", - "key": "Echtzeit-Datensynchronisation:", - "value": "" - }, - { - "context": "ui", - "key": "Eigenschaften", - "value": "" - }, - { - "context": "ui", - "key": "Eingereichte Daten:", - "value": "" - }, - { - "context": "ui", - "key": "Einrichtungsanruf", - "value": "" - }, - { - "context": "ui", - "key": "Einstellungen", - "value": "" - }, - { - "context": "ui", - "key": "Einstellungen erfolgreich gespeichert!", - "value": "" - }, - { - "context": "ui", - "key": "Einstellungen werden in zukünftigen Updates hinzugefügt.", - "value": "" - }, - { - "context": "ui", - "key": "Einstellungen wurden erfolgreich zurückgesetzt.", - "value": "" - }, - { - "context": "ui", - "key": "Einträge", - "value": "" - }, - { - "context": "ui", - "key": "Einträge pro Seite:", - "value": "" - }, - { - "context": "ui", - "key": "Empfänger", - "value": "" - }, - { - "context": "ui", - "key": "Endzeit", - "value": "" - }, - { - "context": "ui", - "key": "English", - "value": "" - }, - { - "context": "ui", - "key": "Entdeckte Sites", - "value": "" - }, - { - "context": "ui", - "key": "Entfernen", - "value": "" - }, - { - "context": "ui", - "key": "Erfolgreich", - "value": "" - }, - { - "context": "ui", - "key": "Erfolgsrate", - "value": "" - }, - { - "context": "ui", - "key": "Erleben Sie die Zukunft der Mandantenkommunikation durch unsere strategische Partnerschaft mit Spitch.ai. Diese bahnbrechende Integration verwandelt Ihre PowerOn-Plattform in ein intelligentes Telefonie-System, das externe Mandanten nahtlos mit Unternehmen verbindet.", - "value": "" - }, - { - "context": "ui", - "key": "Erneut versuchen", - "value": "" - }, - { - "context": "ui", - "key": "Erste Seite", - "value": "" - }, - { - "context": "ui", - "key": "Erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Erstellen und verwalten Sie RBAC-Rollen und deren Berechtigungen.", - "value": "" - }, - { - "context": "ui", - "key": "Erstellen...", - "value": "" - }, - { - "context": "ui", - "key": "Erstellt", - "value": "" - }, - { - "context": "ui", - "key": "Erstellte Dateien", - "value": "" - }, - { - "context": "ui", - "key": "Erstellungsdatum", - "value": "" - }, - { - "context": "ui", - "key": "Exportiere...", - "value": "" - }, - { - "context": "ui", - "key": "Externe E-Mail", - "value": "" - }, - { - "context": "ui", - "key": "Externe E-Mail-Adresse eingeben", - "value": "" - }, - { - "context": "ui", - "key": "Externen Benutzernamen eingeben", - "value": "" - }, - { - "context": "ui", - "key": "Externer Benutzername", - "value": "" - }, - { - "context": "ui", - "key": "FEHLER", - "value": "" - }, - { - "context": "ui", - "key": "FEHLGESCHLAGEN", - "value": "" - }, - { - "context": "ui", - "key": "Falls Sie Fragen zu Ihrem Mandat oder dem Integrationsprozess haben, zögern Sie nicht, unser Support-Team zu kontaktieren.", - "value": "" - }, - { - "context": "ui", - "key": "Fehler", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Aktualisieren der Benutzerinformationen", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der Automatisierung", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der Organisation", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der Position", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der RBAC-Regel", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der Rolle", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Dokuments", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Mandats", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Prompts", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Team-Mitglieds", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Vertrags", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Zugriffs", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Benutzer", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Benutzer:", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Benutzerinformationen", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Dateien:", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Logs", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Nachrichten:", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Prompts", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Prompts:", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Laden der SharePoint Dokumente:", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Vorschau", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Workflows:", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Löschen", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Speichern der Einstellungen. Bitte versuchen Sie es erneut.", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Teilen des Prompts", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Verarbeiten der Dateien", - "value": "" - }, - { - "context": "ui", - "key": "Fehler:", - "value": "" - }, - { - "context": "ui", - "key": "Fehlgeschlagen", - "value": "" - }, - { - "context": "ui", - "key": "Filter", - "value": "" - }, - { - "context": "ui", - "key": "Filter löschen", - "value": "" - }, - { - "context": "ui", - "key": "Filter: {value}", - "value": "" - }, - { - "context": "ui", - "key": "Firma", - "value": "" - }, - { - "context": "ui", - "key": "Firmenname", - "value": "" - }, - { - "context": "ui", - "key": "Firmenname ist erforderlich", - "value": "" - }, - { - "context": "ui", - "key": "Folgenachricht wird gesendet...", - "value": "" - }, - { - "context": "ui", - "key": "Fortfahren", - "value": "" - }, - { - "context": "ui", - "key": "Fortsetzen", - "value": "" - }, - { - "context": "ui", - "key": "Fragen?", - "value": "" - }, - { - "context": "ui", - "key": "Français", - "value": "" - }, - { - "context": "ui", - "key": "Fügen Sie eine Nachricht für die Empfänger hinzu", - "value": "" - }, - { - "context": "ui", - "key": "GESTOPPT", - "value": "" - }, - { - "context": "ui", - "key": "Geben Sie Ihren Firmennamen ein", - "value": "" - }, - { - "context": "ui", - "key": "Geben Sie Kunden einen schnellen und effizienten Selbstservice für Sprach- und Textanfragen, der 24/7 verfügbar ist.", - "value": "" - }, - { - "context": "ui", - "key": "Geben Sie den Inhalt des Prompts ein", - "value": "" - }, - { - "context": "ui", - "key": "Geben Sie einen Namen für den Prompt ein", - "value": "" - }, - { - "context": "ui", - "key": "Geben Sie einen benutzerdefinierten Titel ein", - "value": "" - }, - { - "context": "ui", - "key": "Geplante und automatisierte Workflows", - "value": "" - }, - { - "context": "ui", - "key": "Geschäftszeiten", - "value": "" - }, - { - "context": "ui", - "key": "Geschäftszeiten & Zeitzone", - "value": "" - }, - { - "context": "ui", - "key": "Gespräch fortsetzen...", - "value": "" - }, - { - "context": "ui", - "key": "Gestartet", - "value": "" - }, - { - "context": "ui", - "key": "Gestartet:", - "value": "" - }, - { - "context": "ui", - "key": "Gestoppt", - "value": "" - }, - { - "context": "ui", - "key": "Geteilt", - "value": "" - }, - { - "context": "ui", - "key": "Geteilte Dateien", - "value": "" - }, - { - "context": "ui", - "key": "Globale Sprachsets verwalten (SysAdmin).", - "value": "" - }, - { - "context": "ui", - "key": "Google", - "value": "" - }, - { - "context": "ui", - "key": "Google verbinden", - "value": "" - }, - { - "context": "ui", - "key": "Google-Verbindung erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Google-Verbindung hinzufügen", - "value": "" - }, - { - "context": "ui", - "key": "Grundlegende Daten und Ressourcen", - "value": "" - }, - { - "context": "ui", - "key": "Größe", - "value": "" - }, - { - "context": "ui", - "key": "Hell", - "value": "" - }, - { - "context": "ui", - "key": "Herunterladen", - "value": "" - }, - { - "context": "ui", - "key": "Hinzufügen", - "value": "" - }, - { - "context": "ui", - "key": "Hochgeladen", - "value": "" - }, - { - "context": "ui", - "key": "Hochladen", - "value": "" - }, - { - "context": "ui", - "key": "ID", - "value": "" - }, - { - "context": "ui", - "key": "INFO", - "value": "" - }, - { - "context": "ui", - "key": "Identifizieren und authentifizieren Sie Anrufer in Sekunden mit kontinuierlicher Verifizierung und Sicherheit.", - "value": "" - }, - { - "context": "ui", - "key": "Ihre Anfrage wird verarbeitet...", - "value": "" - }, - { - "context": "ui", - "key": "In die Zwischenablage kopiert", - "value": "" - }, - { - "context": "ui", - "key": "Inaktiv", - "value": "" - }, - { - "context": "ui", - "key": "Information", - "value": "" - }, - { - "context": "ui", - "key": "Inhalt", - "value": "" - }, - { - "context": "ui", - "key": "Inhalt ist erforderlich", - "value": "" - }, - { - "context": "ui", - "key": "Ja", - "value": "" - }, - { - "context": "ui", - "key": "Jetzt anmelden", - "value": "" - }, - { - "context": "ui", - "key": "Jetzt überspringen", - "value": "" - }, - { - "context": "ui", - "key": "KI-erstellt", - "value": "" - }, - { - "context": "ui", - "key": "KI-gestützte Dokumentengenerierung:", - "value": "" - }, - { - "context": "ui", - "key": "Kein Auth-Anbieter", - "value": "" - }, - { - "context": "ui", - "key": "Kein Benutzername", - "value": "" - }, - { - "context": "ui", - "key": "Kein Nachrichteninhalt verfügbar", - "value": "" - }, - { - "context": "ui", - "key": "Kein Name", - "value": "" - }, - { - "context": "ui", - "key": "Kein Workflow ausgewählt", - "value": "" - }, - { - "context": "ui", - "key": "Keine", - "value": "" - }, - { - "context": "ui", - "key": "Keine Benutzer verfügbar", - "value": "" - }, - { - "context": "ui", - "key": "Keine Berechtigung", - "value": "" - }, - { - "context": "ui", - "key": "Keine Berechtigung zum Löschen des Prompts", - "value": "" - }, - { - "context": "ui", - "key": "Keine Dateien gefunden.", - "value": "" - }, - { - "context": "ui", - "key": "Keine Daten verfügbar", - "value": "" - }, - { - "context": "ui", - "key": "Keine E-Mail", - "value": "" - }, - { - "context": "ui", - "key": "Keine Einträge", - "value": "" - }, - { - "context": "ui", - "key": "Keine Logs für diesen Workflow verfügbar", - "value": "" - }, - { - "context": "ui", - "key": "Keine Microsoft-Verbindungen gefunden. Bitte erstellen Sie zuerst eine Verbindung.", - "value": "" - }, - { - "context": "ui", - "key": "Keine Optionen verfügbar", - "value": "" - }, - { - "context": "ui", - "key": "Keine Prompts verfügbar", - "value": "" - }, - { - "context": "ui", - "key": "Keine SharePoint-Sites gefunden", - "value": "" - }, - { - "context": "ui", - "key": "Keine Sprach-Integrations-Daten gefunden. Bitte melden Sie sich zuerst an, um auf die Einstellungen zuzugreifen.", - "value": "" - }, - { - "context": "ui", - "key": "Keine Sprache", - "value": "" - }, - { - "context": "ui", - "key": "Keine Transkripte vorhanden", - "value": "" - }, - { - "context": "ui", - "key": "Keine Vorschau verfügbar", - "value": "" - }, - { - "context": "ui", - "key": "Keine Workflows gefunden", - "value": "" - }, - { - "context": "ui", - "key": "Keine Workflows verfügbar", - "value": "" - }, - { - "context": "ui", - "key": "Keine hochgeladenen Dateien gefunden.", - "value": "" - }, - { - "context": "ui", - "key": "Keine mit Ihnen geteilten Dateien gefunden.", - "value": "" - }, - { - "context": "ui", - "key": "Keine von der KI erstellten Dateien gefunden.", - "value": "" - }, - { - "context": "ui", - "key": "Klicken Sie erneut zum Bestätigen", - "value": "" - }, - { - "context": "ui", - "key": "Klicken Sie erneut zum Bestätigen der Löschung", - "value": "" - }, - { - "context": "ui", - "key": "Klicken Sie, um zu öffnen", - "value": "" - }, - { - "context": "ui", - "key": "Knowledge Agent (KA)", - "value": "" - }, - { - "context": "ui", - "key": "Konfigurieren Sie administrative Einstellungen und Systempräferenzen.", - "value": "" - }, - { - "context": "ui", - "key": "Konfigurieren und verwalten Sie rollenbasierte Zugriffssteuerungsregeln.", - "value": "" - }, - { - "context": "ui", - "key": "Kontakte einrichten", - "value": "" - }, - { - "context": "ui", - "key": "Kontaktinformationen", - "value": "" - }, - { - "context": "ui", - "key": "Kontostatus", - "value": "" - }, - { - "context": "ui", - "key": "Kopieren", - "value": "" - }, - { - "context": "ui", - "key": "Kopiert", - "value": "" - }, - { - "context": "ui", - "key": "Kosteneinsparungen & Effizienz:", - "value": "" - }, - { - "context": "ui", - "key": "Kundenverträge verwalten", - "value": "" - }, - { - "context": "ui", - "key": "Lade Filterwerte...", - "value": "" - }, - { - "context": "ui", - "key": "Lade Fortschritt...", - "value": "" - }, - { - "context": "ui", - "key": "Laden...", - "value": "" - }, - { - "context": "ui", - "key": "Land", - "value": "" - }, - { - "context": "ui", - "key": "Land ist erforderlich", - "value": "" - }, - { - "context": "ui", - "key": "Leer = Zugriff auf alle Verträge", - "value": "" - }, - { - "context": "ui", - "key": "Letzte Aktivität", - "value": "" - }, - { - "context": "ui", - "key": "Letzte Aktivität:", - "value": "" - }, - { - "context": "ui", - "key": "Letzte Aktivitäten - Sehen Sie Ihre neueste Arbeit", - "value": "" - }, - { - "context": "ui", - "key": "Letzte Seite", - "value": "" - }, - { - "context": "ui", - "key": "Link konnte nicht gesendet werden", - "value": "" - }, - { - "context": "ui", - "key": "Log", - "value": "" - }, - { - "context": "ui", - "key": "Logs konnten nicht geladen werden", - "value": "" - }, - { - "context": "ui", - "key": "Logs werden geladen...", - "value": "" - }, - { - "context": "ui", - "key": "Lokal", - "value": "" - }, - { - "context": "ui", - "key": "LÄUFT", - "value": "" - }, - { - "context": "ui", - "key": "Lädt hoch...", - "value": "" - }, - { - "context": "ui", - "key": "Läuft", - "value": "" - }, - { - "context": "ui", - "key": "Läuft ab am", - "value": "" - }, - { - "context": "ui", - "key": "Löschen", - "value": "" - }, - { - "context": "ui", - "key": "Löschen ({count})", - "value": "" - }, - { - "context": "ui", - "key": "Löschen...", - "value": "" - }, - { - "context": "ui", - "key": "MIME-Typ", - "value": "" - }, - { - "context": "ui", - "key": "Management-Tools umfassen:", - "value": "" - }, - { - "context": "ui", - "key": "Mandanten können jederzeit auf die technische SIP-Nummer umstellen und dabei erhebliche Telefoniekosten sparen. Die Integration funktioniert wie ein weiterer Connector (Outlook, SharePoint) und wird nahtlos in Ihren bestehenden Workflow integriert.", - "value": "" - }, - { - "context": "ui", - "key": "Mandat erfolgreich eingereicht!", - "value": "" - }, - { - "context": "ui", - "key": "Mandat erfolgreich erstellt", - "value": "" - }, - { - "context": "ui", - "key": "Mandat erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Mandat hinzufügen", - "value": "" - }, - { - "context": "ui", - "key": "Mandat-ID", - "value": "" - }, - { - "context": "ui", - "key": "Mandate", - "value": "" - }, - { - "context": "ui", - "key": "Mandate und Berechtigungen verwalten", - "value": "" - }, - { - "context": "ui", - "key": "Mandatsverwaltung", - "value": "" - }, - { - "context": "ui", - "key": "Mehr erfahren", - "value": "" - }, - { - "context": "ui", - "key": "Meine Uploads", - "value": "" - }, - { - "context": "ui", - "key": "Microsoft", - "value": "" - }, - { - "context": "ui", - "key": "Microsoft Verbindungen", - "value": "" - }, - { - "context": "ui", - "key": "Microsoft verbinden", - "value": "" - }, - { - "context": "ui", - "key": "Microsoft-Verbindung erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Microsoft-Verbindung hinzufügen", - "value": "" - }, - { - "context": "ui", - "key": "Mitglied hinzufügen", - "value": "" - }, - { - "context": "ui", - "key": "MwSt %", - "value": "" - }, - { - "context": "ui", - "key": "MwSt Betrag", - "value": "" - }, - { - "context": "ui", - "key": "Möchten Sie jetzt Kontakte für Ihr Mandat einrichten? Sie können dies auch später in den Einstellungen tun.", - "value": "" - }, - { - "context": "ui", - "key": "Nach unten scrollen", - "value": "" - }, - { - "context": "ui", - "key": "Nachricht (optional)", - "value": "" - }, - { - "context": "ui", - "key": "Nachricht eingeben...", - "value": "" - }, - { - "context": "ui", - "key": "Nachricht wird gesendet...", - "value": "" - }, - { - "context": "ui", - "key": "Nachrichten", - "value": "" - }, - { - "context": "ui", - "key": "Nahtloser Mandanten-Workflow:", - "value": "" - }, - { - "context": "ui", - "key": "Name", - "value": "" - }, - { - "context": "ui", - "key": "Name des Unternehmens", - "value": "" - }, - { - "context": "ui", - "key": "Name ist erforderlich", - "value": "" - }, - { - "context": "ui", - "key": "Navigation - Erkunden Sie alle verfügbaren Tools", - "value": "" - }, - { - "context": "ui", - "key": "Nein", - "value": "" - }, - { - "context": "ui", - "key": "Neu starten", - "value": "" - }, - { - "context": "ui", - "key": "Neue Automatisierung", - "value": "" - }, - { - "context": "ui", - "key": "Neue Automatisierung erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Neue Datei hochladen", - "value": "" - }, - { - "context": "ui", - "key": "Neue Organisation", - "value": "" - }, - { - "context": "ui", - "key": "Neue Organisation erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Neue Position", - "value": "" - }, - { - "context": "ui", - "key": "Neue Position erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Neue RBAC-Regel erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Neue Rolle", - "value": "" - }, - { - "context": "ui", - "key": "Neue Rolle erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Neue Sprache", - "value": "" - }, - { - "context": "ui", - "key": "Neuen Prompt erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Neuen Vertrag erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Neuen Zugriff erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Neuer Prompt", - "value": "" - }, - { - "context": "ui", - "key": "Neuer Vertrag", - "value": "" - }, - { - "context": "ui", - "key": "Neuer Zugriff", - "value": "" - }, - { - "context": "ui", - "key": "Neues Dokument", - "value": "" - }, - { - "context": "ui", - "key": "Neues Dokument erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Neues Mandat erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Neues Team-Mitglied erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Neues Transkript", - "value": "" - }, - { - "context": "ui", - "key": "Nicht verfügbar", - "value": "" - }, - { - "context": "ui", - "key": "Noch keine Befehle ausgeführt. Senden Sie einen Befehl, um Ergebnisse hier zu sehen.", - "value": "" - }, - { - "context": "ui", - "key": "Noch keinen Workflow ausgewählt", - "value": "" - }, - { - "context": "ui", - "key": "Nochmal versuchen", - "value": "" - }, - { - "context": "ui", - "key": "Nächste Seite", - "value": "" - }, - { - "context": "ui", - "key": "Oder geben Sie Ihre Nachricht ein...", - "value": "" - }, - { - "context": "ui", - "key": "Ordnerpfade", - "value": "" - }, - { - "context": "ui", - "key": "Organisation", - "value": "" - }, - { - "context": "ui", - "key": "Organisation erfolgreich erstellt", - "value": "" - }, - { - "context": "ui", - "key": "Organisationen", - "value": "" - }, - { - "context": "ui", - "key": "Originalbetrag", - "value": "" - }, - { - "context": "ui", - "key": "Originalwährung", - "value": "" - }, - { - "context": "ui", - "key": "PDF", - "value": "" - }, - { - "context": "ui", - "key": "Passwort", - "value": "" - }, - { - "context": "ui", - "key": "Passwort eingeben", - "value": "" - }, - { - "context": "ui", - "key": "Passwort-Link gesendet!", - "value": "" - }, - { - "context": "ui", - "key": "Passwort-Link senden", - "value": "" - }, - { - "context": "ui", - "key": "Pfad", - "value": "" - }, - { - "context": "ui", - "key": "Position erfolgreich erstellt", - "value": "" - }, - { - "context": "ui", - "key": "Positionen", - "value": "" - }, - { - "context": "ui", - "key": "Postleitzahl", - "value": "" - }, - { - "context": "ui", - "key": "Postleitzahl ist erforderlich", - "value": "" - }, - { - "context": "ui", - "key": "Projekte", - "value": "" - }, - { - "context": "ui", - "key": "Projektverwaltung", - "value": "" - }, - { - "context": "ui", - "key": "Projektverwaltung und -organisation", - "value": "" - }, - { - "context": "ui", - "key": "Prompt", - "value": "" - }, - { - "context": "ui", - "key": "Prompt Einstellungen", - "value": "" - }, - { - "context": "ui", - "key": "Prompt Vorlage", - "value": "" - }, - { - "context": "ui", - "key": "Prompt ausführen", - "value": "" - }, - { - "context": "ui", - "key": "Prompt auswählen...", - "value": "" - }, - { - "context": "ui", - "key": "Prompt bearbeiten", - "value": "" - }, - { - "context": "ui", - "key": "Prompt erfolgreich erstellt", - "value": "" - }, - { - "context": "ui", - "key": "Prompt erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Prompt hinzufügen", - "value": "" - }, - { - "context": "ui", - "key": "Prompt löschen", - "value": "" - }, - { - "context": "ui", - "key": "Prompt teilen", - "value": "" - }, - { - "context": "ui", - "key": "Prompt wird gelöscht...", - "value": "" - }, - { - "context": "ui", - "key": "Prompt-Inhalt", - "value": "" - }, - { - "context": "ui", - "key": "Prompt-Inhalt darf 10.000 Zeichen nicht überschreiten", - "value": "" - }, - { - "context": "ui", - "key": "Prompt-Inhalt darf nicht leer sein", - "value": "" - }, - { - "context": "ui", - "key": "Prompt-Name", - "value": "" - }, - { - "context": "ui", - "key": "Prompt-Name darf 100 Zeichen nicht überschreiten", - "value": "" - }, - { - "context": "ui", - "key": "Prompt-Name darf nicht leer sein", - "value": "" - }, - { - "context": "ui", - "key": "Prompts", - "value": "" - }, - { - "context": "ui", - "key": "Prompts für Ihren KI-Assistenten erstellen und verwalten", - "value": "" - }, - { - "context": "ui", - "key": "Prompts verwalten", - "value": "" - }, - { - "context": "ui", - "key": "Prompts werden geladen...", - "value": "" - }, - { - "context": "ui", - "key": "Python", - "value": "" - }, - { - "context": "ui", - "key": "Quelle", - "value": "" - }, - { - "context": "ui", - "key": "RBAC-Regel erfolgreich erstellt", - "value": "" - }, - { - "context": "ui", - "key": "RBAC-Regel hinzufügen", - "value": "" - }, - { - "context": "ui", - "key": "RBAC-Regeln", - "value": "" - }, - { - "context": "ui", - "key": "RBAC-Regelverwaltung", - "value": "" - }, - { - "context": "ui", - "key": "RBAC-Rollen", - "value": "" - }, - { - "context": "ui", - "key": "RBAC-Rollenverwaltung", - "value": "" - }, - { - "context": "ui", - "key": "Registrieren", - "value": "" - }, - { - "context": "ui", - "key": "Revolutionäre Telefonie-Integration mit Spitch.ai", - "value": "" - }, - { - "context": "ui", - "key": "Rohtext in die Zwischenablage kopieren", - "value": "" - }, - { - "context": "ui", - "key": "Rolle", - "value": "" - }, - { - "context": "ui", - "key": "Rolle erfolgreich erstellt", - "value": "" - }, - { - "context": "ui", - "key": "Rolle hinzufügen", - "value": "" - }, - { - "context": "ui", - "key": "Rollen", - "value": "" - }, - { - "context": "ui", - "key": "Rollen-ID", - "value": "" - }, - { - "context": "ui", - "key": "Rollenbasierte Zugriffssteuerungsregeln", - "value": "" - }, - { - "context": "ui", - "key": "Rollenverwaltung", - "value": "" - }, - { - "context": "ui", - "key": "Rufname am Telefon", - "value": "" - }, - { - "context": "ui", - "key": "Runde", - "value": "" - }, - { - "context": "ui", - "key": "Runden", - "value": "" - }, - { - "context": "ui", - "key": "Schließen", - "value": "" - }, - { - "context": "ui", - "key": "Schnellzugriff", - "value": "" - }, - { - "context": "ui", - "key": "Schnellzugriff - Springen Sie zu häufig verwendeten Features", - "value": "" - }, - { - "context": "ui", - "key": "Seite", - "value": "" - }, - { - "context": "ui", - "key": "Seite {page} von {total} ({count} Einträge)", - "value": "" - }, - { - "context": "ui", - "key": "Senden", - "value": "" - }, - { - "context": "ui", - "key": "Service", - "value": "" - }, - { - "context": "ui", - "key": "Service-Verbindungen", - "value": "" - }, - { - "context": "ui", - "key": "SharePoint Dokumente", - "value": "" - }, - { - "context": "ui", - "key": "SharePoint Site URL", - "value": "" - }, - { - "context": "ui", - "key": "SharePoint Test", - "value": "" - }, - { - "context": "ui", - "key": "Sie erhalten in den nächsten Minuten eine Bestätigungs-E-Mail.", - "value": "" - }, - { - "context": "ui", - "key": "Sie können auch auf den Upload-Button klicken", - "value": "" - }, - { - "context": "ui", - "key": "Sie müssen sich zuerst für die Sprach-Integration anmelden, um auf die Transkriptverwaltung zuzugreifen.", - "value": "" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie \"{name}\" löschen möchten?", - "value": "" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie Workflow \"{id}...\" löschen möchten?", - "value": "" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie alle Sprach-Integrations-Einstellungen zurücksetzen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", - "value": "" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie das ausgewählte Element löschen möchten?", - "value": "" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie den Workflow \"{name}\" löschen möchten?", - "value": "" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie die Datei \"{name}\" löschen möchten?", - "value": "" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie die {count} ausgewählten Elemente löschen möchten?", - "value": "" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie die {service} Verbindung löschen möchten?", - "value": "" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie diesen Benutzer löschen möchten?", - "value": "" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie {count} Benutzer löschen möchten?", - "value": "" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie {count} Prompts löschen möchten?", - "value": "" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie {count} Verbindungen löschen möchten?", - "value": "" - }, - { - "context": "ui", - "key": "Sites entdecken", - "value": "" - }, - { - "context": "ui", - "key": "Sortierung {position}: {direction}", - "value": "" - }, - { - "context": "ui", - "key": "Speech Analytics (SA)", - "value": "" - }, - { - "context": "ui", - "key": "Speichern", - "value": "" - }, - { - "context": "ui", - "key": "Speichern...", - "value": "" - }, - { - "context": "ui", - "key": "Spitch prüft vor jedem Anruf die Mandantenberechtigung bei PowerOn, während alle Datenänderungen zentral von PowerOn initiiert werden. Call-Transkripte werden in Echtzeit in Ihrer PowerOn-Datenbank gespeichert, mit vollständiger Mandantenisolation und Sicherheit. Bei Ausfällen werden Anrufe automatisch blockiert, um die Integrität zu gewährleisten.", - "value": "" - }, - { - "context": "ui", - "key": "Sprach Integration", - "value": "" - }, - { - "context": "ui", - "key": "Sprach-Einstellungen", - "value": "" - }, - { - "context": "ui", - "key": "Sprach-Integration Einstellungen", - "value": "" - }, - { - "context": "ui", - "key": "Sprache", - "value": "" - }, - { - "context": "ui", - "key": "Sprachset {code} wirklich löschen?", - "value": "" - }, - { - "context": "ui", - "key": "Stadt", - "value": "" - }, - { - "context": "ui", - "key": "Stadt ist erforderlich", - "value": "" - }, - { - "context": "ui", - "key": "Start", - "value": "" - }, - { - "context": "ui", - "key": "Startzeit", - "value": "" - }, - { - "context": "ui", - "key": "Status", - "value": "" - }, - { - "context": "ui", - "key": "Stellen Sie alles, was Ihre Agenten benötigen, in ihren Händen bereit, mit einem einheitlichen Agent-Desktop.", - "value": "" - }, - { - "context": "ui", - "key": "Stoppen", - "value": "" - }, - { - "context": "ui", - "key": "Straße", - "value": "" - }, - { - "context": "ui", - "key": "Straße ist erforderlich", - "value": "" - }, - { - "context": "ui", - "key": "Suchen Sie nach Standorten über Adresse oder Koordinaten, oder verwenden Sie natürliche Sprache, um Projekte zu erstellen und zu verwalten.", - "value": "" - }, - { - "context": "ui", - "key": "Suchen...", - "value": "" - }, - { - "context": "ui", - "key": "Systemadministrator", - "value": "" - }, - { - "context": "ui", - "key": "Systemeinstellungen - Arbeitsbereich-Einstellungen konfigurieren", - "value": "" - }, - { - "context": "ui", - "key": "Tabelle", - "value": "" - }, - { - "context": "ui", - "key": "Tags", - "value": "" - }, - { - "context": "ui", - "key": "Team-Bereich", - "value": "" - }, - { - "context": "ui", - "key": "Team-Mitglied erfolgreich erstellt", - "value": "" - }, - { - "context": "ui", - "key": "Team-Mitglieder", - "value": "" - }, - { - "context": "ui", - "key": "Team-Mitglieder verwalten", - "value": "" - }, - { - "context": "ui", - "key": "Team-Mitglieder verwalten, Berechtigungen festlegen und Zusammenarbeitseinstellungen konfigurieren", - "value": "" - }, - { - "context": "ui", - "key": "Teilen", - "value": "" - }, - { - "context": "ui", - "key": "Telefon", - "value": "" - }, - { - "context": "ui", - "key": "Telefonnummer", - "value": "" - }, - { - "context": "ui", - "key": "Telefonnummer ist erforderlich", - "value": "" - }, - { - "context": "ui", - "key": "Text", - "value": "" - }, - { - "context": "ui", - "key": "Textvorschau", - "value": "" - }, - { - "context": "ui", - "key": "Theme", - "value": "" - }, - { - "context": "ui", - "key": "Token", - "value": "" - }, - { - "context": "ui", - "key": "Transkript", - "value": "" - }, - { - "context": "ui", - "key": "Transkript wird verarbeitet...", - "value": "" - }, - { - "context": "ui", - "key": "Transkriptverwaltung", - "value": "" - }, - { - "context": "ui", - "key": "Trennungsfehler", - "value": "" - }, - { - "context": "ui", - "key": "Treuhand", - "value": "" - }, - { - "context": "ui", - "key": "Treuhandverwaltung", - "value": "" - }, - { - "context": "ui", - "key": "Trustee-Organisationen verwalten", - "value": "" - }, - { - "context": "ui", - "key": "Trustee-Rollen verwalten", - "value": "" - }, - { - "context": "ui", - "key": "Typ", - "value": "" - }, - { - "context": "ui", - "key": "UI-Sprachen", - "value": "" - }, - { - "context": "ui", - "key": "Unbekannt", - "value": "" - }, - { - "context": "ui", - "key": "Unbekannte Größe", - "value": "" - }, - { - "context": "ui", - "key": "Unbekanntes Datum", - "value": "" - }, - { - "context": "ui", - "key": "Unbenannt", - "value": "" - }, - { - "context": "ui", - "key": "Unbenannter Workflow", - "value": "" - }, - { - "context": "ui", - "key": "Ungültige Auswahl", - "value": "" - }, - { - "context": "ui", - "key": "Ungültige URL", - "value": "" - }, - { - "context": "ui", - "key": "Ungültiges Datum", - "value": "" - }, - { - "context": "ui", - "key": "Ungültiges Datumsformat", - "value": "" - }, - { - "context": "ui", - "key": "Ungültiges E-Mail-Format", - "value": "" - }, - { - "context": "ui", - "key": "Ungültiges JSON", - "value": "" - }, - { - "context": "ui", - "key": "Unser Team wird Ihr Mandat innerhalb von 1-2 Werktagen überprüfen.", - "value": "" - }, - { - "context": "ui", - "key": "Unsere bereits aktive Dokumenten-Extraktions-Engine generiert automatisch personalisierte Dokumente für Spitch, basierend auf Mandantenspezifischen Daten. Die KI nutzt FAQ-Datenbanken, Mitarbeiterinformationen und Service-Details, um jeden Anruf kontextuell und hochpersonalisiert zu gestalten.", - "value": "" - }, - { - "context": "ui", - "key": "Unternehmensinformationen", - "value": "" - }, - { - "context": "ui", - "key": "Unterstützt von", - "value": "" - }, - { - "context": "ui", - "key": "Upload fehlgeschlagen. Bitte versuchen Sie es erneut.", - "value": "" - }, - { - "context": "ui", - "key": "VERARBEITUNG", - "value": "" - }, - { - "context": "ui", - "key": "Valutadatum", - "value": "" - }, - { - "context": "ui", - "key": "Verarbeitung", - "value": "" - }, - { - "context": "ui", - "key": "Verbinden", - "value": "" - }, - { - "context": "ui", - "key": "Verbindung aktualisieren", - "value": "" - }, - { - "context": "ui", - "key": "Verbindung testen", - "value": "" - }, - { - "context": "ui", - "key": "Verbindungen", - "value": "" - }, - { - "context": "ui", - "key": "Verbindungen werden geladen...", - "value": "" - }, - { - "context": "ui", - "key": "Verbindungsfehler", - "value": "" - }, - { - "context": "ui", - "key": "Verbunden am", - "value": "" - }, - { - "context": "ui", - "key": "Vereinheitlichen und liefern Sie Informationen an Ihre Kunden und Mitarbeiter, wann und wo sie sie benötigen.", - "value": "" - }, - { - "context": "ui", - "key": "Verfügbare Tools", - "value": "" - }, - { - "context": "ui", - "key": "Verfügbare Workflows", - "value": "" - }, - { - "context": "ui", - "key": "Version", - "value": "" - }, - { - "context": "ui", - "key": "Versuchen Sie, Ihr Microsoft-Konto auf der Verbindungsseite erneut zu verbinden.", - "value": "" - }, - { - "context": "ui", - "key": "Vertrag", - "value": "" - }, - { - "context": "ui", - "key": "Vertrag (optional)", - "value": "" - }, - { - "context": "ui", - "key": "Vertrag erfolgreich erstellt", - "value": "" - }, - { - "context": "ui", - "key": "Verträge", - "value": "" - }, - { - "context": "ui", - "key": "Verwalten Sie Daten über Tabellen. Wählen Sie eine Tabelle aus oder verwenden Sie natürliche Sprache, um Befehle auszuführen.", - "value": "" - }, - { - "context": "ui", - "key": "Verwalten Sie Ihre Kontoinformationen", - "value": "" - }, - { - "context": "ui", - "key": "Verwalten Sie Ihre Service-Verbindungen", - "value": "" - }, - { - "context": "ui", - "key": "Verwalten Sie Ihre Sprach-Integrations-Konfiguration und Einstellungen.", - "value": "" - }, - { - "context": "ui", - "key": "Verwalten Sie Mandate und deren zugehörige Berechtigungen.", - "value": "" - }, - { - "context": "ui", - "key": "Verwaltet von {provider}", - "value": "" - }, - { - "context": "ui", - "key": "Verwaltung der Benutzerzugriffe auf Organisationen", - "value": "" - }, - { - "context": "ui", - "key": "Verwaltung der Buchungspositionen (Speseneinträge)", - "value": "" - }, - { - "context": "ui", - "key": "Verwaltung der Dokumente und Belege", - "value": "" - }, - { - "context": "ui", - "key": "Verwaltung der Feature-spezifischen Rollen", - "value": "" - }, - { - "context": "ui", - "key": "Verwaltung der Kundenverträge", - "value": "" - }, - { - "context": "ui", - "key": "Verwaltung der Treuhand-Organisationen", - "value": "" - }, - { - "context": "ui", - "key": "Verwaltung von Treuhand-Organisationen, Verträgen und Buchungen", - "value": "" - }, - { - "context": "ui", - "key": "Verwaltungs- und Management-Tools", - "value": "" - }, - { - "context": "ui", - "key": "Verwende Vorlage:", - "value": "" - }, - { - "context": "ui", - "key": "Video", - "value": "" - }, - { - "context": "ui", - "key": "Vielen Dank für Ihr Interesse an unserer Sprach Integration powered by Spitch.ai. Wir haben Ihr Mandat erhalten und werden es in Kürze überprüfen.", - "value": "" - }, - { - "context": "ui", - "key": "Virtual Assistant (VA)", - "value": "" - }, - { - "context": "ui", - "key": "Voice Biometrics (VB)", - "value": "" - }, - { - "context": "ui", - "key": "Vollständiger Name", - "value": "" - }, - { - "context": "ui", - "key": "Von", - "value": "" - }, - { - "context": "ui", - "key": "Von der Registrierung bis zur technischen Einrichtung - Ihr Mandant registriert sich bei PowerOn für Telefonie-Services, lädt Dokumente hoch und erhält automatisch eine technische SIP-Nummer von Spitch. Die Call-Weiterleitung kann jederzeit aktiviert oder deaktiviert werden, was maximale Flexibilität und BCM-Sicherheit gewährleistet.", - "value": "" - }, - { - "context": "ui", - "key": "Vorherige Seite", - "value": "" - }, - { - "context": "ui", - "key": "Vorschau", - "value": "" - }, - { - "context": "ui", - "key": "Vorschau für diesen Dateityp nicht verfügbar", - "value": "" - }, - { - "context": "ui", - "key": "Vorschau schließen", - "value": "" - }, - { - "context": "ui", - "key": "Vorschau wird geladen...", - "value": "" - }, - { - "context": "ui", - "key": "WARTEND", - "value": "" - }, - { - "context": "ui", - "key": "Wartend", - "value": "" - }, - { - "context": "ui", - "key": "Was passiert als nächstes?", - "value": "" - }, - { - "context": "ui", - "key": "Wechseln Sie zwischen hellem und dunklem Modus", - "value": "" - }, - { - "context": "ui", - "key": "Werkzeuge", - "value": "" - }, - { - "context": "ui", - "key": "Werkzeuge und Hilfsmittel", - "value": "" - }, - { - "context": "ui", - "key": "Wie möchten Sie am Telefon genannt werden?", - "value": "" - }, - { - "context": "ui", - "key": "Wiederholen", - "value": "" - }, - { - "context": "ui", - "key": "Willkommen in Ihrem Arbeitsbereich", - "value": "" - }, - { - "context": "ui", - "key": "Wird gesendet...", - "value": "" - }, - { - "context": "ui", - "key": "Wird gestoppt...", - "value": "" - }, - { - "context": "ui", - "key": "Wird geteilt...", - "value": "" - }, - { - "context": "ui", - "key": "Wird hochgeladen...", - "value": "" - }, - { - "context": "ui", - "key": "Wird verarbeitet...", - "value": "" - }, - { - "context": "ui", - "key": "Workflow", - "value": "" - }, - { - "context": "ui", - "key": "Workflow Fortschritt", - "value": "" - }, - { - "context": "ui", - "key": "Workflow auswählen", - "value": "" - }, - { - "context": "ui", - "key": "Workflow fehlgeschlagen.", - "value": "" - }, - { - "context": "ui", - "key": "Workflow fortsetzen", - "value": "" - }, - { - "context": "ui", - "key": "Workflow läuft... Warte auf Logs...", - "value": "" - }, - { - "context": "ui", - "key": "Workflow löschen", - "value": "" - }, - { - "context": "ui", - "key": "Workflow stoppen", - "value": "" - }, - { - "context": "ui", - "key": "Workflow wird fortgesetzt", - "value": "" - }, - { - "context": "ui", - "key": "Workflow wird gelöscht...", - "value": "" - }, - { - "context": "ui", - "key": "Workflow-Automatisierungen verwalten", - "value": "" - }, - { - "context": "ui", - "key": "Workflow-Nachrichten werden geladen...", - "value": "" - }, - { - "context": "ui", - "key": "Workflow-Verlauf", - "value": "" - }, - { - "context": "ui", - "key": "Workflows", - "value": "" - }, - { - "context": "ui", - "key": "Workflows werden geladen...", - "value": "" - }, - { - "context": "ui", - "key": "Wähle einen Workflow aus der Liste aus oder starte einen neuen Workflow", - "value": "" - }, - { - "context": "ui", - "key": "Wählen Sie Ihre bevorzugte Sprache", - "value": "" - }, - { - "context": "ui", - "key": "You", - "value": "" - }, - { - "context": "ui", - "key": "Zeitzone", - "value": "" - }, - { - "context": "ui", - "key": "Zentrale", - "value": "" - }, - { - "context": "ui", - "key": "Zu dunklem Modus wechseln", - "value": "" - }, - { - "context": "ui", - "key": "Zu hellem Modus wechseln", - "value": "" - }, - { - "context": "ui", - "key": "Zugriff", - "value": "" - }, - { - "context": "ui", - "key": "Zugriff erfolgreich erstellt", - "value": "" - }, - { - "context": "ui", - "key": "Zugriff verweigert", - "value": "" - }, - { - "context": "ui", - "key": "Zuletzt geprüft", - "value": "" - }, - { - "context": "ui", - "key": "Zum Bestätigen klicken", - "value": "" - }, - { - "context": "ui", - "key": "Zum Bestätigen klicken...", - "value": "" - }, - { - "context": "ui", - "key": "Zum Ein-/Ausklappen klicken", - "value": "" - }, - { - "context": "ui", - "key": "Zum Filtern klicken", - "value": "" - }, - { - "context": "ui", - "key": "Zum Sortieren klicken", - "value": "" - }, - { - "context": "ui", - "key": "Zurück zur Sprach Integration", - "value": "" - }, - { - "context": "ui", - "key": "angehängt", - "value": "" - }, - { - "context": "ui", - "key": "ausgewählt", - "value": "" - }, - { - "context": "ui", - "key": "k. A.", - "value": "" - }, - { - "context": "ui", - "key": "kontakt@firma.com", - "value": "" - }, - { - "context": "ui", - "key": "oder", - "value": "" - }, - { - "context": "ui", - "key": "z.B. Beleg.pdf", - "value": "" - }, - { - "context": "ui", - "key": "z.B. Finanzdienstleistungen, Technologie, etc.", - "value": "" - }, - { - "context": "ui", - "key": "z.B. Muster AG 2026", - "value": "" - }, - { - "context": "ui", - "key": "z.B. Treuhand AG Zürich", - "value": "" - }, - { - "context": "ui", - "key": "z.B. admin, operate, userreport", - "value": "" - }, - { - "context": "ui", - "key": "z.B. treuhand-ag-zuerich", - "value": "" - }, - { - "context": "ui", - "key": "{authority} Verbindung bearbeiten", - "value": "" - }, - { - "context": "ui", - "key": "{column} filtern", - "value": "" - }, - { - "context": "ui", - "key": "{count} Benutzer ausgewählt", - "value": "" - }, - { - "context": "ui", - "key": "{fieldLabel} ist erforderlich", - "value": "" - }, - { - "context": "ui", - "key": "{fieldLabel} muss eine gültige Ganzzahl sein", - "value": "" - }, - { - "context": "ui", - "key": "{fieldLabel} muss eine gültige Zahl sein", - "value": "" - }, - { - "context": "ui", - "key": "Änderungen speichern", - "value": "" - }, - { - "context": "ui", - "key": "Über", - "value": "" - }, - { - "context": "ui", - "key": "Überprüfungsprozess", - "value": "" - }, - { - "context": "ui", - "key": "Übersicht - Sehen Sie den Arbeitsbereich-Status und Updates", - "value": "" - }, - { - "context": "ui", - "key": "Überwachen Sie automatisch 100% der Gespräche, um wertvolle Einblicke für Ihr Unternehmen zu erhalten.", - "value": "" - }, - { - "context": "ui", - "key": "(gefiltert nach {name})", - "value": "" - }, - { - "context": "ui", - "key": "({count} gefiltert)", - "value": "" - }, - { - "context": "ui", - "key": "Abonnement, Einstellungen und Guthaben pro Mandant", - "value": "" - }, - { - "context": "ui", - "key": "Abrechnung", - "value": "" - }, - { - "context": "ui", - "key": "Aktion", - "value": "" - }, - { - "context": "ui", - "key": "Benutzer-Billing", - "value": "" - }, - { - "context": "ui", - "key": "Benutzer-Guthaben", - "value": "" - }, - { - "context": "ui", - "key": "Benutzer:", - "value": "" - }, - { - "context": "ui", - "key": "Deaktiviert", - "value": "" - }, - { - "context": "ui", - "key": "Du hast Zugriff auf {instanceCount} {instanceWord} in {mandateCount} {mandateWord}.", - "value": "" - }, - { - "context": "ui", - "key": "Einstellungen gespeichert!", - "value": "" - }, - { - "context": "ui", - "key": "Feature-Instanz", - "value": "" - }, - { - "context": "ui", - "key": "Feature-Instanzen", - "value": "" - }, - { - "context": "ui", - "key": "Fehler beim Speichern", - "value": "" - }, - { - "context": "ui", - "key": "Gesamtguthaben", - "value": "" - }, - { - "context": "ui", - "key": "Mandant:", - "value": "" - }, - { - "context": "ui", - "key": "Mandanten", - "value": "" - }, - { - "context": "ui", - "key": "Mandanten-Billing", - "value": "" - }, - { - "context": "ui", - "key": "Mandanten-Guthaben", - "value": "" - }, - { - "context": "ui", - "key": "Mandant", - "value": "" - }, - { - "context": "ui", - "key": "Niedrig", - "value": "" - }, - { - "context": "ui", - "key": "Transaktionen", - "value": "" - }, - { - "context": "ui", - "key": "Warnschwelle", - "value": "" - }, - { - "context": "ui", - "key": "Ansicht an Fenster anpassen", - "value": "" - }, - { - "context": "ui", - "key": "Ansicht zurücksetzen", - "value": "" - }, - { - "context": "ui", - "key": "Auswahl löschen", - "value": "" - }, - { - "context": "ui", - "key": "Canvas bearbeiten", - "value": "" - }, - { - "context": "ui", - "key": "Klicken Sie auf einen Ausgang, dann auf einen Eingang", - "value": "" - }, - { - "context": "ui", - "key": "Klicken Sie auf einen Eingang, um die Verbindung zu erstellen", - "value": "" - }, - { - "context": "ui", - "key": "Kommentar (optional)", - "value": "" - }, - { - "context": "ui", - "key": "Kommentar bearbeiten", - "value": "" - }, - { - "context": "ui", - "key": "Knoten duplizieren", - "value": "" - }, - { - "context": "ui", - "key": "Rückgängig", - "value": "" - }, - { - "context": "ui", - "key": "Verbindungen zeichnen", - "value": "" - }, - { - "context": "ui", - "key": "Vergrößern", - "value": "" - }, - { - "context": "ui", - "key": "Verkleinern", - "value": "" - }, - { - "context": "ui", - "key": "Wiederholen", - "value": "" - }, - { - "context": "ui", - "key": "Zoom-Voreinstellungen", - "value": "" - }, - { - "context": "ui", - "key": "Zoomstufe (Prozent)", - "value": "" - }, - { - "context": "ui", - "key": "Doppelklick zum Bearbeiten", - "value": "" - }, - { - "context": "ui", - "key": "Kommentar auf dem Canvas einfügen", - "value": "" - }, - { - "context": "ui", - "key": "Kommentar eingeben …", - "value": "" - }, - { - "context": "ui", - "key": "Canvas-Notiz verschieben", - "value": "" - }, - { - "context": "ui", - "key": "Notizfarbe", - "value": "" - }, - { - "context": "ui", - "key": "Notizgröße ändern", - "value": "" - }, - { - "context": "ui", - "key": "✓ Mandat eingereicht", - "value": "" - } - ], - "status": "complete", - "isDefault": true - }, - { - "id": "de", - "label": "Deutsch", - "entries": [ - { - "context": "ui", - "key": "+41 123 456 789", - "value": "+41 123 456 789" - }, - { - "context": "ui", - "key": "1 Benutzer ausgewählt", - "value": "1 Benutzer ausgewählt" - }, - { - "context": "ui", - "key": "ABGEBROCHEN", - "value": "ABGEBROCHEN" - }, - { - "context": "ui", - "key": "ABGESCHLOSSEN", - "value": "ABGESCHLOSSEN" - }, - { - "context": "ui", - "key": "Abbrechen", - "value": "Abbrechen" - }, - { - "context": "ui", - "key": "Abgeschlossen", - "value": "Abgeschlossen" - }, - { - "context": "ui", - "key": "Abmelden", - "value": "Abmelden" - }, - { - "context": "ui", - "key": "Admin-Einstellungen", - "value": "Admin-Einstellungen" - }, - { - "context": "ui", - "key": "Administrative Einstellungen", - "value": "Administrative Einstellungen" - }, - { - "context": "ui", - "key": "Administrator", - "value": "Administrator" - }, - { - "context": "ui", - "key": "Adresse", - "value": "Adresse" - }, - { - "context": "ui", - "key": "Agent Assist (AA)", - "value": "Agent Assist (AA)" - }, - { - "context": "ui", - "key": "Aktionen", - "value": "Aktionen" - }, - { - "context": "ui", - "key": "Aktiv", - "value": "Aktiv" - }, - { - "context": "ui", - "key": "Aktiviert", - "value": "Aktiviert" - }, - { - "context": "ui", - "key": "Aktualisieren", - "value": "Aktualisieren" - }, - { - "context": "ui", - "key": "Aktuelle Transkripte", - "value": "Aktuelle Transkripte" - }, - { - "context": "ui", - "key": "Alle", - "value": "Alle" - }, - { - "context": "ui", - "key": "Alle Dateien", - "value": "Alle Dateien" - }, - { - "context": "ui", - "key": "Alle Daten als CSV exportieren", - "value": "Alle Daten als CSV exportieren" - }, - { - "context": "ui", - "key": "Alle Elemente auswählen", - "value": "Alle Elemente auswählen" - }, - { - "context": "ui", - "key": "Alle Nicht-Standard-Sprachsets jetzt mit dem deutschen Master synchronisieren?", - "value": "Alle Nicht-Standard-Sprachsets jetzt mit dem deutschen Master synchronisieren?" - }, - { - "context": "ui", - "key": "Alle abwählen", - "value": "Alle abwählen" - }, - { - "context": "ui", - "key": "Alle aktualisieren", - "value": "Alle aktualisieren" - }, - { - "context": "ui", - "key": "Alle auswählen", - "value": "Alle auswählen" - }, - { - "context": "ui", - "key": "Alle {count} Elemente löschen", - "value": "Alle {count} Elemente löschen" - }, - { - "context": "ui", - "key": "Analysiere Workflow...", - "value": "Analysiere Workflow..." - }, - { - "context": "ui", - "key": "Anmelden", - "value": "Anmelden" - }, - { - "context": "ui", - "key": "Anrufer", - "value": "Anrufer" - }, - { - "context": "ui", - "key": "Anzeigen", - "value": "Anzeigen" - }, - { - "context": "ui", - "key": "Anzeigename", - "value": "Anzeigename" - }, - { - "context": "ui", - "key": "Audio", - "value": "Audio" - }, - { - "context": "ui", - "key": "Auf Standard zurücksetzen", - "value": "Auf Standard zurücksetzen" - }, - { - "context": "ui", - "key": "Aufgaben", - "value": "Aufgaben" - }, - { - "context": "ui", - "key": "Ausführen", - "value": "Ausführen" - }, - { - "context": "ui", - "key": "Ausgewählte Datei:", - "value": "Ausgewählte Datei:" - }, - { - "context": "ui", - "key": "Auth-Anbieter", - "value": "Auth-Anbieter" - }, - { - "context": "ui", - "key": "Authentifizierungsanbieter", - "value": "Authentifizierungsanbieter" - }, - { - "context": "ui", - "key": "Authentifizierungstoken abgelaufen oder ungültig. Bitte verbinden Sie Ihr Microsoft-Konto erneut.", - "value": "Authentifizierungstoken abgelaufen oder ungültig. Bitte verbinden Sie Ihr Microsoft-Konto erneut." - }, - { - "context": "ui", - "key": "Automatisierung erfolgreich erstellt", - "value": "Automatisierung erfolgreich erstellt" - }, - { - "context": "ui", - "key": "Automatisierungen", - "value": "Automatisierungen" - }, - { - "context": "ui", - "key": "Basisdaten", - "value": "Basisdaten" - }, - { - "context": "ui", - "key": "Bearbeiten", - "value": "Bearbeiten" - }, - { - "context": "ui", - "key": "Befehl eingeben (z.B., \"Erstelle ein neues Projekt namens 'Hauptstrasse 42'\")", - "value": "Befehl eingeben (z.B., \"Erstelle ein neues Projekt namens 'Hauptstrasse 42'\")" - }, - { - "context": "ui", - "key": "Beginne ein Gespräch, indem du eine Nachricht eingibst, eine Vorlage auswählst oder einen vorherigen Workflow fortsetzt …", - "value": "Beginne ein Gespräch, indem du eine Nachricht eingibst, eine Vorlage auswählst oder einen vorherigen Workflow fortsetzt …" - }, - { - "context": "ui", - "key": "Beginnen Sie mit:", - "value": "Beginnen Sie mit:" - }, - { - "context": "ui", - "key": "Bei Genehmigung planen wir einen Einrichtungsanruf zur Konfiguration Ihrer Integration.", - "value": "Bei Genehmigung planen wir einen Einrichtungsanruf zur Konfiguration Ihrer Integration." - }, - { - "context": "ui", - "key": "Beim Hochladen ist ein Fehler aufgetreten.", - "value": "Beim Hochladen ist ein Fehler aufgetreten." - }, - { - "context": "ui", - "key": "Beim Hochladen ist ein unerwarteter Fehler aufgetreten.", - "value": "Beim Hochladen ist ein unerwarteter Fehler aufgetreten." - }, - { - "context": "ui", - "key": "Belege verwalten", - "value": "Belege verwalten" - }, - { - "context": "ui", - "key": "Benutzer", - "value": "Benutzer" - }, - { - "context": "ui", - "key": "Benutzer auswählen", - "value": "Benutzer auswählen" - }, - { - "context": "ui", - "key": "Benutzer bearbeiten", - "value": "Benutzer bearbeiten" - }, - { - "context": "ui", - "key": "Benutzer erstellen", - "value": "Benutzer erstellen" - }, - { - "context": "ui", - "key": "Benutzer hinzufügen", - "value": "Benutzer hinzufügen" - }, - { - "context": "ui", - "key": "Benutzer löschen", - "value": "Benutzer löschen" - }, - { - "context": "ui", - "key": "Benutzer werden geladen...", - "value": "Benutzer werden geladen..." - }, - { - "context": "ui", - "key": "Benutzer-Zugriff verwalten", - "value": "Benutzer-Zugriff verwalten" - }, - { - "context": "ui", - "key": "Benutzerdefinierter Titel (optional)", - "value": "Benutzerdefinierter Titel (optional)" - }, - { - "context": "ui", - "key": "Benutzerinformationen", - "value": "Benutzerinformationen" - }, - { - "context": "ui", - "key": "Benutzerinformationen erfolgreich aktualisiert", - "value": "Benutzerinformationen erfolgreich aktualisiert" - }, - { - "context": "ui", - "key": "Benutzerinformationen werden geladen...", - "value": "Benutzerinformationen werden geladen..." - }, - { - "context": "ui", - "key": "Benutzername", - "value": "Benutzername" - }, - { - "context": "ui", - "key": "Benutzerverwaltung - Teammitglieder und Berechtigungen verwalten", - "value": "Benutzerverwaltung - Teammitglieder und Berechtigungen verwalten" - }, - { - "context": "ui", - "key": "Berechtigung", - "value": "Berechtigung" - }, - { - "context": "ui", - "key": "Berechtigungsstufe", - "value": "Berechtigungsstufe" - }, - { - "context": "ui", - "key": "Beschreibung", - "value": "Beschreibung" - }, - { - "context": "ui", - "key": "Beschreibung der Rolle", - "value": "Beschreibung der Rolle" - }, - { - "context": "ui", - "key": "Betrachter", - "value": "Betrachter" - }, - { - "context": "ui", - "key": "Betreff", - "value": "Betreff" - }, - { - "context": "ui", - "key": "Bezeichnung", - "value": "Bezeichnung" - }, - { - "context": "ui", - "key": "Bieten Sie Unterstützung im Live-Chat und setzen Sie intelligente Chatbots in allen Kanälen ein.", - "value": "Bieten Sie Unterstützung im Live-Chat und setzen Sie intelligente Chatbots in allen Kanälen ein." - }, - { - "context": "ui", - "key": "Bild", - "value": "Bild" - }, - { - "context": "ui", - "key": "Bis", - "value": "Bis" - }, - { - "context": "ui", - "key": "Bitte geben Sie eine gültige E-Mail-Adresse ein", - "value": "Bitte geben Sie eine gültige E-Mail-Adresse ein" - }, - { - "context": "ui", - "key": "Bitte wählen Sie mindestens einen Benutzer aus", - "value": "Bitte wählen Sie mindestens einen Benutzer aus" - }, - { - "context": "ui", - "key": "Branche", - "value": "Branche" - }, - { - "context": "ui", - "key": "Branche ist erforderlich", - "value": "Branche ist erforderlich" - }, - { - "context": "ui", - "key": "Buchungsbetrag", - "value": "Buchungsbetrag" - }, - { - "context": "ui", - "key": "Buchungspositionen verwalten", - "value": "Buchungspositionen verwalten" - }, - { - "context": "ui", - "key": "Buchungswährung", - "value": "Buchungswährung" - }, - { - "context": "ui", - "key": "Chat Platform (CP)", - "value": "Chat Platform (CP)" - }, - { - "context": "ui", - "key": "Chat leeren...", - "value": "Chat leeren..." - }, - { - "context": "ui", - "key": "Chatbereich", - "value": "Chatbereich" - }, - { - "context": "ui", - "key": "Darstellung", - "value": "Darstellung" - }, - { - "context": "ui", - "key": "Datei", - "value": "Datei" - }, - { - "context": "ui", - "key": "Datei anhängen", - "value": "Datei anhängen" - }, - { - "context": "ui", - "key": "Datei bearbeiten", - "value": "Datei bearbeiten" - }, - { - "context": "ui", - "key": "Datei bereits vorhanden", - "value": "Datei bereits vorhanden" - }, - { - "context": "ui", - "key": "Datei entfernen", - "value": "Datei entfernen" - }, - { - "context": "ui", - "key": "Datei erfolgreich hochgeladen!", - "value": "Datei erfolgreich hochgeladen!" - }, - { - "context": "ui", - "key": "Datei herunterladen", - "value": "Datei herunterladen" - }, - { - "context": "ui", - "key": "Datei hier ablegen...", - "value": "Datei hier ablegen..." - }, - { - "context": "ui", - "key": "Datei hinzufügen", - "value": "Datei hinzufügen" - }, - { - "context": "ui", - "key": "Datei hochladen", - "value": "Datei hochladen" - }, - { - "context": "ui", - "key": "Datei löschen", - "value": "Datei löschen" - }, - { - "context": "ui", - "key": "Datei vorschauen", - "value": "Datei vorschauen" - }, - { - "context": "ui", - "key": "Datei-Ablage während Workflow deaktiviert", - "value": "Datei-Ablage während Workflow deaktiviert" - }, - { - "context": "ui", - "key": "Dateien", - "value": "Dateien" - }, - { - "context": "ui", - "key": "Dateien anhängen", - "value": "Dateien anhängen" - }, - { - "context": "ui", - "key": "Dateien auswählen", - "value": "Dateien auswählen" - }, - { - "context": "ui", - "key": "Dateien hier ablegen", - "value": "Dateien hier ablegen" - }, - { - "context": "ui", - "key": "Dateien hier ablegen zum Anhängen", - "value": "Dateien hier ablegen zum Anhängen" - }, - { - "context": "ui", - "key": "Dateien hierher ziehen", - "value": "Dateien hierher ziehen" - }, - { - "context": "ui", - "key": "Dateien hochladen", - "value": "Dateien hochladen" - }, - { - "context": "ui", - "key": "Dateien werden geladen...", - "value": "Dateien werden geladen..." - }, - { - "context": "ui", - "key": "Dateien werden verarbeitet...", - "value": "Dateien werden verarbeitet..." - }, - { - "context": "ui", - "key": "Dateigröße", - "value": "Dateigröße" - }, - { - "context": "ui", - "key": "Dateiname", - "value": "Dateiname" - }, - { - "context": "ui", - "key": "Dateityp", - "value": "Dateityp" - }, - { - "context": "ui", - "key": "Dateiverwaltung - Dokumente hochladen und organisieren", - "value": "Dateiverwaltung - Dokumente hochladen und organisieren" - }, - { - "context": "ui", - "key": "Dateivorschau", - "value": "Dateivorschau" - }, - { - "context": "ui", - "key": "Daten aktualisieren", - "value": "Daten aktualisieren" - }, - { - "context": "ui", - "key": "Daten empfangen", - "value": "Daten empfangen" - }, - { - "context": "ui", - "key": "Daten gesendet", - "value": "Daten gesendet" - }, - { - "context": "ui", - "key": "Datenverwaltung", - "value": "Datenverwaltung" - }, - { - "context": "ui", - "key": "Datenverwaltung - Datenimporte und -exporte verwalten", - "value": "Datenverwaltung - Datenimporte und -exporte verwalten" - }, - { - "context": "ui", - "key": "Datenverwaltung mit Tabellen", - "value": "Datenverwaltung mit Tabellen" - }, - { - "context": "ui", - "key": "Datum", - "value": "Datum" - }, - { - "context": "ui", - "key": "Dauer", - "value": "Dauer" - }, - { - "context": "ui", - "key": "Details", - "value": "Details" - }, - { - "context": "ui", - "key": "Deutsch", - "value": "Deutsch" - }, - { - "context": "ui", - "key": "Die Datei \"{fileName}\" existiert bereits mit identischem Inhalt. Die vorhandene Datei wird wiederverwendet.", - "value": "Die Datei \"{fileName}\" existiert bereits mit identischem Inhalt. Die vorhandene Datei wird wiederverwendet." - }, - { - "context": "ui", - "key": "Die Erstellung einer neuen Sprache kann AI-Guthaben auf Ihrem Mandats-Pool belasten. Fortfahren?", - "value": "Die Erstellung einer neuen Sprache kann AI-Guthaben auf Ihrem Mandats-Pool belasten. Fortfahren?" - }, - { - "context": "ui", - "key": "Dies ist Ihr Ausgangspunkt für den Zugriff auf alle Arbeitsbereich-Features und -Tools.", - "value": "Dies ist Ihr Ausgangspunkt für den Zugriff auf alle Arbeitsbereich-Features und -Tools." - }, - { - "context": "ui", - "key": "Diese Aktion kann nicht rückgängig gemacht werden.", - "value": "Diese Aktion kann nicht rückgängig gemacht werden." - }, - { - "context": "ui", - "key": "Diese Datei scheint beschädigt zu sein. Sie hat eine PDF-Erweiterung, enthält aber Textinhalte. Bitte laden Sie die Datei erneut hoch, falls möglich.", - "value": "Diese Datei scheint beschädigt zu sein. Sie hat eine PDF-Erweiterung, enthält aber Textinhalte. Bitte laden Sie die Datei erneut hoch, falls möglich." - }, - { - "context": "ui", - "key": "Dieser Bereich enthält alle Verwaltungs- und Management-Tools für Ihren Arbeitsbereich.", - "value": "Dieser Bereich enthält alle Verwaltungs- und Management-Tools für Ihren Arbeitsbereich." - }, - { - "context": "ui", - "key": "Dieses Element auswählen", - "value": "Dieses Element auswählen" - }, - { - "context": "ui", - "key": "Dieses Element kann nicht ausgewählt werden", - "value": "Dieses Element kann nicht ausgewählt werden" - }, - { - "context": "ui", - "key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden", - "value": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden" - }, - { - "context": "ui", - "key": "Dossiers", - "value": "Dossiers" - }, - { - "context": "ui", - "key": "Dokument", - "value": "Dokument" - }, - { - "context": "ui", - "key": "Dokument erfolgreich erstellt", - "value": "Dokument erfolgreich erstellt" - }, - { - "context": "ui", - "key": "Dokument herunterladen", - "value": "Dokument herunterladen" - }, - { - "context": "ui", - "key": "Dokument vorschauen", - "value": "Dokument vorschauen" - }, - { - "context": "ui", - "key": "Dokumente", - "value": "Dokumente" - }, - { - "context": "ui", - "key": "Dokumente auflisten", - "value": "Dokumente auflisten" - }, - { - "context": "ui", - "key": "Dokumentname", - "value": "Dokumentname" - }, - { - "context": "ui", - "key": "Dunkel", - "value": "Dunkel" - }, - { - "context": "ui", - "key": "Durchsuchen", - "value": "Durchsuchen" - }, - { - "context": "ui", - "key": "E-Mail", - "value": "E-Mail" - }, - { - "context": "ui", - "key": "E-Mail-Adresse", - "value": "E-Mail-Adresse" - }, - { - "context": "ui", - "key": "E-Mail-Adresse ist erforderlich", - "value": "E-Mail-Adresse ist erforderlich" - }, - { - "context": "ui", - "key": "E-Mail-Bestätigung", - "value": "E-Mail-Bestätigung" - }, - { - "context": "ui", - "key": "Echtzeit-Datensynchronisation:", - "value": "Echtzeit-Datensynchronisation:" - }, - { - "context": "ui", - "key": "Eigenschaften", - "value": "Eigenschaften" - }, - { - "context": "ui", - "key": "Eingereichte Daten:", - "value": "Eingereichte Daten:" - }, - { - "context": "ui", - "key": "Einrichtungsanruf", - "value": "Einrichtungsanruf" - }, - { - "context": "ui", - "key": "Einstellungen", - "value": "Einstellungen" - }, - { - "context": "ui", - "key": "Einstellungen erfolgreich gespeichert!", - "value": "Einstellungen erfolgreich gespeichert!" - }, - { - "context": "ui", - "key": "Einstellungen werden in zukünftigen Updates hinzugefügt.", - "value": "Einstellungen werden in zukünftigen Updates hinzugefügt." - }, - { - "context": "ui", - "key": "Einstellungen wurden erfolgreich zurückgesetzt.", - "value": "Einstellungen wurden erfolgreich zurückgesetzt." - }, - { - "context": "ui", - "key": "Einträge", - "value": "Einträge" - }, - { - "context": "ui", - "key": "Einträge pro Seite:", - "value": "Einträge pro Seite:" - }, - { - "context": "ui", - "key": "Empfänger", - "value": "Empfänger" - }, - { - "context": "ui", - "key": "Endzeit", - "value": "Endzeit" - }, - { - "context": "ui", - "key": "English", - "value": "English" - }, - { - "context": "ui", - "key": "Entdeckte Sites", - "value": "Entdeckte Sites" - }, - { - "context": "ui", - "key": "Entfernen", - "value": "Entfernen" - }, - { - "context": "ui", - "key": "Erfolgreich", - "value": "Erfolgreich" - }, - { - "context": "ui", - "key": "Erfolgsrate", - "value": "Erfolgsrate" - }, - { - "context": "ui", - "key": "Erleben Sie die Zukunft der Mandantenkommunikation durch unsere strategische Partnerschaft mit Spitch.ai. Diese bahnbrechende Integration verwandelt Ihre PowerOn-Plattform in ein intelligentes Telefonie-System, das externe Mandanten nahtlos mit Unternehmen verbindet.", - "value": "Erleben Sie die Zukunft der Mandantenkommunikation durch unsere strategische Partnerschaft mit Spitch.ai. Diese bahnbrechende Integration verwandelt Ihre PowerOn-Plattform in ein intelligentes Telefonie-System, das externe Mandanten nahtlos mit Unternehmen verbindet." - }, - { - "context": "ui", - "key": "Erneut versuchen", - "value": "Erneut versuchen" - }, - { - "context": "ui", - "key": "Erste Seite", - "value": "Erste Seite" - }, - { - "context": "ui", - "key": "Erstellen", - "value": "Erstellen" - }, - { - "context": "ui", - "key": "Erstellen und verwalten Sie RBAC-Rollen und deren Berechtigungen.", - "value": "Erstellen und verwalten Sie RBAC-Rollen und deren Berechtigungen." - }, - { - "context": "ui", - "key": "Erstellen...", - "value": "Erstellen..." - }, - { - "context": "ui", - "key": "Erstellt", - "value": "Erstellt" - }, - { - "context": "ui", - "key": "Erstellte Dateien", - "value": "Erstellte Dateien" - }, - { - "context": "ui", - "key": "Erstellungsdatum", - "value": "Erstellungsdatum" - }, - { - "context": "ui", - "key": "Exportiere...", - "value": "Exportiere..." - }, - { - "context": "ui", - "key": "Externe E-Mail", - "value": "Externe E-Mail" - }, - { - "context": "ui", - "key": "Externe E-Mail-Adresse eingeben", - "value": "Externe E-Mail-Adresse eingeben" - }, - { - "context": "ui", - "key": "Externen Benutzernamen eingeben", - "value": "Externen Benutzernamen eingeben" - }, - { - "context": "ui", - "key": "Externer Benutzername", - "value": "Externer Benutzername" - }, - { - "context": "ui", - "key": "FEHLER", - "value": "FEHLER" - }, - { - "context": "ui", - "key": "FEHLGESCHLAGEN", - "value": "FEHLGESCHLAGEN" - }, - { - "context": "ui", - "key": "Falls Sie Fragen zu Ihrem Mandat oder dem Integrationsprozess haben, zögern Sie nicht, unser Support-Team zu kontaktieren.", - "value": "Falls Sie Fragen zu Ihrem Mandat oder dem Integrationsprozess haben, zögern Sie nicht, unser Support-Team zu kontaktieren." - }, - { - "context": "ui", - "key": "Fehler", - "value": "Fehler" - }, - { - "context": "ui", - "key": "Fehler beim Aktualisieren der Benutzerinformationen", - "value": "Fehler beim Aktualisieren der Benutzerinformationen" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der Automatisierung", - "value": "Fehler beim Erstellen der Automatisierung" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der Organisation", - "value": "Fehler beim Erstellen der Organisation" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der Position", - "value": "Fehler beim Erstellen der Position" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der RBAC-Regel", - "value": "Fehler beim Erstellen der RBAC-Regel" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der Rolle", - "value": "Fehler beim Erstellen der Rolle" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Dokuments", - "value": "Fehler beim Erstellen des Dokuments" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Mandats", - "value": "Fehler beim Erstellen des Mandats" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Prompts", - "value": "Fehler beim Erstellen des Prompts" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Team-Mitglieds", - "value": "Fehler beim Erstellen des Team-Mitglieds" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Vertrags", - "value": "Fehler beim Erstellen des Vertrags" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Zugriffs", - "value": "Fehler beim Erstellen des Zugriffs" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Benutzer", - "value": "Fehler beim Laden der Benutzer" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Benutzer:", - "value": "Fehler beim Laden der Benutzer:" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Benutzerinformationen", - "value": "Fehler beim Laden der Benutzerinformationen" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Dateien:", - "value": "Fehler beim Laden der Dateien:" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Logs", - "value": "Fehler beim Laden der Logs" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Nachrichten:", - "value": "Fehler beim Laden der Nachrichten:" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Prompts", - "value": "Fehler beim Laden der Prompts" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Prompts:", - "value": "Fehler beim Laden der Prompts:" - }, - { - "context": "ui", - "key": "Fehler beim Laden der SharePoint Dokumente:", - "value": "Fehler beim Laden der SharePoint Dokumente:" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Vorschau", - "value": "Fehler beim Laden der Vorschau" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Workflows:", - "value": "Fehler beim Laden der Workflows:" - }, - { - "context": "ui", - "key": "Fehler beim Löschen", - "value": "Fehler beim Löschen" - }, - { - "context": "ui", - "key": "Fehler beim Speichern der Einstellungen. Bitte versuchen Sie es erneut.", - "value": "Fehler beim Speichern der Einstellungen. Bitte versuchen Sie es erneut." - }, - { - "context": "ui", - "key": "Fehler beim Teilen des Prompts", - "value": "Fehler beim Teilen des Prompts" - }, - { - "context": "ui", - "key": "Fehler beim Verarbeiten der Dateien", - "value": "Fehler beim Verarbeiten der Dateien" - }, - { - "context": "ui", - "key": "Fehler:", - "value": "Fehler:" - }, - { - "context": "ui", - "key": "Fehlgeschlagen", - "value": "Fehlgeschlagen" - }, - { - "context": "ui", - "key": "Filter", - "value": "Filter" - }, - { - "context": "ui", - "key": "Filter löschen", - "value": "Filter löschen" - }, - { - "context": "ui", - "key": "Filter: {value}", - "value": "Filter: {value}" - }, - { - "context": "ui", - "key": "Firma", - "value": "Firma" - }, - { - "context": "ui", - "key": "Firmenname", - "value": "Firmenname" - }, - { - "context": "ui", - "key": "Firmenname ist erforderlich", - "value": "Firmenname ist erforderlich" - }, - { - "context": "ui", - "key": "Folgenachricht wird gesendet...", - "value": "Folgenachricht wird gesendet..." - }, - { - "context": "ui", - "key": "Fortfahren", - "value": "Fortfahren" - }, - { - "context": "ui", - "key": "Fortsetzen", - "value": "Fortsetzen" - }, - { - "context": "ui", - "key": "Fragen?", - "value": "Fragen?" - }, - { - "context": "ui", - "key": "Français", - "value": "Français" - }, - { - "context": "ui", - "key": "Fügen Sie eine Nachricht für die Empfänger hinzu", - "value": "Fügen Sie eine Nachricht für die Empfänger hinzu" - }, - { - "context": "ui", - "key": "GESTOPPT", - "value": "GESTOPPT" - }, - { - "context": "ui", - "key": "Geben Sie Ihren Firmennamen ein", - "value": "Geben Sie Ihren Firmennamen ein" - }, - { - "context": "ui", - "key": "Geben Sie Kunden einen schnellen und effizienten Selbstservice für Sprach- und Textanfragen, der 24/7 verfügbar ist.", - "value": "Geben Sie Kunden einen schnellen und effizienten Selbstservice für Sprach- und Textanfragen, der 24/7 verfügbar ist." - }, - { - "context": "ui", - "key": "Geben Sie den Inhalt des Prompts ein", - "value": "Geben Sie den Inhalt des Prompts ein" - }, - { - "context": "ui", - "key": "Geben Sie einen Namen für den Prompt ein", - "value": "Geben Sie einen Namen für den Prompt ein" - }, - { - "context": "ui", - "key": "Geben Sie einen benutzerdefinierten Titel ein", - "value": "Geben Sie einen benutzerdefinierten Titel ein" - }, - { - "context": "ui", - "key": "Geplante und automatisierte Workflows", - "value": "Geplante und automatisierte Workflows" - }, - { - "context": "ui", - "key": "Geschäftszeiten", - "value": "Geschäftszeiten" - }, - { - "context": "ui", - "key": "Geschäftszeiten & Zeitzone", - "value": "Geschäftszeiten & Zeitzone" - }, - { - "context": "ui", - "key": "Gespräch fortsetzen...", - "value": "Gespräch fortsetzen..." - }, - { - "context": "ui", - "key": "Gestartet", - "value": "Gestartet" - }, - { - "context": "ui", - "key": "Gestartet:", - "value": "Gestartet:" - }, - { - "context": "ui", - "key": "Gestoppt", - "value": "Gestoppt" - }, - { - "context": "ui", - "key": "Geteilt", - "value": "Geteilt" - }, - { - "context": "ui", - "key": "Geteilte Dateien", - "value": "Geteilte Dateien" - }, - { - "context": "ui", - "key": "Globale Sprachsets verwalten (SysAdmin).", - "value": "Globale Sprachsets verwalten (SysAdmin)." - }, - { - "context": "ui", - "key": "Google", - "value": "Google" - }, - { - "context": "ui", - "key": "Google verbinden", - "value": "Google verbinden" - }, - { - "context": "ui", - "key": "Google-Verbindung erstellen", - "value": "Google-Verbindung erstellen" - }, - { - "context": "ui", - "key": "Google-Verbindung hinzufügen", - "value": "Google-Verbindung hinzufügen" - }, - { - "context": "ui", - "key": "Grundlegende Daten und Ressourcen", - "value": "Grundlegende Daten und Ressourcen" - }, - { - "context": "ui", - "key": "Größe", - "value": "Größe" - }, - { - "context": "ui", - "key": "Hell", - "value": "Hell" - }, - { - "context": "ui", - "key": "Herunterladen", - "value": "Herunterladen" - }, - { - "context": "ui", - "key": "Hinzufügen", - "value": "Hinzufügen" - }, - { - "context": "ui", - "key": "Hochgeladen", - "value": "Hochgeladen" - }, - { - "context": "ui", - "key": "Hochladen", - "value": "Hochladen" - }, - { - "context": "ui", - "key": "ID", - "value": "ID" - }, - { - "context": "ui", - "key": "INFO", - "value": "INFO" - }, - { - "context": "ui", - "key": "Identifizieren und authentifizieren Sie Anrufer in Sekunden mit kontinuierlicher Verifizierung und Sicherheit.", - "value": "Identifizieren und authentifizieren Sie Anrufer in Sekunden mit kontinuierlicher Verifizierung und Sicherheit." - }, - { - "context": "ui", - "key": "Ihre Anfrage wird verarbeitet...", - "value": "Ihre Anfrage wird verarbeitet..." - }, - { - "context": "ui", - "key": "In die Zwischenablage kopiert", - "value": "In die Zwischenablage kopiert" - }, - { - "context": "ui", - "key": "Inaktiv", - "value": "Inaktiv" - }, - { - "context": "ui", - "key": "Information", - "value": "Information" - }, - { - "context": "ui", - "key": "Inhalt", - "value": "Inhalt" - }, - { - "context": "ui", - "key": "Inhalt ist erforderlich", - "value": "Inhalt ist erforderlich" - }, - { - "context": "ui", - "key": "Ja", - "value": "Ja" - }, - { - "context": "ui", - "key": "Jetzt anmelden", - "value": "Jetzt anmelden" - }, - { - "context": "ui", - "key": "Jetzt überspringen", - "value": "Jetzt überspringen" - }, - { - "context": "ui", - "key": "KI-erstellt", - "value": "KI-erstellt" - }, - { - "context": "ui", - "key": "KI-gestützte Dokumentengenerierung:", - "value": "KI-gestützte Dokumentengenerierung:" - }, - { - "context": "ui", - "key": "Kein Auth-Anbieter", - "value": "Kein Auth-Anbieter" - }, - { - "context": "ui", - "key": "Kein Benutzername", - "value": "Kein Benutzername" - }, - { - "context": "ui", - "key": "Kein Nachrichteninhalt verfügbar", - "value": "Kein Nachrichteninhalt verfügbar" - }, - { - "context": "ui", - "key": "Kein Name", - "value": "Kein Name" - }, - { - "context": "ui", - "key": "Kein Workflow ausgewählt", - "value": "Kein Workflow ausgewählt" - }, - { - "context": "ui", - "key": "Keine", - "value": "Keine" - }, - { - "context": "ui", - "key": "Keine Benutzer verfügbar", - "value": "Keine Benutzer verfügbar" - }, - { - "context": "ui", - "key": "Keine Berechtigung", - "value": "Keine Berechtigung" - }, - { - "context": "ui", - "key": "Keine Berechtigung zum Löschen des Prompts", - "value": "Keine Berechtigung zum Löschen des Prompts" - }, - { - "context": "ui", - "key": "Keine Dateien gefunden.", - "value": "Keine Dateien gefunden." - }, - { - "context": "ui", - "key": "Keine Daten verfügbar", - "value": "Keine Daten verfügbar" - }, - { - "context": "ui", - "key": "Keine E-Mail", - "value": "Keine E-Mail" - }, - { - "context": "ui", - "key": "Keine Einträge", - "value": "Keine Einträge" - }, - { - "context": "ui", - "key": "Keine Logs für diesen Workflow verfügbar", - "value": "Keine Logs für diesen Workflow verfügbar" - }, - { - "context": "ui", - "key": "Keine Microsoft-Verbindungen gefunden. Bitte erstellen Sie zuerst eine Verbindung.", - "value": "Keine Microsoft-Verbindungen gefunden. Bitte erstellen Sie zuerst eine Verbindung." - }, - { - "context": "ui", - "key": "Keine Optionen verfügbar", - "value": "Keine Optionen verfügbar" - }, - { - "context": "ui", - "key": "Keine Prompts verfügbar", - "value": "Keine Prompts verfügbar" - }, - { - "context": "ui", - "key": "Keine SharePoint-Sites gefunden", - "value": "Keine SharePoint-Sites gefunden" - }, - { - "context": "ui", - "key": "Keine Sprach-Integrations-Daten gefunden. Bitte melden Sie sich zuerst an, um auf die Einstellungen zuzugreifen.", - "value": "Keine Sprach-Integrations-Daten gefunden. Bitte melden Sie sich zuerst an, um auf die Einstellungen zuzugreifen." - }, - { - "context": "ui", - "key": "Keine Sprache", - "value": "Keine Sprache" - }, - { - "context": "ui", - "key": "Keine Transkripte vorhanden", - "value": "Keine Transkripte vorhanden" - }, - { - "context": "ui", - "key": "Keine Vorschau verfügbar", - "value": "Keine Vorschau verfügbar" - }, - { - "context": "ui", - "key": "Keine Workflows gefunden", - "value": "Keine Workflows gefunden" - }, - { - "context": "ui", - "key": "Keine Workflows verfügbar", - "value": "Keine Workflows verfügbar" - }, - { - "context": "ui", - "key": "Keine hochgeladenen Dateien gefunden.", - "value": "Keine hochgeladenen Dateien gefunden." - }, - { - "context": "ui", - "key": "Keine mit Ihnen geteilten Dateien gefunden.", - "value": "Keine mit Ihnen geteilten Dateien gefunden." - }, - { - "context": "ui", - "key": "Keine von der KI erstellten Dateien gefunden.", - "value": "Keine von der KI erstellten Dateien gefunden." - }, - { - "context": "ui", - "key": "Klicken Sie erneut zum Bestätigen", - "value": "Klicken Sie erneut zum Bestätigen" - }, - { - "context": "ui", - "key": "Klicken Sie erneut zum Bestätigen der Löschung", - "value": "Klicken Sie erneut zum Bestätigen der Löschung" - }, - { - "context": "ui", - "key": "Klicken Sie, um zu öffnen", - "value": "Klicken Sie, um zu öffnen" - }, - { - "context": "ui", - "key": "Knowledge Agent (KA)", - "value": "Knowledge Agent (KA)" - }, - { - "context": "ui", - "key": "Konfigurieren Sie administrative Einstellungen und Systempräferenzen.", - "value": "Konfigurieren Sie administrative Einstellungen und Systempräferenzen." - }, - { - "context": "ui", - "key": "Konfigurieren und verwalten Sie rollenbasierte Zugriffssteuerungsregeln.", - "value": "Konfigurieren und verwalten Sie rollenbasierte Zugriffssteuerungsregeln." - }, - { - "context": "ui", - "key": "Kontakte einrichten", - "value": "Kontakte einrichten" - }, - { - "context": "ui", - "key": "Kontaktinformationen", - "value": "Kontaktinformationen" - }, - { - "context": "ui", - "key": "Kontostatus", - "value": "Kontostatus" - }, - { - "context": "ui", - "key": "Kopieren", - "value": "Kopieren" - }, - { - "context": "ui", - "key": "Kopiert", - "value": "Kopiert" - }, - { - "context": "ui", - "key": "Kosteneinsparungen & Effizienz:", - "value": "Kosteneinsparungen & Effizienz:" - }, - { - "context": "ui", - "key": "Kundenverträge verwalten", - "value": "Kundenverträge verwalten" - }, - { - "context": "ui", - "key": "Lade Filterwerte...", - "value": "Lade Filterwerte..." - }, - { - "context": "ui", - "key": "Lade Fortschritt...", - "value": "Lade Fortschritt..." - }, - { - "context": "ui", - "key": "Laden...", - "value": "Laden..." - }, - { - "context": "ui", - "key": "Land", - "value": "Land" - }, - { - "context": "ui", - "key": "Land ist erforderlich", - "value": "Land ist erforderlich" - }, - { - "context": "ui", - "key": "Leer = Zugriff auf alle Verträge", - "value": "Leer = Zugriff auf alle Verträge" - }, - { - "context": "ui", - "key": "Letzte Aktivität", - "value": "Letzte Aktivität" - }, - { - "context": "ui", - "key": "Letzte Aktivität:", - "value": "Letzte Aktivität:" - }, - { - "context": "ui", - "key": "Letzte Aktivitäten - Sehen Sie Ihre neueste Arbeit", - "value": "Letzte Aktivitäten - Sehen Sie Ihre neueste Arbeit" - }, - { - "context": "ui", - "key": "Letzte Seite", - "value": "Letzte Seite" - }, - { - "context": "ui", - "key": "Link konnte nicht gesendet werden", - "value": "Link konnte nicht gesendet werden" - }, - { - "context": "ui", - "key": "Log", - "value": "Log" - }, - { - "context": "ui", - "key": "Logs konnten nicht geladen werden", - "value": "Logs konnten nicht geladen werden" - }, - { - "context": "ui", - "key": "Logs werden geladen...", - "value": "Logs werden geladen..." - }, - { - "context": "ui", - "key": "Lokal", - "value": "Lokal" - }, - { - "context": "ui", - "key": "LÄUFT", - "value": "LÄUFT" - }, - { - "context": "ui", - "key": "Lädt hoch...", - "value": "Lädt hoch..." - }, - { - "context": "ui", - "key": "Läuft", - "value": "Läuft" - }, - { - "context": "ui", - "key": "Läuft ab am", - "value": "Läuft ab am" - }, - { - "context": "ui", - "key": "Löschen", - "value": "Löschen" - }, - { - "context": "ui", - "key": "Löschen ({count})", - "value": "Löschen ({count})" - }, - { - "context": "ui", - "key": "Löschen...", - "value": "Löschen..." - }, - { - "context": "ui", - "key": "MIME-Typ", - "value": "MIME-Typ" - }, - { - "context": "ui", - "key": "Management-Tools umfassen:", - "value": "Management-Tools umfassen:" - }, - { - "context": "ui", - "key": "Mandanten können jederzeit auf die technische SIP-Nummer umstellen und dabei erhebliche Telefoniekosten sparen. Die Integration funktioniert wie ein weiterer Connector (Outlook, SharePoint) und wird nahtlos in Ihren bestehenden Workflow integriert.", - "value": "Mandanten können jederzeit auf die technische SIP-Nummer umstellen und dabei erhebliche Telefoniekosten sparen. Die Integration funktioniert wie ein weiterer Connector (Outlook, SharePoint) und wird nahtlos in Ihren bestehenden Workflow integriert." - }, - { - "context": "ui", - "key": "Mandat erfolgreich eingereicht!", - "value": "Mandat erfolgreich eingereicht!" - }, - { - "context": "ui", - "key": "Mandat erfolgreich erstellt", - "value": "Mandat erfolgreich erstellt" - }, - { - "context": "ui", - "key": "Mandat erstellen", - "value": "Mandat erstellen" - }, - { - "context": "ui", - "key": "Mandat hinzufügen", - "value": "Mandat hinzufügen" - }, - { - "context": "ui", - "key": "Mandat-ID", - "value": "Mandat-ID" - }, - { - "context": "ui", - "key": "Mandate", - "value": "Mandate" - }, - { - "context": "ui", - "key": "Mandate und Berechtigungen verwalten", - "value": "Mandate und Berechtigungen verwalten" - }, - { - "context": "ui", - "key": "Mandatsverwaltung", - "value": "Mandatsverwaltung" - }, - { - "context": "ui", - "key": "Mehr erfahren", - "value": "Mehr erfahren" - }, - { - "context": "ui", - "key": "Meine Uploads", - "value": "Meine Uploads" - }, - { - "context": "ui", - "key": "Microsoft", - "value": "Microsoft" - }, - { - "context": "ui", - "key": "Microsoft Verbindungen", - "value": "Microsoft Verbindungen" - }, - { - "context": "ui", - "key": "Microsoft verbinden", - "value": "Microsoft verbinden" - }, - { - "context": "ui", - "key": "Microsoft-Verbindung erstellen", - "value": "Microsoft-Verbindung erstellen" - }, - { - "context": "ui", - "key": "Microsoft-Verbindung hinzufügen", - "value": "Microsoft-Verbindung hinzufügen" - }, - { - "context": "ui", - "key": "Mitglied hinzufügen", - "value": "Mitglied hinzufügen" - }, - { - "context": "ui", - "key": "MwSt %", - "value": "MwSt %" - }, - { - "context": "ui", - "key": "MwSt Betrag", - "value": "MwSt Betrag" - }, - { - "context": "ui", - "key": "Möchten Sie jetzt Kontakte für Ihr Mandat einrichten? Sie können dies auch später in den Einstellungen tun.", - "value": "Möchten Sie jetzt Kontakte für Ihr Mandat einrichten? Sie können dies auch später in den Einstellungen tun." - }, - { - "context": "ui", - "key": "Nach unten scrollen", - "value": "Nach unten scrollen" - }, - { - "context": "ui", - "key": "Nachricht (optional)", - "value": "Nachricht (optional)" - }, - { - "context": "ui", - "key": "Nachricht eingeben...", - "value": "Nachricht eingeben..." - }, - { - "context": "ui", - "key": "Nachricht wird gesendet...", - "value": "Nachricht wird gesendet..." - }, - { - "context": "ui", - "key": "Nachrichten", - "value": "Nachrichten" - }, - { - "context": "ui", - "key": "Nahtloser Mandanten-Workflow:", - "value": "Nahtloser Mandanten-Workflow:" - }, - { - "context": "ui", - "key": "Name", - "value": "Name" - }, - { - "context": "ui", - "key": "Name des Unternehmens", - "value": "Name des Unternehmens" - }, - { - "context": "ui", - "key": "Name ist erforderlich", - "value": "Name ist erforderlich" - }, - { - "context": "ui", - "key": "Navigation - Erkunden Sie alle verfügbaren Tools", - "value": "Navigation - Erkunden Sie alle verfügbaren Tools" - }, - { - "context": "ui", - "key": "Nein", - "value": "Nein" - }, - { - "context": "ui", - "key": "Neu starten", - "value": "Neu starten" - }, - { - "context": "ui", - "key": "Neue Automatisierung", - "value": "Neue Automatisierung" - }, - { - "context": "ui", - "key": "Neue Automatisierung erstellen", - "value": "Neue Automatisierung erstellen" - }, - { - "context": "ui", - "key": "Neue Datei hochladen", - "value": "Neue Datei hochladen" - }, - { - "context": "ui", - "key": "Neue Organisation", - "value": "Neue Organisation" - }, - { - "context": "ui", - "key": "Neue Organisation erstellen", - "value": "Neue Organisation erstellen" - }, - { - "context": "ui", - "key": "Neue Position", - "value": "Neue Position" - }, - { - "context": "ui", - "key": "Neue Position erstellen", - "value": "Neue Position erstellen" - }, - { - "context": "ui", - "key": "Neue RBAC-Regel erstellen", - "value": "Neue RBAC-Regel erstellen" - }, - { - "context": "ui", - "key": "Neue Rolle", - "value": "Neue Rolle" - }, - { - "context": "ui", - "key": "Neue Rolle erstellen", - "value": "Neue Rolle erstellen" - }, - { - "context": "ui", - "key": "Neue Sprache", - "value": "Neue Sprache" - }, - { - "context": "ui", - "key": "Neuen Prompt erstellen", - "value": "Neuen Prompt erstellen" - }, - { - "context": "ui", - "key": "Neuen Vertrag erstellen", - "value": "Neuen Vertrag erstellen" - }, - { - "context": "ui", - "key": "Neuen Zugriff erstellen", - "value": "Neuen Zugriff erstellen" - }, - { - "context": "ui", - "key": "Neuer Prompt", - "value": "Neuer Prompt" - }, - { - "context": "ui", - "key": "Neuer Vertrag", - "value": "Neuer Vertrag" - }, - { - "context": "ui", - "key": "Neuer Zugriff", - "value": "Neuer Zugriff" - }, - { - "context": "ui", - "key": "Neues Dokument", - "value": "Neues Dokument" - }, - { - "context": "ui", - "key": "Neues Dokument erstellen", - "value": "Neues Dokument erstellen" - }, - { - "context": "ui", - "key": "Neues Mandat erstellen", - "value": "Neues Mandat erstellen" - }, - { - "context": "ui", - "key": "Neues Team-Mitglied erstellen", - "value": "Neues Team-Mitglied erstellen" - }, - { - "context": "ui", - "key": "Neues Transkript", - "value": "Neues Transkript" - }, - { - "context": "ui", - "key": "Nicht verfügbar", - "value": "Nicht verfügbar" - }, - { - "context": "ui", - "key": "Noch keine Befehle ausgeführt. Senden Sie einen Befehl, um Ergebnisse hier zu sehen.", - "value": "Noch keine Befehle ausgeführt. Senden Sie einen Befehl, um Ergebnisse hier zu sehen." - }, - { - "context": "ui", - "key": "Noch keinen Workflow ausgewählt", - "value": "Noch keinen Workflow ausgewählt" - }, - { - "context": "ui", - "key": "Nochmal versuchen", - "value": "Nochmal versuchen" - }, - { - "context": "ui", - "key": "Nächste Seite", - "value": "Nächste Seite" - }, - { - "context": "ui", - "key": "Oder geben Sie Ihre Nachricht ein...", - "value": "Oder geben Sie Ihre Nachricht ein..." - }, - { - "context": "ui", - "key": "Ordnerpfade", - "value": "Ordnerpfade" - }, - { - "context": "ui", - "key": "Organisation", - "value": "Organisation" - }, - { - "context": "ui", - "key": "Organisation erfolgreich erstellt", - "value": "Organisation erfolgreich erstellt" - }, - { - "context": "ui", - "key": "Organisationen", - "value": "Organisationen" - }, - { - "context": "ui", - "key": "Originalbetrag", - "value": "Originalbetrag" - }, - { - "context": "ui", - "key": "Originalwährung", - "value": "Originalwährung" - }, - { - "context": "ui", - "key": "PDF", - "value": "PDF" - }, - { - "context": "ui", - "key": "Passwort", - "value": "Passwort" - }, - { - "context": "ui", - "key": "Passwort eingeben", - "value": "Passwort eingeben" - }, - { - "context": "ui", - "key": "Passwort-Link gesendet!", - "value": "Passwort-Link gesendet!" - }, - { - "context": "ui", - "key": "Passwort-Link senden", - "value": "Passwort-Link senden" - }, - { - "context": "ui", - "key": "Pfad", - "value": "Pfad" - }, - { - "context": "ui", - "key": "Position erfolgreich erstellt", - "value": "Position erfolgreich erstellt" - }, - { - "context": "ui", - "key": "Positionen", - "value": "Positionen" - }, - { - "context": "ui", - "key": "Postleitzahl", - "value": "Postleitzahl" - }, - { - "context": "ui", - "key": "Postleitzahl ist erforderlich", - "value": "Postleitzahl ist erforderlich" - }, - { - "context": "ui", - "key": "Projekte", - "value": "Projekte" - }, - { - "context": "ui", - "key": "Projektverwaltung", - "value": "Projektverwaltung" - }, - { - "context": "ui", - "key": "Projektverwaltung und -organisation", - "value": "Projektverwaltung und -organisation" - }, - { - "context": "ui", - "key": "Prompt", - "value": "Prompt" - }, - { - "context": "ui", - "key": "Prompt Einstellungen", - "value": "Prompt Einstellungen" - }, - { - "context": "ui", - "key": "Prompt Vorlage", - "value": "Prompt Vorlage" - }, - { - "context": "ui", - "key": "Prompt ausführen", - "value": "Prompt ausführen" - }, - { - "context": "ui", - "key": "Prompt auswählen...", - "value": "Prompt auswählen..." - }, - { - "context": "ui", - "key": "Prompt bearbeiten", - "value": "Prompt bearbeiten" - }, - { - "context": "ui", - "key": "Prompt erfolgreich erstellt", - "value": "Prompt erfolgreich erstellt" - }, - { - "context": "ui", - "key": "Prompt erstellen", - "value": "Prompt erstellen" - }, - { - "context": "ui", - "key": "Prompt hinzufügen", - "value": "Prompt hinzufügen" - }, - { - "context": "ui", - "key": "Prompt löschen", - "value": "Prompt löschen" - }, - { - "context": "ui", - "key": "Prompt teilen", - "value": "Prompt teilen" - }, - { - "context": "ui", - "key": "Prompt wird gelöscht...", - "value": "Prompt wird gelöscht..." - }, - { - "context": "ui", - "key": "Prompt-Inhalt", - "value": "Prompt-Inhalt" - }, - { - "context": "ui", - "key": "Prompt-Inhalt darf 10.000 Zeichen nicht überschreiten", - "value": "Prompt-Inhalt darf 10.000 Zeichen nicht überschreiten" - }, - { - "context": "ui", - "key": "Prompt-Inhalt darf nicht leer sein", - "value": "Prompt-Inhalt darf nicht leer sein" - }, - { - "context": "ui", - "key": "Prompt-Name", - "value": "Prompt-Name" - }, - { - "context": "ui", - "key": "Prompt-Name darf 100 Zeichen nicht überschreiten", - "value": "Prompt-Name darf 100 Zeichen nicht überschreiten" - }, - { - "context": "ui", - "key": "Prompt-Name darf nicht leer sein", - "value": "Prompt-Name darf nicht leer sein" - }, - { - "context": "ui", - "key": "Prompts", - "value": "Prompts" - }, - { - "context": "ui", - "key": "Prompts für Ihren KI-Assistenten erstellen und verwalten", - "value": "Prompts für Ihren KI-Assistenten erstellen und verwalten" - }, - { - "context": "ui", - "key": "Prompts verwalten", - "value": "Prompts verwalten" - }, - { - "context": "ui", - "key": "Prompts werden geladen...", - "value": "Prompts werden geladen..." - }, - { - "context": "ui", - "key": "Python", - "value": "Python" - }, - { - "context": "ui", - "key": "Quelle", - "value": "Quelle" - }, - { - "context": "ui", - "key": "RBAC-Regel erfolgreich erstellt", - "value": "RBAC-Regel erfolgreich erstellt" - }, - { - "context": "ui", - "key": "RBAC-Regel hinzufügen", - "value": "RBAC-Regel hinzufügen" - }, - { - "context": "ui", - "key": "RBAC-Regeln", - "value": "RBAC-Regeln" - }, - { - "context": "ui", - "key": "RBAC-Regelverwaltung", - "value": "RBAC-Regelverwaltung" - }, - { - "context": "ui", - "key": "RBAC-Rollen", - "value": "RBAC-Rollen" - }, - { - "context": "ui", - "key": "RBAC-Rollenverwaltung", - "value": "RBAC-Rollenverwaltung" - }, - { - "context": "ui", - "key": "Registrieren", - "value": "Registrieren" - }, - { - "context": "ui", - "key": "Revolutionäre Telefonie-Integration mit Spitch.ai", - "value": "Revolutionäre Telefonie-Integration mit Spitch.ai" - }, - { - "context": "ui", - "key": "Rohtext in die Zwischenablage kopieren", - "value": "Rohtext in die Zwischenablage kopieren" - }, - { - "context": "ui", - "key": "Rolle", - "value": "Rolle" - }, - { - "context": "ui", - "key": "Rolle erfolgreich erstellt", - "value": "Rolle erfolgreich erstellt" - }, - { - "context": "ui", - "key": "Rolle hinzufügen", - "value": "Rolle hinzufügen" - }, - { - "context": "ui", - "key": "Rollen", - "value": "Rollen" - }, - { - "context": "ui", - "key": "Rollen-ID", - "value": "Rollen-ID" - }, - { - "context": "ui", - "key": "Rollenbasierte Zugriffssteuerungsregeln", - "value": "Rollenbasierte Zugriffssteuerungsregeln" - }, - { - "context": "ui", - "key": "Rollenverwaltung", - "value": "Rollenverwaltung" - }, - { - "context": "ui", - "key": "Rufname am Telefon", - "value": "Rufname am Telefon" - }, - { - "context": "ui", - "key": "Runde", - "value": "Runde" - }, - { - "context": "ui", - "key": "Runden", - "value": "Runden" - }, - { - "context": "ui", - "key": "Schließen", - "value": "Schließen" - }, - { - "context": "ui", - "key": "Schnellzugriff", - "value": "Schnellzugriff" - }, - { - "context": "ui", - "key": "Schnellzugriff - Springen Sie zu häufig verwendeten Features", - "value": "Schnellzugriff - Springen Sie zu häufig verwendeten Features" - }, - { - "context": "ui", - "key": "Seite", - "value": "Seite" - }, - { - "context": "ui", - "key": "Seite {page} von {total} ({count} Einträge)", - "value": "Seite {page} von {total} ({count} Einträge)" - }, - { - "context": "ui", - "key": "Senden", - "value": "Senden" - }, - { - "context": "ui", - "key": "Service", - "value": "Service" - }, - { - "context": "ui", - "key": "Service-Verbindungen", - "value": "Service-Verbindungen" - }, - { - "context": "ui", - "key": "SharePoint Dokumente", - "value": "SharePoint Dokumente" - }, - { - "context": "ui", - "key": "SharePoint Site URL", - "value": "SharePoint Site URL" - }, - { - "context": "ui", - "key": "SharePoint Test", - "value": "SharePoint Test" - }, - { - "context": "ui", - "key": "Sie erhalten in den nächsten Minuten eine Bestätigungs-E-Mail.", - "value": "Sie erhalten in den nächsten Minuten eine Bestätigungs-E-Mail." - }, - { - "context": "ui", - "key": "Sie können auch auf den Upload-Button klicken", - "value": "Sie können auch auf den Upload-Button klicken" - }, - { - "context": "ui", - "key": "Sie müssen sich zuerst für die Sprach-Integration anmelden, um auf die Transkriptverwaltung zuzugreifen.", - "value": "Sie müssen sich zuerst für die Sprach-Integration anmelden, um auf die Transkriptverwaltung zuzugreifen." - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie \"{name}\" löschen möchten?", - "value": "Sind Sie sicher, dass Sie \"{name}\" löschen möchten?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie Workflow \"{id}...\" löschen möchten?", - "value": "Sind Sie sicher, dass Sie Workflow \"{id}...\" löschen möchten?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie alle Sprach-Integrations-Einstellungen zurücksetzen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", - "value": "Sind Sie sicher, dass Sie alle Sprach-Integrations-Einstellungen zurücksetzen möchten? Diese Aktion kann nicht rückgängig gemacht werden." - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie das ausgewählte Element löschen möchten?", - "value": "Sind Sie sicher, dass Sie das ausgewählte Element löschen möchten?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie den Workflow \"{name}\" löschen möchten?", - "value": "Sind Sie sicher, dass Sie den Workflow \"{name}\" löschen möchten?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie die Datei \"{name}\" löschen möchten?", - "value": "Sind Sie sicher, dass Sie die Datei \"{name}\" löschen möchten?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie die {count} ausgewählten Elemente löschen möchten?", - "value": "Sind Sie sicher, dass Sie die {count} ausgewählten Elemente löschen möchten?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie die {service} Verbindung löschen möchten?", - "value": "Sind Sie sicher, dass Sie die {service} Verbindung löschen möchten?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie diesen Benutzer löschen möchten?", - "value": "Sind Sie sicher, dass Sie diesen Benutzer löschen möchten?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie {count} Benutzer löschen möchten?", - "value": "Sind Sie sicher, dass Sie {count} Benutzer löschen möchten?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie {count} Prompts löschen möchten?", - "value": "Sind Sie sicher, dass Sie {count} Prompts löschen möchten?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie {count} Verbindungen löschen möchten?", - "value": "Sind Sie sicher, dass Sie {count} Verbindungen löschen möchten?" - }, - { - "context": "ui", - "key": "Sites entdecken", - "value": "Sites entdecken" - }, - { - "context": "ui", - "key": "Sortierung {position}: {direction}", - "value": "Sortierung {position}: {direction}" - }, - { - "context": "ui", - "key": "Speech Analytics (SA)", - "value": "Speech Analytics (SA)" - }, - { - "context": "ui", - "key": "Speichern", - "value": "Speichern" - }, - { - "context": "ui", - "key": "Speichern...", - "value": "Speichern..." - }, - { - "context": "ui", - "key": "Spitch prüft vor jedem Anruf die Mandantenberechtigung bei PowerOn, während alle Datenänderungen zentral von PowerOn initiiert werden. Call-Transkripte werden in Echtzeit in Ihrer PowerOn-Datenbank gespeichert, mit vollständiger Mandantenisolation und Sicherheit. Bei Ausfällen werden Anrufe automatisch blockiert, um die Integrität zu gewährleisten.", - "value": "Spitch prüft vor jedem Anruf die Mandantenberechtigung bei PowerOn, während alle Datenänderungen zentral von PowerOn initiiert werden. Call-Transkripte werden in Echtzeit in Ihrer PowerOn-Datenbank gespeichert, mit vollständiger Mandantenisolation und Sicherheit. Bei Ausfällen werden Anrufe automatisch blockiert, um die Integrität zu gewährleisten." - }, - { - "context": "ui", - "key": "Sprach Integration", - "value": "Sprach Integration" - }, - { - "context": "ui", - "key": "Sprach-Einstellungen", - "value": "Sprach-Einstellungen" - }, - { - "context": "ui", - "key": "Sprach-Integration Einstellungen", - "value": "Sprach-Integration Einstellungen" - }, - { - "context": "ui", - "key": "Sprache", - "value": "Sprache" - }, - { - "context": "ui", - "key": "Sprachset {code} wirklich löschen?", - "value": "Sprachset {code} wirklich löschen?" - }, - { - "context": "ui", - "key": "Stadt", - "value": "Stadt" - }, - { - "context": "ui", - "key": "Stadt ist erforderlich", - "value": "Stadt ist erforderlich" - }, - { - "context": "ui", - "key": "Start", - "value": "Start" - }, - { - "context": "ui", - "key": "Startzeit", - "value": "Startzeit" - }, - { - "context": "ui", - "key": "Status", - "value": "Status" - }, - { - "context": "ui", - "key": "Stellen Sie alles, was Ihre Agenten benötigen, in ihren Händen bereit, mit einem einheitlichen Agent-Desktop.", - "value": "Stellen Sie alles, was Ihre Agenten benötigen, in ihren Händen bereit, mit einem einheitlichen Agent-Desktop." - }, - { - "context": "ui", - "key": "Stoppen", - "value": "Stoppen" - }, - { - "context": "ui", - "key": "Straße", - "value": "Straße" - }, - { - "context": "ui", - "key": "Straße ist erforderlich", - "value": "Straße ist erforderlich" - }, - { - "context": "ui", - "key": "Suchen Sie nach Standorten über Adresse oder Koordinaten, oder verwenden Sie natürliche Sprache, um Projekte zu erstellen und zu verwalten.", - "value": "Suchen Sie nach Standorten über Adresse oder Koordinaten, oder verwenden Sie natürliche Sprache, um Projekte zu erstellen und zu verwalten." - }, - { - "context": "ui", - "key": "Suchen...", - "value": "Suchen..." - }, - { - "context": "ui", - "key": "Systemadministrator", - "value": "Systemadministrator" - }, - { - "context": "ui", - "key": "Systemeinstellungen - Arbeitsbereich-Einstellungen konfigurieren", - "value": "Systemeinstellungen - Arbeitsbereich-Einstellungen konfigurieren" - }, - { - "context": "ui", - "key": "Tabelle", - "value": "Tabelle" - }, - { - "context": "ui", - "key": "Tags", - "value": "Tags" - }, - { - "context": "ui", - "key": "Team-Bereich", - "value": "Team-Bereich" - }, - { - "context": "ui", - "key": "Team-Mitglied erfolgreich erstellt", - "value": "Team-Mitglied erfolgreich erstellt" - }, - { - "context": "ui", - "key": "Team-Mitglieder", - "value": "Team-Mitglieder" - }, - { - "context": "ui", - "key": "Team-Mitglieder verwalten", - "value": "Team-Mitglieder verwalten" - }, - { - "context": "ui", - "key": "Team-Mitglieder verwalten, Berechtigungen festlegen und Zusammenarbeitseinstellungen konfigurieren", - "value": "Team-Mitglieder verwalten, Berechtigungen festlegen und Zusammenarbeitseinstellungen konfigurieren" - }, - { - "context": "ui", - "key": "Teilen", - "value": "Teilen" - }, - { - "context": "ui", - "key": "Telefon", - "value": "Telefon" - }, - { - "context": "ui", - "key": "Telefonnummer", - "value": "Telefonnummer" - }, - { - "context": "ui", - "key": "Telefonnummer ist erforderlich", - "value": "Telefonnummer ist erforderlich" - }, - { - "context": "ui", - "key": "Text", - "value": "Text" - }, - { - "context": "ui", - "key": "Textvorschau", - "value": "Textvorschau" - }, - { - "context": "ui", - "key": "Theme", - "value": "Theme" - }, - { - "context": "ui", - "key": "Token", - "value": "Token" - }, - { - "context": "ui", - "key": "Transkript", - "value": "Transkript" - }, - { - "context": "ui", - "key": "Transkript wird verarbeitet...", - "value": "Transkript wird verarbeitet..." - }, - { - "context": "ui", - "key": "Transkriptverwaltung", - "value": "Transkriptverwaltung" - }, - { - "context": "ui", - "key": "Trennungsfehler", - "value": "Trennungsfehler" - }, - { - "context": "ui", - "key": "Treuhand", - "value": "Treuhand" - }, - { - "context": "ui", - "key": "Treuhandverwaltung", - "value": "Treuhandverwaltung" - }, - { - "context": "ui", - "key": "Trustee-Organisationen verwalten", - "value": "Trustee-Organisationen verwalten" - }, - { - "context": "ui", - "key": "Trustee-Rollen verwalten", - "value": "Trustee-Rollen verwalten" - }, - { - "context": "ui", - "key": "Typ", - "value": "Typ" - }, - { - "context": "ui", - "key": "UI-Sprachen", - "value": "UI-Sprachen" - }, - { - "context": "ui", - "key": "Unbekannt", - "value": "Unbekannt" - }, - { - "context": "ui", - "key": "Unbekannte Größe", - "value": "Unbekannte Größe" - }, - { - "context": "ui", - "key": "Unbekanntes Datum", - "value": "Unbekanntes Datum" - }, - { - "context": "ui", - "key": "Unbenannt", - "value": "Unbenannt" - }, - { - "context": "ui", - "key": "Unbenannter Workflow", - "value": "Unbenannter Workflow" - }, - { - "context": "ui", - "key": "Ungültige Auswahl", - "value": "Ungültige Auswahl" - }, - { - "context": "ui", - "key": "Ungültige URL", - "value": "Ungültige URL" - }, - { - "context": "ui", - "key": "Ungültiges Datum", - "value": "Ungültiges Datum" - }, - { - "context": "ui", - "key": "Ungültiges Datumsformat", - "value": "Ungültiges Datumsformat" - }, - { - "context": "ui", - "key": "Ungültiges E-Mail-Format", - "value": "Ungültiges E-Mail-Format" - }, - { - "context": "ui", - "key": "Ungültiges JSON", - "value": "Ungültiges JSON" - }, - { - "context": "ui", - "key": "Unser Team wird Ihr Mandat innerhalb von 1-2 Werktagen überprüfen.", - "value": "Unser Team wird Ihr Mandat innerhalb von 1-2 Werktagen überprüfen." - }, - { - "context": "ui", - "key": "Unsere bereits aktive Dokumenten-Extraktions-Engine generiert automatisch personalisierte Dokumente für Spitch, basierend auf Mandantenspezifischen Daten. Die KI nutzt FAQ-Datenbanken, Mitarbeiterinformationen und Service-Details, um jeden Anruf kontextuell und hochpersonalisiert zu gestalten.", - "value": "Unsere bereits aktive Dokumenten-Extraktions-Engine generiert automatisch personalisierte Dokumente für Spitch, basierend auf Mandantenspezifischen Daten. Die KI nutzt FAQ-Datenbanken, Mitarbeiterinformationen und Service-Details, um jeden Anruf kontextuell und hochpersonalisiert zu gestalten." - }, - { - "context": "ui", - "key": "Unternehmensinformationen", - "value": "Unternehmensinformationen" - }, - { - "context": "ui", - "key": "Unterstützt von", - "value": "Unterstützt von" - }, - { - "context": "ui", - "key": "Upload fehlgeschlagen. Bitte versuchen Sie es erneut.", - "value": "Upload fehlgeschlagen. Bitte versuchen Sie es erneut." - }, - { - "context": "ui", - "key": "VERARBEITUNG", - "value": "VERARBEITUNG" - }, - { - "context": "ui", - "key": "Valutadatum", - "value": "Valutadatum" - }, - { - "context": "ui", - "key": "Verarbeitung", - "value": "Verarbeitung" - }, - { - "context": "ui", - "key": "Verbinden", - "value": "Verbinden" - }, - { - "context": "ui", - "key": "Verbindung aktualisieren", - "value": "Verbindung aktualisieren" - }, - { - "context": "ui", - "key": "Verbindung testen", - "value": "Verbindung testen" - }, - { - "context": "ui", - "key": "Verbindungen", - "value": "Verbindungen" - }, - { - "context": "ui", - "key": "Verbindungen werden geladen...", - "value": "Verbindungen werden geladen..." - }, - { - "context": "ui", - "key": "Verbindungsfehler", - "value": "Verbindungsfehler" - }, - { - "context": "ui", - "key": "Verbunden am", - "value": "Verbunden am" - }, - { - "context": "ui", - "key": "Vereinheitlichen und liefern Sie Informationen an Ihre Kunden und Mitarbeiter, wann und wo sie sie benötigen.", - "value": "Vereinheitlichen und liefern Sie Informationen an Ihre Kunden und Mitarbeiter, wann und wo sie sie benötigen." - }, - { - "context": "ui", - "key": "Verfügbare Tools", - "value": "Verfügbare Tools" - }, - { - "context": "ui", - "key": "Verfügbare Workflows", - "value": "Verfügbare Workflows" - }, - { - "context": "ui", - "key": "Version", - "value": "Version" - }, - { - "context": "ui", - "key": "Versuchen Sie, Ihr Microsoft-Konto auf der Verbindungsseite erneut zu verbinden.", - "value": "Versuchen Sie, Ihr Microsoft-Konto auf der Verbindungsseite erneut zu verbinden." - }, - { - "context": "ui", - "key": "Vertrag", - "value": "Vertrag" - }, - { - "context": "ui", - "key": "Vertrag (optional)", - "value": "Vertrag (optional)" - }, - { - "context": "ui", - "key": "Vertrag erfolgreich erstellt", - "value": "Vertrag erfolgreich erstellt" - }, - { - "context": "ui", - "key": "Verträge", - "value": "Verträge" - }, - { - "context": "ui", - "key": "Verwalten Sie Daten über Tabellen. Wählen Sie eine Tabelle aus oder verwenden Sie natürliche Sprache, um Befehle auszuführen.", - "value": "Verwalten Sie Daten über Tabellen. Wählen Sie eine Tabelle aus oder verwenden Sie natürliche Sprache, um Befehle auszuführen." - }, - { - "context": "ui", - "key": "Verwalten Sie Ihre Kontoinformationen", - "value": "Verwalten Sie Ihre Kontoinformationen" - }, - { - "context": "ui", - "key": "Verwalten Sie Ihre Service-Verbindungen", - "value": "Verwalten Sie Ihre Service-Verbindungen" - }, - { - "context": "ui", - "key": "Verwalten Sie Ihre Sprach-Integrations-Konfiguration und Einstellungen.", - "value": "Verwalten Sie Ihre Sprach-Integrations-Konfiguration und Einstellungen." - }, - { - "context": "ui", - "key": "Verwalten Sie Mandate und deren zugehörige Berechtigungen.", - "value": "Verwalten Sie Mandate und deren zugehörige Berechtigungen." - }, - { - "context": "ui", - "key": "Verwaltet von {provider}", - "value": "Verwaltet von {provider}" - }, - { - "context": "ui", - "key": "Verwaltung der Benutzerzugriffe auf Organisationen", - "value": "Verwaltung der Benutzerzugriffe auf Organisationen" - }, - { - "context": "ui", - "key": "Verwaltung der Buchungspositionen (Speseneinträge)", - "value": "Verwaltung der Buchungspositionen (Speseneinträge)" - }, - { - "context": "ui", - "key": "Verwaltung der Dokumente und Belege", - "value": "Verwaltung der Dokumente und Belege" - }, - { - "context": "ui", - "key": "Verwaltung der Feature-spezifischen Rollen", - "value": "Verwaltung der Feature-spezifischen Rollen" - }, - { - "context": "ui", - "key": "Verwaltung der Kundenverträge", - "value": "Verwaltung der Kundenverträge" - }, - { - "context": "ui", - "key": "Verwaltung der Treuhand-Organisationen", - "value": "Verwaltung der Treuhand-Organisationen" - }, - { - "context": "ui", - "key": "Verwaltung von Treuhand-Organisationen, Verträgen und Buchungen", - "value": "Verwaltung von Treuhand-Organisationen, Verträgen und Buchungen" - }, - { - "context": "ui", - "key": "Verwaltungs- und Management-Tools", - "value": "Verwaltungs- und Management-Tools" - }, - { - "context": "ui", - "key": "Verwende Vorlage:", - "value": "Verwende Vorlage:" - }, - { - "context": "ui", - "key": "Video", - "value": "Video" - }, - { - "context": "ui", - "key": "Vielen Dank für Ihr Interesse an unserer Sprach Integration powered by Spitch.ai. Wir haben Ihr Mandat erhalten und werden es in Kürze überprüfen.", - "value": "Vielen Dank für Ihr Interesse an unserer Sprach Integration powered by Spitch.ai. Wir haben Ihr Mandat erhalten und werden es in Kürze überprüfen." - }, - { - "context": "ui", - "key": "Virtual Assistant (VA)", - "value": "Virtual Assistant (VA)" - }, - { - "context": "ui", - "key": "Voice Biometrics (VB)", - "value": "Voice Biometrics (VB)" - }, - { - "context": "ui", - "key": "Vollständiger Name", - "value": "Vollständiger Name" - }, - { - "context": "ui", - "key": "Von", - "value": "Von" - }, - { - "context": "ui", - "key": "Von der Registrierung bis zur technischen Einrichtung - Ihr Mandant registriert sich bei PowerOn für Telefonie-Services, lädt Dokumente hoch und erhält automatisch eine technische SIP-Nummer von Spitch. Die Call-Weiterleitung kann jederzeit aktiviert oder deaktiviert werden, was maximale Flexibilität und BCM-Sicherheit gewährleistet.", - "value": "Von der Registrierung bis zur technischen Einrichtung - Ihr Mandant registriert sich bei PowerOn für Telefonie-Services, lädt Dokumente hoch und erhält automatisch eine technische SIP-Nummer von Spitch. Die Call-Weiterleitung kann jederzeit aktiviert oder deaktiviert werden, was maximale Flexibilität und BCM-Sicherheit gewährleistet." - }, - { - "context": "ui", - "key": "Vorherige Seite", - "value": "Vorherige Seite" - }, - { - "context": "ui", - "key": "Vorschau", - "value": "Vorschau" - }, - { - "context": "ui", - "key": "Vorschau für diesen Dateityp nicht verfügbar", - "value": "Vorschau für diesen Dateityp nicht verfügbar" - }, - { - "context": "ui", - "key": "Vorschau schließen", - "value": "Vorschau schließen" - }, - { - "context": "ui", - "key": "Vorschau wird geladen...", - "value": "Vorschau wird geladen..." - }, - { - "context": "ui", - "key": "WARTEND", - "value": "WARTEND" - }, - { - "context": "ui", - "key": "Wartend", - "value": "Wartend" - }, - { - "context": "ui", - "key": "Was passiert als nächstes?", - "value": "Was passiert als nächstes?" - }, - { - "context": "ui", - "key": "Wechseln Sie zwischen hellem und dunklem Modus", - "value": "Wechseln Sie zwischen hellem und dunklem Modus" - }, - { - "context": "ui", - "key": "Werkzeuge", - "value": "Werkzeuge" - }, - { - "context": "ui", - "key": "Werkzeuge und Hilfsmittel", - "value": "Werkzeuge und Hilfsmittel" - }, - { - "context": "ui", - "key": "Wie möchten Sie am Telefon genannt werden?", - "value": "Wie möchten Sie am Telefon genannt werden?" - }, - { - "context": "ui", - "key": "Wiederholen", - "value": "Wiederholen" - }, - { - "context": "ui", - "key": "Willkommen in Ihrem Arbeitsbereich", - "value": "Willkommen in Ihrem Arbeitsbereich" - }, - { - "context": "ui", - "key": "Wird gesendet...", - "value": "Wird gesendet..." - }, - { - "context": "ui", - "key": "Wird gestoppt...", - "value": "Wird gestoppt..." - }, - { - "context": "ui", - "key": "Wird geteilt...", - "value": "Wird geteilt..." - }, - { - "context": "ui", - "key": "Wird hochgeladen...", - "value": "Wird hochgeladen..." - }, - { - "context": "ui", - "key": "Wird verarbeitet...", - "value": "Wird verarbeitet..." - }, - { - "context": "ui", - "key": "Workflow", - "value": "Workflow" - }, - { - "context": "ui", - "key": "Workflow Fortschritt", - "value": "Workflow Fortschritt" - }, - { - "context": "ui", - "key": "Workflow auswählen", - "value": "Workflow auswählen" - }, - { - "context": "ui", - "key": "Workflow fehlgeschlagen.", - "value": "Workflow fehlgeschlagen." - }, - { - "context": "ui", - "key": "Workflow fortsetzen", - "value": "Workflow fortsetzen" - }, - { - "context": "ui", - "key": "Workflow läuft... Warte auf Logs...", - "value": "Workflow läuft... Warte auf Logs..." - }, - { - "context": "ui", - "key": "Workflow löschen", - "value": "Workflow löschen" - }, - { - "context": "ui", - "key": "Workflow stoppen", - "value": "Workflow stoppen" - }, - { - "context": "ui", - "key": "Workflow wird fortgesetzt", - "value": "Workflow wird fortgesetzt" - }, - { - "context": "ui", - "key": "Workflow wird gelöscht...", - "value": "Workflow wird gelöscht..." - }, - { - "context": "ui", - "key": "Workflow-Automatisierungen verwalten", - "value": "Workflow-Automatisierungen verwalten" - }, - { - "context": "ui", - "key": "Workflow-Nachrichten werden geladen...", - "value": "Workflow-Nachrichten werden geladen..." - }, - { - "context": "ui", - "key": "Workflow-Verlauf", - "value": "Workflow-Verlauf" - }, - { - "context": "ui", - "key": "Workflows", - "value": "Workflows" - }, - { - "context": "ui", - "key": "Workflows werden geladen...", - "value": "Workflows werden geladen..." - }, - { - "context": "ui", - "key": "Wähle einen Workflow aus der Liste aus oder starte einen neuen Workflow", - "value": "Wähle einen Workflow aus der Liste aus oder starte einen neuen Workflow" - }, - { - "context": "ui", - "key": "Wählen Sie Ihre bevorzugte Sprache", - "value": "Wählen Sie Ihre bevorzugte Sprache" - }, - { - "context": "ui", - "key": "You", - "value": "You" - }, - { - "context": "ui", - "key": "Zeitzone", - "value": "Zeitzone" - }, - { - "context": "ui", - "key": "Zentrale", - "value": "Zentrale" - }, - { - "context": "ui", - "key": "Zu dunklem Modus wechseln", - "value": "Zu dunklem Modus wechseln" - }, - { - "context": "ui", - "key": "Zu hellem Modus wechseln", - "value": "Zu hellem Modus wechseln" - }, - { - "context": "ui", - "key": "Zugriff", - "value": "Zugriff" - }, - { - "context": "ui", - "key": "Zugriff erfolgreich erstellt", - "value": "Zugriff erfolgreich erstellt" - }, - { - "context": "ui", - "key": "Zugriff verweigert", - "value": "Zugriff verweigert" - }, - { - "context": "ui", - "key": "Zuletzt geprüft", - "value": "Zuletzt geprüft" - }, - { - "context": "ui", - "key": "Zum Bestätigen klicken", - "value": "Zum Bestätigen klicken" - }, - { - "context": "ui", - "key": "Zum Bestätigen klicken...", - "value": "Zum Bestätigen klicken..." - }, - { - "context": "ui", - "key": "Zum Ein-/Ausklappen klicken", - "value": "Zum Ein-/Ausklappen klicken" - }, - { - "context": "ui", - "key": "Zum Filtern klicken", - "value": "Zum Filtern klicken" - }, - { - "context": "ui", - "key": "Zum Sortieren klicken", - "value": "Zum Sortieren klicken" - }, - { - "context": "ui", - "key": "Zurück zur Sprach Integration", - "value": "Zurück zur Sprach Integration" - }, - { - "context": "ui", - "key": "angehängt", - "value": "angehängt" - }, - { - "context": "ui", - "key": "ausgewählt", - "value": "ausgewählt" - }, - { - "context": "ui", - "key": "k. A.", - "value": "k. A." - }, - { - "context": "ui", - "key": "kontakt@firma.com", - "value": "kontakt@firma.com" - }, - { - "context": "ui", - "key": "oder", - "value": "oder" - }, - { - "context": "ui", - "key": "z.B. Beleg.pdf", - "value": "z.B. Beleg.pdf" - }, - { - "context": "ui", - "key": "z.B. Finanzdienstleistungen, Technologie, etc.", - "value": "z.B. Finanzdienstleistungen, Technologie, etc." - }, - { - "context": "ui", - "key": "z.B. Muster AG 2026", - "value": "z.B. Muster AG 2026" - }, - { - "context": "ui", - "key": "z.B. Treuhand AG Zürich", - "value": "z.B. Treuhand AG Zürich" - }, - { - "context": "ui", - "key": "z.B. admin, operate, userreport", - "value": "z.B. admin, operate, userreport" - }, - { - "context": "ui", - "key": "z.B. treuhand-ag-zuerich", - "value": "z.B. treuhand-ag-zuerich" - }, - { - "context": "ui", - "key": "{authority} Verbindung bearbeiten", - "value": "{authority} Verbindung bearbeiten" - }, - { - "context": "ui", - "key": "{column} filtern", - "value": "{column} filtern" - }, - { - "context": "ui", - "key": "{count} Benutzer ausgewählt", - "value": "{count} Benutzer ausgewählt" - }, - { - "context": "ui", - "key": "{fieldLabel} ist erforderlich", - "value": "{fieldLabel} ist erforderlich" - }, - { - "context": "ui", - "key": "{fieldLabel} muss eine gültige Ganzzahl sein", - "value": "{fieldLabel} muss eine gültige Ganzzahl sein" - }, - { - "context": "ui", - "key": "{fieldLabel} muss eine gültige Zahl sein", - "value": "{fieldLabel} muss eine gültige Zahl sein" - }, - { - "context": "ui", - "key": "Änderungen speichern", - "value": "Änderungen speichern" - }, - { - "context": "ui", - "key": "Über", - "value": "Über" - }, - { - "context": "ui", - "key": "Überprüfungsprozess", - "value": "Überprüfungsprozess" - }, - { - "context": "ui", - "key": "Übersicht - Sehen Sie den Arbeitsbereich-Status und Updates", - "value": "Übersicht - Sehen Sie den Arbeitsbereich-Status und Updates" - }, - { - "context": "ui", - "key": "Überwachen Sie automatisch 100% der Gespräche, um wertvolle Einblicke für Ihr Unternehmen zu erhalten.", - "value": "Überwachen Sie automatisch 100% der Gespräche, um wertvolle Einblicke für Ihr Unternehmen zu erhalten." - }, - { - "context": "ui", - "key": "(gefiltert nach {name})", - "value": "(gefiltert nach {name})" - }, - { - "context": "ui", - "key": "({count} gefiltert)", - "value": "({count} gefiltert)" - }, - { - "context": "ui", - "key": "Abonnement, Einstellungen und Guthaben pro Mandant", - "value": "Abonnement, Einstellungen und Guthaben pro Mandant" - }, - { - "context": "ui", - "key": "Abrechnung", - "value": "Abrechnung" - }, - { - "context": "ui", - "key": "Aktion", - "value": "Aktion" - }, - { - "context": "ui", - "key": "Benutzer-Billing", - "value": "Benutzer-Billing" - }, - { - "context": "ui", - "key": "Benutzer-Guthaben", - "value": "Benutzer-Guthaben" - }, - { - "context": "ui", - "key": "Benutzer:", - "value": "Benutzer:" - }, - { - "context": "ui", - "key": "Deaktiviert", - "value": "Deaktiviert" - }, - { - "context": "ui", - "key": "Du hast Zugriff auf {instanceCount} {instanceWord} in {mandateCount} {mandateWord}.", - "value": "Du hast Zugriff auf {instanceCount} {instanceWord} in {mandateCount} {mandateWord}." - }, - { - "context": "ui", - "key": "Einstellungen gespeichert!", - "value": "Einstellungen gespeichert!" - }, - { - "context": "ui", - "key": "Feature-Instanz", - "value": "Feature-Instanz" - }, - { - "context": "ui", - "key": "Feature-Instanzen", - "value": "Feature-Instanzen" - }, - { - "context": "ui", - "key": "Fehler beim Speichern", - "value": "Fehler beim Speichern" - }, - { - "context": "ui", - "key": "Gesamtguthaben", - "value": "Gesamtguthaben" - }, - { - "context": "ui", - "key": "Mandant:", - "value": "Mandant:" - }, - { - "context": "ui", - "key": "Mandanten", - "value": "Mandanten" - }, - { - "context": "ui", - "key": "Mandanten-Billing", - "value": "Mandanten-Billing" - }, - { - "context": "ui", - "key": "Mandanten-Guthaben", - "value": "Mandanten-Guthaben" - }, - { - "context": "ui", - "key": "Mandant", - "value": "Mandant" - }, - { - "context": "ui", - "key": "Niedrig", - "value": "Niedrig" - }, - { - "context": "ui", - "key": "Transaktionen", - "value": "Transaktionen" - }, - { - "context": "ui", - "key": "Warnschwelle", - "value": "Warnschwelle" - }, - { - "context": "ui", - "key": "Ansicht an Fenster anpassen", - "value": "Ansicht an Fenster anpassen" - }, - { - "context": "ui", - "key": "Ansicht zurücksetzen", - "value": "Ansicht zurücksetzen" - }, - { - "context": "ui", - "key": "Auswahl löschen", - "value": "Auswahl löschen" - }, - { - "context": "ui", - "key": "Canvas bearbeiten", - "value": "Canvas bearbeiten" - }, - { - "context": "ui", - "key": "Klicken Sie auf einen Ausgang, dann auf einen Eingang", - "value": "Klicken Sie auf einen Ausgang, dann auf einen Eingang" - }, - { - "context": "ui", - "key": "Klicken Sie auf einen Eingang, um die Verbindung zu erstellen", - "value": "Klicken Sie auf einen Eingang, um die Verbindung zu erstellen" - }, - { - "context": "ui", - "key": "Kommentar (optional)", - "value": "Kommentar (optional)" - }, - { - "context": "ui", - "key": "Kommentar bearbeiten", - "value": "Kommentar bearbeiten" - }, - { - "context": "ui", - "key": "Knoten duplizieren", - "value": "Knoten duplizieren" - }, - { - "context": "ui", - "key": "Rückgängig", - "value": "Rückgängig" - }, - { - "context": "ui", - "key": "Verbindungen zeichnen", - "value": "Verbindungen zeichnen" - }, - { - "context": "ui", - "key": "Vergrößern", - "value": "Vergrößern" - }, - { - "context": "ui", - "key": "Verkleinern", - "value": "Verkleinern" - }, - { - "context": "ui", - "key": "Wiederholen", - "value": "Wiederholen" - }, - { - "context": "ui", - "key": "Zoom-Voreinstellungen", - "value": "Zoom-Voreinstellungen" - }, - { - "context": "ui", - "key": "Zoomstufe (Prozent)", - "value": "Zoomstufe (Prozent)" - }, - { - "context": "ui", - "key": "Doppelklick zum Bearbeiten", - "value": "Doppelklick zum Bearbeiten" - }, - { - "context": "ui", - "key": "Kommentar auf dem Canvas einfügen", - "value": "Kommentar auf dem Canvas einfügen" - }, - { - "context": "ui", - "key": "Kommentar eingeben …", - "value": "Kommentar eingeben …" - }, - { - "context": "ui", - "key": "Canvas-Notiz verschieben", - "value": "Zum Verschieben greifen" - }, - { - "context": "ui", - "key": "Notizfarbe", - "value": "Notizfarbe" - }, - { - "context": "ui", - "key": "Notizgröße ändern", - "value": "Notizgröße ändern" - }, - { - "context": "ui", - "key": "✓ Mandat eingereicht", - "value": "✓ Mandat eingereicht" - } - ], - "status": "complete", - "isDefault": false - }, - { - "id": "en", - "label": "English", - "entries": [ - { - "context": "ui", - "key": "+41 123 456 789", - "value": "+41 123 456 789" - }, - { - "context": "ui", - "key": "1 Benutzer ausgewählt", - "value": "1 user selected" - }, - { - "context": "ui", - "key": "ABGEBROCHEN", - "value": "CANCELLED" - }, - { - "context": "ui", - "key": "ABGESCHLOSSEN", - "value": "COMPLETED" - }, - { - "context": "ui", - "key": "Abbrechen", - "value": "Cancel" - }, - { - "context": "ui", - "key": "Abgeschlossen", - "value": "Completed" - }, - { - "context": "ui", - "key": "Abmelden", - "value": "Logout" - }, - { - "context": "ui", - "key": "Admin-Einstellungen", - "value": "Admin Settings" - }, - { - "context": "ui", - "key": "Administrative Einstellungen", - "value": "Administrative settings" - }, - { - "context": "ui", - "key": "Administrator", - "value": "Admin" - }, - { - "context": "ui", - "key": "Adresse", - "value": "Address" - }, - { - "context": "ui", - "key": "Agent Assist (AA)", - "value": "Agent Assist (AA)" - }, - { - "context": "ui", - "key": "Aktionen", - "value": "Actions" - }, - { - "context": "ui", - "key": "Aktiv", - "value": "Active" - }, - { - "context": "ui", - "key": "Aktiviert", - "value": "Enabled" - }, - { - "context": "ui", - "key": "Aktualisieren", - "value": "Update" - }, - { - "context": "ui", - "key": "Aktuelle Transkripte", - "value": "Recent Transcripts" - }, - { - "context": "ui", - "key": "Alle Dateien", - "value": "All Files" - }, - { - "context": "ui", - "key": "Alle Elemente auswählen", - "value": "Select all items" - }, - { - "context": "ui", - "key": "Alle Nicht-Standard-Sprachsets jetzt mit dem deutschen Master synchronisieren?", - "value": "Synchronize all non-default language sets with the German master now?" - }, - { - "context": "ui", - "key": "Alle abwählen", - "value": "Deselect all" - }, - { - "context": "ui", - "key": "Alle aktualisieren", - "value": "Update all" - }, - { - "context": "ui", - "key": "Alle auswählen", - "value": "Select all" - }, - { - "context": "ui", - "key": "Analysiere Workflow...", - "value": "Analyzing workflow..." - }, - { - "context": "ui", - "key": "Anmelden", - "value": "Login" - }, - { - "context": "ui", - "key": "Anrufer", - "value": "Caller" - }, - { - "context": "ui", - "key": "Anzeigen", - "value": "View" - }, - { - "context": "ui", - "key": "Anzeigename", - "value": "Display name" - }, - { - "context": "ui", - "key": "Audio", - "value": "Audio" - }, - { - "context": "ui", - "key": "Auf Standard zurücksetzen", - "value": "Reset to Default" - }, - { - "context": "ui", - "key": "Aufgaben", - "value": "Tasks" - }, - { - "context": "ui", - "key": "Ausführen", - "value": "Execute" - }, - { - "context": "ui", - "key": "Ausgewählte Datei:", - "value": "Selected file:" - }, - { - "context": "ui", - "key": "Auth-Anbieter", - "value": "Auth Authority" - }, - { - "context": "ui", - "key": "Authentifizierungsanbieter", - "value": "Authentication Provider" - }, - { - "context": "ui", - "key": "Authentifizierungstoken abgelaufen oder ungültig. Bitte verbinden Sie Ihr Microsoft-Konto erneut.", - "value": "Authentication token expired or invalid. Please reconnect your Microsoft account." - }, - { - "context": "ui", - "key": "Automatisierung erfolgreich erstellt", - "value": "Automation created successfully" - }, - { - "context": "ui", - "key": "Automatisierungen", - "value": "Automations" - }, - { - "context": "ui", - "key": "Basisdaten", - "value": "Base Data" - }, - { - "context": "ui", - "key": "Bearbeiten", - "value": "Edit" - }, - { - "context": "ui", - "key": "Befehl eingeben (z.B., \"Erstelle ein neues Projekt namens 'Hauptstrasse 42'\")", - "value": "Enter a command (e.g., \"Create a new project named 'Main Street 42'\")" - }, - { - "context": "ui", - "key": "Beginne ein Gespräch, indem du eine Nachricht eingibst, eine Vorlage auswählst oder einen vorherigen Workflow fortsetzt …", - "value": "Start a conversation by entering a message, selecting a template, or continuing a previous workflow..." - }, - { - "context": "ui", - "key": "Beginnen Sie mit:", - "value": "Get started with:" - }, - { - "context": "ui", - "key": "Bei Genehmigung planen wir einen Einrichtungsanruf zur Konfiguration Ihrer Integration.", - "value": "If approved, we'll schedule a setup call to configure your integration." - }, - { - "context": "ui", - "key": "Beim Hochladen ist ein Fehler aufgetreten.", - "value": "An error occurred while uploading." - }, - { - "context": "ui", - "key": "Beim Hochladen ist ein unerwarteter Fehler aufgetreten.", - "value": "An unexpected error occurred while uploading." - }, - { - "context": "ui", - "key": "Belege verwalten", - "value": "Manage receipts" - }, - { - "context": "ui", - "key": "Benutzer", - "value": "User" - }, - { - "context": "ui", - "key": "Benutzer auswählen", - "value": "Select Users" - }, - { - "context": "ui", - "key": "Benutzer bearbeiten", - "value": "Edit User" - }, - { - "context": "ui", - "key": "Benutzer erstellen", - "value": "Create User" - }, - { - "context": "ui", - "key": "Benutzer hinzufügen", - "value": "Add User" - }, - { - "context": "ui", - "key": "Benutzer löschen", - "value": "Delete User" - }, - { - "context": "ui", - "key": "Benutzer werden geladen...", - "value": "Loading users..." - }, - { - "context": "ui", - "key": "Benutzer-Zugriff verwalten", - "value": "Manage user access" - }, - { - "context": "ui", - "key": "Benutzerdefinierter Titel (optional)", - "value": "Custom Title (optional)" - }, - { - "context": "ui", - "key": "Benutzerinformationen", - "value": "User Information" - }, - { - "context": "ui", - "key": "Benutzerinformationen erfolgreich aktualisiert", - "value": "User information updated successfully" - }, - { - "context": "ui", - "key": "Benutzerinformationen werden geladen...", - "value": "Loading user information..." - }, - { - "context": "ui", - "key": "Benutzername", - "value": "Username" - }, - { - "context": "ui", - "key": "Benutzerverwaltung - Teammitglieder und Berechtigungen verwalten", - "value": "User Management - Manage team members and permissions" - }, - { - "context": "ui", - "key": "Berechtigung", - "value": "Privilege" - }, - { - "context": "ui", - "key": "Berechtigungsstufe", - "value": "Privilege Level" - }, - { - "context": "ui", - "key": "Beschreibung", - "value": "Description" - }, - { - "context": "ui", - "key": "Beschreibung der Rolle", - "value": "Role description" - }, - { - "context": "ui", - "key": "Betrachter", - "value": "Viewer" - }, - { - "context": "ui", - "key": "Betreff", - "value": "Subject" - }, - { - "context": "ui", - "key": "Bezeichnung", - "value": "Label" - }, - { - "context": "ui", - "key": "Bieten Sie Unterstützung im Live-Chat und setzen Sie intelligente Chatbots in allen Kanälen ein.", - "value": "Deliver assistance in live chat and deploy intelligent chatbots in all channels." - }, - { - "context": "ui", - "key": "Bild", - "value": "Image" - }, - { - "context": "ui", - "key": "Bitte geben Sie eine gültige E-Mail-Adresse ein", - "value": "Please enter a valid email address" - }, - { - "context": "ui", - "key": "Bitte wählen Sie mindestens einen Benutzer aus", - "value": "Please select at least one user" - }, - { - "context": "ui", - "key": "Branche", - "value": "Industry" - }, - { - "context": "ui", - "key": "Branche ist erforderlich", - "value": "Industry is required" - }, - { - "context": "ui", - "key": "Buchungsbetrag", - "value": "Booking Amount" - }, - { - "context": "ui", - "key": "Buchungspositionen verwalten", - "value": "Manage booking positions" - }, - { - "context": "ui", - "key": "Buchungswährung", - "value": "Booking Currency" - }, - { - "context": "ui", - "key": "Chat Platform (CP)", - "value": "Chat Platform (CP)" - }, - { - "context": "ui", - "key": "Chat leeren...", - "value": "New Chat" - }, - { - "context": "ui", - "key": "Chatbereich", - "value": "Chat Area" - }, - { - "context": "ui", - "key": "Darstellung", - "value": "Appearance" - }, - { - "context": "ui", - "key": "Datei", - "value": "File" - }, - { - "context": "ui", - "key": "Datei anhängen", - "value": "Attach file" - }, - { - "context": "ui", - "key": "Datei bereits vorhanden", - "value": "File Already Exists" - }, - { - "context": "ui", - "key": "Datei entfernen", - "value": "Remove file" - }, - { - "context": "ui", - "key": "Datei erfolgreich hochgeladen!", - "value": "File uploaded successfully!" - }, - { - "context": "ui", - "key": "Datei herunterladen", - "value": "Download file" - }, - { - "context": "ui", - "key": "Datei hier ablegen...", - "value": "Drop file here..." - }, - { - "context": "ui", - "key": "Datei hinzufügen", - "value": "Add File" - }, - { - "context": "ui", - "key": "Datei hochladen", - "value": "Upload file" - }, - { - "context": "ui", - "key": "Datei löschen", - "value": "Delete file" - }, - { - "context": "ui", - "key": "Datei vorschauen", - "value": "Preview file" - }, - { - "context": "ui", - "key": "Datei-Ablage während Workflow deaktiviert", - "value": "File drop disabled during workflow" - }, - { - "context": "ui", - "key": "Dateien", - "value": "Files" - }, - { - "context": "ui", - "key": "Dateien anhängen", - "value": "Attach Files" - }, - { - "context": "ui", - "key": "Dateien auswählen", - "value": "Select files" - }, - { - "context": "ui", - "key": "Dateien hier ablegen", - "value": "Drop files here" - }, - { - "context": "ui", - "key": "Dateien hier ablegen zum Anhängen", - "value": "Drop files here to attach" - }, - { - "context": "ui", - "key": "Dateien hierher ziehen", - "value": "Drag files here" - }, - { - "context": "ui", - "key": "Dateien hochladen", - "value": "Upload files" - }, - { - "context": "ui", - "key": "Dateien werden geladen...", - "value": "Loading files..." - }, - { - "context": "ui", - "key": "Dateien werden verarbeitet...", - "value": "Processing files..." - }, - { - "context": "ui", - "key": "Dateigröße", - "value": "File Size" - }, - { - "context": "ui", - "key": "Dateiname", - "value": "File Name" - }, - { - "context": "ui", - "key": "Dateityp", - "value": "File Type" - }, - { - "context": "ui", - "key": "Dateiverwaltung - Dokumente hochladen und organisieren", - "value": "File Management - Upload and organize documents" - }, - { - "context": "ui", - "key": "Dateivorschau", - "value": "File Preview" - }, - { - "context": "ui", - "key": "Daten aktualisieren", - "value": "Refresh data" - }, - { - "context": "ui", - "key": "Daten empfangen", - "value": "Data Received" - }, - { - "context": "ui", - "key": "Daten gesendet", - "value": "Data Sent" - }, - { - "context": "ui", - "key": "Datenverwaltung", - "value": "Data Management" - }, - { - "context": "ui", - "key": "Datenverwaltung - Datenimporte und -exporte verwalten", - "value": "Data Management - Handle data imports and exports" - }, - { - "context": "ui", - "key": "Datenverwaltung mit Tabellen", - "value": "Data management with tables" - }, - { - "context": "ui", - "key": "Datum", - "value": "Date" - }, - { - "context": "ui", - "key": "Dauer", - "value": "Duration" - }, - { - "context": "ui", - "key": "Deutsch", - "value": "Deutsch" - }, - { - "context": "ui", - "key": "Die Datei \"{fileName}\" existiert bereits mit identischem Inhalt. Die vorhandene Datei wird wiederverwendet.", - "value": "The file \"{fileName}\" already exists with identical content. The existing file will be reused." - }, - { - "context": "ui", - "key": "Die Erstellung einer neuen Sprache kann AI-Guthaben auf Ihrem Mandats-Pool belasten. Fortfahren?", - "value": "Creating a new language may consume AI credits from your mandate pool. Continue?" - }, - { - "context": "ui", - "key": "Dies ist Ihr Ausgangspunkt für den Zugriff auf alle Arbeitsbereich-Features und -Tools.", - "value": "This is your starting point for accessing all workspace features and tools." - }, - { - "context": "ui", - "key": "Diese Aktion kann nicht rückgängig gemacht werden.", - "value": "This action cannot be undone." - }, - { - "context": "ui", - "key": "Diese Datei scheint beschädigt zu sein. Sie hat eine PDF-Erweiterung, enthält aber Textinhalte. Bitte laden Sie die Datei erneut hoch, falls möglich.", - "value": "This file appears to be corrupted. It has a PDF extension but contains text content. Please re-upload the file if possible." - }, - { - "context": "ui", - "key": "Dieser Bereich enthält alle Verwaltungs- und Management-Tools für Ihren Arbeitsbereich.", - "value": "This section contains all administration and management tools for your workspace." - }, - { - "context": "ui", - "key": "Dieses Element auswählen", - "value": "Select this item" - }, - { - "context": "ui", - "key": "Dieses Element kann nicht ausgewählt werden", - "value": "This item cannot be selected" - }, - { - "context": "ui", - "key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden", - "value": "This field is managed by {provider} and cannot be changed" - }, - { - "context": "ui", - "key": "Dossiers", - "value": "Dossiers" - }, - { - "context": "ui", - "key": "Dokument", - "value": "Document" - }, - { - "context": "ui", - "key": "Dokument erfolgreich erstellt", - "value": "Document created successfully" - }, - { - "context": "ui", - "key": "Dokument herunterladen", - "value": "Download document" - }, - { - "context": "ui", - "key": "Dokument vorschauen", - "value": "Preview document" - }, - { - "context": "ui", - "key": "Dokumente", - "value": "Documents" - }, - { - "context": "ui", - "key": "Dokumente auflisten", - "value": "List Documents" - }, - { - "context": "ui", - "key": "Dokumentname", - "value": "Document Name" - }, - { - "context": "ui", - "key": "Dunkel", - "value": "Dark" - }, - { - "context": "ui", - "key": "Durchsuchen", - "value": "Browse" - }, - { - "context": "ui", - "key": "E-Mail", - "value": "Email" - }, - { - "context": "ui", - "key": "E-Mail-Adresse", - "value": "Email Address" - }, - { - "context": "ui", - "key": "E-Mail-Adresse ist erforderlich", - "value": "Email address is required" - }, - { - "context": "ui", - "key": "E-Mail-Bestätigung", - "value": "Email Confirmation" - }, - { - "context": "ui", - "key": "Echtzeit-Datensynchronisation:", - "value": "Real-time Data Synchronization:" - }, - { - "context": "ui", - "key": "Eingereichte Daten:", - "value": "Submitted Data:" - }, - { - "context": "ui", - "key": "Einrichtungsanruf", - "value": "Setup Call" - }, - { - "context": "ui", - "key": "Einstellungen", - "value": "Settings" - }, - { - "context": "ui", - "key": "Einstellungen erfolgreich gespeichert!", - "value": "Settings saved successfully!" - }, - { - "context": "ui", - "key": "Einstellungen werden in zukünftigen Updates hinzugefügt.", - "value": "Settings content will be added here in future updates." - }, - { - "context": "ui", - "key": "Einstellungen wurden erfolgreich zurückgesetzt.", - "value": "Settings have been reset successfully." - }, - { - "context": "ui", - "key": "Einträge pro Seite:", - "value": "Items per page:" - }, - { - "context": "ui", - "key": "Empfänger", - "value": "Recipient" - }, - { - "context": "ui", - "key": "Endzeit", - "value": "End Time" - }, - { - "context": "ui", - "key": "English", - "value": "English" - }, - { - "context": "ui", - "key": "Entdeckte Sites", - "value": "Discovered Sites" - }, - { - "context": "ui", - "key": "Erfolgreich", - "value": "Success" - }, - { - "context": "ui", - "key": "Erfolgsrate", - "value": "Success Rate" - }, - { - "context": "ui", - "key": "Erleben Sie die Zukunft der Mandantenkommunikation durch unsere strategische Partnerschaft mit Spitch.ai. Diese bahnbrechende Integration verwandelt Ihre PowerOn-Plattform in ein intelligentes Telefonie-System, das externe Mandanten nahtlos mit Unternehmen verbindet.", - "value": "Experience the future of client communication through our strategic partnership with Spitch.ai. This groundbreaking integration transforms your PowerOn platform into an intelligent telephony system that seamlessly connects external clients with companies." - }, - { - "context": "ui", - "key": "Erneut versuchen", - "value": "Try again" - }, - { - "context": "ui", - "key": "Erste Seite", - "value": "First page" - }, - { - "context": "ui", - "key": "Erstellen", - "value": "Create" - }, - { - "context": "ui", - "key": "Erstellen und verwalten Sie RBAC-Rollen und deren Berechtigungen.", - "value": "Create and manage RBAC roles and their permissions." - }, - { - "context": "ui", - "key": "Erstellen...", - "value": "Creating..." - }, - { - "context": "ui", - "key": "Erstellt", - "value": "Created" - }, - { - "context": "ui", - "key": "Erstellte Dateien", - "value": "Created Files" - }, - { - "context": "ui", - "key": "Erstellungsdatum", - "value": "Creation Date" - }, - { - "context": "ui", - "key": "Externe E-Mail", - "value": "External Email" - }, - { - "context": "ui", - "key": "Externe E-Mail-Adresse eingeben", - "value": "Enter external email address" - }, - { - "context": "ui", - "key": "Externen Benutzernamen eingeben", - "value": "Enter external username" - }, - { - "context": "ui", - "key": "Externer Benutzername", - "value": "External Username" - }, - { - "context": "ui", - "key": "FEHLER", - "value": "ERROR" - }, - { - "context": "ui", - "key": "FEHLGESCHLAGEN", - "value": "FAILED" - }, - { - "context": "ui", - "key": "Falls Sie Fragen zu Ihrem Mandat oder dem Integrationsprozess haben, zögern Sie nicht, unser Support-Team zu kontaktieren.", - "value": "If you have any questions about your mandate or the integration process, please don't hesitate to contact our support team." - }, - { - "context": "ui", - "key": "Fehler", - "value": "Error" - }, - { - "context": "ui", - "key": "Fehler beim Aktualisieren der Benutzerinformationen", - "value": "Error updating user information" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der Automatisierung", - "value": "Error creating automation" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der Organisation", - "value": "Error creating organisation" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der Position", - "value": "Error creating position" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der RBAC-Regel", - "value": "Error creating RBAC rule" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der Rolle", - "value": "Error creating role" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Dokuments", - "value": "Error creating document" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Mandats", - "value": "Error creating mandate" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Prompts", - "value": "Error creating prompt" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Team-Mitglieds", - "value": "Error creating team member" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Vertrags", - "value": "Error creating contract" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Zugriffs", - "value": "Error creating access" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Benutzer", - "value": "Error loading users" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Benutzer:", - "value": "Error loading users:" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Benutzerinformationen", - "value": "Error loading user information" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Dateien:", - "value": "Error loading files:" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Logs", - "value": "Error loading logs" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Nachrichten:", - "value": "Error loading messages:" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Prompts", - "value": "Error loading prompts" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Prompts:", - "value": "Error loading prompts:" - }, - { - "context": "ui", - "key": "Fehler beim Laden der SharePoint Dokumente:", - "value": "Error loading SharePoint documents:" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Vorschau", - "value": "Error loading preview" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Workflows:", - "value": "Error loading workflows:" - }, - { - "context": "ui", - "key": "Fehler beim Löschen", - "value": "Error deleting" - }, - { - "context": "ui", - "key": "Fehler beim Speichern der Einstellungen. Bitte versuchen Sie es erneut.", - "value": "Failed to save settings. Please try again." - }, - { - "context": "ui", - "key": "Fehler beim Teilen des Prompts", - "value": "Error sharing prompt" - }, - { - "context": "ui", - "key": "Fehler beim Verarbeiten der Dateien", - "value": "Error processing files" - }, - { - "context": "ui", - "key": "Fehler:", - "value": "Error:" - }, - { - "context": "ui", - "key": "Fehlgeschlagen", - "value": "Failed" - }, - { - "context": "ui", - "key": "Filter löschen", - "value": "Clear filter" - }, - { - "context": "ui", - "key": "Firma", - "value": "Company" - }, - { - "context": "ui", - "key": "Firmenname", - "value": "Company Name" - }, - { - "context": "ui", - "key": "Firmenname ist erforderlich", - "value": "Company name is required" - }, - { - "context": "ui", - "key": "Folgenachricht wird gesendet...", - "value": "Sending follow-up message..." - }, - { - "context": "ui", - "key": "Fortfahren", - "value": "Continue" - }, - { - "context": "ui", - "key": "Fortsetzen", - "value": "Continue" - }, - { - "context": "ui", - "key": "Fragen?", - "value": "Questions?" - }, - { - "context": "ui", - "key": "Français", - "value": "Français" - }, - { - "context": "ui", - "key": "Fügen Sie eine Nachricht für die Empfänger hinzu", - "value": "Add a message for recipients" - }, - { - "context": "ui", - "key": "GESTOPPT", - "value": "STOPPED" - }, - { - "context": "ui", - "key": "Geben Sie Ihren Firmennamen ein", - "value": "Enter your company name" - }, - { - "context": "ui", - "key": "Geben Sie Kunden einen schnellen und effizienten Selbstservice für Sprach- und Textanfragen, der 24/7 verfügbar ist.", - "value": "Give customers a fast and efficient self-service for voice and text queries that's available 24/7." - }, - { - "context": "ui", - "key": "Geben Sie den Inhalt des Prompts ein", - "value": "Enter the prompt content" - }, - { - "context": "ui", - "key": "Geben Sie einen Namen für den Prompt ein", - "value": "Enter a name for the prompt" - }, - { - "context": "ui", - "key": "Geben Sie einen benutzerdefinierten Titel ein", - "value": "Enter a custom title" - }, - { - "context": "ui", - "key": "Geplante und automatisierte Workflows", - "value": "Scheduled and automated workflows" - }, - { - "context": "ui", - "key": "Geschäftszeiten", - "value": "Business Hours" - }, - { - "context": "ui", - "key": "Geschäftszeiten & Zeitzone", - "value": "Business Hours & Timezone" - }, - { - "context": "ui", - "key": "Gespräch fortsetzen...", - "value": "Continue the conversation..." - }, - { - "context": "ui", - "key": "Gestartet", - "value": "Started" - }, - { - "context": "ui", - "key": "Gestartet:", - "value": "Started:" - }, - { - "context": "ui", - "key": "Gestoppt", - "value": "Stopped" - }, - { - "context": "ui", - "key": "Geteilt", - "value": "Shared" - }, - { - "context": "ui", - "key": "Geteilte Dateien", - "value": "Shared Files" - }, - { - "context": "ui", - "key": "Globale Sprachsets verwalten (SysAdmin).", - "value": "Manage global UI language sets (SysAdmin)." - }, - { - "context": "ui", - "key": "Google", - "value": "Google" - }, - { - "context": "ui", - "key": "Google verbinden", - "value": "Connect Google" - }, - { - "context": "ui", - "key": "Google-Verbindung erstellen", - "value": "Create Google Connection" - }, - { - "context": "ui", - "key": "Google-Verbindung hinzufügen", - "value": "Add Google Connection" - }, - { - "context": "ui", - "key": "Grundlegende Daten und Ressourcen", - "value": "Basic data and resources" - }, - { - "context": "ui", - "key": "Größe", - "value": "Size" - }, - { - "context": "ui", - "key": "Hell", - "value": "Light" - }, - { - "context": "ui", - "key": "Herunterladen", - "value": "Download" - }, - { - "context": "ui", - "key": "Hinzufügen", - "value": "Add" - }, - { - "context": "ui", - "key": "Hochgeladen", - "value": "Uploaded" - }, - { - "context": "ui", - "key": "Hochladen", - "value": "Upload" - }, - { - "context": "ui", - "key": "ID", - "value": "ID" - }, - { - "context": "ui", - "key": "INFO", - "value": "INFO" - }, - { - "context": "ui", - "key": "Identifizieren und authentifizieren Sie Anrufer in Sekunden mit kontinuierlicher Verifizierung und Sicherheit.", - "value": "Identify and authenticate callers in seconds with continuous verification and security." - }, - { - "context": "ui", - "key": "Ihre Anfrage wird verarbeitet...", - "value": "Processing your request..." - }, - { - "context": "ui", - "key": "Inaktiv", - "value": "Inactive" - }, - { - "context": "ui", - "key": "Information", - "value": "Information" - }, - { - "context": "ui", - "key": "Inhalt", - "value": "Content" - }, - { - "context": "ui", - "key": "Inhalt ist erforderlich", - "value": "Content is required" - }, - { - "context": "ui", - "key": "Ja", - "value": "Yes" - }, - { - "context": "ui", - "key": "Jetzt anmelden", - "value": "Sign Up Now" - }, - { - "context": "ui", - "key": "Jetzt überspringen", - "value": "Skip for Now" - }, - { - "context": "ui", - "key": "KI-erstellt", - "value": "AI-created" - }, - { - "context": "ui", - "key": "KI-gestützte Dokumentengenerierung:", - "value": "AI-Powered Document Generation:" - }, - { - "context": "ui", - "key": "Kein Auth-Anbieter", - "value": "No Auth Authority" - }, - { - "context": "ui", - "key": "Kein Benutzername", - "value": "No Username" - }, - { - "context": "ui", - "key": "Kein Nachrichteninhalt verfügbar", - "value": "No message content available" - }, - { - "context": "ui", - "key": "Kein Name", - "value": "No Name" - }, - { - "context": "ui", - "key": "Kein Workflow ausgewählt", - "value": "No workflow selected" - }, - { - "context": "ui", - "key": "Keine Benutzer verfügbar", - "value": "No users available" - }, - { - "context": "ui", - "key": "Keine Berechtigung", - "value": "No Privilege" - }, - { - "context": "ui", - "key": "Keine Berechtigung zum Löschen des Prompts", - "value": "No permission to delete prompt" - }, - { - "context": "ui", - "key": "Keine Dateien gefunden.", - "value": "No files found." - }, - { - "context": "ui", - "key": "Keine E-Mail", - "value": "No Email" - }, - { - "context": "ui", - "key": "Keine Einträge", - "value": "No entries" - }, - { - "context": "ui", - "key": "Keine Logs für diesen Workflow verfügbar", - "value": "No logs available for this workflow" - }, - { - "context": "ui", - "key": "Keine Microsoft-Verbindungen gefunden. Bitte erstellen Sie zuerst eine Verbindung.", - "value": "No Microsoft connections found. Please create a connection first." - }, - { - "context": "ui", - "key": "Keine Prompts verfügbar", - "value": "No prompts available" - }, - { - "context": "ui", - "key": "Keine SharePoint-Sites gefunden", - "value": "No SharePoint sites found" - }, - { - "context": "ui", - "key": "Keine Sprach-Integrations-Daten gefunden. Bitte melden Sie sich zuerst an, um auf die Einstellungen zuzugreifen.", - "value": "No speech integration data found. Please sign up first to access settings." - }, - { - "context": "ui", - "key": "Keine Sprache", - "value": "No Language" - }, - { - "context": "ui", - "key": "Keine Transkripte vorhanden", - "value": "No transcripts available" - }, - { - "context": "ui", - "key": "Keine Vorschau verfügbar", - "value": "No preview available" - }, - { - "context": "ui", - "key": "Keine Workflows gefunden", - "value": "No workflows found" - }, - { - "context": "ui", - "key": "Keine Workflows verfügbar", - "value": "No workflows available" - }, - { - "context": "ui", - "key": "Keine hochgeladenen Dateien gefunden.", - "value": "No uploaded files found." - }, - { - "context": "ui", - "key": "Keine mit Ihnen geteilten Dateien gefunden.", - "value": "No shared files found." - }, - { - "context": "ui", - "key": "Keine von der KI erstellten Dateien gefunden.", - "value": "No AI-created files found." - }, - { - "context": "ui", - "key": "Klicken Sie erneut zum Bestätigen", - "value": "Click again to confirm" - }, - { - "context": "ui", - "key": "Klicken Sie erneut zum Bestätigen der Löschung", - "value": "Click again to confirm deletion" - }, - { - "context": "ui", - "key": "Klicken Sie, um zu öffnen", - "value": "Click to open" - }, - { - "context": "ui", - "key": "Knowledge Agent (KA)", - "value": "Knowledge Agent (KA)" - }, - { - "context": "ui", - "key": "Konfigurieren Sie administrative Einstellungen und Systempräferenzen.", - "value": "Configure administrative settings and system preferences." - }, - { - "context": "ui", - "key": "Konfigurieren und verwalten Sie rollenbasierte Zugriffssteuerungsregeln.", - "value": "Configure and manage Role-Based Access Control rules." - }, - { - "context": "ui", - "key": "Kontakte einrichten", - "value": "Setup Contacts" - }, - { - "context": "ui", - "key": "Kontaktinformationen", - "value": "Contact Information" - }, - { - "context": "ui", - "key": "Kontostatus", - "value": "Account Status" - }, - { - "context": "ui", - "key": "Kopieren", - "value": "Copy" - }, - { - "context": "ui", - "key": "Kosteneinsparungen & Effizienz:", - "value": "Cost Savings & Efficiency:" - }, - { - "context": "ui", - "key": "Kundenverträge verwalten", - "value": "Manage customer contracts" - }, - { - "context": "ui", - "key": "Lade Fortschritt...", - "value": "Loading progress..." - }, - { - "context": "ui", - "key": "Laden...", - "value": "Downloading..." - }, - { - "context": "ui", - "key": "Land", - "value": "Country" - }, - { - "context": "ui", - "key": "Land ist erforderlich", - "value": "Country is required" - }, - { - "context": "ui", - "key": "Leer = Zugriff auf alle Verträge", - "value": "Empty = Access to all contracts" - }, - { - "context": "ui", - "key": "Letzte Aktivität", - "value": "Last Activity" - }, - { - "context": "ui", - "key": "Letzte Aktivität:", - "value": "Last Activity:" - }, - { - "context": "ui", - "key": "Letzte Aktivitäten - Sehen Sie Ihre neueste Arbeit", - "value": "Recent Activities - View your latest work" - }, - { - "context": "ui", - "key": "Letzte Seite", - "value": "Last page" - }, - { - "context": "ui", - "key": "Link konnte nicht gesendet werden", - "value": "Failed to send link" - }, - { - "context": "ui", - "key": "Log", - "value": "Log" - }, - { - "context": "ui", - "key": "Logs konnten nicht geladen werden", - "value": "Failed to fetch logs" - }, - { - "context": "ui", - "key": "Logs werden geladen...", - "value": "Loading logs..." - }, - { - "context": "ui", - "key": "Lokal", - "value": "Local" - }, - { - "context": "ui", - "key": "LÄUFT", - "value": "RUNNING" - }, - { - "context": "ui", - "key": "Lädt hoch...", - "value": "Uploading..." - }, - { - "context": "ui", - "key": "Läuft", - "value": "Running" - }, - { - "context": "ui", - "key": "Läuft ab am", - "value": "Expires At" - }, - { - "context": "ui", - "key": "Löschen", - "value": "Delete" - }, - { - "context": "ui", - "key": "Löschen ({count})", - "value": "Delete ({count})" - }, - { - "context": "ui", - "key": "Löschen...", - "value": "Deleting..." - }, - { - "context": "ui", - "key": "MIME-Typ", - "value": "MIME Type" - }, - { - "context": "ui", - "key": "Management-Tools umfassen:", - "value": "Management tools include:" - }, - { - "context": "ui", - "key": "Mandanten können jederzeit auf die technische SIP-Nummer umstellen und dabei erhebliche Telefoniekosten sparen. Die Integration funktioniert wie ein weiterer Connector (Outlook, SharePoint) und wird nahtlos in Ihren bestehenden Workflow integriert.", - "value": "Clients can switch to the technical SIP number at any time and save significant telephony costs. The integration works like another connector (Outlook, SharePoint) and is seamlessly integrated into your existing workflow." - }, - { - "context": "ui", - "key": "Mandat erfolgreich eingereicht!", - "value": "Mandate Submitted Successfully!" - }, - { - "context": "ui", - "key": "Mandat erfolgreich erstellt", - "value": "Mandate created successfully" - }, - { - "context": "ui", - "key": "Mandat erstellen", - "value": "Create Mandate" - }, - { - "context": "ui", - "key": "Mandat hinzufügen", - "value": "Add Mandate" - }, - { - "context": "ui", - "key": "Mandat-ID", - "value": "Mandate ID" - }, - { - "context": "ui", - "key": "Mandate", - "value": "Mandates" - }, - { - "context": "ui", - "key": "Mandate und Berechtigungen verwalten", - "value": "Manage mandates and permissions" - }, - { - "context": "ui", - "key": "Mandatsverwaltung", - "value": "Mandate management" - }, - { - "context": "ui", - "key": "Mehr erfahren", - "value": "Learn more" - }, - { - "context": "ui", - "key": "Meine Uploads", - "value": "My Uploads" - }, - { - "context": "ui", - "key": "Microsoft", - "value": "Microsoft" - }, - { - "context": "ui", - "key": "Microsoft Verbindungen", - "value": "Microsoft Connections" - }, - { - "context": "ui", - "key": "Microsoft verbinden", - "value": "Connect Microsoft" - }, - { - "context": "ui", - "key": "Microsoft-Verbindung erstellen", - "value": "Create Microsoft Connection" - }, - { - "context": "ui", - "key": "Microsoft-Verbindung hinzufügen", - "value": "Add Microsoft Connection" - }, - { - "context": "ui", - "key": "Mitglied hinzufügen", - "value": "Add Member" - }, - { - "context": "ui", - "key": "MwSt %", - "value": "VAT %" - }, - { - "context": "ui", - "key": "MwSt Betrag", - "value": "VAT Amount" - }, - { - "context": "ui", - "key": "Möchten Sie jetzt Kontakte für Ihr Mandat einrichten? Sie können dies auch später in den Einstellungen tun.", - "value": "Would you like to setup contacts for your mandate now? You can also do this later in settings." - }, - { - "context": "ui", - "key": "Nach unten scrollen", - "value": "Scroll to bottom" - }, - { - "context": "ui", - "key": "Nachricht (optional)", - "value": "Message (optional)" - }, - { - "context": "ui", - "key": "Nachricht eingeben...", - "value": "Enter message..." - }, - { - "context": "ui", - "key": "Nachricht wird gesendet...", - "value": "Sending message..." - }, - { - "context": "ui", - "key": "Nachrichten", - "value": "Messages" - }, - { - "context": "ui", - "key": "Nahtloser Mandanten-Workflow:", - "value": "Seamless Client Workflow:" - }, - { - "context": "ui", - "key": "Name", - "value": "Name" - }, - { - "context": "ui", - "key": "Name des Unternehmens", - "value": "Company name" - }, - { - "context": "ui", - "key": "Name ist erforderlich", - "value": "Name is required" - }, - { - "context": "ui", - "key": "Navigation - Erkunden Sie alle verfügbaren Tools", - "value": "Navigation - Explore all available tools" - }, - { - "context": "ui", - "key": "Nein", - "value": "No" - }, - { - "context": "ui", - "key": "Neu starten", - "value": "Start Over" - }, - { - "context": "ui", - "key": "Neue Automatisierung", - "value": "New Automation" - }, - { - "context": "ui", - "key": "Neue Automatisierung erstellen", - "value": "Create New Automation" - }, - { - "context": "ui", - "key": "Neue Datei hochladen", - "value": "Upload new file" - }, - { - "context": "ui", - "key": "Neue Organisation", - "value": "New Organisation" - }, - { - "context": "ui", - "key": "Neue Organisation erstellen", - "value": "Create New Organisation" - }, - { - "context": "ui", - "key": "Neue Position", - "value": "New Position" - }, - { - "context": "ui", - "key": "Neue Position erstellen", - "value": "Create New Position" - }, - { - "context": "ui", - "key": "Neue RBAC-Regel erstellen", - "value": "Create New RBAC Rule" - }, - { - "context": "ui", - "key": "Neue Rolle", - "value": "New Role" - }, - { - "context": "ui", - "key": "Neue Rolle erstellen", - "value": "Create New Role" - }, - { - "context": "ui", - "key": "Neue Sprache", - "value": "New language" - }, - { - "context": "ui", - "key": "Neuen Prompt erstellen", - "value": "Create New Prompt" - }, - { - "context": "ui", - "key": "Neuen Vertrag erstellen", - "value": "Create New Contract" - }, - { - "context": "ui", - "key": "Neuen Zugriff erstellen", - "value": "Create New Access" - }, - { - "context": "ui", - "key": "Neuer Prompt", - "value": "New Prompt" - }, - { - "context": "ui", - "key": "Neuer Vertrag", - "value": "New Contract" - }, - { - "context": "ui", - "key": "Neuer Zugriff", - "value": "New Access" - }, - { - "context": "ui", - "key": "Neues Dokument", - "value": "New Document" - }, - { - "context": "ui", - "key": "Neues Dokument erstellen", - "value": "Create New Document" - }, - { - "context": "ui", - "key": "Neues Mandat erstellen", - "value": "Create New Mandate" - }, - { - "context": "ui", - "key": "Neues Team-Mitglied erstellen", - "value": "Create New Team Member" - }, - { - "context": "ui", - "key": "Neues Transkript", - "value": "New Transcript" - }, - { - "context": "ui", - "key": "Nicht verfügbar", - "value": "N/A" - }, - { - "context": "ui", - "key": "Noch keine Befehle ausgeführt. Senden Sie einen Befehl, um Ergebnisse hier zu sehen.", - "value": "No commands executed yet. Send a command to see results here." - }, - { - "context": "ui", - "key": "Noch keinen Workflow ausgewählt", - "value": "No workflow selected" - }, - { - "context": "ui", - "key": "Nochmal versuchen", - "value": "Try Again" - }, - { - "context": "ui", - "key": "Nächste Seite", - "value": "Next page" - }, - { - "context": "ui", - "key": "Oder geben Sie Ihre Nachricht ein...", - "value": "Or enter your message..." - }, - { - "context": "ui", - "key": "Ordnerpfade", - "value": "Folder Paths" - }, - { - "context": "ui", - "key": "Organisation", - "value": "Organisation" - }, - { - "context": "ui", - "key": "Organisation erfolgreich erstellt", - "value": "Organisation created successfully" - }, - { - "context": "ui", - "key": "Organisationen", - "value": "Organisations" - }, - { - "context": "ui", - "key": "Originalbetrag", - "value": "Original Amount" - }, - { - "context": "ui", - "key": "Originalwährung", - "value": "Original Currency" - }, - { - "context": "ui", - "key": "PDF", - "value": "PDF" - }, - { - "context": "ui", - "key": "Passwort", - "value": "Password" - }, - { - "context": "ui", - "key": "Passwort eingeben", - "value": "Enter password" - }, - { - "context": "ui", - "key": "Passwort-Link gesendet!", - "value": "Password link sent!" - }, - { - "context": "ui", - "key": "Passwort-Link senden", - "value": "Send password setup link" - }, - { - "context": "ui", - "key": "Pfad", - "value": "Path" - }, - { - "context": "ui", - "key": "Position erfolgreich erstellt", - "value": "Position created successfully" - }, - { - "context": "ui", - "key": "Positionen", - "value": "Positions" - }, - { - "context": "ui", - "key": "Postleitzahl", - "value": "Postal Code" - }, - { - "context": "ui", - "key": "Postleitzahl ist erforderlich", - "value": "Postal code is required" - }, - { - "context": "ui", - "key": "Projekte", - "value": "Projects" - }, - { - "context": "ui", - "key": "Projektverwaltung", - "value": "Project Management" - }, - { - "context": "ui", - "key": "Projektverwaltung und -organisation", - "value": "Project management and organization" - }, - { - "context": "ui", - "key": "Prompt", - "value": "Prompt" - }, - { - "context": "ui", - "key": "Prompt Einstellungen", - "value": "Prompt Settings" - }, - { - "context": "ui", - "key": "Prompt Vorlage", - "value": "Prompt Template" - }, - { - "context": "ui", - "key": "Prompt ausführen", - "value": "Run prompt" - }, - { - "context": "ui", - "key": "Prompt auswählen...", - "value": "Select a prompt..." - }, - { - "context": "ui", - "key": "Prompt bearbeiten", - "value": "Edit Prompt" - }, - { - "context": "ui", - "key": "Prompt erfolgreich erstellt", - "value": "Prompt created successfully" - }, - { - "context": "ui", - "key": "Prompt erstellen", - "value": "Create Prompt" - }, - { - "context": "ui", - "key": "Prompt hinzufügen", - "value": "Add Prompt" - }, - { - "context": "ui", - "key": "Prompt löschen", - "value": "Clear prompt" - }, - { - "context": "ui", - "key": "Prompt teilen", - "value": "Share Prompt" - }, - { - "context": "ui", - "key": "Prompt wird gelöscht...", - "value": "Deleting prompt..." - }, - { - "context": "ui", - "key": "Prompt-Inhalt", - "value": "Prompt Content" - }, - { - "context": "ui", - "key": "Prompt-Inhalt darf 10.000 Zeichen nicht überschreiten", - "value": "Prompt content cannot exceed 10,000 characters" - }, - { - "context": "ui", - "key": "Prompt-Inhalt darf nicht leer sein", - "value": "Prompt content cannot be empty" - }, - { - "context": "ui", - "key": "Prompt-Name", - "value": "Prompt Name" - }, - { - "context": "ui", - "key": "Prompt-Name darf 100 Zeichen nicht überschreiten", - "value": "Prompt name cannot exceed 100 characters" - }, - { - "context": "ui", - "key": "Prompt-Name darf nicht leer sein", - "value": "Prompt name cannot be empty" - }, - { - "context": "ui", - "key": "Prompts", - "value": "Prompts" - }, - { - "context": "ui", - "key": "Prompts für Ihren KI-Assistenten erstellen und verwalten", - "value": "Create and manage prompts for your AI assistant" - }, - { - "context": "ui", - "key": "Prompts verwalten", - "value": "Manage your prompts" - }, - { - "context": "ui", - "key": "Prompts werden geladen...", - "value": "Loading prompts..." - }, - { - "context": "ui", - "key": "Python", - "value": "Python" - }, - { - "context": "ui", - "key": "Quelle", - "value": "Source" - }, - { - "context": "ui", - "key": "RBAC-Regel erfolgreich erstellt", - "value": "RBAC rule created successfully" - }, - { - "context": "ui", - "key": "RBAC-Regel hinzufügen", - "value": "Add RBAC Rule" - }, - { - "context": "ui", - "key": "RBAC-Regeln", - "value": "RBAC Rules" - }, - { - "context": "ui", - "key": "RBAC-Regelverwaltung", - "value": "RBAC rules management" - }, - { - "context": "ui", - "key": "RBAC-Rollen", - "value": "RBAC Roles" - }, - { - "context": "ui", - "key": "RBAC-Rollenverwaltung", - "value": "RBAC role management" - }, - { - "context": "ui", - "key": "Registrieren", - "value": "Register" - }, - { - "context": "ui", - "key": "Revolutionäre Telefonie-Integration mit Spitch.ai", - "value": "Revolutionary Telephony Integration with Spitch.ai" - }, - { - "context": "ui", - "key": "Rolle", - "value": "Role" - }, - { - "context": "ui", - "key": "Rolle erfolgreich erstellt", - "value": "Role created successfully" - }, - { - "context": "ui", - "key": "Rolle hinzufügen", - "value": "Add Role" - }, - { - "context": "ui", - "key": "Rollen", - "value": "Roles" - }, - { - "context": "ui", - "key": "Rollen-ID", - "value": "Role ID" - }, - { - "context": "ui", - "key": "Rollenbasierte Zugriffssteuerungsregeln", - "value": "Role-Based Access Control rules" - }, - { - "context": "ui", - "key": "Rollenverwaltung", - "value": "Role management" - }, - { - "context": "ui", - "key": "Rufname am Telefon", - "value": "Phone Name" - }, - { - "context": "ui", - "key": "Runde", - "value": "Round" - }, - { - "context": "ui", - "key": "Runden", - "value": "Rounds" - }, - { - "context": "ui", - "key": "Schließen", - "value": "Close" - }, - { - "context": "ui", - "key": "Schnellzugriff", - "value": "Quick Access" - }, - { - "context": "ui", - "key": "Schnellzugriff - Springen Sie zu häufig verwendeten Features", - "value": "Quick Access - Jump to frequently used features" - }, - { - "context": "ui", - "key": "Seite {page} von {total} ({count} Einträge)", - "value": "Page {page} of {total} ({count} items)" - }, - { - "context": "ui", - "key": "Senden", - "value": "Send" - }, - { - "context": "ui", - "key": "Service", - "value": "Service" - }, - { - "context": "ui", - "key": "Service-Verbindungen", - "value": "Service Connections" - }, - { - "context": "ui", - "key": "SharePoint Dokumente", - "value": "SharePoint Documents" - }, - { - "context": "ui", - "key": "SharePoint Site URL", - "value": "SharePoint Site URL" - }, - { - "context": "ui", - "key": "SharePoint Test", - "value": "SharePoint Test" - }, - { - "context": "ui", - "key": "Sie erhalten in den nächsten Minuten eine Bestätigungs-E-Mail.", - "value": "You will receive a confirmation email within the next few minutes." - }, - { - "context": "ui", - "key": "Sie können auch auf den Upload-Button klicken", - "value": "You can also click the upload button" - }, - { - "context": "ui", - "key": "Sie müssen sich zuerst für die Sprach-Integration anmelden, um auf die Transkriptverwaltung zuzugreifen.", - "value": "You must first sign up for speech integration to access transcript management." - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie \"{name}\" löschen möchten?", - "value": "Are you sure you want to delete \"{name}\"?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie Workflow \"{id}...\" löschen möchten?", - "value": "Are you sure you want to delete workflow \"{id}...\"?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie alle Sprach-Integrations-Einstellungen zurücksetzen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", - "value": "Are you sure you want to reset all speech integration settings? This action cannot be undone." - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie den Workflow \"{name}\" löschen möchten?", - "value": "Are you sure you want to delete workflow \"{name}\"?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie die Datei \"{name}\" löschen möchten?", - "value": "Are you sure you want to delete the file \"{name}\"?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie die {count} ausgewählten Elemente löschen möchten?", - "value": "Are you sure you want to delete the {count} selected items?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie die {service} Verbindung löschen möchten?", - "value": "Are you sure you want to delete the {service} connection?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie diesen Benutzer löschen möchten?", - "value": "Are you sure you want to delete this user?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie {count} Benutzer löschen möchten?", - "value": "Are you sure you want to delete {count} users?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie {count} Prompts löschen möchten?", - "value": "Are you sure you want to delete {count} prompts?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie {count} Verbindungen löschen möchten?", - "value": "Are you sure you want to delete {count} connections?" - }, - { - "context": "ui", - "key": "Sites entdecken", - "value": "Discover Sites" - }, - { - "context": "ui", - "key": "Speech Analytics (SA)", - "value": "Speech Analytics (SA)" - }, - { - "context": "ui", - "key": "Speichern", - "value": "Save" - }, - { - "context": "ui", - "key": "Speichern...", - "value": "Saving..." - }, - { - "context": "ui", - "key": "Spitch prüft vor jedem Anruf die Mandantenberechtigung bei PowerOn, während alle Datenänderungen zentral von PowerOn initiiert werden. Call-Transkripte werden in Echtzeit in Ihrer PowerOn-Datenbank gespeichert, mit vollständiger Mandantenisolation und Sicherheit. Bei Ausfällen werden Anrufe automatisch blockiert, um die Integrität zu gewährleisten.", - "value": "Spitch checks client authorization with PowerOn before each call, while all data changes are centrally initiated by PowerOn. Call transcripts are stored in real-time in your PowerOn database with complete client isolation and security. In case of failures, calls are automatically blocked to ensure integrity." - }, - { - "context": "ui", - "key": "Sprach Integration", - "value": "Speech Integration" - }, - { - "context": "ui", - "key": "Sprach-Einstellungen", - "value": "Speech Settings" - }, - { - "context": "ui", - "key": "Sprach-Integration Einstellungen", - "value": "Speech Integration Settings" - }, - { - "context": "ui", - "key": "Sprache", - "value": "Language" - }, - { - "context": "ui", - "key": "Sprachset {code} wirklich löschen?", - "value": "Really delete language set {code}?" - }, - { - "context": "ui", - "key": "Stadt", - "value": "City" - }, - { - "context": "ui", - "key": "Stadt ist erforderlich", - "value": "City is required" - }, - { - "context": "ui", - "key": "Start", - "value": "Start" - }, - { - "context": "ui", - "key": "Startzeit", - "value": "Start Time" - }, - { - "context": "ui", - "key": "Status", - "value": "Status" - }, - { - "context": "ui", - "key": "Stellen Sie alles, was Ihre Agenten benötigen, in ihren Händen bereit, mit einem einheitlichen Agent-Desktop.", - "value": "Put everything your agents need at their fingertips, with a unified agent desktop." - }, - { - "context": "ui", - "key": "Stoppen", - "value": "Stop" - }, - { - "context": "ui", - "key": "Straße", - "value": "Street" - }, - { - "context": "ui", - "key": "Straße ist erforderlich", - "value": "Street is required" - }, - { - "context": "ui", - "key": "Suchen Sie nach Standorten über Adresse oder Koordinaten, oder verwenden Sie natürliche Sprache, um Projekte zu erstellen und zu verwalten.", - "value": "Search for locations by address or coordinates, or use natural language to create and manage projects." - }, - { - "context": "ui", - "key": "Suchen...", - "value": "Search..." - }, - { - "context": "ui", - "key": "Systemadministrator", - "value": "Sysadmin" - }, - { - "context": "ui", - "key": "Systemeinstellungen - Arbeitsbereich-Einstellungen konfigurieren", - "value": "System Settings - Configure workspace settings" - }, - { - "context": "ui", - "key": "Tabelle", - "value": "Spreadsheet" - }, - { - "context": "ui", - "key": "Tags", - "value": "Tags" - }, - { - "context": "ui", - "key": "Team-Bereich", - "value": "Team Area" - }, - { - "context": "ui", - "key": "Team-Mitglied erfolgreich erstellt", - "value": "Team member created successfully" - }, - { - "context": "ui", - "key": "Team-Mitglieder", - "value": "Team Members" - }, - { - "context": "ui", - "key": "Team-Mitglieder verwalten", - "value": "Manage your team members" - }, - { - "context": "ui", - "key": "Team-Mitglieder verwalten, Berechtigungen festlegen und Zusammenarbeitseinstellungen konfigurieren", - "value": "Manage team members, set permissions, and configure collaboration settings" - }, - { - "context": "ui", - "key": "Teilen", - "value": "Share" - }, - { - "context": "ui", - "key": "Telefon", - "value": "Phone" - }, - { - "context": "ui", - "key": "Telefonnummer", - "value": "Phone Number" - }, - { - "context": "ui", - "key": "Telefonnummer ist erforderlich", - "value": "Phone number is required" - }, - { - "context": "ui", - "key": "Text", - "value": "Text" - }, - { - "context": "ui", - "key": "Textvorschau", - "value": "Text Preview" - }, - { - "context": "ui", - "key": "Theme", - "value": "Theme" - }, - { - "context": "ui", - "key": "Token", - "value": "Tokens" - }, - { - "context": "ui", - "key": "Transkript", - "value": "Transcript" - }, - { - "context": "ui", - "key": "Transkript wird verarbeitet...", - "value": "Processing transcript..." - }, - { - "context": "ui", - "key": "Transkriptverwaltung", - "value": "Transcript Management" - }, - { - "context": "ui", - "key": "Trennungsfehler", - "value": "Disconnect Error" - }, - { - "context": "ui", - "key": "Treuhand", - "value": "Trustee" - }, - { - "context": "ui", - "key": "Treuhandverwaltung", - "value": "Trustee Management" - }, - { - "context": "ui", - "key": "Trustee-Organisationen verwalten", - "value": "Manage trustee organisations" - }, - { - "context": "ui", - "key": "Trustee-Rollen verwalten", - "value": "Manage trustee roles" - }, - { - "context": "ui", - "key": "Typ", - "value": "Type" - }, - { - "context": "ui", - "key": "UI-Sprachen", - "value": "UI languages" - }, - { - "context": "ui", - "key": "Unbekannt", - "value": "Unknown" - }, - { - "context": "ui", - "key": "Unbekannte Größe", - "value": "Unknown Size" - }, - { - "context": "ui", - "key": "Unbekanntes Datum", - "value": "Unknown Date" - }, - { - "context": "ui", - "key": "Unbenannt", - "value": "Unnamed" - }, - { - "context": "ui", - "key": "Unbenannter Workflow", - "value": "Unnamed Workflow" - }, - { - "context": "ui", - "key": "Ungültiges Datum", - "value": "Invalid date" - }, - { - "context": "ui", - "key": "Unser Team wird Ihr Mandat innerhalb von 1-2 Werktagen überprüfen.", - "value": "Our team will review your mandate within 1-2 business days." - }, - { - "context": "ui", - "key": "Unsere bereits aktive Dokumenten-Extraktions-Engine generiert automatisch personalisierte Dokumente für Spitch, basierend auf Mandantenspezifischen Daten. Die KI nutzt FAQ-Datenbanken, Mitarbeiterinformationen und Service-Details, um jeden Anruf kontextuell und hochpersonalisiert zu gestalten.", - "value": "Our already active document extraction engine automatically generates personalized documents for Spitch based on client-specific data. The AI uses FAQ databases, employee information, and service details to make every call contextual and highly personalized." - }, - { - "context": "ui", - "key": "Unternehmensinformationen", - "value": "Company Information" - }, - { - "context": "ui", - "key": "Unterstützt von", - "value": "Powered by" - }, - { - "context": "ui", - "key": "Upload fehlgeschlagen. Bitte versuchen Sie es erneut.", - "value": "Upload failed. Please try again." - }, - { - "context": "ui", - "key": "VERARBEITUNG", - "value": "PROCESSING" - }, - { - "context": "ui", - "key": "Valutadatum", - "value": "Value Date" - }, - { - "context": "ui", - "key": "Verarbeitung", - "value": "Processing" - }, - { - "context": "ui", - "key": "Verbinden", - "value": "Connect" - }, - { - "context": "ui", - "key": "Verbindung aktualisieren", - "value": "Update Connection" - }, - { - "context": "ui", - "key": "Verbindung testen", - "value": "Test Connection" - }, - { - "context": "ui", - "key": "Verbindungen", - "value": "Connections" - }, - { - "context": "ui", - "key": "Verbindungen werden geladen...", - "value": "Loading connections..." - }, - { - "context": "ui", - "key": "Verbindungsfehler", - "value": "Connection Error" - }, - { - "context": "ui", - "key": "Verbunden am", - "value": "Connected At" - }, - { - "context": "ui", - "key": "Vereinheitlichen und liefern Sie Informationen an Ihre Kunden und Mitarbeiter, wann und wo sie sie benötigen.", - "value": "Unify and deliver info to your customers and staff wherever and whenever they need it." - }, - { - "context": "ui", - "key": "Verfügbare Tools", - "value": "Available Tools" - }, - { - "context": "ui", - "key": "Verfügbare Workflows", - "value": "Available Workflows" - }, - { - "context": "ui", - "key": "Version", - "value": "Version" - }, - { - "context": "ui", - "key": "Versuchen Sie, Ihr Microsoft-Konto auf der Verbindungsseite erneut zu verbinden.", - "value": "Try reconnecting your Microsoft account in the Connections page." - }, - { - "context": "ui", - "key": "Vertrag", - "value": "Contract" - }, - { - "context": "ui", - "key": "Vertrag (optional)", - "value": "Contract (optional)" - }, - { - "context": "ui", - "key": "Vertrag erfolgreich erstellt", - "value": "Contract created successfully" - }, - { - "context": "ui", - "key": "Verträge", - "value": "Contracts" - }, - { - "context": "ui", - "key": "Verwalten Sie Daten über Tabellen. Wählen Sie eine Tabelle aus oder verwenden Sie natürliche Sprache, um Befehle auszuführen.", - "value": "Manage data through tables. Select a table or use natural language to execute commands." - }, - { - "context": "ui", - "key": "Verwalten Sie Ihre Kontoinformationen", - "value": "Manage your account information" - }, - { - "context": "ui", - "key": "Verwalten Sie Ihre Service-Verbindungen", - "value": "Manage your service connections" - }, - { - "context": "ui", - "key": "Verwalten Sie Ihre Sprach-Integrations-Konfiguration und Einstellungen.", - "value": "Manage your speech integration configuration and preferences." - }, - { - "context": "ui", - "key": "Verwalten Sie Mandate und deren zugehörige Berechtigungen.", - "value": "Manage mandates and their associated permissions." - }, - { - "context": "ui", - "key": "Verwaltet von {provider}", - "value": "Managed by {provider}" - }, - { - "context": "ui", - "key": "Verwaltung der Benutzerzugriffe auf Organisationen", - "value": "Management of user access to organisations" - }, - { - "context": "ui", - "key": "Verwaltung der Buchungspositionen (Speseneinträge)", - "value": "Management of booking positions (expense entries)" - }, - { - "context": "ui", - "key": "Verwaltung der Dokumente und Belege", - "value": "Management of documents and receipts" - }, - { - "context": "ui", - "key": "Verwaltung der Feature-spezifischen Rollen", - "value": "Management of feature-specific roles" - }, - { - "context": "ui", - "key": "Verwaltung der Kundenverträge", - "value": "Management of customer contracts" - }, - { - "context": "ui", - "key": "Verwaltung der Treuhand-Organisationen", - "value": "Management of trustee organisations" - }, - { - "context": "ui", - "key": "Verwaltung von Treuhand-Organisationen, Verträgen und Buchungen", - "value": "Manage trustee organisations, contracts, and bookings" - }, - { - "context": "ui", - "key": "Verwaltungs- und Management-Tools", - "value": "Administration and management tools" - }, - { - "context": "ui", - "key": "Verwende Vorlage:", - "value": "Using prompt:" - }, - { - "context": "ui", - "key": "Video", - "value": "Video" - }, - { - "context": "ui", - "key": "Vielen Dank für Ihr Interesse an unserer Sprach Integration powered by Spitch.ai. Wir haben Ihr Mandat erhalten und werden es in Kürze überprüfen.", - "value": "Thank you for your interest in our Speech Integration powered by Spitch.ai. We have received your mandate and will review it shortly." - }, - { - "context": "ui", - "key": "Virtual Assistant (VA)", - "value": "Virtual Assistant (VA)" - }, - { - "context": "ui", - "key": "Voice Biometrics (VB)", - "value": "Voice Biometrics (VB)" - }, - { - "context": "ui", - "key": "Vollständiger Name", - "value": "Full Name" - }, - { - "context": "ui", - "key": "Von der Registrierung bis zur technischen Einrichtung - Ihr Mandant registriert sich bei PowerOn für Telefonie-Services, lädt Dokumente hoch und erhält automatisch eine technische SIP-Nummer von Spitch. Die Call-Weiterleitung kann jederzeit aktiviert oder deaktiviert werden, was maximale Flexibilität und BCM-Sicherheit gewährleistet.", - "value": "From registration to technical setup - your client registers with PowerOn for telephony services, uploads documents, and automatically receives a technical SIP number from Spitch. Call forwarding can be activated or deactivated at any time, ensuring maximum flexibility and BCM safety." - }, - { - "context": "ui", - "key": "Vorherige Seite", - "value": "Previous page" - }, - { - "context": "ui", - "key": "Vorschau", - "value": "Preview" - }, - { - "context": "ui", - "key": "Vorschau für diesen Dateityp nicht verfügbar", - "value": "Preview not available for this file type" - }, - { - "context": "ui", - "key": "Vorschau schließen", - "value": "Close preview" - }, - { - "context": "ui", - "key": "Vorschau wird geladen...", - "value": "Loading preview..." - }, - { - "context": "ui", - "key": "WARTEND", - "value": "PENDING" - }, - { - "context": "ui", - "key": "Wartend", - "value": "Pending" - }, - { - "context": "ui", - "key": "Was passiert als nächstes?", - "value": "What happens next?" - }, - { - "context": "ui", - "key": "Wechseln Sie zwischen hellem und dunklem Modus", - "value": "Switch between light and dark mode" - }, - { - "context": "ui", - "key": "Werkzeuge", - "value": "Utils" - }, - { - "context": "ui", - "key": "Werkzeuge und Hilfsmittel", - "value": "Utilities and tools" - }, - { - "context": "ui", - "key": "Wie möchten Sie am Telefon genannt werden?", - "value": "How would you like to be called on the phone?" - }, - { - "context": "ui", - "key": "Wiederholen", - "value": "Retry" - }, - { - "context": "ui", - "key": "Willkommen in Ihrem Arbeitsbereich", - "value": "Welcome to your workspace" - }, - { - "context": "ui", - "key": "Wird gesendet...", - "value": "Sending..." - }, - { - "context": "ui", - "key": "Wird gestoppt...", - "value": "Stopping..." - }, - { - "context": "ui", - "key": "Wird geteilt...", - "value": "Sharing..." - }, - { - "context": "ui", - "key": "Wird hochgeladen...", - "value": "Uploading..." - }, - { - "context": "ui", - "key": "Wird verarbeitet...", - "value": "Processing..." - }, - { - "context": "ui", - "key": "Workflow", - "value": "Workflow" - }, - { - "context": "ui", - "key": "Workflow Fortschritt", - "value": "Workflow Progress" - }, - { - "context": "ui", - "key": "Workflow auswählen", - "value": "Select Workflow" - }, - { - "context": "ui", - "key": "Workflow fehlgeschlagen.", - "value": "Workflow failed." - }, - { - "context": "ui", - "key": "Workflow fortsetzen", - "value": "Resume workflow" - }, - { - "context": "ui", - "key": "Workflow läuft... Warte auf Logs...", - "value": "Workflow running... Waiting for logs..." - }, - { - "context": "ui", - "key": "Workflow löschen", - "value": "Delete workflow" - }, - { - "context": "ui", - "key": "Workflow stoppen", - "value": "Stop workflow" - }, - { - "context": "ui", - "key": "Workflow wird fortgesetzt", - "value": "Continuing workflow" - }, - { - "context": "ui", - "key": "Workflow wird gelöscht...", - "value": "Deleting workflow..." - }, - { - "context": "ui", - "key": "Workflow-Automatisierungen verwalten", - "value": "Manage workflow automations" - }, - { - "context": "ui", - "key": "Workflow-Nachrichten werden geladen...", - "value": "Loading workflow messages..." - }, - { - "context": "ui", - "key": "Workflow-Verlauf", - "value": "Workflow History" - }, - { - "context": "ui", - "key": "Workflows", - "value": "Workflows" - }, - { - "context": "ui", - "key": "Workflows werden geladen...", - "value": "Loading workflows..." - }, - { - "context": "ui", - "key": "Wähle einen Workflow aus der Liste aus oder starte einen neuen Workflow", - "value": "Select a workflow from the list or start a new workflow" - }, - { - "context": "ui", - "key": "Wählen Sie Ihre bevorzugte Sprache", - "value": "Choose your preferred language" - }, - { - "context": "ui", - "key": "You", - "value": "You" - }, - { - "context": "ui", - "key": "Zeitzone", - "value": "Timezone" - }, - { - "context": "ui", - "key": "Zentrale", - "value": "Dashboard" - }, - { - "context": "ui", - "key": "Zu dunklem Modus wechseln", - "value": "Switch to dark mode" - }, - { - "context": "ui", - "key": "Zu hellem Modus wechseln", - "value": "Switch to light mode" - }, - { - "context": "ui", - "key": "Zugriff", - "value": "Access" - }, - { - "context": "ui", - "key": "Zugriff erfolgreich erstellt", - "value": "Access created successfully" - }, - { - "context": "ui", - "key": "Zugriff verweigert", - "value": "Access Denied" - }, - { - "context": "ui", - "key": "Zuletzt geprüft", - "value": "Last Checked" - }, - { - "context": "ui", - "key": "Zum Bestätigen klicken", - "value": "Click to confirm" - }, - { - "context": "ui", - "key": "Zum Bestätigen klicken...", - "value": "Click to confirm..." - }, - { - "context": "ui", - "key": "Zurück zur Sprach Integration", - "value": "Back to Speech Integration" - }, - { - "context": "ui", - "key": "angehängt", - "value": "attached" - }, - { - "context": "ui", - "key": "ausgewählt", - "value": "selected" - }, - { - "context": "ui", - "key": "kontakt@firma.com", - "value": "contact@company.com" - }, - { - "context": "ui", - "key": "oder", - "value": "or" - }, - { - "context": "ui", - "key": "z.B. Beleg.pdf", - "value": "e.g. Receipt.pdf" - }, - { - "context": "ui", - "key": "z.B. Finanzdienstleistungen, Technologie, etc.", - "value": "e.g. Financial Services, Technology, etc." - }, - { - "context": "ui", - "key": "z.B. Muster AG 2026", - "value": "e.g. Muster AG 2026" - }, - { - "context": "ui", - "key": "z.B. Treuhand AG Zürich", - "value": "e.g. Trustee AG Zurich" - }, - { - "context": "ui", - "key": "z.B. admin, operate, userreport", - "value": "e.g. admin, operate, userreport" - }, - { - "context": "ui", - "key": "z.B. treuhand-ag-zuerich", - "value": "e.g. trustee-ag-zurich" - }, - { - "context": "ui", - "key": "{authority} Verbindung bearbeiten", - "value": "Edit {authority} Connection" - }, - { - "context": "ui", - "key": "{column} filtern", - "value": "Filter {column}" - }, - { - "context": "ui", - "key": "{count} Benutzer ausgewählt", - "value": "{count} users selected" - }, - { - "context": "ui", - "key": "Änderungen speichern", - "value": "Save Changes" - }, - { - "context": "ui", - "key": "Über", - "value": "About" - }, - { - "context": "ui", - "key": "Überprüfungsprozess", - "value": "Review Process" - }, - { - "context": "ui", - "key": "Übersicht - Sehen Sie den Arbeitsbereich-Status und Updates", - "value": "Overview - See workspace status and updates" - }, - { - "context": "ui", - "key": "Überwachen Sie automatisch 100% der Gespräche, um wertvolle Einblicke für Ihr Unternehmen zu erhalten.", - "value": "Automatically monitor 100% of conversations to get valuable insights for your business." - }, - { - "context": "ui", - "key": "(gefiltert nach {name})", - "value": "(filtered by {name})" - }, - { - "context": "ui", - "key": "({count} gefiltert)", - "value": "({count} filtered)" - }, - { - "context": "ui", - "key": "Abonnement, Einstellungen und Guthaben pro Mandant", - "value": "Subscription, settings, and credit per tenant" - }, - { - "context": "ui", - "key": "Abrechnung", - "value": "Billing" - }, - { - "context": "ui", - "key": "Aktion", - "value": "Action" - }, - { - "context": "ui", - "key": "Benutzer-Billing", - "value": "User billing" - }, - { - "context": "ui", - "key": "Benutzer-Guthaben", - "value": "User credits" - }, - { - "context": "ui", - "key": "Benutzer:", - "value": "User:" - }, - { - "context": "ui", - "key": "Deaktiviert", - "value": "Disabled" - }, - { - "context": "ui", - "key": "Du hast Zugriff auf {instanceCount} {instanceWord} in {mandateCount} {mandateWord}.", - "value": "You have access to {instanceCount} {instanceWord} in {mandateCount} {mandateWord}." - }, - { - "context": "ui", - "key": "Einstellungen gespeichert!", - "value": "Settings saved!" - }, - { - "context": "ui", - "key": "Feature-Instanz", - "value": "Feature instance" - }, - { - "context": "ui", - "key": "Feature-Instanzen", - "value": "Feature instances" - }, - { - "context": "ui", - "key": "Fehler beim Speichern", - "value": "Error saving" - }, - { - "context": "ui", - "key": "Gesamtguthaben", - "value": "Total credit" - }, - { - "context": "ui", - "key": "Mandant:", - "value": "Tenant:" - }, - { - "context": "ui", - "key": "Mandanten", - "value": "Tenants" - }, - { - "context": "ui", - "key": "Mandanten-Billing", - "value": "Tenant billing" - }, - { - "context": "ui", - "key": "Mandanten-Guthaben", - "value": "Tenant credits" - }, - { - "context": "ui", - "key": "Mandant", - "value": "Tenant" - }, - { - "context": "ui", - "key": "Niedrig", - "value": "Low" - }, - { - "context": "ui", - "key": "Transaktionen", - "value": "Transactions" - }, - { - "context": "ui", - "key": "Warnschwelle", - "value": "Warning threshold" - }, - { - "context": "ui", - "key": "Ansicht an Fenster anpassen", - "value": "Fit to window" - }, - { - "context": "ui", - "key": "Ansicht zurücksetzen", - "value": "Reset view" - }, - { - "context": "ui", - "key": "Auswahl löschen", - "value": "Delete selection" - }, - { - "context": "ui", - "key": "Canvas bearbeiten", - "value": "Edit canvas" - }, - { - "context": "ui", - "key": "Klicken Sie auf einen Ausgang, dann auf einen Eingang", - "value": "Click an output, then an input" - }, - { - "context": "ui", - "key": "Klicken Sie auf einen Eingang, um die Verbindung zu erstellen", - "value": "Click an input to create the connection" - }, - { - "context": "ui", - "key": "Kommentar (optional)", - "value": "Comment (optional)" - }, - { - "context": "ui", - "key": "Kommentar bearbeiten", - "value": "Edit comment" - }, - { - "context": "ui", - "key": "Knoten duplizieren", - "value": "Duplicate node" - }, - { - "context": "ui", - "key": "Rückgängig", - "value": "Undo" - }, - { - "context": "ui", - "key": "Verbindungen zeichnen", - "value": "Draw connections" - }, - { - "context": "ui", - "key": "Vergrößern", - "value": "Zoom in" - }, - { - "context": "ui", - "key": "Verkleinern", - "value": "Zoom out" - }, - { - "context": "ui", - "key": "Wiederholen", - "value": "Redo" - }, - { - "context": "ui", - "key": "Zoom-Voreinstellungen", - "value": "Zoom presets" - }, - { - "context": "ui", - "key": "Zoomstufe (Prozent)", - "value": "Zoom level (percent)" - }, - { - "context": "ui", - "key": "Doppelklick zum Bearbeiten", - "value": "Double-click to edit" - }, - { - "context": "ui", - "key": "Kommentar auf dem Canvas einfügen", - "value": "Add comment on canvas" - }, - { - "context": "ui", - "key": "Kommentar eingeben …", - "value": "Enter a comment…" - }, - { - "context": "ui", - "key": "Canvas-Notiz verschieben", - "value": "Drag to move note" - }, - { - "context": "ui", - "key": "Notizfarbe", - "value": "Note color" - }, - { - "context": "ui", - "key": "Notizgröße ändern", - "value": "Resize note" - }, - { - "context": "ui", - "key": "✓ Mandat eingereicht", - "value": "✓ Mandate Submitted" - } - ], - "status": "complete", - "isDefault": false - }, - { - "id": "fr", - "label": "Français", - "entries": [ - { - "context": "ui", - "key": "+41 123 456 789", - "value": "+41 123 456 789" - }, - { - "context": "ui", - "key": "1 Benutzer ausgewählt", - "value": "1 utilisateur sélectionné" - }, - { - "context": "ui", - "key": "ABGEBROCHEN", - "value": "ANNULÉ" - }, - { - "context": "ui", - "key": "ABGESCHLOSSEN", - "value": "TERMINÉ" - }, - { - "context": "ui", - "key": "Abbrechen", - "value": "Annuler" - }, - { - "context": "ui", - "key": "Abgeschlossen", - "value": "Terminé" - }, - { - "context": "ui", - "key": "Abmelden", - "value": "Se déconnecter" - }, - { - "context": "ui", - "key": "Admin-Einstellungen", - "value": "Paramètres Admin" - }, - { - "context": "ui", - "key": "Administrative Einstellungen", - "value": "Paramètres administratifs" - }, - { - "context": "ui", - "key": "Administrator", - "value": "Administrateur" - }, - { - "context": "ui", - "key": "Adresse", - "value": "Adresse" - }, - { - "context": "ui", - "key": "Agent Assist (AA)", - "value": "Assistance Agent (AA)" - }, - { - "context": "ui", - "key": "Aktionen", - "value": "Actions" - }, - { - "context": "ui", - "key": "Aktiv", - "value": "Actif" - }, - { - "context": "ui", - "key": "Aktiviert", - "value": "Activé" - }, - { - "context": "ui", - "key": "Aktualisieren", - "value": "Mettre à jour" - }, - { - "context": "ui", - "key": "Aktuelle Transkripte", - "value": "Transcriptions Récentes" - }, - { - "context": "ui", - "key": "Alle Dateien", - "value": "Tous les fichiers" - }, - { - "context": "ui", - "key": "Alle Elemente auswählen", - "value": "Sélectionner tous les éléments" - }, - { - "context": "ui", - "key": "Alle Nicht-Standard-Sprachsets jetzt mit dem deutschen Master synchronisieren?", - "value": "Synchroniser maintenant tous les jeux (sauf défaut) avec l’allemand ?" - }, - { - "context": "ui", - "key": "Alle abwählen", - "value": "Tout désélectionner" - }, - { - "context": "ui", - "key": "Alle aktualisieren", - "value": "Tout mettre à jour" - }, - { - "context": "ui", - "key": "Alle auswählen", - "value": "Tout sélectionner" - }, - { - "context": "ui", - "key": "Analysiere Workflow...", - "value": "Analyse du workflow..." - }, - { - "context": "ui", - "key": "Anmelden", - "value": "Se connecter" - }, - { - "context": "ui", - "key": "Anrufer", - "value": "Appelant" - }, - { - "context": "ui", - "key": "Anzeigen", - "value": "Voir" - }, - { - "context": "ui", - "key": "Anzeigename", - "value": "Nom d’affichage" - }, - { - "context": "ui", - "key": "Audio", - "value": "Audio" - }, - { - "context": "ui", - "key": "Auf Standard zurücksetzen", - "value": "Réinitialiser par Défaut" - }, - { - "context": "ui", - "key": "Aufgaben", - "value": "Tâches" - }, - { - "context": "ui", - "key": "Ausführen", - "value": "Exécuter" - }, - { - "context": "ui", - "key": "Ausgewählte Datei:", - "value": "Fichier sélectionné:" - }, - { - "context": "ui", - "key": "Auth-Anbieter", - "value": "Autorité d'authentification" - }, - { - "context": "ui", - "key": "Authentifizierungsanbieter", - "value": "Fournisseur d'authentification" - }, - { - "context": "ui", - "key": "Authentifizierungstoken abgelaufen oder ungültig. Bitte verbinden Sie Ihr Microsoft-Konto erneut.", - "value": "Token d'authentification expiré ou invalide. Veuillez reconnecter votre compte Microsoft." - }, - { - "context": "ui", - "key": "Automatisierung erfolgreich erstellt", - "value": "Automatisation créée avec succès" - }, - { - "context": "ui", - "key": "Automatisierungen", - "value": "Automatisations" - }, - { - "context": "ui", - "key": "Basisdaten", - "value": "Données de Base" - }, - { - "context": "ui", - "key": "Bearbeiten", - "value": "Modifier" - }, - { - "context": "ui", - "key": "Befehl eingeben (z.B., \"Erstelle ein neues Projekt namens 'Hauptstrasse 42'\")", - "value": "Entrez une commande (par exemple, \"Créer un nouveau projet nommé 'Rue Principale 42'\")" - }, - { - "context": "ui", - "key": "Beginne ein Gespräch, indem du eine Nachricht eingibst, eine Vorlage auswählst oder einen vorherigen Workflow fortsetzt …", - "value": "Commencez une conversation en entrant un message, en sélectionnant un modèle ou en continuant un workflow précédent..." - }, - { - "context": "ui", - "key": "Beginnen Sie mit:", - "value": "Commencez avec :" - }, - { - "context": "ui", - "key": "Bei Genehmigung planen wir einen Einrichtungsanruf zur Konfiguration Ihrer Integration.", - "value": "Si approuvé, nous planifierons un appel de configuration pour configurer votre intégration." - }, - { - "context": "ui", - "key": "Beim Hochladen ist ein Fehler aufgetreten.", - "value": "Une erreur s'est produite lors du téléchargement." - }, - { - "context": "ui", - "key": "Beim Hochladen ist ein unerwarteter Fehler aufgetreten.", - "value": "Une erreur inattendue s'est produite lors du téléchargement." - }, - { - "context": "ui", - "key": "Belege verwalten", - "value": "Gérer les pièces justificatives" - }, - { - "context": "ui", - "key": "Benutzer", - "value": "Utilisateur" - }, - { - "context": "ui", - "key": "Benutzer auswählen", - "value": "Sélectionner les utilisateurs" - }, - { - "context": "ui", - "key": "Benutzer bearbeiten", - "value": "Modifier l'utilisateur" - }, - { - "context": "ui", - "key": "Benutzer erstellen", - "value": "Créer l'utilisateur" - }, - { - "context": "ui", - "key": "Benutzer hinzufügen", - "value": "Ajouter un utilisateur" - }, - { - "context": "ui", - "key": "Benutzer löschen", - "value": "Supprimer l'utilisateur" - }, - { - "context": "ui", - "key": "Benutzer werden geladen...", - "value": "Chargement des utilisateurs..." - }, - { - "context": "ui", - "key": "Benutzer-Zugriff verwalten", - "value": "Gérer les accès utilisateurs" - }, - { - "context": "ui", - "key": "Benutzerdefinierter Titel (optional)", - "value": "Titre personnalisé (facultatif)" - }, - { - "context": "ui", - "key": "Benutzerinformationen", - "value": "Informations utilisateur" - }, - { - "context": "ui", - "key": "Benutzerinformationen erfolgreich aktualisiert", - "value": "Informations utilisateur mises à jour avec succès" - }, - { - "context": "ui", - "key": "Benutzerinformationen werden geladen...", - "value": "Chargement des informations utilisateur..." - }, - { - "context": "ui", - "key": "Benutzername", - "value": "Nom d'utilisateur" - }, - { - "context": "ui", - "key": "Benutzerverwaltung - Teammitglieder und Berechtigungen verwalten", - "value": "Gestion des Utilisateurs - Gérer les membres de l'équipe et les permissions" - }, - { - "context": "ui", - "key": "Berechtigung", - "value": "Privilège" - }, - { - "context": "ui", - "key": "Berechtigungsstufe", - "value": "Niveau de privilège" - }, - { - "context": "ui", - "key": "Beschreibung", - "value": "Description" - }, - { - "context": "ui", - "key": "Beschreibung der Rolle", - "value": "Description du rôle" - }, - { - "context": "ui", - "key": "Betrachter", - "value": "Observateur" - }, - { - "context": "ui", - "key": "Betreff", - "value": "Sujet" - }, - { - "context": "ui", - "key": "Bezeichnung", - "value": "Libellé" - }, - { - "context": "ui", - "key": "Bieten Sie Unterstützung im Live-Chat und setzen Sie intelligente Chatbots in allen Kanälen ein.", - "value": "Offrez une assistance en chat en direct et déployez des chatbots intelligents sur tous les canaux." - }, - { - "context": "ui", - "key": "Bild", - "value": "Image" - }, - { - "context": "ui", - "key": "Bitte geben Sie eine gültige E-Mail-Adresse ein", - "value": "Veuillez entrer une adresse email valide" - }, - { - "context": "ui", - "key": "Bitte wählen Sie mindestens einen Benutzer aus", - "value": "Veuillez sélectionner au moins un utilisateur" - }, - { - "context": "ui", - "key": "Branche", - "value": "Secteur" - }, - { - "context": "ui", - "key": "Branche ist erforderlich", - "value": "Le secteur d'activité est requis" - }, - { - "context": "ui", - "key": "Buchungsbetrag", - "value": "Montant de comptabilisation" - }, - { - "context": "ui", - "key": "Buchungspositionen verwalten", - "value": "Gérer les positions de réservation" - }, - { - "context": "ui", - "key": "Buchungswährung", - "value": "Devise de comptabilisation" - }, - { - "context": "ui", - "key": "Chat Platform (CP)", - "value": "Plateforme de Chat (CP)" - }, - { - "context": "ui", - "key": "Chat leeren...", - "value": "Nouveau Chat" - }, - { - "context": "ui", - "key": "Chatbereich", - "value": "Zone de chat" - }, - { - "context": "ui", - "key": "Darstellung", - "value": "Apparence" - }, - { - "context": "ui", - "key": "Datei", - "value": "Fichier" - }, - { - "context": "ui", - "key": "Datei anhängen", - "value": "Joindre un fichier" - }, - { - "context": "ui", - "key": "Datei bereits vorhanden", - "value": "Fichier Déjà Existant" - }, - { - "context": "ui", - "key": "Datei entfernen", - "value": "Supprimer le fichier" - }, - { - "context": "ui", - "key": "Datei erfolgreich hochgeladen!", - "value": "Fichier téléchargé avec succès !" - }, - { - "context": "ui", - "key": "Datei herunterladen", - "value": "Télécharger le fichier" - }, - { - "context": "ui", - "key": "Datei hier ablegen...", - "value": "Déposer le fichier ici..." - }, - { - "context": "ui", - "key": "Datei hinzufügen", - "value": "Ajouter un fichier" - }, - { - "context": "ui", - "key": "Datei hochladen", - "value": "Télécharger un fichier" - }, - { - "context": "ui", - "key": "Datei löschen", - "value": "Supprimer le fichier" - }, - { - "context": "ui", - "key": "Datei vorschauen", - "value": "Aperçu du fichier" - }, - { - "context": "ui", - "key": "Datei-Ablage während Workflow deaktiviert", - "value": "Dépôt de fichiers désactivé pendant le workflow" - }, - { - "context": "ui", - "key": "Dateien", - "value": "Fichiers" - }, - { - "context": "ui", - "key": "Dateien anhängen", - "value": "Joindre des fichiers" - }, - { - "context": "ui", - "key": "Dateien auswählen", - "value": "Sélectionner des fichiers" - }, - { - "context": "ui", - "key": "Dateien hier ablegen", - "value": "Déposer les fichiers ici" - }, - { - "context": "ui", - "key": "Dateien hier ablegen zum Anhängen", - "value": "Déposez les fichiers ici pour les joindre" - }, - { - "context": "ui", - "key": "Dateien hierher ziehen", - "value": "Glisser les fichiers ici" - }, - { - "context": "ui", - "key": "Dateien hochladen", - "value": "Télécharger des fichiers" - }, - { - "context": "ui", - "key": "Dateien werden geladen...", - "value": "Chargement des fichiers..." - }, - { - "context": "ui", - "key": "Dateien werden verarbeitet...", - "value": "Traitement des fichiers..." - }, - { - "context": "ui", - "key": "Dateigröße", - "value": "Taille du fichier" - }, - { - "context": "ui", - "key": "Dateiname", - "value": "Nom du fichier" - }, - { - "context": "ui", - "key": "Dateityp", - "value": "Type de fichier" - }, - { - "context": "ui", - "key": "Dateiverwaltung - Dokumente hochladen und organisieren", - "value": "Gestion des Fichiers - Télécharger et organiser les documents" - }, - { - "context": "ui", - "key": "Dateivorschau", - "value": "Aperçu du fichier" - }, - { - "context": "ui", - "key": "Daten aktualisieren", - "value": "Actualiser les données" - }, - { - "context": "ui", - "key": "Daten empfangen", - "value": "Données reçues" - }, - { - "context": "ui", - "key": "Daten gesendet", - "value": "Données envoyées" - }, - { - "context": "ui", - "key": "Datenverwaltung", - "value": "Gestion des données" - }, - { - "context": "ui", - "key": "Datenverwaltung - Datenimporte und -exporte verwalten", - "value": "Gestion des Données - Gérer les imports et exports de données" - }, - { - "context": "ui", - "key": "Datenverwaltung mit Tabellen", - "value": "Gestion des données avec des tableaux" - }, - { - "context": "ui", - "key": "Datum", - "value": "Date" - }, - { - "context": "ui", - "key": "Dauer", - "value": "Durée" - }, - { - "context": "ui", - "key": "Deutsch", - "value": "Deutsch" - }, - { - "context": "ui", - "key": "Die Datei \"{fileName}\" existiert bereits mit identischem Inhalt. Die vorhandene Datei wird wiederverwendet.", - "value": "Le fichier \"{fileName}\" existe déjà avec un contenu identique. Le fichier existant sera réutilisé." - }, - { - "context": "ui", - "key": "Die Erstellung einer neuen Sprache kann AI-Guthaben auf Ihrem Mandats-Pool belasten. Fortfahren?", - "value": "Créer une nouvelle langue peut consommer des crédits IA sur le pool du mandat. Continuer ?" - }, - { - "context": "ui", - "key": "Dies ist Ihr Ausgangspunkt für den Zugriff auf alle Arbeitsbereich-Features und -Tools.", - "value": "Ceci est votre point de départ pour accéder à toutes les fonctionnalités et outils de votre espace de travail." - }, - { - "context": "ui", - "key": "Diese Aktion kann nicht rückgängig gemacht werden.", - "value": "Cette action ne peut pas être annulée." - }, - { - "context": "ui", - "key": "Diese Datei scheint beschädigt zu sein. Sie hat eine PDF-Erweiterung, enthält aber Textinhalte. Bitte laden Sie die Datei erneut hoch, falls möglich.", - "value": "Ce fichier semble être corrompu. Il a une extension PDF mais contient du contenu texte. Veuillez le télécharger à nouveau si possible." - }, - { - "context": "ui", - "key": "Dieser Bereich enthält alle Verwaltungs- und Management-Tools für Ihren Arbeitsbereich.", - "value": "Cette section contient tous les outils d'administration et de gestion pour votre espace de travail." - }, - { - "context": "ui", - "key": "Dieses Element auswählen", - "value": "Sélectionner cet élément" - }, - { - "context": "ui", - "key": "Dieses Element kann nicht ausgewählt werden", - "value": "Cet élément ne peut pas être sélectionné" - }, - { - "context": "ui", - "key": "Dieses Feld wird von {provider} verwaltet und kann nicht geändert werden", - "value": "Ce champ est géré par {provider} et ne peut pas être modifié" - }, - { - "context": "ui", - "key": "Dossiers", - "value": "Dossiers" - }, - { - "context": "ui", - "key": "Dokument", - "value": "Document" - }, - { - "context": "ui", - "key": "Dokument erfolgreich erstellt", - "value": "Document créé avec succès" - }, - { - "context": "ui", - "key": "Dokument herunterladen", - "value": "Télécharger le document" - }, - { - "context": "ui", - "key": "Dokument vorschauen", - "value": "Aperçu du document" - }, - { - "context": "ui", - "key": "Dokumente", - "value": "Documents" - }, - { - "context": "ui", - "key": "Dokumente auflisten", - "value": "Lister les documents" - }, - { - "context": "ui", - "key": "Dokumentname", - "value": "Nom du document" - }, - { - "context": "ui", - "key": "Dunkel", - "value": "Sombre" - }, - { - "context": "ui", - "key": "Durchsuchen", - "value": "Parcourir" - }, - { - "context": "ui", - "key": "E-Mail", - "value": "Email" - }, - { - "context": "ui", - "key": "E-Mail-Adresse", - "value": "Adresse Email" - }, - { - "context": "ui", - "key": "E-Mail-Adresse ist erforderlich", - "value": "L'adresse email est requise" - }, - { - "context": "ui", - "key": "E-Mail-Bestätigung", - "value": "Confirmation par Email" - }, - { - "context": "ui", - "key": "Echtzeit-Datensynchronisation:", - "value": "Synchronisation de Données en Temps Réel:" - }, - { - "context": "ui", - "key": "Eingereichte Daten:", - "value": "Données Soumises :" - }, - { - "context": "ui", - "key": "Einrichtungsanruf", - "value": "Appel de Configuration" - }, - { - "context": "ui", - "key": "Einstellungen", - "value": "Paramètres" - }, - { - "context": "ui", - "key": "Einstellungen erfolgreich gespeichert!", - "value": "Paramètres sauvegardés avec succès !" - }, - { - "context": "ui", - "key": "Einstellungen werden in zukünftigen Updates hinzugefügt.", - "value": "Le contenu des paramètres sera ajouté dans les futures mises à jour." - }, - { - "context": "ui", - "key": "Einstellungen wurden erfolgreich zurückgesetzt.", - "value": "Les paramètres ont été réinitialisés avec succès." - }, - { - "context": "ui", - "key": "Einträge pro Seite:", - "value": "Éléments par page:" - }, - { - "context": "ui", - "key": "Empfänger", - "value": "Destinataire" - }, - { - "context": "ui", - "key": "Endzeit", - "value": "Heure de Fin" - }, - { - "context": "ui", - "key": "English", - "value": "English" - }, - { - "context": "ui", - "key": "Entdeckte Sites", - "value": "Sites découverts" - }, - { - "context": "ui", - "key": "Erfolgreich", - "value": "Succès" - }, - { - "context": "ui", - "key": "Erfolgsrate", - "value": "Taux de succès" - }, - { - "context": "ui", - "key": "Erleben Sie die Zukunft der Mandantenkommunikation durch unsere strategische Partnerschaft mit Spitch.ai. Diese bahnbrechende Integration verwandelt Ihre PowerOn-Plattform in ein intelligentes Telefonie-System, das externe Mandanten nahtlos mit Unternehmen verbindet.", - "value": "Découvrez l'avenir de la communication client grâce à notre partenariat stratégique avec Spitch.ai. Cette intégration révolutionnaire transforme votre plateforme PowerOn en un système téléphonique intelligent qui connecte de manière transparente les clients externes avec les entreprises." - }, - { - "context": "ui", - "key": "Erneut versuchen", - "value": "Réessayer" - }, - { - "context": "ui", - "key": "Erste Seite", - "value": "Première page" - }, - { - "context": "ui", - "key": "Erstellen", - "value": "Créer" - }, - { - "context": "ui", - "key": "Erstellen und verwalten Sie RBAC-Rollen und deren Berechtigungen.", - "value": "Créez et gérez les rôles RBAC et leurs permissions." - }, - { - "context": "ui", - "key": "Erstellen...", - "value": "Création..." - }, - { - "context": "ui", - "key": "Erstellt", - "value": "Créé" - }, - { - "context": "ui", - "key": "Erstellte Dateien", - "value": "Fichiers créés" - }, - { - "context": "ui", - "key": "Erstellungsdatum", - "value": "Date de création" - }, - { - "context": "ui", - "key": "Externe E-Mail", - "value": "E-mail externe" - }, - { - "context": "ui", - "key": "Externe E-Mail-Adresse eingeben", - "value": "Entrez l'adresse e-mail externe" - }, - { - "context": "ui", - "key": "Externen Benutzernamen eingeben", - "value": "Entrez le nom d'utilisateur externe" - }, - { - "context": "ui", - "key": "Externer Benutzername", - "value": "Nom d'utilisateur externe" - }, - { - "context": "ui", - "key": "FEHLER", - "value": "ERREUR" - }, - { - "context": "ui", - "key": "FEHLGESCHLAGEN", - "value": "ÉCHEC" - }, - { - "context": "ui", - "key": "Falls Sie Fragen zu Ihrem Mandat oder dem Integrationsprozess haben, zögern Sie nicht, unser Support-Team zu kontaktieren.", - "value": "Si vous avez des questions sur votre mandat ou le processus d'intégration, n'hésitez pas à contacter notre équipe de support." - }, - { - "context": "ui", - "key": "Fehler", - "value": "Erreur" - }, - { - "context": "ui", - "key": "Fehler beim Aktualisieren der Benutzerinformationen", - "value": "Erreur lors de la mise à jour des informations utilisateur" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der Automatisierung", - "value": "Erreur lors de la création de l'automatisation" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der Organisation", - "value": "Erreur lors de la création de l'organisation" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der Position", - "value": "Erreur lors de la création de la position" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der RBAC-Regel", - "value": "Erreur lors de la création de la règle RBAC" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen der Rolle", - "value": "Erreur lors de la création du rôle" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Dokuments", - "value": "Erreur lors de la création du document" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Mandats", - "value": "Erreur lors de la création du mandat" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Prompts", - "value": "Erreur lors de la création du prompt" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Team-Mitglieds", - "value": "Erreur lors de la création du membre de l'équipe" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Vertrags", - "value": "Erreur lors de la création du contrat" - }, - { - "context": "ui", - "key": "Fehler beim Erstellen des Zugriffs", - "value": "Erreur lors de la création de l'accès" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Benutzer", - "value": "Erreur lors du chargement des utilisateurs" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Benutzer:", - "value": "Erreur lors du chargement des utilisateurs:" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Benutzerinformationen", - "value": "Erreur lors du chargement des informations utilisateur" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Dateien:", - "value": "Erreur lors du chargement des fichiers:" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Logs", - "value": "Erreur lors du chargement des logs" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Nachrichten:", - "value": "Erreur lors du chargement des messages:" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Prompts", - "value": "Erreur lors du chargement des prompts" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Prompts:", - "value": "Erreur lors du chargement des prompts:" - }, - { - "context": "ui", - "key": "Fehler beim Laden der SharePoint Dokumente:", - "value": "Erreur lors du chargement des documents SharePoint:" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Vorschau", - "value": "Erreur lors du chargement de l'aperçu" - }, - { - "context": "ui", - "key": "Fehler beim Laden der Workflows:", - "value": "Erreur lors du chargement des workflows:" - }, - { - "context": "ui", - "key": "Fehler beim Löschen", - "value": "Erreur lors de la suppression" - }, - { - "context": "ui", - "key": "Fehler beim Speichern der Einstellungen. Bitte versuchen Sie es erneut.", - "value": "Échec de la sauvegarde des paramètres. Veuillez réessayer." - }, - { - "context": "ui", - "key": "Fehler beim Teilen des Prompts", - "value": "Erreur lors du partage du prompt" - }, - { - "context": "ui", - "key": "Fehler beim Verarbeiten der Dateien", - "value": "Erreur lors du traitement des fichiers" - }, - { - "context": "ui", - "key": "Fehler:", - "value": "Erreur:" - }, - { - "context": "ui", - "key": "Fehlgeschlagen", - "value": "Échoué" - }, - { - "context": "ui", - "key": "Filter löschen", - "value": "Effacer le filtre" - }, - { - "context": "ui", - "key": "Firma", - "value": "Entreprise" - }, - { - "context": "ui", - "key": "Firmenname", - "value": "Nom de l'Entreprise" - }, - { - "context": "ui", - "key": "Firmenname ist erforderlich", - "value": "Le nom de l'entreprise est requis" - }, - { - "context": "ui", - "key": "Folgenachricht wird gesendet...", - "value": "Envoi du message de suivi..." - }, - { - "context": "ui", - "key": "Fortfahren", - "value": "Continuer" - }, - { - "context": "ui", - "key": "Fortsetzen", - "value": "Continuer" - }, - { - "context": "ui", - "key": "Fragen?", - "value": "Questions ?" - }, - { - "context": "ui", - "key": "Français", - "value": "Français" - }, - { - "context": "ui", - "key": "Fügen Sie eine Nachricht für die Empfänger hinzu", - "value": "Ajoutez un message pour les destinataires" - }, - { - "context": "ui", - "key": "GESTOPPT", - "value": "ARRÊTÉ" - }, - { - "context": "ui", - "key": "Geben Sie Ihren Firmennamen ein", - "value": "Entrez le nom de votre entreprise" - }, - { - "context": "ui", - "key": "Geben Sie Kunden einen schnellen und effizienten Selbstservice für Sprach- und Textanfragen, der 24/7 verfügbar ist.", - "value": "Offrez aux clients un libre-service rapide et efficace pour les requêtes vocales et textuelles disponible 24h/24." - }, - { - "context": "ui", - "key": "Geben Sie den Inhalt des Prompts ein", - "value": "Entrez le contenu du prompt" - }, - { - "context": "ui", - "key": "Geben Sie einen Namen für den Prompt ein", - "value": "Entrez un nom pour le prompt" - }, - { - "context": "ui", - "key": "Geben Sie einen benutzerdefinierten Titel ein", - "value": "Entrez un titre personnalisé" - }, - { - "context": "ui", - "key": "Geplante und automatisierte Workflows", - "value": "Workflows planifiés et automatisés" - }, - { - "context": "ui", - "key": "Geschäftszeiten", - "value": "Heures d'Ouverture" - }, - { - "context": "ui", - "key": "Geschäftszeiten & Zeitzone", - "value": "Heures d'Ouverture et Fuseau Horaire" - }, - { - "context": "ui", - "key": "Gespräch fortsetzen...", - "value": "Continuer la conversation..." - }, - { - "context": "ui", - "key": "Gestartet", - "value": "Démarré" - }, - { - "context": "ui", - "key": "Gestartet:", - "value": "Démarré:" - }, - { - "context": "ui", - "key": "Gestoppt", - "value": "Arrêté" - }, - { - "context": "ui", - "key": "Geteilt", - "value": "Partagés" - }, - { - "context": "ui", - "key": "Geteilte Dateien", - "value": "Fichiers partagés" - }, - { - "context": "ui", - "key": "Globale Sprachsets verwalten (SysAdmin).", - "value": "Gérer les jeux de langue globaux (SysAdmin)." - }, - { - "context": "ui", - "key": "Google", - "value": "Google" - }, - { - "context": "ui", - "key": "Google verbinden", - "value": "Connecter Google" - }, - { - "context": "ui", - "key": "Google-Verbindung erstellen", - "value": "Créer une connexion Google" - }, - { - "context": "ui", - "key": "Google-Verbindung hinzufügen", - "value": "Ajouter une connexion Google" - }, - { - "context": "ui", - "key": "Grundlegende Daten und Ressourcen", - "value": "Données et ressources de base" - }, - { - "context": "ui", - "key": "Größe", - "value": "Taille" - }, - { - "context": "ui", - "key": "Hell", - "value": "Clair" - }, - { - "context": "ui", - "key": "Herunterladen", - "value": "Télécharger" - }, - { - "context": "ui", - "key": "Hinzufügen", - "value": "Ajouter" - }, - { - "context": "ui", - "key": "Hochgeladen", - "value": "Téléchargés" - }, - { - "context": "ui", - "key": "Hochladen", - "value": "Télécharger" - }, - { - "context": "ui", - "key": "ID", - "value": "ID" - }, - { - "context": "ui", - "key": "INFO", - "value": "INFO" - }, - { - "context": "ui", - "key": "Identifizieren und authentifizieren Sie Anrufer in Sekunden mit kontinuierlicher Verifizierung und Sicherheit.", - "value": "Identifiez et authentifiez les appelants en quelques secondes avec une vérification et sécurité continues." - }, - { - "context": "ui", - "key": "Ihre Anfrage wird verarbeitet...", - "value": "Traitement de votre demande..." - }, - { - "context": "ui", - "key": "Inaktiv", - "value": "Inactif" - }, - { - "context": "ui", - "key": "Information", - "value": "Information" - }, - { - "context": "ui", - "key": "Inhalt", - "value": "Contenu" - }, - { - "context": "ui", - "key": "Inhalt ist erforderlich", - "value": "Le contenu est requis" - }, - { - "context": "ui", - "key": "Ja", - "value": "Oui" - }, - { - "context": "ui", - "key": "Jetzt anmelden", - "value": "S'inscrire Maintenant" - }, - { - "context": "ui", - "key": "Jetzt überspringen", - "value": "Ignorer pour l'Instant" - }, - { - "context": "ui", - "key": "KI-erstellt", - "value": "Créés par IA" - }, - { - "context": "ui", - "key": "KI-gestützte Dokumentengenerierung:", - "value": "Génération de Documents alimentée par l'IA:" - }, - { - "context": "ui", - "key": "Kein Auth-Anbieter", - "value": "Aucune autorité d'authentification" - }, - { - "context": "ui", - "key": "Kein Benutzername", - "value": "Aucun nom d'utilisateur" - }, - { - "context": "ui", - "key": "Kein Nachrichteninhalt verfügbar", - "value": "Aucun contenu de message disponible" - }, - { - "context": "ui", - "key": "Kein Name", - "value": "Aucun nom" - }, - { - "context": "ui", - "key": "Kein Workflow ausgewählt", - "value": "Aucun workflow sélectionné" - }, - { - "context": "ui", - "key": "Keine Benutzer verfügbar", - "value": "Aucun utilisateur disponible" - }, - { - "context": "ui", - "key": "Keine Berechtigung", - "value": "Aucun privilège" - }, - { - "context": "ui", - "key": "Keine Berechtigung zum Löschen des Prompts", - "value": "Aucune permission de supprimer l'invite" - }, - { - "context": "ui", - "key": "Keine Dateien gefunden.", - "value": "Aucun fichier trouvé." - }, - { - "context": "ui", - "key": "Keine E-Mail", - "value": "Aucun e-mail" - }, - { - "context": "ui", - "key": "Keine Einträge", - "value": "Aucune entrée" - }, - { - "context": "ui", - "key": "Keine Logs für diesen Workflow verfügbar", - "value": "Aucun log disponible pour ce workflow" - }, - { - "context": "ui", - "key": "Keine Microsoft-Verbindungen gefunden. Bitte erstellen Sie zuerst eine Verbindung.", - "value": "Aucune connexion Microsoft trouvée. Veuillez d'abord créer une connexion." - }, - { - "context": "ui", - "key": "Keine Prompts verfügbar", - "value": "Aucun prompt disponible" - }, - { - "context": "ui", - "key": "Keine SharePoint-Sites gefunden", - "value": "Aucun site SharePoint trouvé" - }, - { - "context": "ui", - "key": "Keine Sprach-Integrations-Daten gefunden. Bitte melden Sie sich zuerst an, um auf die Einstellungen zuzugreifen.", - "value": "Aucune donnée d'intégration vocale trouvée. Veuillez d'abord vous inscrire pour accéder aux paramètres." - }, - { - "context": "ui", - "key": "Keine Sprache", - "value": "Aucune langue" - }, - { - "context": "ui", - "key": "Keine Transkripte vorhanden", - "value": "Aucune transcription disponible" - }, - { - "context": "ui", - "key": "Keine Vorschau verfügbar", - "value": "Aucun aperçu disponible" - }, - { - "context": "ui", - "key": "Keine Workflows gefunden", - "value": "Aucun workflow trouvé" - }, - { - "context": "ui", - "key": "Keine Workflows verfügbar", - "value": "Aucun workflow disponible" - }, - { - "context": "ui", - "key": "Keine hochgeladenen Dateien gefunden.", - "value": "Aucun fichier téléchargé trouvé." - }, - { - "context": "ui", - "key": "Keine mit Ihnen geteilten Dateien gefunden.", - "value": "Aucun fichier partagé trouvé." - }, - { - "context": "ui", - "key": "Keine von der KI erstellten Dateien gefunden.", - "value": "Aucun fichier créé par IA trouvé." - }, - { - "context": "ui", - "key": "Klicken Sie erneut zum Bestätigen", - "value": "Cliquez à nouveau pour confirmer" - }, - { - "context": "ui", - "key": "Klicken Sie erneut zum Bestätigen der Löschung", - "value": "Cliquez à nouveau pour confirmer la suppression" - }, - { - "context": "ui", - "key": "Klicken Sie, um zu öffnen", - "value": "Cliquez pour ouvrir" - }, - { - "context": "ui", - "key": "Knowledge Agent (KA)", - "value": "Agent de Connaissance (KA)" - }, - { - "context": "ui", - "key": "Konfigurieren Sie administrative Einstellungen und Systempräferenzen.", - "value": "Configurez les paramètres administratifs et les préférences système." - }, - { - "context": "ui", - "key": "Konfigurieren und verwalten Sie rollenbasierte Zugriffssteuerungsregeln.", - "value": "Configurez et gérez les règles de contrôle d'accès basé sur les rôles." - }, - { - "context": "ui", - "key": "Kontakte einrichten", - "value": "Configurer les Contacts" - }, - { - "context": "ui", - "key": "Kontaktinformationen", - "value": "Informations de Contact" - }, - { - "context": "ui", - "key": "Kontostatus", - "value": "Statut du compte" - }, - { - "context": "ui", - "key": "Kopieren", - "value": "Copier" - }, - { - "context": "ui", - "key": "Kosteneinsparungen & Effizienz:", - "value": "Économies de Coûts & Efficacité:" - }, - { - "context": "ui", - "key": "Kundenverträge verwalten", - "value": "Gérer les contrats clients" - }, - { - "context": "ui", - "key": "Lade Fortschritt...", - "value": "Chargement du progrès..." - }, - { - "context": "ui", - "key": "Laden...", - "value": "Téléchargement..." - }, - { - "context": "ui", - "key": "Land", - "value": "Pays" - }, - { - "context": "ui", - "key": "Land ist erforderlich", - "value": "Le pays est requis" - }, - { - "context": "ui", - "key": "Leer = Zugriff auf alle Verträge", - "value": "Vide = Accès à tous les contrats" - }, - { - "context": "ui", - "key": "Letzte Aktivität", - "value": "Dernière activité" - }, - { - "context": "ui", - "key": "Letzte Aktivität:", - "value": "Dernière activité:" - }, - { - "context": "ui", - "key": "Letzte Aktivitäten - Sehen Sie Ihre neueste Arbeit", - "value": "Activités Récentes - Consultez votre travail le plus récent" - }, - { - "context": "ui", - "key": "Letzte Seite", - "value": "Dernière page" - }, - { - "context": "ui", - "key": "Link konnte nicht gesendet werden", - "value": "Échec de l'envoi du lien" - }, - { - "context": "ui", - "key": "Log", - "value": "Journal" - }, - { - "context": "ui", - "key": "Logs konnten nicht geladen werden", - "value": "Échec du chargement des logs" - }, - { - "context": "ui", - "key": "Logs werden geladen...", - "value": "Chargement des logs..." - }, - { - "context": "ui", - "key": "Lokal", - "value": "Local" - }, - { - "context": "ui", - "key": "LÄUFT", - "value": "EN COURS" - }, - { - "context": "ui", - "key": "Lädt hoch...", - "value": "Téléchargement..." - }, - { - "context": "ui", - "key": "Läuft", - "value": "En cours" - }, - { - "context": "ui", - "key": "Läuft ab am", - "value": "Expire le" - }, - { - "context": "ui", - "key": "Löschen", - "value": "Supprimer" - }, - { - "context": "ui", - "key": "Löschen ({count})", - "value": "Supprimer ({count})" - }, - { - "context": "ui", - "key": "Löschen...", - "value": "Suppression..." - }, - { - "context": "ui", - "key": "MIME-Typ", - "value": "Type MIME" - }, - { - "context": "ui", - "key": "Management-Tools umfassen:", - "value": "Les outils de gestion incluent:" - }, - { - "context": "ui", - "key": "Mandanten können jederzeit auf die technische SIP-Nummer umstellen und dabei erhebliche Telefoniekosten sparen. Die Integration funktioniert wie ein weiterer Connector (Outlook, SharePoint) und wird nahtlos in Ihren bestehenden Workflow integriert.", - "value": "Les clients peuvent basculer sur le numéro SIP technique à tout moment et économiser des coûts téléphoniques significatifs. L'intégration fonctionne comme un autre connecteur (Outlook, SharePoint) et est intégrée de manière transparente dans votre workflow existant." - }, - { - "context": "ui", - "key": "Mandat erfolgreich eingereicht!", - "value": "Mandat Soumis avec Succès !" - }, - { - "context": "ui", - "key": "Mandat erfolgreich erstellt", - "value": "Mandat créé avec succès" - }, - { - "context": "ui", - "key": "Mandat erstellen", - "value": "Créer le Mandat" - }, - { - "context": "ui", - "key": "Mandat hinzufügen", - "value": "Ajouter un mandat" - }, - { - "context": "ui", - "key": "Mandat-ID", - "value": "ID Mandat" - }, - { - "context": "ui", - "key": "Mandate", - "value": "Mandats" - }, - { - "context": "ui", - "key": "Mandate und Berechtigungen verwalten", - "value": "Gérer les mandats et les permissions" - }, - { - "context": "ui", - "key": "Mandatsverwaltung", - "value": "Gestion des mandats" - }, - { - "context": "ui", - "key": "Mehr erfahren", - "value": "En savoir plus" - }, - { - "context": "ui", - "key": "Meine Uploads", - "value": "Mes téléchargements" - }, - { - "context": "ui", - "key": "Microsoft", - "value": "Microsoft" - }, - { - "context": "ui", - "key": "Microsoft Verbindungen", - "value": "Connexions Microsoft" - }, - { - "context": "ui", - "key": "Microsoft verbinden", - "value": "Connecter Microsoft" - }, - { - "context": "ui", - "key": "Microsoft-Verbindung erstellen", - "value": "Créer une connexion Microsoft" - }, - { - "context": "ui", - "key": "Microsoft-Verbindung hinzufügen", - "value": "Ajouter une connexion Microsoft" - }, - { - "context": "ui", - "key": "Mitglied hinzufügen", - "value": "Ajouter un membre" - }, - { - "context": "ui", - "key": "MwSt %", - "value": "TVA %" - }, - { - "context": "ui", - "key": "MwSt Betrag", - "value": "Montant TVA" - }, - { - "context": "ui", - "key": "Möchten Sie jetzt Kontakte für Ihr Mandat einrichten? Sie können dies auch später in den Einstellungen tun.", - "value": "Souhaitez-vous configurer les contacts pour votre mandat maintenant ? Vous pouvez également le faire plus tard dans les paramètres." - }, - { - "context": "ui", - "key": "Nach unten scrollen", - "value": "Faire défiler vers le bas" - }, - { - "context": "ui", - "key": "Nachricht (optional)", - "value": "Message (facultatif)" - }, - { - "context": "ui", - "key": "Nachricht eingeben...", - "value": "Entrez votre message..." - }, - { - "context": "ui", - "key": "Nachricht wird gesendet...", - "value": "Envoi du message..." - }, - { - "context": "ui", - "key": "Nachrichten", - "value": "Messages" - }, - { - "context": "ui", - "key": "Nahtloser Mandanten-Workflow:", - "value": "Workflow Client Transparent:" - }, - { - "context": "ui", - "key": "Name", - "value": "Nom" - }, - { - "context": "ui", - "key": "Name des Unternehmens", - "value": "Nom de l'entreprise" - }, - { - "context": "ui", - "key": "Name ist erforderlich", - "value": "Le nom est requis" - }, - { - "context": "ui", - "key": "Navigation - Erkunden Sie alle verfügbaren Tools", - "value": "Navigation - Explorez tous les outils disponibles" - }, - { - "context": "ui", - "key": "Nein", - "value": "Non" - }, - { - "context": "ui", - "key": "Neu starten", - "value": "Recommencer" - }, - { - "context": "ui", - "key": "Neue Automatisierung", - "value": "Nouvelle Automatisation" - }, - { - "context": "ui", - "key": "Neue Automatisierung erstellen", - "value": "Créer une Nouvelle Automatisation" - }, - { - "context": "ui", - "key": "Neue Datei hochladen", - "value": "Télécharger un nouveau fichier" - }, - { - "context": "ui", - "key": "Neue Organisation", - "value": "Nouvelle Organisation" - }, - { - "context": "ui", - "key": "Neue Organisation erstellen", - "value": "Créer une nouvelle organisation" - }, - { - "context": "ui", - "key": "Neue Position", - "value": "Nouvelle Position" - }, - { - "context": "ui", - "key": "Neue Position erstellen", - "value": "Créer une nouvelle position" - }, - { - "context": "ui", - "key": "Neue RBAC-Regel erstellen", - "value": "Créer une nouvelle règle RBAC" - }, - { - "context": "ui", - "key": "Neue Rolle", - "value": "Nouveau Rôle" - }, - { - "context": "ui", - "key": "Neue Rolle erstellen", - "value": "Créer un nouveau rôle" - }, - { - "context": "ui", - "key": "Neue Sprache", - "value": "Nouvelle langue" - }, - { - "context": "ui", - "key": "Neuen Prompt erstellen", - "value": "Créer un nouveau prompt" - }, - { - "context": "ui", - "key": "Neuen Vertrag erstellen", - "value": "Créer un nouveau contrat" - }, - { - "context": "ui", - "key": "Neuen Zugriff erstellen", - "value": "Créer un nouvel accès" - }, - { - "context": "ui", - "key": "Neuer Prompt", - "value": "Nouveau prompt" - }, - { - "context": "ui", - "key": "Neuer Vertrag", - "value": "Nouveau Contrat" - }, - { - "context": "ui", - "key": "Neuer Zugriff", - "value": "Nouvel Accès" - }, - { - "context": "ui", - "key": "Neues Dokument", - "value": "Nouveau Document" - }, - { - "context": "ui", - "key": "Neues Dokument erstellen", - "value": "Créer un nouveau document" - }, - { - "context": "ui", - "key": "Neues Mandat erstellen", - "value": "Créer un nouveau mandat" - }, - { - "context": "ui", - "key": "Neues Team-Mitglied erstellen", - "value": "Créer un nouveau membre de l'équipe" - }, - { - "context": "ui", - "key": "Neues Transkript", - "value": "Nouvelle Transcription" - }, - { - "context": "ui", - "key": "Nicht verfügbar", - "value": "N/D" - }, - { - "context": "ui", - "key": "Noch keine Befehle ausgeführt. Senden Sie einen Befehl, um Ergebnisse hier zu sehen.", - "value": "Aucune commande exécutée pour le moment. Envoyez une commande pour voir les résultats ici." - }, - { - "context": "ui", - "key": "Noch keinen Workflow ausgewählt", - "value": "Aucun workflow sélectionné" - }, - { - "context": "ui", - "key": "Nochmal versuchen", - "value": "Réessayer" - }, - { - "context": "ui", - "key": "Nächste Seite", - "value": "Page suivante" - }, - { - "context": "ui", - "key": "Oder geben Sie Ihre Nachricht ein...", - "value": "Ou entrez votre message..." - }, - { - "context": "ui", - "key": "Ordnerpfade", - "value": "Chemins des dossiers" - }, - { - "context": "ui", - "key": "Organisation", - "value": "Organisation" - }, - { - "context": "ui", - "key": "Organisation erfolgreich erstellt", - "value": "Organisation créée avec succès" - }, - { - "context": "ui", - "key": "Organisationen", - "value": "Organisations" - }, - { - "context": "ui", - "key": "Originalbetrag", - "value": "Montant d'origine" - }, - { - "context": "ui", - "key": "Originalwährung", - "value": "Devise d'origine" - }, - { - "context": "ui", - "key": "PDF", - "value": "PDF" - }, - { - "context": "ui", - "key": "Passwort", - "value": "Mot de passe" - }, - { - "context": "ui", - "key": "Passwort eingeben", - "value": "Entrez le mot de passe" - }, - { - "context": "ui", - "key": "Passwort-Link gesendet!", - "value": "Lien de mot de passe envoyé!" - }, - { - "context": "ui", - "key": "Passwort-Link senden", - "value": "Envoyer le lien de mot de passe" - }, - { - "context": "ui", - "key": "Pfad", - "value": "Chemin" - }, - { - "context": "ui", - "key": "Position erfolgreich erstellt", - "value": "Position créée avec succès" - }, - { - "context": "ui", - "key": "Positionen", - "value": "Positions" - }, - { - "context": "ui", - "key": "Postleitzahl", - "value": "Code Postal" - }, - { - "context": "ui", - "key": "Postleitzahl ist erforderlich", - "value": "Le code postal est requis" - }, - { - "context": "ui", - "key": "Projekte", - "value": "Projets" - }, - { - "context": "ui", - "key": "Projektverwaltung", - "value": "Gestion de projets" - }, - { - "context": "ui", - "key": "Projektverwaltung und -organisation", - "value": "Gestion et organisation de projets" - }, - { - "context": "ui", - "key": "Prompt", - "value": "Prompt" - }, - { - "context": "ui", - "key": "Prompt Einstellungen", - "value": "Paramètres de prompt" - }, - { - "context": "ui", - "key": "Prompt Vorlage", - "value": "Modèle de prompt" - }, - { - "context": "ui", - "key": "Prompt ausführen", - "value": "Exécuter le prompt" - }, - { - "context": "ui", - "key": "Prompt auswählen...", - "value": "Sélectionner un prompt..." - }, - { - "context": "ui", - "key": "Prompt bearbeiten", - "value": "Modifier le prompt" - }, - { - "context": "ui", - "key": "Prompt erfolgreich erstellt", - "value": "Prompt créé avec succès" - }, - { - "context": "ui", - "key": "Prompt erstellen", - "value": "Créer le prompt" - }, - { - "context": "ui", - "key": "Prompt hinzufügen", - "value": "Ajouter un prompt" - }, - { - "context": "ui", - "key": "Prompt löschen", - "value": "Effacer le prompt" - }, - { - "context": "ui", - "key": "Prompt teilen", - "value": "Partager le prompt" - }, - { - "context": "ui", - "key": "Prompt wird gelöscht...", - "value": "Suppression du prompt..." - }, - { - "context": "ui", - "key": "Prompt-Inhalt", - "value": "Contenu du prompt" - }, - { - "context": "ui", - "key": "Prompt-Inhalt darf 10.000 Zeichen nicht überschreiten", - "value": "Le contenu du prompt ne peut pas dépasser 10 000 caractères" - }, - { - "context": "ui", - "key": "Prompt-Inhalt darf nicht leer sein", - "value": "Le contenu du prompt ne peut pas être vide" - }, - { - "context": "ui", - "key": "Prompt-Name", - "value": "Nom du prompt" - }, - { - "context": "ui", - "key": "Prompt-Name darf 100 Zeichen nicht überschreiten", - "value": "Le nom du prompt ne peut pas dépasser 100 caractères" - }, - { - "context": "ui", - "key": "Prompt-Name darf nicht leer sein", - "value": "Le nom du prompt ne peut pas être vide" - }, - { - "context": "ui", - "key": "Prompts", - "value": "Prompts" - }, - { - "context": "ui", - "key": "Prompts für Ihren KI-Assistenten erstellen und verwalten", - "value": "Créer et gérer des prompts pour votre assistant IA" - }, - { - "context": "ui", - "key": "Prompts verwalten", - "value": "Gérer vos prompts" - }, - { - "context": "ui", - "key": "Prompts werden geladen...", - "value": "Chargement des prompts..." - }, - { - "context": "ui", - "key": "Python", - "value": "Python" - }, - { - "context": "ui", - "key": "Quelle", - "value": "Source" - }, - { - "context": "ui", - "key": "RBAC-Regel erfolgreich erstellt", - "value": "Règle RBAC créée avec succès" - }, - { - "context": "ui", - "key": "RBAC-Regel hinzufügen", - "value": "Ajouter une règle RBAC" - }, - { - "context": "ui", - "key": "RBAC-Regeln", - "value": "Règles RBAC" - }, - { - "context": "ui", - "key": "RBAC-Regelverwaltung", - "value": "Gestion des règles RBAC" - }, - { - "context": "ui", - "key": "RBAC-Rollen", - "value": "Rôles RBAC" - }, - { - "context": "ui", - "key": "RBAC-Rollenverwaltung", - "value": "Gestion des rôles RBAC" - }, - { - "context": "ui", - "key": "Registrieren", - "value": "S'inscrire" - }, - { - "context": "ui", - "key": "Revolutionäre Telefonie-Integration mit Spitch.ai", - "value": "Intégration Téléphonique Révolutionnaire avec Spitch.ai" - }, - { - "context": "ui", - "key": "Rolle", - "value": "Rôle" - }, - { - "context": "ui", - "key": "Rolle erfolgreich erstellt", - "value": "Rôle créé avec succès" - }, - { - "context": "ui", - "key": "Rolle hinzufügen", - "value": "Ajouter un rôle" - }, - { - "context": "ui", - "key": "Rollen", - "value": "Rôles" - }, - { - "context": "ui", - "key": "Rollen-ID", - "value": "ID du rôle" - }, - { - "context": "ui", - "key": "Rollenbasierte Zugriffssteuerungsregeln", - "value": "Règles de contrôle d'accès basé sur les rôles" - }, - { - "context": "ui", - "key": "Rollenverwaltung", - "value": "Gestion des rôles" - }, - { - "context": "ui", - "key": "Rufname am Telefon", - "value": "Nom au téléphone" - }, - { - "context": "ui", - "key": "Runde", - "value": "Tour" - }, - { - "context": "ui", - "key": "Runden", - "value": "Tours" - }, - { - "context": "ui", - "key": "Schließen", - "value": "Fermer" - }, - { - "context": "ui", - "key": "Schnellzugriff", - "value": "Accès Rapide" - }, - { - "context": "ui", - "key": "Schnellzugriff - Springen Sie zu häufig verwendeten Features", - "value": "Accès Rapide - Accédez rapidement aux fonctionnalités fréquemment utilisées" - }, - { - "context": "ui", - "key": "Seite {page} von {total} ({count} Einträge)", - "value": "Page {page} sur {total} ({count} éléments)" - }, - { - "context": "ui", - "key": "Senden", - "value": "Envoyer" - }, - { - "context": "ui", - "key": "Service", - "value": "Service" - }, - { - "context": "ui", - "key": "Service-Verbindungen", - "value": "Connexions de service" - }, - { - "context": "ui", - "key": "SharePoint Dokumente", - "value": "Documents SharePoint" - }, - { - "context": "ui", - "key": "SharePoint Site URL", - "value": "URL du site SharePoint" - }, - { - "context": "ui", - "key": "SharePoint Test", - "value": "Test SharePoint" - }, - { - "context": "ui", - "key": "Sie erhalten in den nächsten Minuten eine Bestätigungs-E-Mail.", - "value": "Vous recevrez un email de confirmation dans les prochaines minutes." - }, - { - "context": "ui", - "key": "Sie können auch auf den Upload-Button klicken", - "value": "Vous pouvez aussi cliquer sur le bouton de téléchargement" - }, - { - "context": "ui", - "key": "Sie müssen sich zuerst für die Sprach-Integration anmelden, um auf die Transkriptverwaltung zuzugreifen.", - "value": "Vous devez d'abord vous inscrire à l'intégration vocale pour accéder à la gestion des transcriptions." - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie \"{name}\" löschen möchten?", - "value": "Êtes-vous sûr de vouloir supprimer \"{name}\" ?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie Workflow \"{id}...\" löschen möchten?", - "value": "Êtes-vous sûr de vouloir supprimer le workflow \"{id}...\"?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie alle Sprach-Integrations-Einstellungen zurücksetzen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", - "value": "Êtes-vous sûr de vouloir réinitialiser tous les paramètres d'intégration vocale ? Cette action ne peut pas être annulée." - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie den Workflow \"{name}\" löschen möchten?", - "value": "Êtes-vous sûr de vouloir supprimer le workflow \"{name}\"?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie die Datei \"{name}\" löschen möchten?", - "value": "Êtes-vous sûr de vouloir supprimer le fichier \"{name}\"?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie die {count} ausgewählten Elemente löschen möchten?", - "value": "Êtes-vous sûr de vouloir supprimer les {count} éléments sélectionnés ?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie die {service} Verbindung löschen möchten?", - "value": "Êtes-vous sûr de vouloir supprimer la connexion {service} ?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie diesen Benutzer löschen möchten?", - "value": "Êtes-vous sûr de vouloir supprimer cet utilisateur ?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie {count} Benutzer löschen möchten?", - "value": "Êtes-vous sûr de vouloir supprimer {count} utilisateurs ?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie {count} Prompts löschen möchten?", - "value": "Êtes-vous sûr de vouloir supprimer {count} prompts ?" - }, - { - "context": "ui", - "key": "Sind Sie sicher, dass Sie {count} Verbindungen löschen möchten?", - "value": "Êtes-vous sûr de vouloir supprimer {count} connexions ?" - }, - { - "context": "ui", - "key": "Sites entdecken", - "value": "Découvrir les sites" - }, - { - "context": "ui", - "key": "Speech Analytics (SA)", - "value": "Analyse Vocale (SA)" - }, - { - "context": "ui", - "key": "Speichern", - "value": "Enregistrer" - }, - { - "context": "ui", - "key": "Speichern...", - "value": "Sauvegarde..." - }, - { - "context": "ui", - "key": "Spitch prüft vor jedem Anruf die Mandantenberechtigung bei PowerOn, während alle Datenänderungen zentral von PowerOn initiiert werden. Call-Transkripte werden in Echtzeit in Ihrer PowerOn-Datenbank gespeichert, mit vollständiger Mandantenisolation und Sicherheit. Bei Ausfällen werden Anrufe automatisch blockiert, um die Integrität zu gewährleisten.", - "value": "Spitch vérifie l'autorisation client avec PowerOn avant chaque appel, tandis que tous les changements de données sont initiés centralement par PowerOn. Les transcriptions d'appels sont stockées en temps réel dans votre base de données PowerOn avec une isolation complète du client et la sécurité. En cas de panne, les appels sont automatiquement bloqués pour assurer l'intégrité." - }, - { - "context": "ui", - "key": "Sprach Integration", - "value": "Intégration Vocale" - }, - { - "context": "ui", - "key": "Sprach-Einstellungen", - "value": "Paramètres Vocaux" - }, - { - "context": "ui", - "key": "Sprach-Integration Einstellungen", - "value": "Paramètres d'Intégration Vocale" - }, - { - "context": "ui", - "key": "Sprache", - "value": "Langue" - }, - { - "context": "ui", - "key": "Sprachset {code} wirklich löschen?", - "value": "Supprimer vraiment le jeu de langue {code} ?" - }, - { - "context": "ui", - "key": "Stadt", - "value": "Ville" - }, - { - "context": "ui", - "key": "Stadt ist erforderlich", - "value": "La ville est requise" - }, - { - "context": "ui", - "key": "Start", - "value": "Démarrage" - }, - { - "context": "ui", - "key": "Startzeit", - "value": "Heure de Début" - }, - { - "context": "ui", - "key": "Status", - "value": "Statut" - }, - { - "context": "ui", - "key": "Stellen Sie alles, was Ihre Agenten benötigen, in ihren Händen bereit, mit einem einheitlichen Agent-Desktop.", - "value": "Mettez tout ce dont vos agents ont besoin à portée de main, avec un bureau d'agent unifié." - }, - { - "context": "ui", - "key": "Stoppen", - "value": "Arrêter" - }, - { - "context": "ui", - "key": "Straße", - "value": "Rue" - }, - { - "context": "ui", - "key": "Straße ist erforderlich", - "value": "La rue est requise" - }, - { - "context": "ui", - "key": "Suchen Sie nach Standorten über Adresse oder Koordinaten, oder verwenden Sie natürliche Sprache, um Projekte zu erstellen und zu verwalten.", - "value": "Recherchez des emplacements par adresse ou coordonnées, ou utilisez le langage naturel pour créer et gérer des projets." - }, - { - "context": "ui", - "key": "Suchen...", - "value": "Rechercher..." - }, - { - "context": "ui", - "key": "Systemadministrator", - "value": "Administrateur système" - }, - { - "context": "ui", - "key": "Systemeinstellungen - Arbeitsbereich-Einstellungen konfigurieren", - "value": "Paramètres Système - Configurer les paramètres de l'espace de travail" - }, - { - "context": "ui", - "key": "Tabelle", - "value": "Feuille de calcul" - }, - { - "context": "ui", - "key": "Tags", - "value": "Étiquettes" - }, - { - "context": "ui", - "key": "Team-Bereich", - "value": "Espace équipe" - }, - { - "context": "ui", - "key": "Team-Mitglied erfolgreich erstellt", - "value": "Membre de l'équipe créé avec succès" - }, - { - "context": "ui", - "key": "Team-Mitglieder", - "value": "Membres de l'équipe" - }, - { - "context": "ui", - "key": "Team-Mitglieder verwalten", - "value": "Gérer les membres de votre équipe" - }, - { - "context": "ui", - "key": "Team-Mitglieder verwalten, Berechtigungen festlegen und Zusammenarbeitseinstellungen konfigurieren", - "value": "Gérer les membres de l'équipe, définir les permissions et configurer les paramètres de collaboration" - }, - { - "context": "ui", - "key": "Teilen", - "value": "Partager" - }, - { - "context": "ui", - "key": "Telefon", - "value": "Téléphone" - }, - { - "context": "ui", - "key": "Telefonnummer", - "value": "Numéro de Téléphone" - }, - { - "context": "ui", - "key": "Telefonnummer ist erforderlich", - "value": "Le numéro de téléphone est requis" - }, - { - "context": "ui", - "key": "Text", - "value": "Texte" - }, - { - "context": "ui", - "key": "Textvorschau", - "value": "Aperçu du texte" - }, - { - "context": "ui", - "key": "Theme", - "value": "Thème" - }, - { - "context": "ui", - "key": "Token", - "value": "Jetons" - }, - { - "context": "ui", - "key": "Transkript", - "value": "Transcription" - }, - { - "context": "ui", - "key": "Transkript wird verarbeitet...", - "value": "Traitement de la transcription..." - }, - { - "context": "ui", - "key": "Transkriptverwaltung", - "value": "Gestion des Transcriptions" - }, - { - "context": "ui", - "key": "Trennungsfehler", - "value": "Erreur de déconnexion" - }, - { - "context": "ui", - "key": "Treuhand", - "value": "Fiduciaire" - }, - { - "context": "ui", - "key": "Treuhandverwaltung", - "value": "Gestion Fiduciaire" - }, - { - "context": "ui", - "key": "Trustee-Organisationen verwalten", - "value": "Gérer les organisations fiduciaires" - }, - { - "context": "ui", - "key": "Trustee-Rollen verwalten", - "value": "Gérer les rôles fiduciaires" - }, - { - "context": "ui", - "key": "Typ", - "value": "Type" - }, - { - "context": "ui", - "key": "UI-Sprachen", - "value": "Langues de l’UI" - }, - { - "context": "ui", - "key": "Unbekannt", - "value": "Inconnu" - }, - { - "context": "ui", - "key": "Unbekannte Größe", - "value": "Taille inconnue" - }, - { - "context": "ui", - "key": "Unbekanntes Datum", - "value": "Date inconnue" - }, - { - "context": "ui", - "key": "Unbenannt", - "value": "Sans nom" - }, - { - "context": "ui", - "key": "Unbenannter Workflow", - "value": "Workflow sans nom" - }, - { - "context": "ui", - "key": "Ungültiges Datum", - "value": "Date invalide" - }, - { - "context": "ui", - "key": "Unser Team wird Ihr Mandat innerhalb von 1-2 Werktagen überprüfen.", - "value": "Notre équipe examinera votre mandat dans les 1-2 jours ouvrables." - }, - { - "context": "ui", - "key": "Unsere bereits aktive Dokumenten-Extraktions-Engine generiert automatisch personalisierte Dokumente für Spitch, basierend auf Mandantenspezifischen Daten. Die KI nutzt FAQ-Datenbanken, Mitarbeiterinformationen und Service-Details, um jeden Anruf kontextuell und hochpersonalisiert zu gestalten.", - "value": "Notre moteur d'extraction de documents déjà actif génère automatiquement des documents personnalisés pour Spitch basés sur les données spécifiques au client. L'IA utilise les bases de données FAQ, les informations employés et les détails de service pour rendre chaque appel contextuel et hautement personnalisé." - }, - { - "context": "ui", - "key": "Unternehmensinformationen", - "value": "Informations de l'Entreprise" - }, - { - "context": "ui", - "key": "Unterstützt von", - "value": "Alimenté par" - }, - { - "context": "ui", - "key": "Upload fehlgeschlagen. Bitte versuchen Sie es erneut.", - "value": "Échec du téléchargement. Veuillez réessayer." - }, - { - "context": "ui", - "key": "VERARBEITUNG", - "value": "TRAITEMENT" - }, - { - "context": "ui", - "key": "Valutadatum", - "value": "Date de valeur" - }, - { - "context": "ui", - "key": "Verarbeitung", - "value": "En cours" - }, - { - "context": "ui", - "key": "Verbinden", - "value": "Connecter" - }, - { - "context": "ui", - "key": "Verbindung aktualisieren", - "value": "Mettre à jour la connexion" - }, - { - "context": "ui", - "key": "Verbindung testen", - "value": "Tester la connexion" - }, - { - "context": "ui", - "key": "Verbindungen", - "value": "Connexions" - }, - { - "context": "ui", - "key": "Verbindungen werden geladen...", - "value": "Chargement des connexions..." - }, - { - "context": "ui", - "key": "Verbindungsfehler", - "value": "Erreur de connexion" - }, - { - "context": "ui", - "key": "Verbunden am", - "value": "Connecté le" - }, - { - "context": "ui", - "key": "Vereinheitlichen und liefern Sie Informationen an Ihre Kunden und Mitarbeiter, wann und wo sie sie benötigen.", - "value": "Unifiez et livrez des informations à vos clients et employés où et quand ils en ont besoin." - }, - { - "context": "ui", - "key": "Verfügbare Tools", - "value": "Outils Disponibles" - }, - { - "context": "ui", - "key": "Verfügbare Workflows", - "value": "Workflows disponibles" - }, - { - "context": "ui", - "key": "Version", - "value": "Version" - }, - { - "context": "ui", - "key": "Versuchen Sie, Ihr Microsoft-Konto auf der Verbindungsseite erneut zu verbinden.", - "value": "Essayez de reconnecter votre compte Microsoft dans la page Connexions." - }, - { - "context": "ui", - "key": "Vertrag", - "value": "Contrat" - }, - { - "context": "ui", - "key": "Vertrag (optional)", - "value": "Contrat (optionnel)" - }, - { - "context": "ui", - "key": "Vertrag erfolgreich erstellt", - "value": "Contrat créé avec succès" - }, - { - "context": "ui", - "key": "Verträge", - "value": "Contrats" - }, - { - "context": "ui", - "key": "Verwalten Sie Daten über Tabellen. Wählen Sie eine Tabelle aus oder verwenden Sie natürliche Sprache, um Befehle auszuführen.", - "value": "Gérez les données via des tableaux. Sélectionnez un tableau ou utilisez le langage naturel pour exécuter des commandes." - }, - { - "context": "ui", - "key": "Verwalten Sie Ihre Kontoinformationen", - "value": "Gérez vos informations de compte" - }, - { - "context": "ui", - "key": "Verwalten Sie Ihre Service-Verbindungen", - "value": "Gérez vos connexions de service" - }, - { - "context": "ui", - "key": "Verwalten Sie Ihre Sprach-Integrations-Konfiguration und Einstellungen.", - "value": "Gérez votre configuration et vos préférences d'intégration vocale." - }, - { - "context": "ui", - "key": "Verwalten Sie Mandate und deren zugehörige Berechtigungen.", - "value": "Gérez les mandats et leurs permissions associées." - }, - { - "context": "ui", - "key": "Verwaltet von {provider}", - "value": "Géré par {provider}" - }, - { - "context": "ui", - "key": "Verwaltung der Benutzerzugriffe auf Organisationen", - "value": "Gestion des accès utilisateurs aux organisations" - }, - { - "context": "ui", - "key": "Verwaltung der Buchungspositionen (Speseneinträge)", - "value": "Gestion des positions de réservation (entrées de dépenses)" - }, - { - "context": "ui", - "key": "Verwaltung der Dokumente und Belege", - "value": "Gestion des documents et pièces justificatives" - }, - { - "context": "ui", - "key": "Verwaltung der Feature-spezifischen Rollen", - "value": "Gestion des rôles spécifiques à la fonctionnalité" - }, - { - "context": "ui", - "key": "Verwaltung der Kundenverträge", - "value": "Gestion des contrats clients" - }, - { - "context": "ui", - "key": "Verwaltung der Treuhand-Organisationen", - "value": "Gestion des organisations fiduciaires" - }, - { - "context": "ui", - "key": "Verwaltung von Treuhand-Organisationen, Verträgen und Buchungen", - "value": "Gestion des organisations fiduciaires, contrats et réservations" - }, - { - "context": "ui", - "key": "Verwaltungs- und Management-Tools", - "value": "Outils d'administration et de gestion" - }, - { - "context": "ui", - "key": "Verwende Vorlage:", - "value": "Utilisation du modèle:" - }, - { - "context": "ui", - "key": "Video", - "value": "Vidéo" - }, - { - "context": "ui", - "key": "Vielen Dank für Ihr Interesse an unserer Sprach Integration powered by Spitch.ai. Wir haben Ihr Mandat erhalten und werden es in Kürze überprüfen.", - "value": "Merci pour votre intérêt pour notre Intégration Vocale powered by Spitch.ai. Nous avons reçu votre mandat et l'examinerons sous peu." - }, - { - "context": "ui", - "key": "Virtual Assistant (VA)", - "value": "Assistant Virtuel (VA)" - }, - { - "context": "ui", - "key": "Voice Biometrics (VB)", - "value": "Biométrie Vocale (VB)" - }, - { - "context": "ui", - "key": "Vollständiger Name", - "value": "Nom complet" - }, - { - "context": "ui", - "key": "Von der Registrierung bis zur technischen Einrichtung - Ihr Mandant registriert sich bei PowerOn für Telefonie-Services, lädt Dokumente hoch und erhält automatisch eine technische SIP-Nummer von Spitch. Die Call-Weiterleitung kann jederzeit aktiviert oder deaktiviert werden, was maximale Flexibilität und BCM-Sicherheit gewährleistet.", - "value": "De l'inscription à la configuration technique - votre client s'inscrit auprès de PowerOn pour les services téléphoniques, télécharge des documents et reçoit automatiquement un numéro SIP technique de Spitch. Le transfert d'appel peut être activé ou désactivé à tout moment, garantissant une flexibilité maximale et la sécurité BCM." - }, - { - "context": "ui", - "key": "Vorherige Seite", - "value": "Page précédente" - }, - { - "context": "ui", - "key": "Vorschau", - "value": "Aperçu" - }, - { - "context": "ui", - "key": "Vorschau für diesen Dateityp nicht verfügbar", - "value": "Aperçu non disponible pour ce type de fichier" - }, - { - "context": "ui", - "key": "Vorschau schließen", - "value": "Fermer l'aperçu" - }, - { - "context": "ui", - "key": "Vorschau wird geladen...", - "value": "Chargement de l'aperçu..." - }, - { - "context": "ui", - "key": "WARTEND", - "value": "EN ATTENTE" - }, - { - "context": "ui", - "key": "Wartend", - "value": "En attente" - }, - { - "context": "ui", - "key": "Was passiert als nächstes?", - "value": "Que se passe-t-il ensuite ?" - }, - { - "context": "ui", - "key": "Wechseln Sie zwischen hellem und dunklem Modus", - "value": "Basculer entre le mode clair et sombre" - }, - { - "context": "ui", - "key": "Werkzeuge", - "value": "Outils" - }, - { - "context": "ui", - "key": "Werkzeuge und Hilfsmittel", - "value": "Outils et utilitaires" - }, - { - "context": "ui", - "key": "Wie möchten Sie am Telefon genannt werden?", - "value": "Comment souhaitez-vous être appelé au téléphone ?" - }, - { - "context": "ui", - "key": "Wiederholen", - "value": "Réessayer" - }, - { - "context": "ui", - "key": "Willkommen in Ihrem Arbeitsbereich", - "value": "Bienvenue dans votre espace de travail" - }, - { - "context": "ui", - "key": "Wird gesendet...", - "value": "Envoi..." - }, - { - "context": "ui", - "key": "Wird gestoppt...", - "value": "Arrêt..." - }, - { - "context": "ui", - "key": "Wird geteilt...", - "value": "Partage en cours..." - }, - { - "context": "ui", - "key": "Wird hochgeladen...", - "value": "Téléchargement..." - }, - { - "context": "ui", - "key": "Wird verarbeitet...", - "value": "Traitement..." - }, - { - "context": "ui", - "key": "Workflow", - "value": "Workflow" - }, - { - "context": "ui", - "key": "Workflow Fortschritt", - "value": "Progression du workflow" - }, - { - "context": "ui", - "key": "Workflow auswählen", - "value": "Sélectionner un workflow" - }, - { - "context": "ui", - "key": "Workflow fehlgeschlagen.", - "value": "Échec du workflow." - }, - { - "context": "ui", - "key": "Workflow fortsetzen", - "value": "Reprendre le workflow" - }, - { - "context": "ui", - "key": "Workflow läuft... Warte auf Logs...", - "value": "Workflow en cours... En attente des logs..." - }, - { - "context": "ui", - "key": "Workflow löschen", - "value": "Supprimer le workflow" - }, - { - "context": "ui", - "key": "Workflow stoppen", - "value": "Arrêter le workflow" - }, - { - "context": "ui", - "key": "Workflow wird fortgesetzt", - "value": "Workflow en cours" - }, - { - "context": "ui", - "key": "Workflow wird gelöscht...", - "value": "Suppression du workflow..." - }, - { - "context": "ui", - "key": "Workflow-Automatisierungen verwalten", - "value": "Gérer les automatisations de workflow" - }, - { - "context": "ui", - "key": "Workflow-Nachrichten werden geladen...", - "value": "Chargement des messages de workflow..." - }, - { - "context": "ui", - "key": "Workflow-Verlauf", - "value": "Historique des workflows" - }, - { - "context": "ui", - "key": "Workflows", - "value": "Workflows" - }, - { - "context": "ui", - "key": "Workflows werden geladen...", - "value": "Chargement des workflows..." - }, - { - "context": "ui", - "key": "Wähle einen Workflow aus der Liste aus oder starte einen neuen Workflow", - "value": "Sélectionnez un workflow dans la liste ou démarrez un nouveau workflow" - }, - { - "context": "ui", - "key": "Wählen Sie Ihre bevorzugte Sprache", - "value": "Choisissez votre langue préférée" - }, - { - "context": "ui", - "key": "You", - "value": "Vous" - }, - { - "context": "ui", - "key": "Zeitzone", - "value": "Fuseau Horaire" - }, - { - "context": "ui", - "key": "Zentrale", - "value": "Centre d'activité" - }, - { - "context": "ui", - "key": "Zu dunklem Modus wechseln", - "value": "Passer en mode sombre" - }, - { - "context": "ui", - "key": "Zu hellem Modus wechseln", - "value": "Passer en mode clair" - }, - { - "context": "ui", - "key": "Zugriff", - "value": "Accès" - }, - { - "context": "ui", - "key": "Zugriff erfolgreich erstellt", - "value": "Accès créé avec succès" - }, - { - "context": "ui", - "key": "Zugriff verweigert", - "value": "Accès Refusé" - }, - { - "context": "ui", - "key": "Zuletzt geprüft", - "value": "Dernière vérification" - }, - { - "context": "ui", - "key": "Zum Bestätigen klicken", - "value": "Cliquez pour confirmer" - }, - { - "context": "ui", - "key": "Zum Bestätigen klicken...", - "value": "Cliquez pour confirmer..." - }, - { - "context": "ui", - "key": "Zurück zur Sprach Integration", - "value": "Retour à l'Intégration Vocale" - }, - { - "context": "ui", - "key": "angehängt", - "value": "attaché" - }, - { - "context": "ui", - "key": "ausgewählt", - "value": "sélectionné(s)" - }, - { - "context": "ui", - "key": "kontakt@firma.com", - "value": "contact@entreprise.com" - }, - { - "context": "ui", - "key": "oder", - "value": "ou" - }, - { - "context": "ui", - "key": "z.B. Beleg.pdf", - "value": "ex. Justificatif.pdf" - }, - { - "context": "ui", - "key": "z.B. Finanzdienstleistungen, Technologie, etc.", - "value": "ex. Services Financiers, Technologie, etc." - }, - { - "context": "ui", - "key": "z.B. Muster AG 2026", - "value": "ex. Muster AG 2026" - }, - { - "context": "ui", - "key": "z.B. Treuhand AG Zürich", - "value": "ex. Fiduciaire AG Zurich" - }, - { - "context": "ui", - "key": "z.B. admin, operate, userreport", - "value": "ex. admin, operate, userreport" - }, - { - "context": "ui", - "key": "z.B. treuhand-ag-zuerich", - "value": "ex. fiduciaire-ag-zurich" - }, - { - "context": "ui", - "key": "{authority} Verbindung bearbeiten", - "value": "Modifier la connexion {authority}" - }, - { - "context": "ui", - "key": "{column} filtern", - "value": "Filtrer {column}" - }, - { - "context": "ui", - "key": "{count} Benutzer ausgewählt", - "value": "{count} utilisateurs sélectionnés" - }, - { - "context": "ui", - "key": "Änderungen speichern", - "value": "Sauvegarder les Modifications" - }, - { - "context": "ui", - "key": "Über", - "value": "À propos" - }, - { - "context": "ui", - "key": "Überprüfungsprozess", - "value": "Processus de Révision" - }, - { - "context": "ui", - "key": "Übersicht - Sehen Sie den Arbeitsbereich-Status und Updates", - "value": "Aperçu - Consultez le statut et les mises à jour de l'espace de travail" - }, - { - "context": "ui", - "key": "Überwachen Sie automatisch 100% der Gespräche, um wertvolle Einblicke für Ihr Unternehmen zu erhalten.", - "value": "Surveillez automatiquement 100% des conversations pour obtenir des insights précieux pour votre entreprise." - }, - { - "context": "ui", - "key": "(gefiltert nach {name})", - "value": "(filtré par {name})" - }, - { - "context": "ui", - "key": "({count} gefiltert)", - "value": "({count} filtrés)" - }, - { - "context": "ui", - "key": "Abonnement, Einstellungen und Guthaben pro Mandant", - "value": "Abonnement, paramètres et crédits par client" - }, - { - "context": "ui", - "key": "Abrechnung", - "value": "Facturation" - }, - { - "context": "ui", - "key": "Aktion", - "value": "Action" - }, - { - "context": "ui", - "key": "Benutzer-Billing", - "value": "Facturation utilisateurs" - }, - { - "context": "ui", - "key": "Benutzer-Guthaben", - "value": "Crédits utilisateur" - }, - { - "context": "ui", - "key": "Benutzer:", - "value": "Utilisateur :" - }, - { - "context": "ui", - "key": "Deaktiviert", - "value": "Désactivé" - }, - { - "context": "ui", - "key": "Du hast Zugriff auf {instanceCount} {instanceWord} in {mandateCount} {mandateWord}.", - "value": "Vous avez accès à {instanceCount} {instanceWord} sur {mandateCount} {mandateWord}." - }, - { - "context": "ui", - "key": "Einstellungen gespeichert!", - "value": "Paramètres enregistrés !" - }, - { - "context": "ui", - "key": "Feature-Instanz", - "value": "instance de fonctionnalité" - }, - { - "context": "ui", - "key": "Feature-Instanzen", - "value": "instances de fonctionnalité" - }, - { - "context": "ui", - "key": "Fehler beim Speichern", - "value": "Erreur lors de l'enregistrement" - }, - { - "context": "ui", - "key": "Gesamtguthaben", - "value": "Crédit total" - }, - { - "context": "ui", - "key": "Mandant:", - "value": "Client :" - }, - { - "context": "ui", - "key": "Mandanten", - "value": "Clients" - }, - { - "context": "ui", - "key": "Mandanten-Billing", - "value": "Facturation clients" - }, - { - "context": "ui", - "key": "Mandanten-Guthaben", - "value": "Crédits clients" - }, - { - "context": "ui", - "key": "Mandant", - "value": "Client" - }, - { - "context": "ui", - "key": "Niedrig", - "value": "Faible" - }, - { - "context": "ui", - "key": "Transaktionen", - "value": "Transactions" - }, - { - "context": "ui", - "key": "Warnschwelle", - "value": "Seuil d'alerte" - }, - { - "context": "ui", - "key": "✓ Mandat eingereicht", - "value": "✓ Mandat Soumis" - } - ], - "status": "complete", - "isDefault": false - } -] diff --git a/modules/migrations/_archive/README.md b/modules/migrations/_archive/README.md deleted file mode 100644 index c488801a..00000000 --- a/modules/migrations/_archive/README.md +++ /dev/null @@ -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 -``` diff --git a/modules/migrations/_archive/__init__.py b/modules/migrations/_archive/__init__.py deleted file mode 100644 index a733bae9..00000000 --- a/modules/migrations/_archive/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# Subpackage for archived one-off migration scripts (not part of normal app startup). diff --git a/modules/migrations/_archive/migrate_folders_to_groups.py b/modules/migrations/_archive/migrate_folders_to_groups.py deleted file mode 100644 index 6beed744..00000000 --- a/modules/migrations/_archive/migrate_folders_to_groups.py +++ /dev/null @@ -1,261 +0,0 @@ -""" -One-time migration: Convert FileFolder tree + FileItem.folderId to table_groupings. - -Archived per wiki plan 2026-05-formgenerator-tree-and-folder-recovery (Stage 1.A). -Product direction: keep FileFolder + folderId; do not run DROP migrations. -This script remains for audit / one-off data rescue only. - -Run this BEFORE dropping the physical FileFolder table and FileItem.folderId column -from the database (those would be separate Alembic/SQL steps -- not part of current product path). - -Usage (from gateway working directory): - python -m modules.migrations._archive.migrate_folders_to_groups [--dry-run] [--verbose] - python -m modules.migrations._archive.migrate_folders_to_groups --execute --verbose - -Steps: - 1. For each distinct (userId, mandateId) combination that has FileFolder records: - a. Build the full folder tree (recursive) - b. Write it as a TableGroupNode tree into table_groupings (contextKey='files/list') - – merges with any existing groups rather than overwriting - c. For each FileItem with a folderId that maps into this tree, - add its id to the matching group's itemIds - 2. Print a summary (rows migrated, groups created, files assigned) - 3. If not --dry-run: commits the inserts/updates - NOTE: Schema changes (ALTER TABLE DROP COLUMN, DROP TABLE) are intentionally - NOT performed by this script. Run the corresponding Alembic migration - (migrations/versions/xxxx_drop_folder_columns.py) afterwards. -""" - -import argparse -import json -import logging -import uuid -from typing import Optional - -logger = logging.getLogger(__name__) - - -def _scalarRow(row): - if row is None: - return None - if isinstance(row, dict): - return next(iter(row.values())) - return row[0] - - -# ── Helpers ────────────────────────────────────────────────────────────────── - -def _build_tree(folders: list, parent_id: Optional[str]) -> list: - """Recursively build TableGroupNode-compatible dicts from a flat folder list.""" - children = [f for f in folders if f.get("parentId") == parent_id] - result = [] - for folder in children: - node = { - "id": str(uuid.uuid4()), - "name": folder["name"], - "itemIds": [], - "subGroups": _build_tree(folders, folder["id"]), - "meta": {"migratedFromFolderId": folder["id"]}, - } - result.append(node) - return result - - -def _assign_files_to_nodes(nodes: list, files_by_folder: dict) -> list: - """Recursively assign file IDs to group nodes based on folder mapping.""" - for node in nodes: - folder_id = (node.get("meta") or {}).get("migratedFromFolderId") - if folder_id and folder_id in files_by_folder: - node["itemIds"] = list(files_by_folder[folder_id]) - node["subGroups"] = _assign_files_to_nodes(node.get("subGroups", []), files_by_folder) - return nodes - - -def _count_items(nodes: list) -> int: - total = 0 - for node in nodes: - total += len(node.get("itemIds", [])) - total += _count_items(node.get("subGroups", [])) - return total - - -def _now_ts() -> str: - from modules.shared.timeUtils import getUtcTimestamp - return getUtcTimestamp() - - -# ── Main migration ──────────────────────────────────────────────────────────── - -def run_migration(dry_run: bool = True, verbose: bool = False): - """Main migration entry point.""" - logging.basicConfig(level=logging.DEBUG if verbose else logging.INFO) - logger.info(f"Starting folder to group migration (dry_run={dry_run})") - - from modules.connectors.connectorDbPostgre import getCachedConnector - from modules.shared.configuration import APP_CONFIG - - connector = getCachedConnector( - dbHost=APP_CONFIG.get("DB_HOST", "_no_config_default_data"), - dbDatabase="poweron_management", - dbUser=APP_CONFIG.get("DB_USER"), - dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"), - dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), - userId=None, - ) - if not connector or not connector.connection: - logger.error("Could not obtain a DB connection. Aborting.") - return - - conn = connector.connection - cur = conn.cursor() - - # ── 1. Check that the source tables still exist ─────────────────────────── - cur.execute(""" - SELECT EXISTS ( - SELECT 1 FROM information_schema.tables - WHERE table_name = 'FileFolder' - ) AS ok - """) - folder_table_exists = bool(_scalarRow(cur.fetchone())) - - cur.execute(""" - SELECT EXISTS ( - SELECT 1 FROM information_schema.columns - WHERE table_name = 'FileItem' AND column_name = 'folderId' - ) AS ok - """) - folder_column_exists = bool(_scalarRow(cur.fetchone())) - - if not folder_table_exists and not folder_column_exists: - logger.info("FileFolder table and FileItem.folderId column not found — migration already applied or not needed.") - return - - if not folder_table_exists: - logger.warning("FileFolder table missing but FileItem.folderId column still present. Only file assignments will be migrated.") - if not folder_column_exists: - logger.warning("FileItem.folderId column missing but FileFolder table still present. Only group tree structure will be migrated.") - - # ── 2. Load all folders ─────────────────────────────────────────────────── - folders_by_user: dict = {} - if folder_table_exists: - cur.execute('SELECT "id", "name", "parentId", "sysCreatedBy", "mandateId" FROM "FileFolder"') - for row in cur.fetchall(): - fid, fname, parent_id, user_id, mandate_id = row - key = (str(user_id), str(mandate_id) if mandate_id else "") - folders_by_user.setdefault(key, []).append({ - "id": fid, "name": fname, "parentId": parent_id, - }) - logger.info(f"Loaded folders for {len(folders_by_user)} (user, mandate) combinations") - - # ── 3. Load file to folder assignments ──────────────────────────────────── - files_by_key: dict = {} - if folder_column_exists: - cur.execute( - 'SELECT "id", "folderId", "sysCreatedBy", "mandateId" FROM "FileItem" WHERE "folderId" IS NOT NULL AND "folderId" != \'\'' - ) - for row in cur.fetchall(): - file_id, folder_id, user_id, mandate_id = row - key = (str(user_id), str(mandate_id) if mandate_id else "") - files_by_key.setdefault(key, {}).setdefault(folder_id, []).append(file_id) - total_files = sum( - sum(len(v) for v in d.values()) for d in files_by_key.values() - ) - logger.info(f"Found {total_files} file to folder assignments across {len(files_by_key)} (user, mandate) combos") - - # ── 4. Combine and upsert groupings ────────────────────────────────────── - all_keys = set(folders_by_user.keys()) | set(files_by_key.keys()) - stats = {"groups_created": 0, "groupings_upserted": 0, "files_assigned": 0} - - for key in all_keys: - user_id, mandate_id = key - folders = folders_by_user.get(key, []) - files_by_folder = files_by_key.get(key, {}) - - # Build tree - roots = _build_tree(folders, None) - roots = _assign_files_to_nodes(roots, files_by_folder) - - # Handle files in unknown folders (folder no longer in tree) - known_folder_ids = {f["id"] for f in folders} - for folder_id, file_ids in files_by_folder.items(): - if folder_id not in known_folder_ids: - # Orphaned files: put them in an "Orphaned" group - roots.append({ - "id": str(uuid.uuid4()), - "name": f"Orphaned (folder {folder_id[:8]}…)", - "itemIds": file_ids, - "subGroups": [], - "meta": {"migratedFromFolderId": folder_id, "orphaned": True}, - }) - - if not roots: - continue - - n_items = _count_items(roots) - stats["groups_created"] += len(roots) - stats["files_assigned"] += n_items - - context_key = "files/list" - if verbose: - logger.debug(f" user={user_id} mandate={mandate_id}: {len(roots)} root groups, {n_items} files") - - if not dry_run: - # Check for existing grouping - cur.execute( - 'SELECT "id", "rootGroups" FROM "TableGrouping" WHERE "userId" = %s AND "contextKey" = %s', - (user_id, context_key), - ) - existing_row = cur.fetchone() - - if existing_row: - existing_id, existing_raw = existing_row - existing_roots = json.loads(existing_raw) if isinstance(existing_raw, str) else (existing_raw or []) - # Merge: append migrated groups (avoid duplicates by migratedFromFolderId) - existing_meta_ids = { - (n.get("meta") or {}).get("migratedFromFolderId") - for n in existing_roots - if (n.get("meta") or {}).get("migratedFromFolderId") - } - new_roots = existing_roots + [ - r for r in roots - if (r.get("meta") or {}).get("migratedFromFolderId") not in existing_meta_ids - ] - cur.execute( - 'UPDATE "TableGrouping" SET "rootGroups" = %s, "updatedAt" = %s WHERE "id" = %s', - (json.dumps(new_roots), _now_ts(), existing_id), - ) - else: - new_id = str(uuid.uuid4()) - cur.execute( - 'INSERT INTO "TableGrouping" ("id", "userId", "contextKey", "rootGroups", "updatedAt") VALUES (%s, %s, %s, %s, %s)', - (new_id, user_id, context_key, json.dumps(roots), _now_ts()), - ) - stats["groupings_upserted"] += 1 - - # ── 5. Summary ──────────────────────────────────────────────────────────── - if not dry_run: - conn.commit() - logger.info("Migration committed.") - else: - logger.info("DRY RUN — no changes written.") - - logger.info( - f"Summary: groupings_upserted={stats['groupings_upserted']}, " - f"groups_created={stats['groups_created']}, " - f"files_assigned={stats['files_assigned']}" - ) - logger.info( - "Next steps (run after verifying data):\n" - " 1. Run Alembic migration to DROP COLUMN FileItem.folderId\n" - " 2. Run Alembic migration to DROP TABLE FileFolder" - ) - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Migrate FileFolder tree to table_groupings (archived script)") - parser.add_argument("--dry-run", action="store_true", default=True, help="Preview only, no DB writes (default)") - parser.add_argument("--execute", action="store_true", help="Actually write to DB (disables dry-run)") - parser.add_argument("--verbose", action="store_true", help="Show per-user details") - args = parser.parse_args() - dry_run = not args.execute - run_migration(dry_run=dry_run, verbose=args.verbose) diff --git a/modules/routes/routeAdminDatabaseHealth.py b/modules/routes/routeAdminDatabaseHealth.py index c547cc76..f2f866d9 100644 --- a/modules/routes/routeAdminDatabaseHealth.py +++ b/modules/routes/routeAdminDatabaseHealth.py @@ -8,6 +8,10 @@ and database migration (backup / restore). import json import logging import os +import tempfile +import threading +import uuid +from datetime import datetime, timezone from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status @@ -337,8 +341,6 @@ def getMigrationExport( detail=f"Export failed: {e}", ) from e - from datetime import datetime, timezone - ts = datetime.now(timezone.utc).strftime("%Y-%m-%d_%H-%M") filename = f"migration_backup_{ts}.json" @@ -452,7 +454,7 @@ def getMigrationExportSingle( ) -> Dict[str, Any]: """Export a single database. Returns full payload so the frontend can assemble the final JSON client-side (no server-side state needed).""" - from modules.shared.dbRegistry import getRegisteredDatabases + from modules.dbHelpers.dbRegistry import getRegisteredDatabases if database not in getRegisteredDatabases(): raise HTTPException( @@ -494,8 +496,7 @@ def getMigrationExportStream( Uses server-side cursors and row-by-row serialization so that neither backend memory nor browser JS heap is exhausted — works for any DB size. """ - from datetime import datetime, timezone - from modules.shared.dbRegistry import getRegisteredDatabases + from modules.dbHelpers.dbRegistry import getRegisteredDatabases registeredDbs = getRegisteredDatabases() @@ -542,10 +543,6 @@ async def postMigrationUploadImport( """Upload a backup file to disk (chunked). Returns a token that the frontend passes to ``/process-import-stream`` for streaming validation. """ - import os - import tempfile - import uuid - token = str(uuid.uuid4()) tmpDir = tempfile.gettempdir() filePath = os.path.join(tmpDir, f"poweron_import_{token}.json") @@ -577,7 +574,6 @@ async def postMigrationUploadImport( def _tokenMetaPath(token: str, kind: str) -> str: - import tempfile return os.path.join(tempfile.gettempdir(), f"poweron_{kind}_{token}.meta.json") @@ -616,9 +612,7 @@ def getProcessImportStream( - ``{"phase":"done","result":{valid, databases, warnings, ...}}`` - ``{"phase":"error","detail":"..."}`` """ - import os import queue - import threading pending = _readTokenMeta(token, "processing", pop=True) if not pending: @@ -717,8 +711,6 @@ def postMigrationImportSingle( Body: ``{token, database, mode}`` """ - import os - token = body.get("token", "") database = body.get("database", "") mode = body.get("mode", "merge") @@ -768,8 +760,6 @@ def postMigrationImportDone( currentUser: User = Depends(requireSysAdmin), ) -> Dict[str, Any]: """Clean up the per-DB / per-table temp files.""" - import os - token = body.get("token", "") pending = _readTokenMeta(token, "import", pop=True) if pending: diff --git a/modules/routes/routeAdminFeatures.py b/modules/routes/routeAdminFeatures.py index 2b1a928e..b3072edc 100644 --- a/modules/routes/routeAdminFeatures.py +++ b/modules/routes/routeAdminFeatures.py @@ -18,7 +18,7 @@ import json import math from pydantic import BaseModel, Field from modules.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict -from modules.routes.routeHelpers import applyFiltersAndSort, handleFilterValuesInMemory, handleIdsInMemory +from modules.dbHelpers.paginationHelpers import applyFiltersAndSort, handleFilterValuesInMemory, handleIdsInMemory from modules.auth import limiter, getRequestContext, RequestContext, requirePlatformAdmin from modules.datamodels.datamodelUam import User, UserInDB @@ -475,9 +475,9 @@ def list_feature_instances( if mode == "filterValues": if not column: raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") - from modules.routes.routeHelpers import enrichRowsWithFkLabels + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels from modules.datamodels.datamodelFeatures import FeatureInstance - enrichRowsWithFkLabels(items, FeatureInstance) + enrichRowsWithFkLabels(items, FeatureInstance, db=rootInterface.db) return handleFilterValuesInMemory(items, column, pagination) if mode == "ids": @@ -933,10 +933,9 @@ def _syncInstanceWorkflows( skipped += 1 continue - import json as _json - graphJson = _json.dumps(template.get("graph", {})) + graphJson = json.dumps(template.get("graph", {})) graphJson = graphJson.replace("{{featureInstanceId}}", instanceId) - graph = _json.loads(graphJson) + graph = json.loads(graphJson) label = resolveText(template.get("label")) diff --git a/modules/routes/routeAdminRbacRules.py b/modules/routes/routeAdminRbacRules.py index 3eb45f1b..3b09d7eb 100644 --- a/modules/routes/routeAdminRbacRules.py +++ b/modules/routes/routeAdminRbacRules.py @@ -929,16 +929,17 @@ def list_roles( if mode == "filterValues": if not column: raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") - from modules.routes.routeHelpers import handleFilterValuesInMemory, enrichRowsWithFkLabels - enrichRowsWithFkLabels(result, Role) + from modules.dbHelpers.paginationHelpers import handleFilterValuesInMemory + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels + enrichRowsWithFkLabels(result, Role, db=interface.db) return handleFilterValuesInMemory(result, column, pagination) if mode == "ids": - from modules.routes.routeHelpers import handleIdsInMemory + from modules.dbHelpers.paginationHelpers import handleIdsInMemory return handleIdsInMemory(result, pagination) if paginationParams: - from modules.routes.routeHelpers import applyFiltersAndSort + from modules.dbHelpers.paginationHelpers import applyFiltersAndSort sortedResult = applyFiltersAndSort(result, paginationParams) totalItems = len(sortedResult) totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0 diff --git a/modules/routes/routeAttributes.py b/modules/routes/routeAttributes.py index 0118fecf..ae0cd6f4 100644 --- a/modules/routes/routeAttributes.py +++ b/modules/routes/routeAttributes.py @@ -9,7 +9,7 @@ from modules.auth import limiter # Import the attribute definition and helper functions from modules.shared.attributeUtils import getModelClasses, getModelAttributeDefinitions, AttributeResponse, AttributeDefinition -from modules.shared.i18nRegistry import apiRouteContext, _CURRENT_LANGUAGE +from modules.shared.i18nRegistry import apiRouteContext, getCurrentLanguage routeApiMsg = apiRouteContext("routeAttributes") @@ -51,7 +51,7 @@ def get_entity_attributes( # Get model class and derive attributes from it modelClass = modelClasses[entityType] - userLanguage = _CURRENT_LANGUAGE.get() + userLanguage = getCurrentLanguage() try: attribute_defs = getModelAttributeDefinitions(modelClass, userLanguage=userLanguage) except Exception as e: diff --git a/modules/routes/routeAudit.py b/modules/routes/routeAudit.py index d7d58728..c9888339 100644 --- a/modules/routes/routeAudit.py +++ b/modules/routes/routeAudit.py @@ -42,7 +42,7 @@ def _applySortFilterSearch( date-range filters (``between`` operator) and null/empty filters work consistently across all in-memory routes. """ - from modules.routes.routeHelpers import applyFiltersAndSort + from modules.dbHelpers.paginationHelpers import applyFiltersAndSort from modules.datamodels.datamodelPagination import PaginationParams, SortField filtersDict: Optional[Dict[str, Any]] = None @@ -112,7 +112,9 @@ def _enrichUserAndInstanceLabels( Uses the central resolvers from routeHelpers. Falls back to ``NA()`` for unresolvable entries so filter dropdowns still show an entry. """ - from modules.routes.routeHelpers import resolveUserLabels, resolveInstanceLabels + from modules.dbHelpers.fkLabelResolver import resolveUserLabels, resolveInstanceLabels + from modules.interfaces.interfaceDbApp import getRootInterface + db = getRootInterface().db userIds = list({r.get(userKey) for r in items if r.get(userKey) and not r.get(usernameKey)}) instanceIds = list({r.get(instanceKey) for r in items if r.get(instanceKey)}) @@ -121,9 +123,9 @@ def _enrichUserAndInstanceLabels( instanceMap: Dict[str, Optional[str]] = {} if userIds: - userMap = resolveUserLabels(userIds) + userMap = resolveUserLabels(db, userIds) if instanceIds: - instanceMap = resolveInstanceLabels(instanceIds) + instanceMap = resolveInstanceLabels(db, instanceIds) for r in items: uid = r.get(userKey) @@ -179,7 +181,7 @@ async def getAiAuditLog( if not mandateId: raise HTTPException(status_code=400, detail=routeApiMsg("Mandanten-ID erforderlich")) - from modules.shared.aiAuditLogger import aiAuditLogger + from modules.dbHelpers.aiAuditLogger import aiAuditLogger result = aiAuditLogger.getAiAuditLogs( mandateId, userId=userId, @@ -221,7 +223,7 @@ async def getAiAuditEntryContent( _requireAuditAccess(context) mandateId = str(context.mandateId) if context.mandateId else "" - from modules.shared.aiAuditLogger import aiAuditLogger + from modules.dbHelpers.aiAuditLogger import aiAuditLogger result = aiAuditLogger.getAiAuditEntryContent(entryId, mandateId) if not result: raise HTTPException(status_code=404, detail=routeApiMsg("Audit-Eintrag nicht gefunden")) @@ -273,7 +275,7 @@ async def getAuditLog( _requireAuditAccess(context) mandateId = str(context.mandateId) if context.mandateId else None - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger records = audit_logger.getAuditLogs( userId=userId, mandateId=mandateId, @@ -320,7 +322,7 @@ async def getAuditStats( if not mandateId: raise HTTPException(status_code=400, detail=routeApiMsg("Mandanten-ID erforderlich")) - from modules.shared.aiAuditLogger import aiAuditLogger + from modules.dbHelpers.aiAuditLogger import aiAuditLogger from modules.shared.dateRange import isoDateRangeToLocalEpoch fromTs, toTs = isoDateRangeToLocalEpoch(dateFrom, dateTo) diff --git a/modules/routes/routeAutomationWorkspace.py b/modules/routes/routeAutomationWorkspace.py index 32624363..09c5238c 100644 --- a/modules/routes/routeAutomationWorkspace.py +++ b/modules/routes/routeAutomationWorkspace.py @@ -11,6 +11,7 @@ Nutzung > Automation. import logging import math +from functools import partial from typing import Optional from fastapi import APIRouter, Depends, Request, Query, Path, HTTPException @@ -167,7 +168,7 @@ def listWorkspaceRuns( total = len(filtered) page = filtered[offset: offset + limit] - from modules.routes.routeHelpers import enrichRowsWithFkLabels, resolveMandateLabels, resolveInstanceLabels + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels, resolveMandateLabels, resolveInstanceLabels for row in page: wf = wfMap.get(row.get("workflowId"), {}) @@ -176,9 +177,10 @@ def listWorkspaceRuns( enrichRowsWithFkLabels( page, + db=db, labelResolvers={ - "mandateId": resolveMandateLabels, - "targetFeatureInstanceId": resolveInstanceLabels, + "mandateId": partial(resolveMandateLabels, db), + "targetFeatureInstanceId": partial(resolveInstanceLabels, db), }, ) for row in page: @@ -285,8 +287,8 @@ def getWorkspaceRunDetail( targetInstanceLabel = None if tid: try: - from modules.routes.routeHelpers import resolveInstanceLabels - labelMap = resolveInstanceLabels([tid]) + from modules.dbHelpers.fkLabelResolver import resolveInstanceLabels + labelMap = resolveInstanceLabels(db, [tid]) targetInstanceLabel = labelMap.get(tid) except Exception: pass diff --git a/modules/routes/routeBilling.py b/modules/routes/routeBilling.py index 04251e09..19341394 100644 --- a/modules/routes/routeBilling.py +++ b/modules/routes/routeBilling.py @@ -13,7 +13,7 @@ from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Resp from fastapi.responses import JSONResponse from typing import List, Dict, Any, Optional import logging -from datetime import date, datetime, timezone +from datetime import date, datetime, timedelta, timezone from pydantic import BaseModel, Field # Import auth module @@ -486,13 +486,11 @@ def getBalanceForMandate( def _normalize_billing_tx_dict(t: Dict[str, Any]) -> Dict[str, Any]: """Make billing transaction rows JSON/grouping-safe (datetimes → str, enums → str).""" - from datetime import date as date_cls, datetime as dt_cls - r = dict(t) for k, v in list(r.items()): - if isinstance(v, dt_cls): + if isinstance(v, datetime): r[k] = v.isoformat() - elif isinstance(v, date_cls): + elif isinstance(v, date): r[k] = v.isoformat() for ek in ("transactionType", "referenceType"): if ek in r and r[ek] is not None and not isinstance(r[ek], str): @@ -567,13 +565,15 @@ def getTransactions( ) if pagination: - from modules.routes.routeHelpers import ( + from modules.interfaces.interfaceTableHelpers import ( applyViewToParams, buildGroupLayout, effective_group_by_levels, + resolveView, + ) + from modules.dbHelpers.paginationHelpers import ( handleFilterValuesInMemory, handleIdsInMemory, - resolveView, ) from modules.interfaces.interfaceDbApp import getInterface as getAppInterface from modules.interfaces.interfaceDbManagement import ComponentObjects @@ -699,8 +699,7 @@ def getStatistics( startDate, toDateInclusive = parseIsoDateRange(dateFrom, dateTo) # `calculateStatisticsFromTransactions` expects a half-open # [startDate, endDate) interval, so widen the upper bound by one day. - from datetime import timedelta as _td - endDate = toDateInclusive + _td(days=1) + endDate = toDateInclusive + timedelta(days=1) billingInterface = getBillingInterface(ctx.user, ctx.mandateId) settings = billingInterface.getSettings(ctx.mandateId) @@ -1158,7 +1157,6 @@ def handleSubscriptionCheckoutCompleted(session, eventId: str) -> None: _notifySubscriptionChange, ) from modules.security.rootAccess import getRootUser - from datetime import datetime, timezone if not isinstance(session, dict): from modules.shared.stripeClient import stripeToDict @@ -1327,7 +1325,6 @@ def _handleSubscriptionWebhook(event) -> None: _notifySubscriptionChange, ) from modules.security.rootAccess import getRootUser - from datetime import datetime, timezone obj = event.data.object rawSub = obj.get("id") if event.type.startswith("customer.subscription") else obj.get("subscription") @@ -1424,7 +1421,7 @@ def _handleSubscriptionWebhook(event) -> None: elif event.type == "customer.subscription.trial_will_end": logger.info("Trial ending soon for sub %s (mandate %s)", subId, mandateId) try: - from modules.shared.notifyMandateAdmins import notifyMandateAdmins + from modules.system.notifyMandateAdmins import notifyMandateAdmins notifyMandateAdmins( mandateId, "[PowerOn] Testphase endet bald", @@ -1574,12 +1571,14 @@ def _attachCreatedByUserNamesToTransactionRows(rows: List[Dict[str, Any]]) -> No Returns None (not a truncated UUID) for unresolvable IDs so the frontend renders an explicit NA() indicator instead of a misleading 8-char snippet. """ - from modules.routes.routeHelpers import resolveUserLabels + from modules.dbHelpers.fkLabelResolver import resolveUserLabels + from modules.interfaces.interfaceDbApp import getRootInterface userIds = list({r.get("createdByUserId") for r in rows if r.get("createdByUserId")}) userMap: Dict[str, Optional[str]] = {} if userIds: - userMap = resolveUserLabels(userIds) + db = getRootInterface().db + userMap = resolveUserLabels(db, userIds) for row in rows: uid = row.get("createdByUserId") @@ -1871,9 +1870,9 @@ def getUserViewStatistics( for acc in allAccounts: accountToMandate[acc.get("id", "")] = acc.get("mandateId", "") - from modules.routes.routeHelpers import resolveMandateLabels + from modules.dbHelpers.fkLabelResolver import resolveMandateLabels mandateIdsForLookup = list({v for v in accountToMandate.values() if v}) - mandateMap: Dict[str, Optional[str]] = resolveMandateLabels(mandateIdsForLookup) if mandateIdsForLookup else {} + mandateMap: Dict[str, Optional[str]] = resolveMandateLabels(billingInterface.db, mandateIdsForLookup) if mandateIdsForLookup else {} def _mandateName(accountId: str) -> str: mid = accountToMandate.get(accountId, "") @@ -1934,7 +1933,7 @@ def getUserViewTransactions( - mandateId: required when scope='mandate' - onlyMine: true to restrict to current user's data within the scope """ - from modules.routes.routeHelpers import parseCrossFilterPagination + from modules.dbHelpers.paginationHelpers import parseCrossFilterPagination try: billingInterface = getBillingInterface(ctx.user, ctx.mandateId) @@ -1970,8 +1969,7 @@ def getUserViewTransactions( if mode == "ids": paginationParams = None if pagination: - import json as _json - paginationDict = _json.loads(pagination) + paginationDict = json.loads(pagination) paginationDict = normalize_pagination_dict(paginationDict) paginationParams = PaginationParams(**paginationDict) ids = billingInterface.getTransactionIds( @@ -1985,16 +1983,15 @@ def getUserViewTransactions( if mode == "groupSummary": if not pagination: raise HTTPException(status_code=400, detail="pagination required for groupSummary") - import json as _json from modules.interfaces.interfaceDbApp import getInterface as getAppInterface - from modules.routes.routeHelpers import ( + from modules.interfaces.interfaceTableHelpers import ( applyViewToParams, build_group_summary_groups, effective_group_by_levels, resolveView, ) - pagination_dict = _json.loads(pagination) + pagination_dict = json.loads(pagination) pagination_dict = normalize_pagination_dict(pagination_dict) summary_params = PaginationParams(**pagination_dict) CONTEXT_KEY = "billing/view/users/transactions" @@ -2023,8 +2020,7 @@ def getUserViewTransactions( paginationParams = None if pagination: - import json as _json - paginationDict = _json.loads(pagination) + paginationDict = json.loads(pagination) paginationDict = normalize_pagination_dict(paginationDict) paginationParams = PaginationParams(**paginationDict) @@ -2034,7 +2030,7 @@ def getUserViewTransactions( paginationParams = PaginationParams(page=1, pageSize=50) from modules.interfaces.interfaceDbApp import getInterface as getAppInterface - from modules.routes.routeHelpers import ( + from modules.interfaces.interfaceTableHelpers import ( applyViewToParams, buildGroupLayout, effective_group_by_levels, diff --git a/modules/routes/routeDataConnections.py b/modules/routes/routeDataConnections.py index 7ab0f6d7..5de77a9b 100644 --- a/modules/routes/routeDataConnections.py +++ b/modules/routes/routeDataConnections.py @@ -154,8 +154,11 @@ async def get_connections( - GET /api/connections/?mode=filterValues&column=status - GET /api/connections/?mode=ids """ - from modules.routes.routeHelpers import ( - handleFilterValuesInMemory, handleIdsInMemory, enrichRowsWithFkLabels, + from modules.dbHelpers.paginationHelpers import ( + handleFilterValuesInMemory, handleIdsInMemory, + ) + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels + from modules.interfaces.interfaceTableHelpers import ( resolveView, applyViewToParams, buildGroupLayout, effective_group_by_levels, ) from modules.datamodels.datamodelPagination import AppliedViewMeta @@ -209,7 +212,7 @@ async def get_connections( raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") try: items = _buildEnhancedItems() - enrichRowsWithFkLabels(items, UserConnection) + enrichRowsWithFkLabels(items, UserConnection, db=interface.db) return handleFilterValuesInMemory(items, column, pagination) except Exception as e: logger.error(f"Error getting filter values for connections: {str(e)}") @@ -225,7 +228,7 @@ async def get_connections( if mode == "groupSummary": if not pagination: raise HTTPException(status_code=400, detail="pagination required for groupSummary") - from modules.routes.routeHelpers import ( + from modules.interfaces.interfaceTableHelpers import ( apply_strategy_b_filters_and_sort, build_group_summary_groups, ) @@ -265,7 +268,7 @@ async def get_connections( "tokenStatus": tokenStatus, "tokenExpiresAt": tokenExpiresAt }) - enrichRowsWithFkLabels(enhanced_connections_dict, UserConnection) + enrichRowsWithFkLabels(enhanced_connections_dict, UserConnection, db=interface.db) filtered = apply_strategy_b_filters_and_sort(enhanced_connections_dict, paginationParams, currentUser) groups_out = build_group_summary_groups(filtered, field, null_label, groupByLevels=groupByLevels) return JSONResponse(content={"groups": groups_out}) @@ -300,7 +303,7 @@ async def get_connections( "tokenExpiresAt": tokenExpiresAt }) - enrichRowsWithFkLabels(enhanced_connections_dict, UserConnection) + enrichRowsWithFkLabels(enhanced_connections_dict, UserConnection, db=interface.db) if paginationParams is None: return {"items": enhanced_connections_dict, "pagination": None} @@ -811,15 +814,14 @@ async def _updateKnowledgeConsent( ) bootstrapEnqueued = True - import json as _json - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger from modules.datamodels.datamodelAudit import AuditCategory audit_logger.logEvent( userId=str(currentUser.id), mandateId=str(getattr(connection, "mandateId", "") or ""), category=AuditCategory.PERMISSION.value, action="knowledge_consent_changed", - details=_json.dumps({"connectionId": connectionId, "enabled": enabled}), + details=json.dumps({"connectionId": connectionId, "enabled": enabled}), ) logger.info("Knowledge consent %s for connection %s by user %s", @@ -888,15 +890,14 @@ def _stopKnowledgeJobs( from modules.serviceCenter.services.serviceBackgroundJobs import cancelJobsByConnection cancelled = cancelJobsByConnection(connectionId) - import json as _json - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger from modules.datamodels.datamodelAudit import AuditCategory audit_logger.logEvent( userId=str(currentUser.id), mandateId=str(getattr(connection, "mandateId", "") or ""), category=AuditCategory.PERMISSION.value, action="knowledge_jobs_stopped", - details=_json.dumps({"connectionId": connectionId, "cancelledCount": cancelled}), + details=json.dumps({"connectionId": connectionId, "cancelledCount": cancelled}), ) logger.info("Stopped %d knowledge jobs for connection %s", cancelled, connectionId) diff --git a/modules/routes/routeDataFiles.py b/modules/routes/routeDataFiles.py index 74886380..52a4b98a 100644 --- a/modules/routes/routeDataFiles.py +++ b/modules/routes/routeDataFiles.py @@ -3,21 +3,25 @@ from fastapi import APIRouter, HTTPException, Depends, File, UploadFile, Form, Path, Request, status, Query, Response, Body, BackgroundTasks from fastapi.responses import JSONResponse from typing import List, Dict, Any, Optional +import asyncio +import io import logging import json import math +import urllib.parse +import zipfile # Import auth module from modules.auth import limiter, getCurrentUser, getRequestContext, RequestContext # Import interfaces -import modules.interfaces.interfaceDbManagement as interfaceDbManagement +from modules.interfaces import interfaceDbManagement from modules.datamodels.datamodelFiles import FileItem, FilePreview, FileFolder from modules.shared.attributeUtils import getModelAttributeDefinitions from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.shared.i18nRegistry import apiRouteContext -from modules.routes.routeHelpers import enrichRowsWithFkLabels +from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels routeApiMsg = apiRouteContext("routeDataFiles") # Configure logger @@ -725,9 +729,11 @@ def get_files( detail=f"Invalid pagination parameter: {str(e)}" ) - from modules.routes.routeHelpers import ( + from modules.dbHelpers.paginationHelpers import ( handleIdsMode, handleFilterValuesInMemory, + ) + from modules.interfaces.interfaceTableHelpers import ( resolveView, applyViewToParams, buildGroupLayout, effective_group_by_levels, ) import modules.interfaces.interfaceDbApp as _appIface @@ -753,7 +759,7 @@ def get_files( if mode == "groupSummary": if not pagination: raise HTTPException(status_code=400, detail="pagination required for groupSummary") - from modules.routes.routeHelpers import ( + from modules.interfaces.interfaceTableHelpers import ( apply_strategy_b_filters_and_sort, build_group_summary_groups, ) @@ -768,6 +774,7 @@ def get_files( allItems = enrichRowsWithFkLabels( _filesToDicts(allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])), FileItem, + db=managementInterface.db, ) filtered = apply_strategy_b_filters_and_sort(allItems, paginationParams, currentUser) groups_out = build_group_summary_groups(filtered, field, null_label, groupByLevels=groupByLevels) @@ -814,9 +821,10 @@ def get_files( allItems = enrichRowsWithFkLabels( _filesToDicts(allFiles if isinstance(allFiles, list) else (allFiles.items if hasattr(allFiles, "items") else [])), FileItem, + db=managementInterface.db, ) - from modules.routes.routeHelpers import apply_strategy_b_filters_and_sort + from modules.interfaces.interfaceTableHelpers import apply_strategy_b_filters_and_sort if paginationParams.filters or paginationParams.sort: allItems = apply_strategy_b_filters_and_sort(allItems, paginationParams, currentUser) @@ -934,7 +942,6 @@ async def upload_file( if shouldIndex: try: - import asyncio asyncio.ensure_future(_autoIndexFile( fileId=fileItem.id, fileName=fileItem.fileName, @@ -1016,8 +1023,6 @@ def batchDownload( ): """Download multiple files and/or folders as a single ZIP archive, preserving the folder hierarchy as ZIP paths.""" - import io, zipfile - fileIds = body.get("fileIds") or [] folderIds = body.get("folderIds") or [] @@ -1203,7 +1208,6 @@ async def bulk_download_zip( context: RequestContext = Depends(getRequestContext), ): """Download a list of files as a ZIP archive.""" - import io, zipfile fileIds: list = body.get("fileIds") or [] if not fileIds: raise HTTPException(status_code=400, detail="fileIds is required") @@ -1546,7 +1550,6 @@ def download_file( # Return file as response # Properly encode filename for Content-Disposition header to handle Unicode characters - import urllib.parse encoded_filename = urllib.parse.quote(fileData.fileName) return Response( diff --git a/modules/routes/routeDataMandates.py b/modules/routes/routeDataMandates.py index 2c9885ef..668c16ed 100644 --- a/modules/routes/routeDataMandates.py +++ b/modules/routes/routeDataMandates.py @@ -15,16 +15,17 @@ from typing import List, Dict, Any, Optional from fastapi import status import logging import json +from datetime import datetime, timezone, timedelta from pydantic import BaseModel, Field # Import auth module from modules.auth import limiter, requirePlatformAdmin, getRequestContext, getCurrentUser, RequestContext # Import interfaces -import modules.interfaces.interfaceDbApp as interfaceDbApp +from modules.interfaces import interfaceDbApp from modules.interfaces.interfaceDbBilling import getRootInterface as _getBillingRootInterface from modules.shared.attributeUtils import getModelAttributeDefinitions -from modules.shared.auditLogger import audit_logger +from modules.dbHelpers.auditLogger import audit_logger # Import the model classes from modules.datamodels.datamodelUam import Mandate, User @@ -127,7 +128,7 @@ def get_mandates( detail=f"Invalid pagination parameter: {str(e)}" ) - from modules.routes.routeHelpers import ( + from modules.dbHelpers.paginationHelpers import ( handleFilterValuesInMemory, handleIdsInMemory, handleFilterValuesMode, handleIdsMode, parseCrossFilterPagination, @@ -302,7 +303,6 @@ def create_mandate( MandateSubscription, SubscriptionStatusEnum, BUILTIN_PLANS, ) from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot - from datetime import datetime, timezone, timedelta planKey = mandateData.get("planKey", "TRIAL_14D") plan = BUILTIN_PLANS.get(planKey) @@ -642,7 +642,7 @@ def list_mandate_users( "enabled": um.enabled }) - from modules.routes.routeHelpers import ( + from modules.dbHelpers.paginationHelpers import ( handleFilterValuesInMemory, handleIdsInMemory, applyFiltersAndSort as _sharedApplyFiltersAndSort, paginateInMemory, diff --git a/modules/routes/routeDataPrompts.py b/modules/routes/routeDataPrompts.py index 406e8d59..4d46630c 100644 --- a/modules/routes/routeDataPrompts.py +++ b/modules/routes/routeDataPrompts.py @@ -13,7 +13,7 @@ from copy import deepcopy from modules.auth import limiter, getCurrentUser # Import interfaces -import modules.interfaces.interfaceDbManagement as interfaceDbManagement +from modules.interfaces import interfaceDbManagement from modules.datamodels.datamodelUtils import Prompt from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict @@ -47,8 +47,11 @@ def get_prompts( - filterValues: distinct values for a column (cross-filtered) - ids: all IDs matching current filters """ - from modules.routes.routeHelpers import ( - handleFilterValuesInMemory, handleIdsInMemory, enrichRowsWithFkLabels, + from modules.dbHelpers.paginationHelpers import ( + handleFilterValuesInMemory, handleIdsInMemory, + ) + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels + from modules.interfaces.interfaceTableHelpers import ( resolveView, applyViewToParams, buildGroupLayout, effective_group_by_levels, ) from modules.interfaces.interfaceDbApp import getInterface as getAppInterface @@ -117,7 +120,7 @@ def get_prompts( def _promptsToEnrichedDicts(promptItems): dicts = [r.model_dump() if hasattr(r, 'model_dump') else (dict(r) if not isinstance(r, dict) else r) for r in promptItems] - enrichRowsWithFkLabels(dicts, Prompt) + enrichRowsWithFkLabels(dicts, Prompt, db=managementInterface.db) return dicts managementInterface = interfaceDbManagement.getInterface(currentUser) @@ -125,7 +128,7 @@ def get_prompts( if mode == "groupSummary": if not pagination: raise HTTPException(status_code=400, detail="pagination required for groupSummary") - from modules.routes.routeHelpers import ( + from modules.interfaces.interfaceTableHelpers import ( apply_strategy_b_filters_and_sort, build_group_summary_groups, ) diff --git a/modules/routes/routeDataSources.py b/modules/routes/routeDataSources.py index e448523b..e1a6ee39 100644 --- a/modules/routes/routeDataSources.py +++ b/modules/routes/routeDataSources.py @@ -7,13 +7,14 @@ generic UDB router (`POST /api/udb/node/{key}/flag/{flag}`); see `modules/routes/routeUdb.py` and the wiki UDB reference page. """ +import json import logging from typing import Any, Dict, List, Optional from fastapi import APIRouter, HTTPException, Depends, Path, Request, Body from modules.auth import limiter, getRequestContext, RequestContext from modules.datamodels.datamodelDataSource import DataSource -from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource +from modules.datamodels.datamodelFeatures import FeatureDataSource from modules.datamodels.datamodelUam import UserConnection from modules.shared.i18nRegistry import apiRouteContext routeApiMsg = apiRouteContext("routeDataSources") @@ -149,9 +150,9 @@ def _updateDataSourceSettings( if ownerId and ownerId != currentUserId and not context.isSysAdmin: raise HTTPException(status_code=403, detail="Not allowed to modify this DataSource's settings") else: - from modules.serviceCenter.services.serviceKnowledge.udbNodes import _isFeatureAdmin + from modules.serviceCenter.services.serviceKnowledge.udbNodes import isFeatureAdmin featureInstanceId = str(rec.get("featureInstanceId") or "") - if not (context.isSysAdmin or _isFeatureAdmin(rootIf, currentUserId, featureInstanceId)): + if not (context.isSysAdmin or isFeatureAdmin(rootIf, currentUserId, featureInstanceId)): raise HTTPException(status_code=403, detail="Not allowed to modify this FeatureDataSource's settings") kind = _kindForSource(rec, model) @@ -169,8 +170,7 @@ def _updateDataSourceSettings( rootIf.db.recordModify(model, sourceId, {"settings": newSettings}) - import json - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger from modules.datamodels.datamodelAudit import AuditCategory audit_logger.logEvent( userId=currentUserId, @@ -208,15 +208,15 @@ def _getDataSourceCostEstimate( """ try: from modules.interfaces.interfaceDbApp import getRootInterface - from modules.serviceCenter.services.serviceKnowledge import _ragLimits, _costEstimate + from modules.serviceCenter.services.serviceKnowledge import ragLimits, costEstimate rootIf = getRootInterface() rec, model = _findSourceRecord(rootIf.db, sourceId) if not rec: raise HTTPException(status_code=404, detail=f"DataSource {sourceId} not found") kind = _kindForSource(rec, model) - effective = _ragLimits.getRagLimits(rec, kind) - estimate = _costEstimate.estimateBootstrapCost(effective, kind=kind) + effective = ragLimits.getRagLimits(rec, kind) + estimate = costEstimate.estimateBootstrapCost(effective, kind=kind) estimate["sourceId"] = sourceId return estimate except HTTPException: diff --git a/modules/routes/routeDataUsers.py b/modules/routes/routeDataUsers.py index 671a8ca2..e371b547 100644 --- a/modules/routes/routeDataUsers.py +++ b/modules/routes/routeDataUsers.py @@ -15,9 +15,10 @@ from fastapi import status from pydantic import BaseModel import logging import json +import math # Import interfaces and models -import modules.interfaces.interfaceDbApp as interfaceDbApp +from modules.interfaces import interfaceDbApp from modules.auth import limiter, getRequestContext, RequestContext # Import the attribute definition and helper functions @@ -25,7 +26,7 @@ from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority from modules.interfaces.interfaceDbApp import getRootInterface from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.shared.i18nRegistry import apiRouteContext -from modules.routes.routeHelpers import enrichRowsWithFkLabels +from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels routeApiMsg = apiRouteContext("routeDataUsers") # Configure logger @@ -78,7 +79,7 @@ def _isAdminForUser(context: RequestContext, targetUserId: str) -> bool: def _getUserFilterOrIds(context, paginationJson, column=None, idsMode=False): """Unified handler for mode=filterValues and mode=ids across all user scoping branches.""" - from modules.routes.routeHelpers import ( + from modules.dbHelpers.paginationHelpers import ( handleFilterValuesInMemory, handleIdsInMemory, handleFilterValuesMode, handleIdsMode, parseCrossFilterPagination, @@ -233,7 +234,7 @@ def get_users( if context.mandateId: result = appInterface.getUsersByMandate(str(context.mandateId), paginationParams) if paginationParams and hasattr(result, 'items'): - enriched = enrichRowsWithFkLabels(_usersToDicts(result.items), User) + enriched = enrichRowsWithFkLabels(_usersToDicts(result.items), User, db=getRootInterface().db) return { "items": enriched, "pagination": PaginationMetadata( @@ -247,11 +248,11 @@ def get_users( } else: users = result if isinstance(result, list) else result.items if hasattr(result, 'items') else [] - return {"items": enrichRowsWithFkLabels(_usersToDicts(users), User), "pagination": None} + return {"items": enrichRowsWithFkLabels(_usersToDicts(users), User, db=getRootInterface().db), "pagination": None} elif context.isPlatformAdmin: result = appInterface.getAllUsers(paginationParams) if paginationParams and hasattr(result, 'items'): - enriched = enrichRowsWithFkLabels(_usersToDicts(result.items), User) + enriched = enrichRowsWithFkLabels(_usersToDicts(result.items), User, db=getRootInterface().db) return { "items": enriched, "pagination": PaginationMetadata( @@ -265,7 +266,7 @@ def get_users( } else: users = result if isinstance(result, list) else (result.items if hasattr(result, 'items') else []) - return {"items": enrichRowsWithFkLabels(_usersToDicts(users), User), "pagination": None} + return {"items": enrichRowsWithFkLabels(_usersToDicts(users), User, db=getRootInterface().db), "pagination": None} else: rootInterface = getRootInterface() userMandates = rootInterface.getUserMandates(str(context.user.id)) @@ -295,12 +296,11 @@ def get_users( batchUsers = rootInterface.getUsersByIds(uniqueUserIds) if uniqueUserIds else {} allUsers = [u.model_dump() if hasattr(u, 'model_dump') else vars(u) for u in batchUsers.values()] - from modules.routes.routeHelpers import applyFiltersAndSort as _applyFiltersAndSortHelper - filteredUsers = _applyFiltersAndSortHelper(allUsers, paginationParams) - enriched = enrichRowsWithFkLabels(filteredUsers, User) + from modules.dbHelpers.paginationHelpers import applyFiltersAndSort + filteredUsers = applyFiltersAndSort(allUsers, paginationParams) + enriched = enrichRowsWithFkLabels(filteredUsers, User, db=rootInterface.db) if paginationParams: - import math totalItems = len(enriched) totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0 startIdx = (paginationParams.page - 1) * paginationParams.pageSize @@ -560,7 +560,7 @@ def reset_user_password( # Log password reset try: - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger audit_logger.logSecurityEvent( userId=str(context.user.id), mandateId=str(context.mandateId) if context.mandateId else "system", @@ -640,7 +640,7 @@ def change_password( # Log password change try: - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger audit_logger.logSecurityEvent( userId=str(context.user.id), mandateId=str(context.mandateId) if context.mandateId else "system", @@ -770,7 +770,7 @@ def send_password_link( # Log the action try: - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger audit_logger.logSecurityEvent( userId=str(context.user.id), mandateId=str(context.mandateId) if context.mandateId else "system", diff --git a/modules/routes/routeGdpr.py b/modules/routes/routeGdpr.py index fce8ab69..d07df8a8 100644 --- a/modules/routes/routeGdpr.py +++ b/modules/routes/routeGdpr.py @@ -17,14 +17,15 @@ from typing import List, Dict, Any, Optional from fastapi import status import logging import json +from datetime import datetime, timezone from pydantic import BaseModel, Field from modules.auth import limiter, getCurrentUser from modules.datamodels.datamodelUam import User, UserInDB, Mandate, UserConnection from modules.interfaces.interfaceDbApp import getRootInterface from modules.shared.timeUtils import getUtcTimestamp -from modules.shared.auditLogger import audit_logger -from modules.shared.gdprDeletion import deleteUserDataAcrossAllDatabases, buildDeletionSummary +from modules.dbHelpers.auditLogger import audit_logger +from modules.system.gdprDeletion import deleteUserDataAcrossAllDatabases, buildDeletionSummary from modules.shared.i18nRegistry import apiRouteContext routeApiMsg = apiRouteContext("routeGdpr") @@ -437,6 +438,5 @@ def get_consent_info( def _timestampToIso(timestamp: float) -> str: """Convert Unix timestamp to ISO 8601 format""" - from datetime import datetime, timezone dt = datetime.fromtimestamp(timestamp, tz=timezone.utc) return dt.isoformat() diff --git a/modules/routes/routeHelpers.py b/modules/routes/routeHelpers.py deleted file mode 100644 index b58ffc6d..00000000 --- a/modules/routes/routeHelpers.py +++ /dev/null @@ -1,1024 +0,0 @@ -# Copyright (c) 2025 Patrick Motsch -# All rights reserved. -""" -Shared helpers for route handlers. - -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 -""" - -import copy -import json -import logging -from typing import Any, Dict, List, Optional, Callable, Union - -from fastapi.responses import JSONResponse - -from modules.datamodels.datamodelPagination import ( - PaginationParams, - normalize_pagination_dict, -) -from modules.shared.i18nRegistry import resolveText - -logger = logging.getLogger(__name__) - - -# --------------------------------------------------------------------------- -# Central FK label resolvers (cross-DB) -# --------------------------------------------------------------------------- - -def resolveMandateLabels(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.interfaces.interfaceDbApp import getRootInterface - rootIface = getRootInterface() - mMap = rootIface.getMandatesByIds(ids) - result: Dict[str, Optional[str]] = {} - for mid in ids: - m = mMap.get(mid) - label = (getattr(m, "label", None) or getattr(m, "name", None)) 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(ids: List[str]) -> Dict[str, Optional[str]]: - """Resolve feature-instance IDs to labels. Returns None for unresolvable.""" - from modules.interfaces.interfaceDbApp import getRootInterface - from modules.interfaces.interfaceFeatures import getFeatureInterface - rootIface = getRootInterface() - featureIface = getFeatureInterface(rootIface.db) - result: Dict[str, Optional[str]] = {} - for iid in ids: - fi = featureIface.getFeatureInstance(iid) - label = fi.label if fi and fi.label else None - if not label: - logger.debug("resolveInstanceLabels: no label for id=%s (found=%s)", iid, fi is not None) - result[iid] = label - return result - - -def resolveUserLabels(ids: List[str]) -> Dict[str, Optional[str]]: - """Resolve user IDs to display names. Returns None for unresolvable.""" - from modules.interfaces.interfaceDbApp import getRootInterface - rootIface = getRootInterface() - from modules.datamodels.datamodelUam import UserInDB as _UserInDB - uniqueIds = list(set(ids)) - users = rootIface.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(ids: List[str]) -> Dict[str, Optional[str]]: - """Resolve Role.id to roleLabel. Returns None for unresolvable.""" - if not ids: - return {} - from modules.interfaces.interfaceDbApp import getRootInterface - from modules.datamodels.datamodelRbac import Role as _Role - rootIface = getRootInterface() - recs = rootIface.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 - - -_BUILTIN_FK_RESOLVERS: Dict[str, Callable[[List[str]], Dict[str, str]]] = { - "Mandate": resolveMandateLabels, - "FeatureInstance": resolveInstanceLabels, - "UserInDB": resolveUserLabels, - "Role": resolveRoleLabels, -} - - -def _buildLabelResolversFromModel(modelClass: type) -> 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). - """ - 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: - resolvers[name] = _BUILTIN_FK_RESOLVERS[fkModel] - return resolvers - - -def enrichRowsWithFkLabels( - rows: List[Dict[str, Any]], - modelClass: type = 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``). - - ``labelResolvers`` — explicit resolver map that overrides auto-built ones. - - ``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) - 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 - - -# --------------------------------------------------------------------------- -# 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: - from datetime import datetime, timezone - 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): - # Numeric range (e.g. FormGeneratorTable column filters on INTEGER/FLOAT) - 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. - """ - import math - - if not pagination or not pagination.sort: - return db.getRecordsetPaginated(modelClass, pagination, recordFilter, fieldFilter) - - if labelResolvers is None: - labelResolvers = _buildLabelResolversFromModel(modelClass) - - if not labelResolvers: - return db.getRecordsetPaginated(modelClass, pagination, recordFilter, fieldFilter) - - 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: - return db.getRecordsetPaginated(modelClass, pagination, recordFilter, fieldFilter) - - 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) - 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}") - return db.getRecordsetPaginated(modelClass, pagination, recordFilter, fieldFilter) - - -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 resolution and Strategy B grouping engine -# --------------------------------------------------------------------------- - -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 PaginationParams, SortField - if not viewConfig: - return params - - if params is None: - params = PaginationParams(page=1, pageSize=25) - - # Sort: request wins if non-empty - 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}") - - # Filters: deep-merge (request filters take priority per-key) - 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 = "—", - 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. - """ - from collections import defaultdict - - 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 functools import cmp_to_key - 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", "—") 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, "—") - 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 - - # Sort by group path (per-level asc/desc); order within same path stays stable in Py3.12+ - all_items.sort(key=cmp_to_key(_item_cmp)) - - # Build global band list from the full sorted list - 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)}) - - # Slice to page - page_start = (page - 1) * pageSize - page_end = page_start + pageSize - page_items = all_items[page_start:page_end] - - # Find bands that have at least one row on this page - 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 "—", - 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 diff --git a/modules/routes/routeI18n.py b/modules/routes/routeI18n.py index 927d1bf2..8b1b46d5 100644 --- a/modules/routes/routeI18n.py +++ b/modules/routes/routeI18n.py @@ -44,9 +44,9 @@ from modules.routes.routeNotifications import createNotification from modules.shared.configuration import APP_CONFIG from modules.shared.i18nRegistry import ( _enforceSourcePlaceholders, - loadCache as _reloadI18nCache, apiRouteContext, ) +from modules.system.i18nBootSync import loadCache as _reloadI18nCache from modules.shared.timeUtils import getUtcTimestamp routeApiMsg = apiRouteContext("routeI18n") @@ -404,7 +404,7 @@ def _resolveMandateIdForAiI18n(request: Request, currentUser: User) -> str: # --------------------------------------------------------------------------- _REPO_ROOT = Path(__file__).resolve().parents[3] -_FRONTEND_SRC = _REPO_ROOT / "frontend_nyla" / "src" +_FRONTEND_SRC = _REPO_ROOT / "ui-nyla" / "src" _T_CALL_RE = re.compile(r"""\bt\(\s*'((?:\\.|[^'])+)'\s*(?:,|\))""") diff --git a/modules/routes/routeInvitations.py b/modules/routes/routeInvitations.py index 7651afe0..25049227 100644 --- a/modules/routes/routeInvitations.py +++ b/modules/routes/routeInvitations.py @@ -21,7 +21,8 @@ from pydantic import BaseModel, Field, model_validator from modules.auth import limiter, getRequestContext, RequestContext, getCurrentUser from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict -from modules.routes.routeHelpers import applyFiltersAndSort, handleFilterValuesInMemory, handleIdsInMemory, enrichRowsWithFkLabels +from modules.dbHelpers.paginationHelpers import applyFiltersAndSort, handleFilterValuesInMemory, handleIdsInMemory +from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels from modules.datamodels.datamodelInvitation import Invitation from modules.interfaces.interfaceDbApp import getRootInterface from modules.shared.timeUtils import getUtcTimestamp @@ -477,7 +478,7 @@ def list_invitations( raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") try: items = _buildInvitationItems() - enrichRowsWithFkLabels(items, Invitation) + enrichRowsWithFkLabels(items, Invitation, db=getRootInterface().db) return handleFilterValuesInMemory(items, column, pagination) except Exception as e: logger.error(f"Error getting filter values for invitations: {e}") @@ -509,7 +510,7 @@ def list_invitations( totalPages = math.ceil(totalItems / paginationParams.pageSize) if totalItems > 0 else 0 startIdx = (paginationParams.page - 1) * paginationParams.pageSize endIdx = startIdx + paginationParams.pageSize - enriched = enrichRowsWithFkLabels(filtered[startIdx:endIdx], Invitation) + enriched = enrichRowsWithFkLabels(filtered[startIdx:endIdx], Invitation, db=getRootInterface().db) return { "items": enriched, "pagination": PaginationMetadata( @@ -518,7 +519,7 @@ def list_invitations( sort=paginationParams.sort, filters=paginationParams.filters, ).model_dump(), } - enriched = enrichRowsWithFkLabels(result, Invitation) + enriched = enrichRowsWithFkLabels(result, Invitation, db=getRootInterface().db) return {"items": enriched, "pagination": None} except HTTPException: diff --git a/modules/routes/routeMfa.py b/modules/routes/routeMfa.py index 551faa37..cb681fe0 100644 --- a/modules/routes/routeMfa.py +++ b/modules/routes/routeMfa.py @@ -47,7 +47,7 @@ router = APIRouter(prefix="/api/mfa", tags=["MFA"]) _MFA_PENDING_EXPIRE_MINUTES = 5 -def _createMfaPendingToken(userId: str, username: str, authority: str, sessionId: str) -> str: +def createMfaPendingToken(userId: str, username: str, authority: str, sessionId: str) -> str: """Short-lived JWT that authorises only the /mfa/verify endpoint.""" payload = { "sub": username, @@ -233,7 +233,7 @@ def mfaVerify( logger.info("MFA verify successful for user %s", username) try: - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger audit_logger.logUserAccess( userId=userId, mandateId="system", diff --git a/modules/routes/routeRagInventory.py b/modules/routes/routeRagInventory.py index 0ca7fade..f7219c60 100644 --- a/modules/routes/routeRagInventory.py +++ b/modules/routes/routeRagInventory.py @@ -234,7 +234,7 @@ def _buildFeatureInstanceInventory(featureInstanceIds, rootIf, knowledgeIf) -> L Includes feature.bootstrap job status (running/success/error). """ from modules.datamodels.datamodelKnowledge import FileContentIndex - from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + from modules.datamodels.datamodelFeatures import FeatureDataSource from modules.interfaces.interfaceFeatures import getFeatureInterface from modules.serviceCenter.services.serviceKnowledge._inheritFlags import getEffectiveFlagFds from modules.serviceCenter.services.serviceBackgroundJobs import mainBackgroundJobService as jobService diff --git a/modules/routes/routeRealEstate.py b/modules/routes/routeRealEstate.py index 247d3b8b..81550de2 100644 --- a/modules/routes/routeRealEstate.py +++ b/modules/routes/routeRealEstate.py @@ -1281,7 +1281,6 @@ async def search_parcel( "sr": "2056" } import aiohttp - import ssl ssl_context = ssl.create_default_context() ssl_context.check_hostname = False ssl_context.verify_mode = ssl.CERT_NONE diff --git a/modules/routes/routeSecurityGoogle.py b/modules/routes/routeSecurityGoogle.py index 853d4067..2f1eabd2 100644 --- a/modules/routes/routeSecurityGoogle.py +++ b/modules/routes/routeSecurityGoogle.py @@ -11,6 +11,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse import logging import json import time +import uuid from typing import Dict, Any, Optional from requests_oauthlib import OAuth2Session import httpx @@ -221,7 +222,7 @@ async def auth_login_callback( # --- MFA gate -------------------------------------------------------- from modules.auth.mfaService import isMfaRequired as _isMfaRequired - from modules.routes.routeMfa import _createMfaPendingToken + from modules.routes.routeMfa import createMfaPendingToken userRecord = rootInterface._getUserForAuthentication(user.username) userMandates = rootInterface.getUserMandates(str(user.id)) @@ -239,9 +240,8 @@ async def auth_login_callback( hasMfaSetup = bool(userRecord and userRecord.get("mfaSecret") and getattr(user, "mfaEnabled", False)) if mfaRequired or hasMfaSetup: - import uuid as _uuid - _sid = str(_uuid.uuid4()) - pendingToken = _createMfaPendingToken( + _sid = str(uuid.uuid4()) + pendingToken = createMfaPendingToken( userId=str(user.id), username=user.username, authority=AuthAuthority.GOOGLE.value, @@ -255,9 +255,9 @@ async def auth_login_callback( from modules.auth.mfaService import generateSetup as _generateSetup existingSecret = userRecord.get("mfaSecret") if userRecord else None if existingSecret: - from modules.auth.mfaService import _decryptSecret, _buildTotp, _getMfaIssuer - _plain = _decryptSecret(existingSecret, userId=str(user.id)) - _uri = _buildTotp(_plain).provisioning_uri(name=user.username, issuer_name=_getMfaIssuer()) + from modules.auth.mfaService import decryptSecret, buildTotp, getMfaIssuer + _plain = decryptSecret(existingSecret, userId=str(user.id)) + _uri = buildTotp(_plain).provisioning_uri(name=user.username, issuer_name=getMfaIssuer()) setupResult = {"provisioningUri": _uri} else: setupResult = _generateSetup(userId=str(user.id), username=user.username) @@ -652,7 +652,7 @@ def logout( revoked = 1 try: - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger audit_logger.logUserAccess( userId=str(currentUser.id), diff --git a/modules/routes/routeSecurityInfomaniak.py b/modules/routes/routeSecurityInfomaniak.py index d938b45e..4026f4e9 100644 --- a/modules/routes/routeSecurityInfomaniak.py +++ b/modules/routes/routeSecurityInfomaniak.py @@ -46,7 +46,7 @@ from modules.datamodels.datamodelSecurity import Token, TokenPurpose from modules.auth import getCurrentUser, limiter from modules.shared.timeUtils import getUtcTimestamp, createExpirationTimestamp from modules.shared.i18nRegistry import apiRouteContext -from modules.connectors.providerInfomaniak.connectorInfomaniak import ( +from modules.connectors.connectorProviderInfomaniak import ( resolveOwnerIdentity, listAccessibleDrives, InfomaniakIdentityError, diff --git a/modules/routes/routeSecurityLocal.py b/modules/routes/routeSecurityLocal.py index c8efa0a8..6a25ce04 100644 --- a/modules/routes/routeSecurityLocal.py +++ b/modules/routes/routeSecurityLocal.py @@ -256,7 +256,7 @@ def login( # --- MFA gate -------------------------------------------------------- from modules.auth.mfaService import isMfaRequired as _isMfaRequired - from modules.routes.routeMfa import _createMfaPendingToken + from modules.routes.routeMfa import createMfaPendingToken userRecord = rootInterface._getUserForAuthentication(user.username) userMandates = rootInterface.getUserMandates(str(user.id)) @@ -275,7 +275,7 @@ def login( if mfaRequired or hasMfaSetup: _sid = str(uuid.uuid4()) - pendingToken = _createMfaPendingToken( + pendingToken = createMfaPendingToken( userId=str(user.id), username=user.username, authority=AuthAuthority.LOCAL.value, @@ -287,11 +287,11 @@ def login( from modules.auth.mfaService import generateSetup as _generateSetup existingSecret = userRecord.get("mfaSecret") if userRecord else None if existingSecret: - from modules.auth.mfaService import _decryptSecret, _buildTotp - _plain = _decryptSecret(existingSecret, userId=str(user.id)) - _totp = _buildTotp(_plain) - from modules.auth.mfaService import _getMfaIssuer - _uri = _totp.provisioning_uri(name=user.username, issuer_name=_getMfaIssuer()) + from modules.auth.mfaService import decryptSecret, buildTotp + _plain = decryptSecret(existingSecret, userId=str(user.id)) + _totp = buildTotp(_plain) + from modules.auth.mfaService import getMfaIssuer + _uri = _totp.provisioning_uri(name=user.username, issuer_name=getMfaIssuer()) setupResult = {"provisioningUri": _uri} else: setupResult = _generateSetup(userId=str(user.id), username=user.username) @@ -374,7 +374,7 @@ def login( # Log successful login (app log file + audit DB for traceability) logger.info("Login successful for username=%s (userId=%s)", formData.username, str(user.id)) try: - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger audit_logger.logUserAccess( userId=str(user.id), mandateId="system", @@ -409,7 +409,7 @@ def login( # Log failed login attempt try: - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger audit_logger.logUserAccess( userId=formData.username or "unknown", mandateId="system", @@ -732,7 +732,7 @@ def logout(request: Request, response: Response, currentUser: User = Depends(get # Log successful logout # MULTI-TENANT: Logout is a system-level function, no mandate context try: - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger audit_logger.logUserAccess( userId=str(currentUser.id), mandateId="system", @@ -1001,7 +1001,7 @@ def password_reset( # Log success try: - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger audit_logger.logSecurityEvent( userId="unknown", mandateId="unknown", @@ -1036,7 +1036,7 @@ def _getNeutralizationMappings( """List the current user's neutralization placeholder mappings.""" userId = str(context.user.id) from modules.interfaces.interfaceDbApp import getRootInterface - from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutralizerAttributes + from modules.datamodels.datamodelFeatures import DataNeutralizerAttributes rootIf = getRootInterface() records = rootIf.db.getRecordset(DataNeutralizerAttributes, recordFilter={"userId": userId}) return {"mappings": records} @@ -1052,7 +1052,7 @@ def _deleteNeutralizationMapping( """Delete a specific neutralization mapping owned by the current user.""" userId = str(context.user.id) from modules.interfaces.interfaceDbApp import getRootInterface - from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutralizerAttributes + from modules.datamodels.datamodelFeatures import DataNeutralizerAttributes rootIf = getRootInterface() records = rootIf.db.getRecordset(DataNeutralizerAttributes, recordFilter={"id": mappingId}) if not records: diff --git a/modules/routes/routeSecurityMsft.py b/modules/routes/routeSecurityMsft.py index 80e0d19a..c26503ef 100644 --- a/modules/routes/routeSecurityMsft.py +++ b/modules/routes/routeSecurityMsft.py @@ -11,6 +11,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse import logging import json import time +import uuid from typing import Dict, Any, Optional from urllib.parse import quote import msal @@ -194,7 +195,7 @@ async def auth_login_callback( # --- MFA gate -------------------------------------------------------- from modules.auth.mfaService import isMfaRequired as _isMfaRequired - from modules.routes.routeMfa import _createMfaPendingToken + from modules.routes.routeMfa import createMfaPendingToken userRecord = rootInterface._getUserForAuthentication(user.username) userMandates = rootInterface.getUserMandates(str(user.id)) @@ -212,9 +213,8 @@ async def auth_login_callback( hasMfaSetup = bool(userRecord and userRecord.get("mfaSecret") and getattr(user, "mfaEnabled", False)) if mfaRequired or hasMfaSetup: - import uuid as _uuid - _sid = str(_uuid.uuid4()) - pendingToken = _createMfaPendingToken( + _sid = str(uuid.uuid4()) + pendingToken = createMfaPendingToken( userId=str(user.id), username=user.username, authority=AuthAuthority.MSFT.value, @@ -228,9 +228,9 @@ async def auth_login_callback( from modules.auth.mfaService import generateSetup as _generateSetup existingSecret = userRecord.get("mfaSecret") if userRecord else None if existingSecret: - from modules.auth.mfaService import _decryptSecret, _buildTotp, _getMfaIssuer - _plain = _decryptSecret(existingSecret, userId=str(user.id)) - _uri = _buildTotp(_plain).provisioning_uri(name=user.username, issuer_name=_getMfaIssuer()) + from modules.auth.mfaService import decryptSecret, buildTotp, getMfaIssuer + _plain = decryptSecret(existingSecret, userId=str(user.id)) + _uri = buildTotp(_plain).provisioning_uri(name=user.username, issuer_name=getMfaIssuer()) setupResult = {"provisioningUri": _uri} else: setupResult = _generateSetup(userId=str(user.id), username=user.username) @@ -725,7 +725,7 @@ def logout( revoked = 1 try: - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger audit_logger.logUserAccess( userId=str(currentUser.id), diff --git a/modules/routes/routeStore.py b/modules/routes/routeStore.py index a433c1ed..7356f1aa 100644 --- a/modules/routes/routeStore.py +++ b/modules/routes/routeStore.py @@ -10,6 +10,7 @@ from fastapi import APIRouter, HTTPException, Depends, Request from typing import List, Dict, Any, Optional, Union from fastapi import status import logging +from datetime import datetime, timezone, timedelta from pydantic import BaseModel, Field from modules.auth import limiter, getRequestContext, RequestContext @@ -98,7 +99,6 @@ def _isUserAdminInMandate(db, userId: str, mandateId: str) -> bool: def _autoActivatePending(subInterface, pendingSub: Dict[str, Any]) -> None: """Auto-activate a PENDING subscription to its target operative status.""" from modules.datamodels.datamodelSubscription import SubscriptionStatusEnum, BUILTIN_PLANS - from datetime import datetime, timezone, timedelta subId = pendingSub.get("id") planKey = pendingSub.get("planKey", "") @@ -539,7 +539,7 @@ def _notifyFeatureActivation( ) -> None: """Send email notification to mandate admins about a newly activated feature.""" try: - from modules.shared.notifyMandateAdmins import notifyMandateAdmins + from modules.system.notifyMandateAdmins import notifyMandateAdmins priceLine = "" if plan and plan.pricePerFeatureInstanceCHF: diff --git a/modules/routes/routeSubscription.py b/modules/routes/routeSubscription.py index 9c5ecb01..87d34836 100644 --- a/modules/routes/routeSubscription.py +++ b/modules/routes/routeSubscription.py @@ -22,7 +22,7 @@ from pydantic import BaseModel, Field from modules.auth import limiter, getRequestContext, RequestContext from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict -from modules.routes.routeHelpers import applyFiltersAndSort, handleFilterValuesInMemory, handleIdsInMemory +from modules.dbHelpers.paginationHelpers import applyFiltersAndSort, handleFilterValuesInMemory, handleIdsInMemory from modules.shared.i18nRegistry import apiRouteContext, resolveText routeApiMsg = apiRouteContext("routeSubscription") @@ -409,9 +409,11 @@ def _buildEnrichedSubscriptions() -> List[Dict[str, Any]]: subInterface = getSubRootInterface() allSubs = subInterface.listAll() - from modules.routes.routeHelpers import resolveMandateLabels + from modules.dbHelpers.fkLabelResolver import resolveMandateLabels + from modules.interfaces.interfaceDbApp import getRootInterface allMandateIds = list({sub.get("mandateId") for sub in allSubs if sub.get("mandateId")}) - mandateNames: Dict[str, Optional[str]] = resolveMandateLabels(allMandateIds) if allMandateIds else {} + db = getRootInterface().db + mandateNames: Dict[str, Optional[str]] = resolveMandateLabels(db, allMandateIds) if allMandateIds else {} operativeValues = {s.value for s in OPERATIVE_STATUSES} @@ -491,10 +493,11 @@ def getAllSubscriptions( if mode == "filterValues": if not column: raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") - from modules.routes.routeHelpers import enrichRowsWithFkLabels + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels from modules.datamodels.datamodelSubscription import MandateSubscription + from modules.interfaces.interfaceDbApp import getRootInterface as _getRootIf items = _buildEnrichedSubscriptions() - enrichRowsWithFkLabels(items, MandateSubscription) + enrichRowsWithFkLabels(items, MandateSubscription, db=_getRootIf().db) return handleFilterValuesInMemory(items, column, pagination) if mode == "ids": diff --git a/modules/routes/routeSystem.py b/modules/routes/routeSystem.py index 772e5018..56568cd9 100644 --- a/modules/routes/routeSystem.py +++ b/modules/routes/routeSystem.py @@ -685,7 +685,7 @@ def _buildIntegrationsOverviewPayload(userId: str, user=None) -> Dict[str, Any]: # --- DataSource & FeatureDataSource --- try: from modules.datamodels.datamodelDataSource import DataSource - from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + from modules.datamodels.datamodelFeatures import FeatureDataSource seen_ds: Set[str] = set() diff --git a/modules/routes/routeTableViews.py b/modules/routes/routeTableViews.py index 1b4b2d04..32a4cf7d 100644 --- a/modules/routes/routeTableViews.py +++ b/modules/routes/routeTableViews.py @@ -19,7 +19,7 @@ from fastapi import status from modules.auth import limiter, getCurrentUser from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelPagination import TableListView -import modules.interfaces.interfaceDbApp as interfaceDbApp +from modules.interfaces import interfaceDbApp logger = logging.getLogger(__name__) diff --git a/modules/routes/routeUdb.py b/modules/routes/routeUdb.py index dfec38e9..73c7b55c 100644 --- a/modules/routes/routeUdb.py +++ b/modules/routes/routeUdb.py @@ -24,6 +24,7 @@ model and the rationale behind the hard cut from the previous feature-instance-scoped endpoints. """ +import json import logging from typing import Any, Dict, List, Optional @@ -151,8 +152,7 @@ async def _udbNodeFlag( effective = _computeEffectiveAfterWrite(rootIf, context, node, flag) - import json - from modules.shared.auditLogger import audit_logger + from modules.dbHelpers.auditLogger import audit_logger from modules.datamodels.datamodelAudit import AuditCategory audit_logger.logEvent( userId=str(context.user.id), @@ -200,7 +200,7 @@ def _computeEffectiveAfterWrite(rootIf: Any, context: RequestContext, Re-loads the relevant recordsets so the cascade resets are visible. """ from modules.datamodels.datamodelDataSource import DataSource - from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + from modules.datamodels.datamodelFeatures import FeatureDataSource userId = str(context.user.id) allDs = rootIf.db.getRecordset(DataSource, recordFilter={"userId": userId}) or [] fdsFilter: Dict[str, Any] = {} diff --git a/modules/routes/routeWorkflowDashboard.py b/modules/routes/routeWorkflowDashboard.py index ea4b8854..f29fc557 100644 --- a/modules/routes/routeWorkflowDashboard.py +++ b/modules/routes/routeWorkflowDashboard.py @@ -14,6 +14,8 @@ import logging import math import re import time +from datetime import datetime, timezone +from functools import partial from typing import Optional, List from fastapi import APIRouter, Depends, Request, Query, Path, HTTPException from fastapi.responses import StreamingResponse @@ -220,10 +222,10 @@ _RUN_STATS_SUBQUERY = """ def _firstFkSortFieldForWorkflows(pagination) -> Optional[str]: """First sort field that requires FK label resolution (cross-DB), or None.""" - from modules.routes.routeHelpers import _buildLabelResolversFromModel + from modules.dbHelpers.fkLabelResolver import buildLabelResolversFromModel if not pagination or not pagination.sort: return None - resolvers = _buildLabelResolversFromModel(AutoWorkflow) + resolvers = buildLabelResolversFromModel(AutoWorkflow) if not resolvers: return None for sf in pagination.sort: @@ -287,8 +289,6 @@ def _listingOrderExpr(key: str, wfFieldNames: set, wfFields: dict) -> Optional[s def _appendJoinedListingFilters(whereParts: list, values: list, pagination, wfFields: dict) -> None: """Append WHERE fragments for joined workflow listing (w + rs).""" - from datetime import datetime as _dt, timezone as _tz - wfFieldNames = set(wfFields.keys()) validCols = wfFieldNames | {"lastStartedAt", "runCount", "isRunning"} @@ -389,19 +389,19 @@ def _appendJoinedListingFilters(whereParts: list, values: list, pagination, wfFi ) if isNumericCol and isDateVal: 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() whereParts.append(f"({colRef} >= %s AND {colRef} <= %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() whereParts.append(f"({colRef} >= %s)") values.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() whereParts.append(f"({colRef} <= %s)") values.append(toTs) @@ -577,12 +577,11 @@ def get_workflow_runs( if mode == "filterValues": if not column: - from fastapi import HTTPException as _H - raise _H(status_code=400, detail="column parameter required for mode=filterValues") + raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") return _enrichedFilterValues(db, context, AutoRun, _scopedRunFilter, column) if mode == "ids": - from modules.routes.routeHelpers import handleIdsMode + from modules.dbHelpers.paginationHelpers import handleIdsMode baseFilter = _scopedRunFilter(context) recordFilter = dict(baseFilter) if baseFilter else {} return handleIdsMode(db, AutoRun, pagination, recordFilter) @@ -604,7 +603,7 @@ def get_workflow_runs( sort=[{"field": "startedAt", "direction": "desc"}], ) - from modules.routes.routeHelpers import getRecordsetPaginatedWithFkSort + from modules.dbHelpers.paginationHelpers import getRecordsetPaginatedWithFkSort result = getRecordsetPaginatedWithFkSort( db, AutoRun, pagination=paginationParams, @@ -620,7 +619,7 @@ def get_workflow_runs( for wf in (wfs or []): wfMap[wf.get("id")] = wf - from modules.routes.routeHelpers import enrichRowsWithFkLabels, resolveMandateLabels, resolveInstanceLabels + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels, resolveMandateLabels, resolveInstanceLabels runs = [] for r in pageRuns: @@ -638,9 +637,10 @@ def get_workflow_runs( enrichRowsWithFkLabels( runs, + db=db, labelResolvers={ - "mandateId": resolveMandateLabels, - "featureInstanceId": resolveInstanceLabels, + "mandateId": partial(resolveMandateLabels, db), + "featureInstanceId": partial(resolveInstanceLabels, db), }, ) for row in runs: @@ -755,12 +755,11 @@ def get_system_workflows( if mode == "filterValues": if not column: - from fastapi import HTTPException as _H - raise _H(status_code=400, detail="column parameter required for mode=filterValues") + raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues") return _enrichedFilterValues(db, context, AutoWorkflow, _scopedWorkflowFilter, column) if mode == "ids": - from modules.routes.routeHelpers import handleIdsMode + from modules.dbHelpers.paginationHelpers import handleIdsMode baseFilter = _scopedWorkflowFilter(context) recordFilter = dict(baseFilter) if baseFilter else {} recordFilter["isTemplate"] = False @@ -783,7 +782,7 @@ def get_system_workflows( sort=[{"field": "sysCreatedAt", "direction": "desc"}], ) - from modules.routes.routeHelpers import enrichRowsWithFkLabels, resolveMandateLabels, resolveInstanceLabels + from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels, resolveMandateLabels, resolveInstanceLabels featureCodeMap: dict = {} @@ -811,7 +810,7 @@ def get_system_workflows( fkSortField = _firstFkSortFieldForWorkflows(paginationParams) if fkSortField: - from modules.routes.routeHelpers import getRecordsetPaginatedWithFkSort, applyFiltersAndSort + from modules.dbHelpers.paginationHelpers import getRecordsetPaginatedWithFkSort, applyFiltersAndSort _COMPUTED_FIELDS = {"lastStartedAt", "runCount", "isRunning"} hasComputedFilter = bool( paginationParams.filters @@ -872,8 +871,9 @@ def get_system_workflows( items.append(row) enrichRowsWithFkLabels( items, + db=db, labelResolvers={ - "mandateId": resolveMandateLabels, + "mandateId": partial(resolveMandateLabels, db), "featureInstanceId": _resolveInstanceLabelsWithFeatureCode, }, ) @@ -934,8 +934,9 @@ def get_system_workflows( items.append(row) enrichRowsWithFkLabels( items, + db=db, labelResolvers={ - "mandateId": resolveMandateLabels, + "mandateId": partial(resolveMandateLabels, db), "featureInstanceId": _resolveInstanceLabelsWithFeatureCode, }, ) @@ -1047,7 +1048,7 @@ def _enrichedFilterValues( Returns JSONResponse to bypass FastAPI response_model validation. """ from fastapi.responses import JSONResponse - from modules.routes.routeHelpers import resolveMandateLabels, resolveInstanceLabels + from modules.dbHelpers.fkLabelResolver import resolveMandateLabels, resolveInstanceLabels if _isTimestampColumn(modelClass, column): return JSONResponse(content=[]) @@ -1061,7 +1062,7 @@ def _enrichedFilterValues( allVals = {r.get("mandateId") for r in items} mandateIds = sorted(v for v in allVals if v) hasEmpty = None in allVals or "" in allVals - labelMap = resolveMandateLabels(mandateIds) if mandateIds else {} + labelMap = resolveMandateLabels(db, mandateIds) if mandateIds else {} result = [{"value": mid, "label": labelMap.get(mid) or f"NA({mid})"} for mid in mandateIds] if hasEmpty: result.append(None) @@ -1086,7 +1087,7 @@ def _enrichedFilterValues( allVals = {w.get("featureInstanceId") for w in wfs} instanceIds = sorted(v for v in allVals if v) hasEmpty = None in allVals or "" in allVals - labelMap = resolveInstanceLabels(instanceIds) if instanceIds else {} + labelMap = resolveInstanceLabels(db, instanceIds) if instanceIds else {} result = [{"value": iid, "label": labelMap.get(iid) or f"NA({iid})"} for iid in instanceIds] if hasEmpty: result.append(None) diff --git a/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py b/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py index a23688e5..9389ee85 100644 --- a/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py +++ b/modules/serviceCenter/services/serviceAgent/actionToolAdapter.py @@ -9,6 +9,7 @@ from modules.serviceCenter.services.serviceAgent.datamodelAgent import ( ToolDefinition, ToolResult ) from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry +import time logger = logging.getLogger(__name__) @@ -124,7 +125,7 @@ def _catalogTypeToJsonSchema(typeStr: str, _depth: int = 0) -> Dict[str, Any]: `_depth` guards against pathological recursion in case of a cyclic catalog. """ - from modules.features.graphicalEditor.portTypes import ( + from modules.datamodels.datamodelPortTypes import ( PORT_TYPE_CATALOG, PRIMITIVE_TYPES, ) @@ -204,8 +205,7 @@ def _createDispatchHandler(actionExecutor, methodName: str, actionName: str, ser if "mandateId" not in args and context.get("mandateId"): args["mandateId"] = context["mandateId"] if "parentOperationId" not in args: - import time as _time - toolOpId = f"agentTool_{methodName}_{actionName}_{int(_time.time())}" + toolOpId = f"agentTool_{methodName}_{actionName}_{int(time.time())}" chatSvc = getattr(services, "chat", None) if services else None if chatSvc: try: diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_connectionTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_connectionTools.py index 06a76d28..4bb97de9 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_connectionTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_connectionTools.py @@ -8,6 +8,7 @@ from typing import Any, Dict, List, Optional from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResult from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry +import base64 from modules.serviceCenter.services.serviceAgent.coreTools._helpers import ( _buildResolverDbFromServices, _getOrCreateTempFolder, @@ -19,7 +20,7 @@ from modules.serviceCenter.services.serviceAgent.coreTools._helpers import ( logger = logging.getLogger(__name__) -def _registerConnectionTools(registry: ToolRegistry, services): +def registerConnectionTools(registry: ToolRegistry, services): """Auto-extracted from registerCoreTools.""" # ---- Connection tools (external data sources) ---- @@ -72,7 +73,6 @@ def _registerConnectionTools(registry: ToolRegistry, services): return ToolResult(toolCallId="", toolName="uploadToExternal", success=False, error=str(e)) async def _sendMail(args: Dict[str, Any], context: Dict[str, Any]): - import base64 as _b64 connectionId = args.get("connectionId", "") to = args.get("to", []) @@ -98,7 +98,7 @@ def _registerConnectionTools(registry: ToolRegistry, services): return ToolResult(toolCallId="", toolName="sendMail", success=False, error=f"Attachment file has no data: {fid}") graphAttachments.append({ "name": fileRow.fileName, - "contentBytes": _b64.b64encode(rawBytes).decode("ascii"), + "contentBytes": base64.b64encode(rawBytes).decode("ascii"), "contentType": getattr(fileRow, "mimeType", "application/octet-stream"), }) diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_crossWorkflowTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_crossWorkflowTools.py index 7307e019..055a4055 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_crossWorkflowTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_crossWorkflowTools.py @@ -8,6 +8,7 @@ from typing import Any, Dict, List, Optional from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResult from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry +import json from modules.serviceCenter.services.serviceAgent.coreTools._helpers import ( _getOrCreateTempFolder, _looksLikeBinary, @@ -18,13 +19,12 @@ from modules.serviceCenter.services.serviceAgent.coreTools._helpers import ( logger = logging.getLogger(__name__) -def _registerCrossWorkflowTools(registry: ToolRegistry, services): +def registerCrossWorkflowTools(registry: ToolRegistry, services): """Auto-extracted from registerCoreTools.""" # ---- Cross-workflow tools ---- async def _listWorkflowHistory(args: Dict[str, Any], context: Dict[str, Any]) -> ToolResult: """List all chat workflows in this workspace with metadata.""" - import json as _json try: chatService = services.chat chatInterface = chatService.interfaceDbChat @@ -64,7 +64,7 @@ def _registerCrossWorkflowTools(registry: ToolRegistry, services): return ToolResult( toolCallId="", toolName="listWorkflowHistory", - success=True, data=_json.dumps(items, ensure_ascii=False), + success=True, data=json.dumps(items, ensure_ascii=False), ) except Exception as e: return ToolResult( @@ -90,7 +90,6 @@ def _registerCrossWorkflowTools(registry: ToolRegistry, services): async def _readWorkflowMessages(args: Dict[str, Any], context: Dict[str, Any]) -> ToolResult: """Read messages from a specific workflow.""" - import json as _json targetWorkflowId = args.get("workflowId", "") limit = int(args.get("limit", 20)) offset = int(args.get("offset", 0)) @@ -128,7 +127,7 @@ def _registerCrossWorkflowTools(registry: ToolRegistry, services): return ToolResult( toolCallId="", toolName="readWorkflowMessages", success=True, - data=header + "\n" + _json.dumps(items, ensure_ascii=False), + data=header + "\n" + json.dumps(items, ensure_ascii=False), ) except Exception as e: return ToolResult( diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py index a853301f..291f33dc 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_dataSourceTools.py @@ -8,6 +8,7 @@ from typing import Any, Dict, List, Optional from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResult from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry +from datetime import datetime, timezone from modules.serviceCenter.services.serviceAgent.coreTools._helpers import ( _attachFileAsChatDocument, _buildResolverDbFromServices, @@ -79,7 +80,6 @@ def _formatTaskLine(entry) -> str: dueMs = task.get("due_date") if dueMs: try: - from datetime import datetime, timezone due = datetime.fromtimestamp(int(dueMs) / 1000, tz=timezone.utc).strftime("%Y-%m-%d") parts.append(f"due: {due}") except (TypeError, ValueError, OverflowError): @@ -105,7 +105,7 @@ def _buildCountLine(entries, limit) -> str: return line -def _registerDataSourceTools(registry: ToolRegistry, services): +def registerDataSourceTools(registry: ToolRegistry, services): """Auto-extracted from registerCoreTools.""" def _buildResolverDb(): @@ -314,7 +314,7 @@ def _registerDataSourceTools(registry: ToolRegistry, services): error="Provide either dataSourceId OR connectionId+service") try: from modules.connectors.connectorResolver import ConnectorResolver - from modules.connectors.connectorProviderBase import DownloadResult as _DR + from modules.connectors.connectorProviderBase import DownloadResult _sourceNeutralize = False if dsId: connectionId, service, basePath, _sourceNeutralize = await _resolveDataSource(dsId) @@ -328,7 +328,7 @@ def _registerDataSourceTools(registry: ToolRegistry, services): adapter = await resolver.resolveService(connectionId, service) result = await adapter.download(fullPath) - if isinstance(result, _DR): + if isinstance(result, DownloadResult): fileBytes = result.data resolvedName = result.fileName or fileName if resolvedName != fileName: diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_documentTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_documentTools.py index 62413103..a79f5995 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_documentTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_documentTools.py @@ -9,6 +9,7 @@ from typing import Any, Dict, List, Optional from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResult from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry +import base64 from modules.serviceCenter.services.serviceAgent.coreTools._helpers import ( _getOrCreateTempFolder, _MAX_TOOL_RESULT_CHARS, @@ -87,7 +88,7 @@ def _filterUdmByTypeImpl(udm: Dict[str, Any], content_type: str) -> Dict[str, An return {"nodes": hits, "count": len(hits), "contentType": content_type} -def _registerDocumentTools(registry: ToolRegistry, services): +def registerDocumentTools(registry: ToolRegistry, services): """Auto-extracted from registerCoreTools.""" # ---- Document tools (Smart Documents / Container Handling) ---- @@ -371,7 +372,6 @@ def _registerDocumentTools(registry: ToolRegistry, services): return ToolResult(toolCallId="", toolName="describeImage", success=False, error="fileId is required") try: - import base64 as _b64 imageData = None mimeType = "image/png" @@ -411,7 +411,7 @@ def _registerDocumentTools(registry: ToolRegistry, services): rawContent = chatService.getFileContent(fileId) if not fileContent else fileContent rawData = rawContent.get("data", "") if rawContent else "" if isinstance(rawData, str) and len(rawData) > 100: - pdfBytes = _b64.b64decode(rawData) + pdfBytes = base64.b64decode(rawData) elif isinstance(rawData, bytes): pdfBytes = rawData else: @@ -422,7 +422,7 @@ def _registerDocumentTools(registry: ToolRegistry, services): if 0 <= targetPage < len(doc): page = doc[targetPage] pix = page.get_pixmap(dpi=200) - imageData = _b64.b64encode(pix.tobytes("png")).decode("ascii") + imageData = base64.b64encode(pix.tobytes("png")).decode("ascii") mimeType = "image/png" logger.info("describeImage: rendered PDF page %d as image (%dx%d)", targetPage, pix.width, pix.height) doc.close() @@ -439,7 +439,7 @@ def _registerDocumentTools(registry: ToolRegistry, services): f"This file likely contains text, not images. Use readFile(fileId=\"{fileId}\") to access its text content.") try: - rawHead = _b64.b64decode(imageData[:32]) + rawHead = base64.b64decode(imageData[:32]) if rawHead[:3] == b"\xff\xd8\xff": mimeType = "image/jpeg" elif rawHead[:8] == b"\x89PNG\r\n\x1a\n": @@ -455,9 +455,9 @@ def _registerDocumentTools(registry: ToolRegistry, services): _opType = OTE.IMAGE_ANALYSE try: - from modules.datamodels.datamodelFiles import FileItem as _FileItemModel - from modules.interfaces.interfaceDbManagement import ComponentObjects as _CO - _fRow = _CO().db._loadRecord(_FileItemModel, fileId) + from modules.datamodels.datamodelFiles import FileItem + from modules.interfaces.interfaceDbManagement import ComponentObjects + _fRow = ComponentObjects().db._loadRecord(FileItem, fileId) if _fRow: _fGet = (lambda k, d=None: _fRow.get(k, d)) if isinstance(_fRow, dict) else (lambda k, d=None: getattr(_fRow, k, d)) if bool(_fGet("neutralize", False)): diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_emailTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_emailTools.py index a49403cd..d36a2727 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_emailTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_emailTools.py @@ -59,7 +59,7 @@ def _extractMessageId(args: Dict[str, Any]) -> str: return "" -def _registerEmailTools(registry: ToolRegistry, services): +def registerEmailTools(registry: ToolRegistry, services): """Register Outlook reply/forward/move/delete/flag tools on ``registry``.""" # ------------------------------------------------------------------ diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py index 12732a4b..e6efad99 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_featureSubAgentTools.py @@ -61,7 +61,7 @@ def clearFeatureQueryCache(featureInstanceId: Optional[str] = None) -> int: return len(keys) -def _registerFeatureSubAgentTools(registry: ToolRegistry, services): +def registerFeatureSubAgentTools(registry: ToolRegistry, services): """Auto-extracted from registerCoreTools.""" # ---- Feature Data Sub-Agent tool ---- @@ -76,7 +76,7 @@ def _registerFeatureSubAgentTools(registry: ToolRegistry, services): ) try: from modules.serviceCenter.services.serviceAgent.featureDataAgent import runFeatureDataAgent - from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + from modules.datamodels.datamodelFeatures import FeatureDataSource from modules.interfaces.interfaceDbApp import getRootInterface rootIf = getRootInterface() diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_mediaTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_mediaTools.py index c7e292e2..380c9950 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_mediaTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_mediaTools.py @@ -8,6 +8,9 @@ from typing import Any, Dict, List, Optional from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResult from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry +import base64 +import io +import re from modules.serviceCenter.services.serviceAgent.coreTools._helpers import ( _attachFileAsChatDocument, _formatToolFileResult, @@ -20,7 +23,7 @@ from modules.serviceCenter.services.serviceAgent.coreTools._helpers import ( logger = logging.getLogger(__name__) -def _registerMediaTools(registry: ToolRegistry, services): +def registerMediaTools(registry: ToolRegistry, services): """Auto-extracted from registerCoreTools.""" # ---- Document rendering tool ---- @@ -33,7 +36,6 @@ def _registerMediaTools(registry: ToolRegistry, services): async def _renderDocument(args: Dict[str, Any], context: Dict[str, Any]): """Render agent-produced markdown content into any document format via the RendererRegistry.""" - import re as _re sourceFileId = (args.get("sourceFileId") or "").strip() content = args.get("content", "") if not isinstance(content, str): @@ -130,8 +132,7 @@ def _registerMediaTools(registry: ToolRegistry, services): chunks = knowledgeService._knowledgeDb.getContentChunks(fileId) imageChunks = [c for c in (chunks or []) if c.get("contentType") == "image"] if imageChunks and imageChunks[0].get("data"): - import base64 as _b64 - return _b64.b64decode(imageChunks[0]["data"]) + return base64.b64decode(imageChunks[0]["data"]) except Exception as e: logger.warning(f"renderDocument: lazy knowledge image fetch failed for {fileId}: {e}") try: @@ -158,8 +159,7 @@ def _registerMediaTools(registry: ToolRegistry, services): try: rawBytes = services.chat.getFileData(fileRef) if rawBytes: - import base64 as _b64 - targetObj["base64Data"] = _b64.b64encode(rawBytes).decode("ascii") + targetObj["base64Data"] = base64.b64encode(rawBytes).decode("ascii") targetObj["mimeType"] = "image/png" resolvedImages += 1 except Exception as e: @@ -234,7 +234,7 @@ def _registerMediaTools(registry: ToolRegistry, services): sideEvents = [] chatService = services.chat - sanitizedTitle = _re.sub(r'[^\w._-]', '_', title, flags=_re.UNICODE).strip('_') or "document" + sanitizedTitle = re.sub(r'[^\w._-]', '_', title, flags=re.UNICODE).strip('_') or "document" for doc in documents: docData = doc.documentData if hasattr(doc, "documentData") else b"" @@ -337,24 +337,22 @@ def _registerMediaTools(registry: ToolRegistry, services): # ── textToSpeech tool ────────────────────────────────────────────── def _stripMarkdownForTts(text: str) -> str: """Strip markdown formatting so TTS reads clean speech text.""" - import re as _re t = text - t = _re.sub(r'\*\*(.+?)\*\*', r'\1', t) - t = _re.sub(r'\*(.+?)\*', r'\1', t) - t = _re.sub(r'__(.+?)__', r'\1', t) - t = _re.sub(r'_(.+?)_', r'\1', t) - t = _re.sub(r'`[^`]+`', lambda m: m.group(0)[1:-1], t) - t = _re.sub(r'^#{1,6}\s*', '', t, flags=_re.MULTILINE) - t = _re.sub(r'^\s*[-*+]\s+', '', t, flags=_re.MULTILINE) - t = _re.sub(r'^\s*\d+\.\s+', '', t, flags=_re.MULTILINE) - t = _re.sub(r'\[(.+?)\]\(.+?\)', r'\1', t) - t = _re.sub(r'!\[.*?\]\(.*?\)', '', t) - t = _re.sub(r'\n{3,}', '\n\n', t) + t = re.sub(r'\*\*(.+?)\*\*', r'\1', t) + t = re.sub(r'\*(.+?)\*', r'\1', t) + t = re.sub(r'__(.+?)__', r'\1', t) + t = re.sub(r'_(.+?)_', r'\1', t) + t = re.sub(r'`[^`]+`', lambda m: m.group(0)[1:-1], t) + t = re.sub(r'^#{1,6}\s*', '', t, flags=re.MULTILINE) + t = re.sub(r'^\s*[-*+]\s+', '', t, flags=re.MULTILINE) + t = re.sub(r'^\s*\d+\.\s+', '', t, flags=re.MULTILINE) + t = re.sub(r'\[(.+?)\]\(.+?\)', r'\1', t) + t = re.sub(r'!\[.*?\]\(.*?\)', '', t) + t = re.sub(r'\n{3,}', '\n\n', t) return t.strip() async def _textToSpeech(args: Dict[str, Any], context: Dict[str, Any]): """Convert text to speech using Google Cloud TTS, deliver audio via SSE.""" - import base64 as _b64 text = args.get("text", "") language = args.get("language", "auto") voiceName = args.get("voiceName") @@ -455,7 +453,7 @@ def _registerMediaTools(registry: ToolRegistry, services): return ToolResult(toolCallId="", toolName="textToSpeech", success=False, error="TTS returned no audio") if isinstance(audioContent, bytes): - audioB64 = _b64.b64encode(audioContent).decode("ascii") + audioB64 = base64.b64encode(audioContent).decode("ascii") elif isinstance(audioContent, str): audioB64 = audioContent else: @@ -510,7 +508,6 @@ def _registerMediaTools(registry: ToolRegistry, services): async def _generateImage(args: Dict[str, Any], context: Dict[str, Any]): """Generate an image from a text prompt using AI (DALL-E).""" - import re as _re prompt = (args.get("prompt") or "").strip() style = (args.get("style") or "").strip() or None @@ -537,7 +534,7 @@ def _registerMediaTools(registry: ToolRegistry, services): sideEvents = [] savedFiles = [] chatService = services.chat - sanitizedTitle = _re.sub(r'[^\w._-]', '_', title, flags=_re.UNICODE).strip('_') or "generated_image" + sanitizedTitle = re.sub(r'[^\w._-]', '_', title, flags=re.UNICODE).strip('_') or "generated_image" for doc in aiResponse.documents: docData = doc.documentData if hasattr(doc, "documentData") else b"" @@ -615,7 +612,6 @@ def _registerMediaTools(registry: ToolRegistry, services): async def _createChart(args: Dict[str, Any], context: Dict[str, Any]): """Create a data chart as PNG image using matplotlib.""" - import re as _re chartType = (args.get("chartType") or "bar").strip().lower() title = (args.get("title") or "Chart").strip() @@ -633,10 +629,8 @@ def _registerMediaTools(registry: ToolRegistry, services): try: import matplotlib matplotlib.use("Agg") - import logging as _mpllog - _mpllog.getLogger("matplotlib").setLevel(_mpllog.WARNING) + logging.getLogger("matplotlib").setLevel(logging.WARNING) import matplotlib.pyplot as plt - import io _DEFAULT_COLORS = [ "#4285F4", "#EA4335", "#FBBC04", "#34A853", "#FF6D01", @@ -712,7 +706,7 @@ def _registerMediaTools(registry: ToolRegistry, services): pngData = buf.getvalue() chatService = services.chat - sanitizedTitle = _re.sub(r'[^\w._-]', '_', title, flags=_re.UNICODE).strip('_') or "chart" + sanitizedTitle = re.sub(r'[^\w._-]', '_', title, flags=re.UNICODE).strip('_') or "chart" fileName = f"{sanitizedTitle}.png" if hasattr(chatService.interfaceDbComponent, "saveGeneratedFile"): @@ -883,8 +877,6 @@ def _registerMediaTools(registry: ToolRegistry, services): confirmation -- not the revealed cleartext. Resolution uses ONLY the private local placeholder mapping (no external LLM). """ - import base64 as _b64 - import re as _re text = args.get("text", "") fileId = (args.get("fileId") or "").strip() fileName = (args.get("fileName") or "").strip() @@ -927,13 +919,13 @@ def _registerMediaTools(registry: ToolRegistry, services): fileName = (info.get("fileName") if info else None) or f"{fileId}.txt" # Resolve placeholders locally (private mapping, no LLM). Count for the audit message. - placeholderCount = len(_re.findall(r'\[[a-z]+\.[a-f0-9-]{36}\]', text)) + placeholderCount = len(re.findall(r'\[[a-z]+\.[a-f0-9-]{36}\]', text)) revealed = neutralizationService.resolveText(text) if not fileName: fileName = "revealed.txt" mimeType = "text/markdown" if fileName.lower().endswith((".md", ".markdown")) else "text/plain" - contentB64 = _b64.b64encode(revealed.encode("utf-8")).decode("ascii") + contentB64 = base64.b64encode(revealed.encode("utf-8")).decode("ascii") return ToolResult( toolCallId="", toolName="revealDocument", success=True, diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py b/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py index 8aa83732..9b4d2818 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/_workspaceTools.py @@ -8,6 +8,7 @@ from typing import Any, Dict, List, Optional from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResult from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry +import re from modules.serviceCenter.services.serviceAgent.coreTools._helpers import ( _attachFileAsChatDocument, _formatToolFileResult, @@ -34,11 +35,10 @@ def _isStaleExtractionResult(text: str) -> bool: return any(p in textLower for p in _STALE_EXTRACTION_PATTERNS) -import uuid as _uuid +import uuid -def _registerWorkspaceTools(registry: ToolRegistry, services): +def registerWorkspaceTools(registry: ToolRegistry, services): """Auto-extracted from registerCoreTools.""" - import uuid as _uuid # ---- Read-only tools ---- @@ -152,9 +152,9 @@ def _registerWorkspaceTools(registry: ToolRegistry, services): if text.strip(): _fileNeedNeutralize = False try: - from modules.datamodels.datamodelFiles import FileItem as _FI - from modules.interfaces.interfaceDbManagement import ComponentObjects as _CO - _fRec = _CO().db._loadRecord(_FI, fileId) + from modules.datamodels.datamodelFiles import FileItem + from modules.interfaces.interfaceDbManagement import ComponentObjects + _fRec = ComponentObjects().db._loadRecord(FileItem, fileId) if _fRec: _fG = (lambda k, d=None: _fRec.get(k, d)) if isinstance(_fRec, dict) else (lambda k, d=None: getattr(_fRec, k, d)) _fileNeedNeutralize = bool(_fG("neutralize", False)) @@ -217,7 +217,6 @@ def _registerWorkspaceTools(registry: ToolRegistry, services): return ToolResult(toolCallId="", toolName="listFiles", success=False, error=str(e)) async def _searchInFileContent(args: Dict[str, Any], context: Dict[str, Any]): - import re as _re fileId = args.get("fileId", "") query = args.get("query", "") contextLines = args.get("contextLines", 2) @@ -234,7 +233,7 @@ def _registerWorkspaceTools(registry: ToolRegistry, services): content = rawBytes.decode("latin-1", errors="replace") lines = content.split("\n") - pattern = _re.compile(_re.escape(query), _re.IGNORECASE) + pattern = re.compile(re.escape(query), re.IGNORECASE) matches = [] for i, line in enumerate(lines): if pattern.search(line): @@ -708,7 +707,7 @@ def _registerWorkspaceTools(registry: ToolRegistry, services): newContent = oldContent.replace(oldText, newText) if replaceAll else oldContent.replace(oldText, newText, 1) - editId = str(_uuid.uuid4()) + editId = str(uuid.uuid4()) label = f"all {count} occurrences" if replaceAll else "1 occurrence" return ToolResult( toolCallId="", toolName="replaceInFile", success=True, diff --git a/modules/serviceCenter/services/serviceAgent/coreTools/registerCore.py b/modules/serviceCenter/services/serviceAgent/coreTools/registerCore.py index f740f276..d2a76c9f 100644 --- a/modules/serviceCenter/services/serviceAgent/coreTools/registerCore.py +++ b/modules/serviceCenter/services/serviceAgent/coreTools/registerCore.py @@ -4,14 +4,14 @@ from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry -from modules.serviceCenter.services.serviceAgent.coreTools._workspaceTools import _registerWorkspaceTools -from modules.serviceCenter.services.serviceAgent.coreTools._connectionTools import _registerConnectionTools -from modules.serviceCenter.services.serviceAgent.coreTools._dataSourceTools import _registerDataSourceTools -from modules.serviceCenter.services.serviceAgent.coreTools._documentTools import _registerDocumentTools -from modules.serviceCenter.services.serviceAgent.coreTools._emailTools import _registerEmailTools -from modules.serviceCenter.services.serviceAgent.coreTools._mediaTools import _registerMediaTools -from modules.serviceCenter.services.serviceAgent.coreTools._featureSubAgentTools import _registerFeatureSubAgentTools -from modules.serviceCenter.services.serviceAgent.coreTools._crossWorkflowTools import _registerCrossWorkflowTools +from modules.serviceCenter.services.serviceAgent.coreTools._workspaceTools import registerWorkspaceTools +from modules.serviceCenter.services.serviceAgent.coreTools._connectionTools import registerConnectionTools +from modules.serviceCenter.services.serviceAgent.coreTools._dataSourceTools import registerDataSourceTools +from modules.serviceCenter.services.serviceAgent.coreTools._documentTools import registerDocumentTools +from modules.serviceCenter.services.serviceAgent.coreTools._emailTools import registerEmailTools +from modules.serviceCenter.services.serviceAgent.coreTools._mediaTools import registerMediaTools +from modules.serviceCenter.services.serviceAgent.coreTools._featureSubAgentTools import registerFeatureSubAgentTools +from modules.serviceCenter.services.serviceAgent.coreTools._crossWorkflowTools import registerCrossWorkflowTools def registerCoreTools(registry: ToolRegistry, services): @@ -19,11 +19,11 @@ def registerCoreTools(registry: ToolRegistry, services): Delegates to domain-specific modules under coreTools/. """ - _registerWorkspaceTools(registry, services) - _registerConnectionTools(registry, services) - _registerDataSourceTools(registry, services) - _registerDocumentTools(registry, services) - _registerEmailTools(registry, services) - _registerMediaTools(registry, services) - _registerFeatureSubAgentTools(registry, services) - _registerCrossWorkflowTools(registry, services) + registerWorkspaceTools(registry, services) + registerConnectionTools(registry, services) + registerDataSourceTools(registry, services) + registerDocumentTools(registry, services) + registerEmailTools(registry, services) + registerMediaTools(registry, services) + registerFeatureSubAgentTools(registry, services) + registerCrossWorkflowTools(registry, services) diff --git a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py index 83f9de41..6620f219 100644 --- a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py +++ b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py @@ -15,6 +15,8 @@ from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistr from modules.serviceCenter.services.serviceAgent.agentLoop import runAgentLoop from modules.serviceCenter.services.serviceAgent.actionToolAdapter import ActionToolAdapter from modules.serviceCenter.services.serviceAgent.coreTools import registerCoreTools +import json +import time from modules.serviceCenter.services.serviceBilling.mainServiceBilling import ( getService as getBillingService, InsufficientBalanceException, @@ -530,7 +532,6 @@ class AgentService: userId = self.services.user.id if self.services.user else "" featureInstanceId = self.services.featureInstanceId or "" - import json traceValue = json.dumps(summaryData, default=str) await knowledgeService.storeEntity( @@ -683,8 +684,7 @@ def _buildWorkflowHintItems( if not others: return [] - import time as _time - now = _time.time() + now = time.time() others.sort(key=lambda w: w.get("sysCreatedAt") or w.get("startedAt") or 0, reverse=True) others = others[:10] diff --git a/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py b/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py index 4c747e64..395c674e 100644 --- a/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py +++ b/modules/serviceCenter/services/serviceAgent/sandboxExecutor.py @@ -7,6 +7,8 @@ import sys import io import traceback from typing import Dict, Any +import asyncio +import zipfile logger = logging.getLogger(__name__) @@ -135,30 +137,28 @@ class SafeZipFile: Does not expose extract/write -- only namelist, infolist, and in-memory read.""" def __init__(self, data: bytes): - import zipfile as _zf - self._zf = _zf.ZipFile(io.BytesIO(data), 'r') + self._zf = zipfile.ZipFile(io.BytesIO(data), 'r') def namelist(self): - return self._zf.namelist() + return self.zipfile.namelist() def infolist(self): return [{"filename": i.filename, "file_size": i.file_size, "compress_size": i.compress_size, "date_time": i.date_time} - for i in self._zf.infolist()] + for i in self.zipfile.infolist()] def read(self, name: str) -> bytes: - return self._zf.read(name) + return self.zipfile.read(name) def __enter__(self): return self def __exit__(self, *args): - self._zf.close() + self.zipfile.close() async def executePython(code: str, *, services=None) -> Dict[str, Any]: """Execute Python code in a restricted sandbox. Returns {success, output, error}.""" - import asyncio def _run(): restrictedGlobals = _buildRestrictedGlobals() diff --git a/modules/serviceCenter/services/serviceAgent/workflowTools.py b/modules/serviceCenter/services/serviceAgent/workflowTools.py index 7f01ee79..82eda22d 100644 --- a/modules/serviceCenter/services/serviceAgent/workflowTools.py +++ b/modules/serviceCenter/services/serviceAgent/workflowTools.py @@ -16,12 +16,12 @@ Conventions enforced here (matches coreTools / actionToolAdapter): omits them — the editor agent always runs in exactly one workflow. """ -import json import logging import uuid from typing import Dict, Any, List, Tuple from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResult +import json logger = logging.getLogger(__name__) @@ -712,7 +712,7 @@ async def _readWorkflowMessages(params: Dict[str, Any], context: Any) -> ToolRes if not workflowId or not instanceId: return _err(name, "workflowId and instanceId required") iface = _getInterface(context, instanceId) - from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoRun + from modules.datamodels.datamodelWorkflowAutomation import AutoRun runs = iface.db.getRecordset(AutoRun, recordFilter={"workflowId": workflowId}) or [] runSummaries = [] for r in sorted(runs, key=lambda x: x.get("startedAt") or 0, reverse=True)[:10]: @@ -912,7 +912,6 @@ def _loadEnvelopeFromUdb(fileId: str, context: Any): Returns ``None`` if the file cannot be read or is not valid JSON — the caller turns that into a tool error message. """ - import json try: import modules.interfaces.interfaceDbManagement as interfaceDbManagement user = _resolveUser(context) diff --git a/modules/serviceCenter/services/serviceAi/mainServiceAi.py b/modules/serviceCenter/services/serviceAi/mainServiceAi.py index afbde59a..0fd10678 100644 --- a/modules/serviceCenter/services/serviceAi/mainServiceAi.py +++ b/modules/serviceCenter/services/serviceAi/mainServiceAi.py @@ -1158,7 +1158,7 @@ detectedIntent-Werte: def _writeAuditEntry(self, request, response, wasNeutralized: bool = False): """Write a rich AI audit entry with input, output, and neutralization metadata.""" try: - from modules.shared.aiAuditLogger import aiAuditLogger + from modules.dbHelpers.aiAuditLogger import aiAuditLogger user = self.services.user mandateId = self.services.mandateId @@ -1909,8 +1909,6 @@ Respond with ONLY a JSON object in this exact format: - DYNAMIC mode: Intent analysis (clarifyDocumentIntents) runs first; extraction and processing use the intents and AI-derived extractionPrompt. """ - import time - # Create operation ID workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}" extractOperationId = f"data_extract_{workflowId}_{int(time.time())}" diff --git a/modules/serviceCenter/services/serviceAi/subAiCallLooping.py b/modules/serviceCenter/services/serviceAi/subAiCallLooping.py index 3ef22535..ea218e11 100644 --- a/modules/serviceCenter/services/serviceAi/subAiCallLooping.py +++ b/modules/serviceCenter/services/serviceAi/subAiCallLooping.py @@ -50,13 +50,13 @@ import logging from typing import Dict, Any, List, Optional, Callable from modules.datamodels.datamodelAi import ( - AiCallRequest, AiCallOptions + AiCallRequest, AiCallOptions, ContinuationContext ) from modules.datamodels.datamodelExtraction import ContentPart from .subJsonResponseHandling import JsonResponseHandler from .subLoopingUseCases import LoopingUseCaseRegistry -from modules.workflows.processing.shared.stateTools import checkWorkflowStopped -from modules.shared.jsonContinuation import getContexts +from modules.shared.workflowState import checkWorkflowStopped +from modules.datamodels.jsonContinuation import getContexts from modules.shared.jsonUtils import buildContinuationContext, tryParseJson from modules.shared.jsonUtils import closeJsonStructures from modules.shared.jsonUtils import stripCodeFences, normalizeJsonText @@ -186,7 +186,9 @@ class AiCallLooper: # This is a continuation - build continuation context with raw JSON and rebuild prompt continuationContext = buildContinuationContext( - allSections, lastRawResponse, useCaseId, templateStructure + allSections, lastRawResponse, useCaseId, templateStructure, + continuationContextClass=ContinuationContext, + getContextsFn=getContexts ) if not lastRawResponse: logger.warning(f"Iteration {iteration}: No previous response available for continuation!") diff --git a/modules/serviceCenter/services/serviceAi/subContentExtraction.py b/modules/serviceCenter/services/serviceAi/subContentExtraction.py index e050bb67..6e5ddd42 100644 --- a/modules/serviceCenter/services/serviceAi/subContentExtraction.py +++ b/modules/serviceCenter/services/serviceAi/subContentExtraction.py @@ -16,7 +16,7 @@ from typing import Dict, Any, List, Optional from modules.datamodels.datamodelChat import ChatDocument from modules.datamodels.datamodelExtraction import ContentPart, DocumentIntent, ExtractionOptions, MergeStrategy -from modules.workflows.processing.shared.stateTools import checkWorkflowStopped +from modules.shared.workflowState import checkWorkflowStopped logger = logging.getLogger(__name__) diff --git a/modules/serviceCenter/services/serviceAi/subDocumentIntents.py b/modules/serviceCenter/services/serviceAi/subDocumentIntents.py index 7a462177..d4d7fae7 100644 --- a/modules/serviceCenter/services/serviceAi/subDocumentIntents.py +++ b/modules/serviceCenter/services/serviceAi/subDocumentIntents.py @@ -14,7 +14,7 @@ from typing import Dict, Any, List, Optional from modules.datamodels.datamodelChat import ChatDocument from modules.datamodels.datamodelExtraction import DocumentIntent -from modules.workflows.processing.shared.stateTools import checkWorkflowStopped +from modules.shared.workflowState import checkWorkflowStopped logger = logging.getLogger(__name__) diff --git a/modules/serviceCenter/services/serviceAi/subLoopingUseCases.py b/modules/serviceCenter/services/serviceAi/subLoopingUseCases.py index f6a7c620..fa52fdac 100644 --- a/modules/serviceCenter/services/serviceAi/subLoopingUseCases.py +++ b/modules/serviceCenter/services/serviceAi/subLoopingUseCases.py @@ -9,6 +9,7 @@ Provides parametrized looping infrastructure supporting different JSON formats a import logging from dataclasses import dataclass, field from typing import Dict, Any, List, Optional, Callable +import json logger = logging.getLogger(__name__) @@ -27,7 +28,6 @@ def _handleSectionContentFinalResult(result: str, parsedJsonForUseCase: Any, ext def _handleChapterStructureFinalResult(result: str, parsedJsonForUseCase: Any, extractedJsonForUseCase: str, debugPrefix: str, services: Any) -> str: """Handle final result for chapter_structure: format JSON and write debug file.""" - import json final_json = json.dumps(parsedJsonForUseCase, indent=2, ensure_ascii=False) if parsedJsonForUseCase else (extractedJsonForUseCase or result) # Write final result for chapter structure if services and hasattr(services, 'utils') and hasattr(services.utils, 'writeDebugFile'): @@ -38,7 +38,6 @@ def _handleChapterStructureFinalResult(result: str, parsedJsonForUseCase: Any, e def _handleCodeStructureFinalResult(result: str, parsedJsonForUseCase: Any, extractedJsonForUseCase: str, debugPrefix: str, services: Any) -> str: """Handle final result for code_structure: format JSON and write debug file.""" - import json final_json = json.dumps(parsedJsonForUseCase, indent=2, ensure_ascii=False) if parsedJsonForUseCase else (extractedJsonForUseCase or result) # Write final result for code structure if services and hasattr(services, 'utils') and hasattr(services.utils, 'writeDebugFile'): @@ -49,7 +48,6 @@ def _handleCodeStructureFinalResult(result: str, parsedJsonForUseCase: Any, extr def _handleCodeContentFinalResult(result: str, parsedJsonForUseCase: Any, extractedJsonForUseCase: str, debugPrefix: str, services: Any) -> str: """Handle final result for code_content: format JSON.""" - import json final_json = json.dumps(parsedJsonForUseCase, indent=2, ensure_ascii=False) if parsedJsonForUseCase else (extractedJsonForUseCase or result) return final_json @@ -63,7 +61,7 @@ def _lift_section_plain_text(d: Dict[str, Any]) -> Optional[str]: return None -def _normalizeSectionContentJson(parsed: Any, useCaseId: str) -> Any: +def normalizeSectionContentJson(parsed: Any, useCaseId: str) -> Any: """Normalize JSON structure for section_content use case.""" # For section_content, expect {"elements": [...]} structure if isinstance(parsed, list): @@ -235,7 +233,7 @@ class LoopingUseCaseRegistry: continuationContextBuilder=None, # Will use default continuation context resultBuilder=None, # Return JSON directly finalResultHandler=_handleSectionContentFinalResult, - jsonNormalizer=_normalizeSectionContentJson, + jsonNormalizer=normalizeSectionContentJson, supportsAccumulation=False, requiresExtraction=False )) diff --git a/modules/serviceCenter/services/serviceAi/subStructureFilling.py b/modules/serviceCenter/services/serviceAi/subStructureFilling.py index 2baf0a84..c2e580a4 100644 --- a/modules/serviceCenter/services/serviceAi/subStructureFilling.py +++ b/modules/serviceCenter/services/serviceAi/subStructureFilling.py @@ -16,7 +16,9 @@ from typing import Dict, Any, List, Optional, Tuple from modules.datamodels.datamodelExtraction import ContentPart from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum -from modules.workflows.processing.shared.stateTools import checkWorkflowStopped +from modules.shared.workflowState import checkWorkflowStopped +import base64 +import io class _AiResponseFallback: @@ -44,7 +46,7 @@ def _normalizeImageElement(element: Dict[str, Any]) -> None: def _elements_from_section_content_ai_json(parsed: Any) -> List[Any]: """Normalize section_content AI JSON (incl. models that return {\"text\": ...}) into elements.""" - from modules.serviceCenter.services.serviceAi.subLoopingUseCases import _normalizeSectionContentJson + from modules.serviceCenter.services.serviceAi.subLoopingUseCases import normalizeSectionContentJson if parsed is None: return [] @@ -65,7 +67,7 @@ def _elements_from_section_content_ai_json(parsed: Any) -> List[Any]: and isinstance(parsed["sections"][0], dict) ): parsed = parsed["sections"][0] - norm = _normalizeSectionContentJson(parsed, "section_content") + norm = normalizeSectionContentJson(parsed, "section_content") if isinstance(norm, dict): els = norm.get("elements") return list(els) if isinstance(els, list) else [] @@ -498,7 +500,6 @@ class StructureFiller: # Handle IMAGE_GENERATE differently - returns image data directly if contentType == "image" and operationType == OperationTypeEnum.IMAGE_GENERATE: - import base64 base64Data = "" # Convert image data to base64 string if needed @@ -2528,7 +2529,7 @@ Output requirements: unifiedContext = "" if lastRawJson: # Get contexts directly from jsonContinuation - from modules.shared.jsonContinuation import getContexts + from modules.datamodels.jsonContinuation import getContexts contexts = getContexts(lastRawJson) overlapContext = contexts.overlapContext unifiedContext = contexts.hierarchyContextForPrompt @@ -2715,7 +2716,6 @@ CRITICAL: def _normalizeTableContentString(self, text: str, sectionId: str, elemIndex: int) -> Dict[str, Any]: """Convert a string table content (CSV, markdown, pipe-delimited) into {headers, rows}.""" import csv - import io lines = [l for l in text.strip().splitlines() if l.strip()] if not lines: diff --git a/modules/serviceCenter/services/serviceAi/subStructureGeneration.py b/modules/serviceCenter/services/serviceAi/subStructureGeneration.py index 16cbb786..cee66f60 100644 --- a/modules/serviceCenter/services/serviceAi/subStructureGeneration.py +++ b/modules/serviceCenter/services/serviceAi/subStructureGeneration.py @@ -13,7 +13,7 @@ from typing import Dict, Any, List, Optional from modules.datamodels.datamodelExtraction import ContentPart from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum -from modules.workflows.processing.shared.stateTools import checkWorkflowStopped +from modules.shared.workflowState import checkWorkflowStopped from modules.shared.i18nRegistry import normalizePrimaryLanguageTag logger = logging.getLogger(__name__) @@ -127,7 +127,7 @@ class StructureGenerator: unifiedContext = "" if lastRawJson: # Get contexts directly from jsonContinuation - from modules.shared.jsonContinuation import getContexts + from modules.datamodels.jsonContinuation import getContexts contexts = getContexts(lastRawJson) overlapContext = contexts.overlapContext unifiedContext = contexts.hierarchyContextForPrompt diff --git a/modules/serviceCenter/services/serviceBackgroundJobs/mainBackgroundJobService.py b/modules/serviceCenter/services/serviceBackgroundJobs/mainBackgroundJobService.py index 90b69bce..6ac1cbee 100644 --- a/modules/serviceCenter/services/serviceBackgroundJobs/mainBackgroundJobService.py +++ b/modules/serviceCenter/services/serviceBackgroundJobs/mainBackgroundJobService.py @@ -36,7 +36,7 @@ from typing import Any, Awaitable, Callable, Dict, List, Optional 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.datamodels.datamodelBackgroundJob import ( BackgroundJob, BackgroundJobStatusEnum, diff --git a/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py b/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py index 9e891c6c..9076f9e0 100644 --- a/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py +++ b/modules/serviceCenter/services/serviceBilling/billingExhaustedNotify.py @@ -55,7 +55,7 @@ def maybeEmailMandatePoolExhausted( return try: - from modules.shared.notifyMandateAdmins import notifyMandateAdmins + from modules.system.notifyMandateAdmins import notifyMandateAdmins sent = notifyMandateAdmins( mandateId, diff --git a/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py b/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py index 794f7dec..dd9fac2c 100644 --- a/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py +++ b/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py @@ -524,18 +524,7 @@ class ProviderNotAllowedException(Exception): super().__init__(self.message) -class BillingContextError(Exception): - """Raised when billing context is incomplete (missing mandateId, user, etc.). - - This is a FAIL-SAFE error: AI calls MUST NOT proceed without valid billing context. - Acts like a 0 CHF credit card pre-authorization check - validates that billing - CAN be recorded before any expensive AI operation starts. - """ - - def __init__(self, message: str = None): - self.message = message or "Billing context incomplete - AI call blocked" - super().__init__(self.message) - +from modules.shared.serviceExceptions import BillingContextError # Expose exception classes on BillingService so consumers can use service.InsufficientBalanceException # instead of importing from this module diff --git a/modules/serviceCenter/services/serviceChat/mainServiceChat.py b/modules/serviceCenter/services/serviceChat/mainServiceChat.py index 22eefeed..3e3d9f15 100644 --- a/modules/serviceCenter/services/serviceChat/mainServiceChat.py +++ b/modules/serviceCenter/services/serviceChat/mainServiceChat.py @@ -7,6 +7,7 @@ from modules.datamodels.datamodelUam import User, UserConnection from modules.datamodels.datamodelChat import ChatDocument, ChatMessage, ChatLog from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum from modules.shared.progressLogger import ProgressLogger +import json logger = logging.getLogger(__name__) @@ -694,7 +695,6 @@ class ChatService: int: Size in bytes """ try: - import json import sys if obj is None: diff --git a/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py b/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py index 5bcd1d52..df216810 100644 --- a/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py +++ b/modules/serviceCenter/services/serviceClickup/mainServiceClickup.py @@ -1,14 +1,19 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. -"""ClickUp API service (OAuth or personal token via UserConnection).""" +"""ClickUp API service (OAuth or personal token via UserConnection). + +Extends the low-level ClickupApiClient from connectors with service-layer +concerns (token resolution via SecurityService, extended query params). +""" import json import logging -import asyncio from typing import Any, Callable, Dict, List, Optional, Union import aiohttp +from modules.connectors.connectorProviderClickup import ClickupApiClient, clickupAuthorizationHeader + logger = logging.getLogger(__name__) _CLICKUP_API_BASE = "https://api.clickup.com/api/v2" @@ -16,19 +21,16 @@ _CLICKUP_API_BASE = "https://api.clickup.com/api/v2" def clickup_authorization_header(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}" + return clickupAuthorizationHeader(token) -class ClickupService: - """ClickUp REST API v2 — teams, hierarchy, lists as tables (tasks + custom fields).""" +class ClickupService(ClickupApiClient): + """ClickUp service — adds token resolution and extended API methods on top of the API client.""" def __init__(self, context, get_service: Callable[[str], Any]): + super().__init__(accessToken="") self._context = context self._get_service = get_service - self.accessToken: Optional[str] = None def setAccessTokenFromConnection(self, userConnection) -> bool: """Load OAuth/personal token from SecurityService for this UserConnection.""" @@ -61,54 +63,6 @@ class ClickupService: """Set token directly (e.g. connector adapter).""" self.accessToken = token - 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. Call setAccessTokenFromConnection first."} - url = f"{_CLICKUP_API_BASE}/{path.lstrip('/')}" - headers: Dict[str, str] = { - "Authorization": clickup_authorization_header(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: - # 404 on GET is common (wrong id / preview) — avoid ERROR noise in logs - 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 requestRaw( self, method: str, @@ -120,45 +74,26 @@ class ClickupService: """Escape hatch: call any v2 path under /api/v2 (path without leading /api/v2).""" return await self._request(method, path, params=params, json_body=json_body) - # --- Teams / user --- + # --- Extended API methods (beyond base ClickupApiClient) --- async def getAuthorizedUser(self) -> Dict[str, Any]: return await self._request("GET", "/user") - async def getAuthorizedTeams(self) -> Dict[str, Any]: - return await self._request("GET", "/team") - async def getTeam(self, team_id: str) -> Dict[str, Any]: return await self._request("GET", f"/team/{team_id}") - # --- Hierarchy --- - - async def getSpaces(self, team_id: str) -> Dict[str, Any]: - return await self._request("GET", f"/team/{team_id}/space") - async def getSpace(self, space_id: str) -> Dict[str, Any]: return await self._request("GET", f"/space/{space_id}") - async def getFolders(self, space_id: str) -> Dict[str, Any]: - return await self._request("GET", f"/space/{space_id}/folder") - async def getFolder(self, folder_id: str) -> Dict[str, Any]: return await self._request("GET", f"/folder/{folder_id}") - async def getListsInFolder(self, folder_id: str) -> Dict[str, Any]: - return await self._request("GET", f"/folder/{folder_id}/list") - - async def getFolderlessLists(self, space_id: str) -> Dict[str, Any]: - return await self._request("GET", f"/space/{space_id}/list") - async def getList(self, list_id: str) -> Dict[str, Any]: return await self._request("GET", f"/list/{list_id}") async def getListFields(self, list_id: str) -> Dict[str, Any]: return await self._request("GET", f"/list/{list_id}/field") - # --- Tasks (rows) --- - async def getTasksInList( self, list_id: str, @@ -186,8 +121,7 @@ class ClickupService: if dateUpdatedLt is not None: params["date_updated_lt"] = dateUpdatedLt if customFields: - import json as _json - params["custom_fields"] = _json.dumps(customFields) + params["custom_fields"] = json.dumps(customFields) return await self._request("GET", f"/list/{list_id}/task", params=params) async def getTask(self, task_id: str, *, include_subtasks: bool = True) -> Dict[str, Any]: @@ -202,38 +136,3 @@ class ClickupService: async def deleteTask(self, task_id: str) -> Dict[str, Any]: return await self._request("DELETE", f"/task/{task_id}") - - async def searchTeamTasks( - self, - team_id: str, - *, - query: str, - page: int = 0, - ) -> Dict[str, Any]: - """Search tasks in a workspace (team).""" - params = {"query": query, "page": page} - return await self._request("GET", f"/team/{team_id}/task", params=params) - - async def uploadTaskAttachment(self, task_id: str, file_bytes: bytes, file_name: str) -> Dict[str, Any]: - """Upload a file attachment to a task (multipart).""" - if not self.accessToken: - return {"error": "Access token is not set."} - url = f"{_CLICKUP_API_BASE}/task/{task_id}/attachment" - headers = {"Authorization": clickup_authorization_header(self.accessToken)} - data = aiohttp.FormData() - data.add_field( - "attachment", - file_bytes, - filename=file_name, - 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=data) 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)} diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorContainer.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorContainer.py index a7b06266..a69b2e35 100644 --- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorContainer.py +++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorContainer.py @@ -23,6 +23,7 @@ from ..subUtils import makeId from modules.datamodels.datamodelExtraction import ContentPart from modules.datamodels.datamodelContent import ContainerLimitError, ContentContextRef from ..subRegistry import Extractor +import base64 logger = logging.getLogger(__name__) @@ -225,7 +226,6 @@ def _addFilePart( except Exception as e: logger.warning(f"Type-extractor failed for {fileName} in container: {e}") - import base64 encodedData = base64.b64encode(data).decode("utf-8") if data else "" return [ContentPart( diff --git a/modules/serviceCenter/services/serviceExtraction/extractors/extractorEmail.py b/modules/serviceCenter/services/serviceExtraction/extractors/extractorEmail.py index 7f750835..b557172f 100644 --- a/modules/serviceCenter/services/serviceExtraction/extractors/extractorEmail.py +++ b/modules/serviceCenter/services/serviceExtraction/extractors/extractorEmail.py @@ -19,6 +19,7 @@ import mimetypes from modules.datamodels.datamodelExtraction import ContentPart from ..subUtils import makeId from ..subRegistry import Extractor +import base64 logger = logging.getLogger(__name__) @@ -210,7 +211,6 @@ def _delegateAttachment(attachData: bytes, attachName: str, parentId: str, depth """ if depth >= _MAX_CASCADE_DEPTH: logger.warning(f"Cascade depth {depth} reached for {attachName}, skipping extraction") - import base64 encodedData = base64.b64encode(attachData).decode("utf-8") if attachData else "" return [ContentPart( id=makeId(), parentId=parentId, label=attachName, @@ -242,7 +242,6 @@ def _delegateAttachment(attachData: bytes, attachName: str, parentId: str, depth except Exception as e: logger.warning(f"Extractor failed for email attachment {attachName}: {e}") - import base64 encodedData = base64.b64encode(attachData).decode("utf-8") if attachData else "" return [ContentPart( id=makeId(), parentId=parentId, label=attachName, diff --git a/modules/serviceCenter/services/serviceExtraction/mainServiceExtraction.py b/modules/serviceCenter/services/serviceExtraction/mainServiceExtraction.py index 8747c552..a3fb0baf 100644 --- a/modules/serviceCenter/services/serviceExtraction/mainServiceExtraction.py +++ b/modules/serviceCenter/services/serviceExtraction/mainServiceExtraction.py @@ -1820,9 +1820,8 @@ class ExtractionService: def _sniffImageMime(data) -> Optional[str]: """Detect image format from magic bytes. Returns None if unrecognised.""" - import base64 as _b64 try: - raw = data if isinstance(data, bytes) else _b64.b64decode(data[:32]) + raw = data if isinstance(data, bytes) else base64.b64decode(data[:32]) if raw[:3] == b"\xff\xd8\xff": return "image/jpeg" if raw[:8] == b"\x89PNG\r\n\x1a\n": diff --git a/modules/serviceCenter/services/serviceExtraction/subRegistry.py b/modules/serviceCenter/services/serviceExtraction/subRegistry.py index 422b4b50..864afe65 100644 --- a/modules/serviceCenter/services/serviceExtraction/subRegistry.py +++ b/modules/serviceCenter/services/serviceExtraction/subRegistry.py @@ -4,6 +4,9 @@ from typing import Any, Dict, List, Optional, TYPE_CHECKING import logging from modules.datamodels.datamodelExtraction import ContentPart +import os +import traceback +from pathlib import Path if TYPE_CHECKING: from modules.datamodels.datamodelUdm import UdmDocument @@ -80,9 +83,7 @@ class ExtractorRegistry: def _auto_discover_extractors(self): """Auto-discover and register all extractors from the extractors directory.""" try: - import os import importlib - from pathlib import Path # Get the extractors directory current_dir = Path(__file__).parent @@ -132,7 +133,6 @@ class ExtractorRegistry: except Exception as e: logger.error(f"ExtractorRegistry: Failed to auto-discover extractors: {str(e)}") - import traceback traceback.print_exc() def _auto_register_extractor(self, extractor: Extractor): @@ -262,7 +262,6 @@ class ChunkerRegistry: self.register("videostream", TextChunker()) except Exception as e: logger.error(f"ChunkerRegistry: Failed to register chunkers: {str(e)}") - import traceback traceback.print_exc() def register(self, typeGroup: str, chunker: Chunker): diff --git a/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py b/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py index dbbe61c3..a7e9a36a 100644 --- a/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py +++ b/modules/serviceCenter/services/serviceGeneration/mainServiceGeneration.py @@ -15,6 +15,8 @@ from .subDocumentUtility import ( convertDocumentDataToString ) from .styleDefaults import resolveStyle, deepMerge +import json +import re logger = logging.getLogger(__name__) @@ -105,7 +107,6 @@ class GenerationService: document_data_dict = document_data elif isinstance(document_data, str): # JSON-String: parsen und als dict speichern (z.B. von outlook.composeAndDraftEmailWithContext) - import json try: document_data_dict = json.loads(document_data) except json.JSONDecodeError: @@ -390,14 +391,13 @@ class GenerationService: """ try: from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum - import json as _json, re as _re metadata = extractedContent.get("metadata", {}) if isinstance(extractedContent, dict) else {} docTitle = metadata.get("title", "") if isinstance(metadata, dict) else "" docType = metadata.get("documentType", "") if isinstance(metadata, dict) else "" userHint = (userPrompt or "")[:300] - styleJson = _json.dumps(resolvedStyle, indent=2, default=str) + styleJson = json.dumps(resolvedStyle, indent=2, default=str) prompt = ( "You are a document styling expert. Given the document context below, " @@ -431,12 +431,12 @@ class GenerationService: if not raw: return resolvedStyle - jsonMatch = _re.search(r'```json\s*\n(.*?)\n```', raw, _re.DOTALL) + jsonMatch = re.search(r'```json\s*\n(.*?)\n```', raw, re.DOTALL) if jsonMatch: raw = jsonMatch.group(1).strip() elif raw.startswith('```'): - raw = _re.sub(r'^```\w*\s*', '', raw) - raw = _re.sub(r'\s*```$', '', raw) + raw = re.sub(r'^```\w*\s*', '', raw) + raw = re.sub(r'\s*```$', '', raw) jsonStart = raw.find('{') jsonEnd = raw.rfind('}') @@ -444,7 +444,7 @@ class GenerationService: return resolvedStyle raw = raw[jsonStart:jsonEnd + 1] - delta = _json.loads(raw) + delta = json.loads(raw) if not isinstance(delta, dict) or not delta: return resolvedStyle diff --git a/modules/serviceCenter/services/serviceGeneration/paths/codePath.py b/modules/serviceCenter/services/serviceGeneration/paths/codePath.py index aab4591a..c7f76689 100644 --- a/modules/serviceCenter/services/serviceGeneration/paths/codePath.py +++ b/modules/serviceCenter/services/serviceGeneration/paths/codePath.py @@ -343,7 +343,7 @@ Return ONLY valid JSON matching the request above. unifiedContext = "" if lastRawJson: # Get contexts directly from jsonContinuation - from modules.shared.jsonContinuation import getContexts + from modules.datamodels.jsonContinuation import getContexts contexts = getContexts(lastRawJson) overlapContext = contexts.overlapContext unifiedContext = contexts.hierarchyContextForPrompt @@ -790,7 +790,7 @@ Return ONLY valid JSON in this format: unifiedContext = "" if lastRawJson: # Get contexts directly from jsonContinuation - from modules.shared.jsonContinuation import getContexts + from modules.datamodels.jsonContinuation import getContexts contexts = getContexts(lastRawJson) overlapContext = contexts.overlapContext unifiedContext = contexts.hierarchyContextForPrompt diff --git a/modules/serviceCenter/services/serviceGeneration/paths/documentPath.py b/modules/serviceCenter/services/serviceGeneration/paths/documentPath.py index 4fc6c9d5..b74286eb 100644 --- a/modules/serviceCenter/services/serviceGeneration/paths/documentPath.py +++ b/modules/serviceCenter/services/serviceGeneration/paths/documentPath.py @@ -15,7 +15,7 @@ from modules.datamodels.datamodelWorkflow import AiResponse, AiResponseMetadata, from modules.datamodels.datamodelExtraction import ContentPart, DocumentIntent from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum from modules.datamodels.datamodelDocument import RenderedDocument -from modules.workflows.processing.shared.stateTools import checkWorkflowStopped +from modules.shared.workflowState import checkWorkflowStopped logger = logging.getLogger(__name__) diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/documentRendererBaseTemplate.py b/modules/serviceCenter/services/serviceGeneration/renderers/documentRendererBaseTemplate.py index 519a2697..9fc4d94b 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/documentRendererBaseTemplate.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/documentRendererBaseTemplate.py @@ -16,6 +16,7 @@ import base64 import io from PIL import Image from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum +import threading logger = logging.getLogger(__name__) @@ -408,7 +409,6 @@ class BaseRenderer(ABC): def _determineFilename(self, title: str, mimeType: str) -> str: """Determine filename from title and mimeType.""" - import re # Get extension from mimeType extensionMap = { "text/html": "html", @@ -651,7 +651,6 @@ class BaseRenderer(ABC): # Save styling prompt and response to debug (fire and forget - don't block on slow file I/O) # The writeDebugFile calls os.listdir() which can be slow with many files # Run in background thread to avoid blocking rendering - import threading def _writeDebugFiles(): try: self.services.utils.writeDebugFile(styleTemplate, "renderer_styling_prompt") diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/registry.py b/modules/serviceCenter/services/serviceGeneration/renderers/registry.py index 553c16a1..f0cea780 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/registry.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/registry.py @@ -12,6 +12,7 @@ import importlib from typing import Dict, Type, List, Optional, Tuple from .documentRendererBaseTemplate import BaseRenderer from .codeRendererBaseTemplate import BaseCodeRenderer +from pathlib import Path logger = logging.getLogger(__name__) @@ -36,7 +37,6 @@ class RendererRegistry: return try: - from pathlib import Path currentDir = Path(__file__).parent packageName = __name__.rsplit('.', 1)[0] diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererCsv.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererCsv.py index a8b2c346..d08fc1fe 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererCsv.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererCsv.py @@ -7,6 +7,7 @@ CSV renderer for report generation. from .documentRendererBaseTemplate import BaseRenderer from modules.datamodels.datamodelDocument import RenderedDocument from typing import Dict, Any, List, Optional +import io class RendererCsv(BaseRenderer): """Renders content to CSV format with format-specific extraction.""" @@ -399,7 +400,6 @@ class RendererCsv(BaseRenderer): def _convertRowsToCsv(self, rows: List[List[str]]) -> str: """Convert rows to CSV string.""" import csv - import io output = io.StringIO() writer = csv.writer(output) diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererDocx.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererDocx.py index 28e6fd65..7e427dd4 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererDocx.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererDocx.py @@ -10,6 +10,8 @@ from typing import Dict, Any, List, Optional import io import base64 import re +import math +import time try: from docx import Document @@ -113,7 +115,6 @@ class RendererDocx(BaseRenderer): async def _generateDocxFromJson(self, json_content: Dict[str, Any], title: str, userPrompt: str = None, aiService=None, unifiedStyle: Dict[str, Any] = None) -> str: """Generate DOCX content from structured JSON document.""" - import time start_time = time.time() try: self.logger.debug("_generateDocxFromJson: Starting document generation") @@ -385,7 +386,6 @@ class RendererDocx(BaseRenderer): By building the XML directly, we achieve 100-1000x faster performance. """ - import time from modules.serviceCenter.services.serviceGeneration.styleDefaults import deepMerge tableStart = time.time() @@ -428,7 +428,6 @@ class RendererDocx(BaseRenderer): This bypasses python-docx's slow high-level API and builds the table XML structure directly using lxml, which is 100-1000x faster. """ - import time from docx.oxml.shared import OxmlElement, qn from docx.oxml.ns import nsmap from lxml import etree @@ -955,7 +954,6 @@ class RendererDocx(BaseRenderer): streams = [s for s in (self._imageStreamFromContent(i) for i in images) if s is not None] if not streams: return - import math nrows = math.ceil(len(streams) / columns) table = doc.add_table(rows=nrows, cols=columns) cellWidthInches = max(1.0, 6.5 / columns - 0.1) diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererHtml.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererHtml.py index 1b3fc952..fe624723 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererHtml.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererHtml.py @@ -7,6 +7,8 @@ HTML renderer for report generation. from .documentRendererBaseTemplate import BaseRenderer from modules.datamodels.datamodelDocument import RenderedDocument from typing import Dict, Any, List, Optional +import base64 +import re class RendererHtml(BaseRenderer): """Renders content to HTML format with format-specific extraction.""" @@ -45,7 +47,6 @@ class RendererHtml(BaseRenderer): Render HTML document with images as separate files. Returns list of documents: [HTML document, image1, image2, ...] """ - import base64 # Extract images first images = self._extractImages(extractedContent) @@ -837,7 +838,6 @@ class RendererHtml(BaseRenderer): url = element.get("url", "") or (content.get("url", "") if isinstance(content, dict) else "") if url and isinstance(url, str) and url.startswith("data:image/"): # Extract base64 from data URI: data:image/png;base64, - import re match = re.match(r'data:image/[^;]+;base64,(.+)', url) if match: base64Data = match.group(1) @@ -901,8 +901,6 @@ class RendererHtml(BaseRenderer): HTML content with relative file paths """ try: - import base64 - import re # Find entire img tags with data URIs and replace them # Pattern: diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererImage.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererImage.py index 58c0d04f..2c8524e3 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererImage.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererImage.py @@ -9,6 +9,7 @@ from modules.datamodels.datamodelDocument import RenderedDocument from typing import Dict, Any, List, Optional import logging import base64 +import json logger = logging.getLogger(__name__) @@ -120,7 +121,6 @@ class RendererImage(BaseRenderer): # Format prompt as JSON with image generation parameters from modules.datamodels.datamodelAi import AiCallPromptImage, AiCallOptions, OperationTypeEnum - import json promptModel = AiCallPromptImage( prompt=imagePrompt, diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererMarkdown.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererMarkdown.py index b70c9dbb..b2458f19 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererMarkdown.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererMarkdown.py @@ -7,6 +7,7 @@ Markdown renderer for report generation. from .documentRendererBaseTemplate import BaseRenderer from modules.datamodels.datamodelDocument import RenderedDocument from typing import Any, Dict, List, Optional +import base64 class RendererMarkdown(BaseRenderer): """Renders content to Markdown format with format-specific extraction.""" @@ -44,7 +45,6 @@ class RendererMarkdown(BaseRenderer): def _collectImageDocuments(self, jsonContent: Dict[str, Any]) -> List[Dict[str, Any]]: """Extract image sections into sidecar file payloads for markdown export.""" - import base64 as _b64 out: List[Dict[str, Any]] = [] documents = jsonContent.get("documents") @@ -88,7 +88,7 @@ class RendererMarkdown(BaseRenderer): if not safe_name: raise ValueError(f"image fileName sanitized to empty: {fname!r}") - blob = _b64.b64decode(b64, validate=True) + blob = base64.b64decode(b64, validate=True) if not blob: raise ValueError(f"image base64Data decoded to empty bytes ({fname!r})") diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py index a82a4866..a7df6875 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPdf.py @@ -25,10 +25,12 @@ try: except ImportError: REPORTLAB_AVAILABLE = False -import re as _re_pdf +import re from ._pdfFontFallback import wrapEmojiSpansInXml as _wrapEmojiSpansInXml -from modules.serviceCenter.services.serviceGeneration.styleDefaults import deepMerge as _deepMergeStyle +from modules.serviceCenter.services.serviceGeneration.styleDefaults import deepMerge +import os +import tempfile # A4 width in pt; margins must match SimpleDocTemplate(leftMargin/rightMargin) _PDF_MARGIN_LR_PT = 72.0 @@ -74,7 +76,6 @@ def _resolveFontFamily(fontName: str, bold: bool = False) -> str: try: from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont - import os winFontsDir = os.path.join(os.environ.get("WINDIR", r"C:\Windows"), "Fonts") candidates = [ os.path.join(winFontsDir, f"{fontName}.ttf"), @@ -303,7 +304,6 @@ class RendererPdf(BaseRenderer): def _cleanupTempImageFiles(self) -> None: """Delete temp image files created for streamed (file-backed) PDF images.""" - import os for path in getattr(self, "_tempImageFiles", []) or []: try: os.unlink(path) @@ -541,10 +541,10 @@ class RendererPdf(BaseRenderer): return "" s = self._escapeReportlabXml(text) s = s.replace("\n", "
") - s = _re_pdf.sub(r"\*\*(.+?)\*\*", r"\1", s, flags=_re_pdf.DOTALL) - s = _re_pdf.sub(r"__(.+?)__", r"\1", s, flags=_re_pdf.DOTALL) - s = _re_pdf.sub(r"(?\1", s) - s = _re_pdf.sub(r"(?\1", s) + s = re.sub(r"\*\*(.+?)\*\*", r"\1", s, flags=re.DOTALL) + s = re.sub(r"__(.+?)__", r"\1", s, flags=re.DOTALL) + s = re.sub(r"(?\1", s) + s = re.sub(r"(?\1", s) return s def _markdownInlineToReportlabXml(self, text: str) -> str: @@ -561,7 +561,7 @@ class RendererPdf(BaseRenderer): monoFont = _resolveFontFamily(us["fonts"]["monospace"] if us else "Courier") out: List[str] = [] pos = 0 - for m in _re_pdf.finditer(r"`([^`]*)`", text): + for m in re.finditer(r"`([^`]*)`", text): before = text[pos:m.start()] out.append(self._applyInlineMarkdownToEscapedPlain(before)) code = m.group(1) @@ -749,7 +749,7 @@ class RendererPdf(BaseRenderer): us = getattr(self, '_unifiedStyle', None) or {} globalTableStyle = us.get("table", {}) perTableOverride = content.get("tableStyle", {}) - mergedTableStyle = _deepMergeStyle(globalTableStyle, perTableOverride) if perTableOverride else dict(globalTableStyle) + mergedTableStyle = deepMerge(globalTableStyle, perTableOverride) if perTableOverride else dict(globalTableStyle) numCols = len(headers) colWidth = _PDF_CONTENT_WIDTH_PT / max(numCols, 1) @@ -1139,7 +1139,6 @@ class RendererPdf(BaseRenderer): url = image_data.get("url", "") or (content.get("url", "") if isinstance(content, dict) else "") if url and isinstance(url, str) and url.startswith("data:image/"): # Extract base64 from data URI: data:image/png;base64, - import re match = re.match(r'data:image/[^;]+;base64,(.+)', url) if match: base64_data = match.group(1) @@ -1165,8 +1164,6 @@ class RendererPdf(BaseRenderer): try: from reportlab.platypus import Image as ReportLabImage from reportlab.lib.units import inch - import base64 - import io # Decode base64 image data imageBytes = base64.b64decode(base64_data) @@ -1241,7 +1238,6 @@ class RendererPdf(BaseRenderer): # Create reportlab Image from a TEMP FILE rather than the in-memory # stream: reportlab reads file-backed images lazily at build time, so # the bytes of all images are not held in memory at once (large-doc path). - import tempfile imageStream.seek(0) tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".img") try: diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPptx.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPptx.py index 112f1bf0..0b502e79 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererPptx.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererPptx.py @@ -89,7 +89,6 @@ class RendererPptx(BaseRenderer): from pptx.util import Inches, Pt from pptx.enum.text import PP_ALIGN from pptx.dml.color import RGBColor - import re if not style: from modules.serviceCenter.services.serviceGeneration.styleDefaults import resolveStyle @@ -1248,8 +1247,6 @@ class RendererPptx(BaseRenderer): try: from pptx.util import Inches, Pt from pptx.enum.text import PP_ALIGN - import base64 - import io if not images: logger.debug("No images to render in frame") diff --git a/modules/serviceCenter/services/serviceGeneration/renderers/rendererXlsx.py b/modules/serviceCenter/services/serviceGeneration/renderers/rendererXlsx.py index 44d491d7..d82e4a55 100644 --- a/modules/serviceCenter/services/serviceGeneration/renderers/rendererXlsx.py +++ b/modules/serviceCenter/services/serviceGeneration/renderers/rendererXlsx.py @@ -880,8 +880,6 @@ class RendererXlsx(BaseRenderer): try: from openpyxl.drawing.image import Image as OpenpyxlImage - import base64 - import io # Decode base64 image data imageBytes = base64.b64decode(base64Data) diff --git a/modules/serviceCenter/services/serviceGeneration/subContentGenerator.py b/modules/serviceCenter/services/serviceGeneration/subContentGenerator.py index fd19a4cd..c8713fee 100644 --- a/modules/serviceCenter/services/serviceGeneration/subContentGenerator.py +++ b/modules/serviceCenter/services/serviceGeneration/subContentGenerator.py @@ -13,7 +13,7 @@ import re import traceback from typing import Dict, Any, Optional, List, Callable from .subContentIntegrator import ContentIntegrator -from modules.workflows.processing.shared.stateTools import checkWorkflowStopped +from modules.shared.workflowState import checkWorkflowStopped logger = logging.getLogger(__name__) diff --git a/modules/serviceCenter/services/serviceGeneration/subDocumentUtility.py b/modules/serviceCenter/services/serviceGeneration/subDocumentUtility.py index 6d5404e8..da1194c4 100644 --- a/modules/serviceCenter/services/serviceGeneration/subDocumentUtility.py +++ b/modules/serviceCenter/services/serviceGeneration/subDocumentUtility.py @@ -5,6 +5,7 @@ import logging import os import re from typing import Any, Dict, List, Optional +import io logger = logging.getLogger(__name__) @@ -75,7 +76,7 @@ def enhancePlainTextWithMarkdownTables(body: str) -> str: return "\n\n".join(out_parts) -def _parseInlineRuns(text: str) -> list: +def parseInlineRuns(text: str) -> list: """ Parse inline markdown formatting into a list of InlineRun dicts. Handles: images, links, bold, italic, inline code, plain text. @@ -262,11 +263,11 @@ def markdownToDocumentJson(markdown: str, title: str, language: str = "de") -> D # Tables - cells are List[InlineRun] tableMatch = re.match(r"^\|(.+)\|$", line) if tableMatch and (i + 1) < len(lines) and re.match(r"^\|[\s\-:|]+\|$", lines[i + 1]): - headerCells = [_parseInlineRuns(c.strip()) for c in tableMatch.group(1).split("|")] + headerCells = [parseInlineRuns(c.strip()) for c in tableMatch.group(1).split("|")] i += 2 rows = [] while i < len(lines) and re.match(r"^\|(.+)\|$", lines[i]): - rowCells = [_parseInlineRuns(c.strip()) for c in lines[i][1:-1].split("|")] + rowCells = [parseInlineRuns(c.strip()) for c in lines[i][1:-1].split("|")] rows.append(rowCells) i += 1 sections.append({ @@ -282,7 +283,7 @@ def markdownToDocumentJson(markdown: str, title: str, language: str = "de") -> D items = [] while i < len(lines) and re.match(r"^(\s*)([-*+]|\d+[.)]) (.+)", lines[i]): m = re.match(r"^(\s*)([-*+]|\d+[.)]) (.+)", lines[i]) - items.append(_parseInlineRuns(m.group(3).strip())) + items.append(parseInlineRuns(m.group(3).strip())) i += 1 sections.append({ "id": _nextId(), "content_type": "bullet_list", "order": order, @@ -328,7 +329,7 @@ def markdownToDocumentJson(markdown: str, title: str, language: str = "de") -> D combinedText = " ".join(paraLines) sections.append({ "id": _nextId(), "content_type": "paragraph", "order": order, - "elements": [{"content": {"inlineRuns": _parseInlineRuns(combinedText)}}], + "elements": [{"content": {"inlineRuns": parseInlineRuns(combinedText)}}], }) continue @@ -338,7 +339,7 @@ def markdownToDocumentJson(markdown: str, title: str, language: str = "de") -> D fallbackText = markdown.strip() or "(empty)" sections.append({ "id": _nextId(), "content_type": "paragraph", "order": order, - "elements": [{"content": {"inlineRuns": _parseInlineRuns(fallbackText)}}], + "elements": [{"content": {"inlineRuns": parseInlineRuns(fallbackText)}}], }) return { @@ -564,7 +565,6 @@ def convertDocumentDataToString(document_data: Any, file_extension: str) -> str: elif isinstance(content, list): if content and isinstance(content[0], (list, dict)): import csv - import io output = io.StringIO() if isinstance(content[0], dict): if content: @@ -582,7 +582,6 @@ def convertDocumentDataToString(document_data: Any, file_extension: str) -> str: elif isinstance(document_data, list): if file_extension == 'csv': import csv - import io output = io.StringIO() if document_data and isinstance(document_data[0], dict): fieldnames = document_data[0].keys() diff --git a/modules/serviceCenter/services/serviceKnowledge/_buildTree.py b/modules/serviceCenter/services/serviceKnowledge/_buildTree.py index e4aad028..87021f9d 100644 --- a/modules/serviceCenter/services/serviceKnowledge/_buildTree.py +++ b/modules/serviceCenter/services/serviceKnowledge/_buildTree.py @@ -112,13 +112,13 @@ def _findDsRecord( sourceType: str, path: str, ) -> Optional[Dict[str, Any]]: - from modules.serviceCenter.services.serviceKnowledge._inheritFlags import _normalisePath - norm = _normalisePath(path) + from modules.serviceCenter.services.serviceKnowledge._inheritFlags import normalisePath + norm = normalisePath(path) for ds in allDs: if ( ds.get("connectionId") == connectionId and ds.get("sourceType") == sourceType - and _normalisePath(ds.get("path")) == norm + and normalisePath(ds.get("path")) == norm ): return ds return None @@ -300,8 +300,8 @@ async def _connectionServiceNodes( ) chatService = getService("chat", ctx) securityService = getService("security", ctx) - from modules.features.workspace.routeFeatureWorkspace import _buildResolverDbInterface - dbInterface = _buildResolverDbInterface(chatService) + from modules.interfaces.interfaceDbManagement import buildResolverDbInterface + dbInterface = buildResolverDbInterface(chatService) resolver = ConnectorResolver(securityService, dbInterface) try: provider = await resolver.resolve(connectionId) @@ -352,8 +352,8 @@ async def _browseChildNodes( ) chatService = getService("chat", ctx) securityService = getService("security", ctx) - from modules.features.workspace.routeFeatureWorkspace import _buildResolverDbInterface - dbInterface = _buildResolverDbInterface(chatService) + from modules.interfaces.interfaceDbManagement import buildResolverDbInterface + dbInterface = buildResolverDbInterface(chatService) resolver = ConnectorResolver(securityService, dbInterface) try: adapter = await resolver.resolveService(connectionId, service) @@ -595,7 +595,7 @@ async def getChildrenForParents( """ from modules.interfaces.interfaceDbApp import getRootInterface from modules.datamodels.datamodelDataSource import DataSource - from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + from modules.datamodels.datamodelFeatures import FeatureDataSource rootIf = getRootInterface() diff --git a/modules/serviceCenter/services/serviceKnowledge/_inheritFlags.py b/modules/serviceCenter/services/serviceKnowledge/_inheritFlags.py index 35de8409..69edc3c2 100644 --- a/modules/serviceCenter/services/serviceKnowledge/_inheritFlags.py +++ b/modules/serviceCenter/services/serviceKnowledge/_inheritFlags.py @@ -44,7 +44,7 @@ Mode = Literal["walk", "aggregate"] # Internal helpers # --------------------------------------------------------------------------- -def _normalisePath(path: Optional[str]) -> str: +def normalisePath(path: Optional[str]) -> str: """Normalize a DataSource path to '/'-prefixed, no trailing slash (except root).""" if not path: return "/" @@ -106,7 +106,7 @@ def _findAncestorChain( The connection-root is always the most distant ancestor. """ - recPath = _normalisePath(_getRecordValue(rec, "path")) + recPath = normalisePath(_getRecordValue(rec, "path")) recSourceType = _getRecordValue(rec, "sourceType") recConnectionId = _getRecordValue(rec, "connectionId") sameTypeCandidates: List[Tuple[int, Dict[str, Any]]] = [] @@ -118,7 +118,7 @@ def _findAncestorChain( if _getRecordValue(cand, "connectionId") != recConnectionId: continue candSourceType = _getRecordValue(cand, "sourceType") - candPath = _normalisePath(_getRecordValue(cand, "path")) + candPath = normalisePath(_getRecordValue(cand, "path")) if candSourceType == recSourceType: if candPath == recPath or not _isAncestorPath(candPath, recPath): continue @@ -139,7 +139,7 @@ def _findAncestorChain( def _isDescendantDs(parentRec: Dict[str, Any], candidate: Dict[str, Any]) -> bool: """True iff `candidate` is a descendant of `parentRec` in the DS hierarchy.""" parentSourceType = _getRecordValue(parentRec, "sourceType") - parentPath = _normalisePath(_getRecordValue(parentRec, "path")) + parentPath = normalisePath(_getRecordValue(parentRec, "path")) parentConnectionId = _getRecordValue(parentRec, "connectionId") parentId = _getRecordValue(parentRec, "id") @@ -150,7 +150,7 @@ def _isDescendantDs(parentRec: Dict[str, Any], candidate: Dict[str, Any]) -> boo return False candSourceType = _getRecordValue(candidate, "sourceType") - candPath = _normalisePath(_getRecordValue(candidate, "path")) + candPath = normalisePath(_getRecordValue(candidate, "path")) parentIsConnectionRoot = ( parentSourceType in _AUTHORITY_SOURCE_TYPES and parentPath == "/" @@ -269,7 +269,7 @@ def cascadeResetDescendants( if not _isExplicit(sibVal): continue sibId = _getRecordValue(sib, "id") - sibPath = _normalisePath(_getRecordValue(sib, "path")) + sibPath = normalisePath(_getRecordValue(sib, "path")) toReset.append((_pathDepth(sibPath), sibId)) # Sort deepest first (bottom-up) @@ -455,7 +455,7 @@ def cascadeResetDescendantsFds( """ if flag not in _INHERITABLE_FDS_FLAGS: raise ValueError(f"Unknown inheritable FDS flag: {flag}") - from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + from modules.datamodels.datamodelFeatures import FeatureDataSource featureInstanceId = _getRecordValue(parentRec, "featureInstanceId") if not featureInstanceId: @@ -547,13 +547,13 @@ def resolveEffectiveForPath( Returns dict with effectiveNeutralize, effectiveRagIndexEnabled. (effectiveScope removed 2026-06 — personal sources have no scope.) """ - normPath = _normalisePath(path) + normPath = normalisePath(path) exactRecord = None for ds in allDs: if ( _getRecordValue(ds, "connectionId") == connectionId and _getRecordValue(ds, "sourceType") == sourceType - and _normalisePath(_getRecordValue(ds, "path")) == normPath + and normalisePath(_getRecordValue(ds, "path")) == normPath ): exactRecord = ds break diff --git a/modules/serviceCenter/services/serviceKnowledge/_costEstimate.py b/modules/serviceCenter/services/serviceKnowledge/costEstimate.py similarity index 100% rename from modules/serviceCenter/services/serviceKnowledge/_costEstimate.py rename to modules/serviceCenter/services/serviceKnowledge/costEstimate.py diff --git a/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py b/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py index 01c585d8..291dd9a6 100644 --- a/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py +++ b/modules/serviceCenter/services/serviceKnowledge/mainServiceKnowledge.py @@ -16,6 +16,7 @@ from modules.datamodels.datamodelKnowledge import ( from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum from modules.interfaces.interfaceDbKnowledge import getInterface as getKnowledgeInterface from modules.shared.timeUtils import getUtcTimestamp +import base64 logger = logging.getLogger(__name__) @@ -544,7 +545,6 @@ class KnowledgeService: # 4. Store non-text content objects (images, etc.) without embedding nonTextObjects = [o for o in contentObjects if o.get("contentType") != "text"] if _shouldNeutralize and nonTextObjects and _neutralSvc: - import base64 as _b64 _filteredNonText = [] for _obj in nonTextObjects: if _obj.get("contentType") != "image": @@ -555,7 +555,7 @@ class KnowledgeService: _filteredNonText.append(_obj) continue try: - _imgBytes = _b64.b64decode(_imgData) + _imgBytes = base64.b64decode(_imgData) _imgResult = await _neutralSvc.processImageAsync(_imgBytes, fileName) if _imgResult.get("status") == "ok": _filteredNonText.append(_obj) @@ -903,7 +903,6 @@ class KnowledgeService: fileName = fileContent.get("fileName", "") if isinstance(fileData, str): - import base64 fileData = base64.b64decode(fileData) if mimeType != "application/pdf": diff --git a/modules/serviceCenter/services/serviceKnowledge/_ragLimits.py b/modules/serviceCenter/services/serviceKnowledge/ragLimits.py similarity index 100% rename from modules/serviceCenter/services/serviceKnowledge/_ragLimits.py rename to modules/serviceCenter/services/serviceKnowledge/ragLimits.py diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncClickup.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncClickup.py index 7e14e13e..ac886099 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncClickup.py +++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncClickup.py @@ -33,9 +33,9 @@ from modules.serviceCenter.services.serviceKnowledge.subWalkerHelpers import ( logger = logging.getLogger(__name__) -from modules.serviceCenter.services.serviceKnowledge import _ragLimits as _ragLimitsHelper +from modules.serviceCenter.services.serviceKnowledge import ragLimits -_CLICKUP_DEFAULTS = _ragLimitsHelper.CLICKUP_LIMITS_DEFAULT +_CLICKUP_DEFAULTS = ragLimits.CLICKUP_LIMITS_DEFAULT MAX_TASKS_DEFAULT = _CLICKUP_DEFAULTS["maxTasks"] MAX_WORKSPACES_DEFAULT = _CLICKUP_DEFAULTS["maxWorkspaces"] MAX_LISTS_PER_WORKSPACE_DEFAULT = _CLICKUP_DEFAULTS["maxListsPerWorkspace"] @@ -45,7 +45,7 @@ MAX_AGE_DAYS_DEFAULT = 180 def _resolveDataSourceLimits(dsId: str, ds: Dict[str, Any]) -> Dict[str, int]: """Return explicit RAG-limit overrides stored on the DataSource (or {}).""" - return _ragLimitsHelper.getStoredOverrides(ds, "clickup") + return ragLimits.getStoredOverrides(ds, "clickup") @dataclass @@ -294,7 +294,7 @@ async def bootstrapClickup( async def _resolveDependencies(connectionId: str): from modules.interfaces.interfaceDbApp import getRootInterface from modules.auth import TokenManager - from modules.connectors.providerClickup.connectorClickup import ClickupConnector + from modules.connectors.connectorProviderClickup import ClickupConnector from modules.serviceCenter import getService from modules.serviceCenter.context import ServiceCenterContext from modules.security.rootAccess import getRootUser diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGdrive.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGdrive.py index 08f51ecd..9857bfb7 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGdrive.py +++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGdrive.py @@ -31,9 +31,9 @@ from modules.serviceCenter.services.serviceKnowledge.subWalkerHelpers import ( logger = logging.getLogger(__name__) -from modules.serviceCenter.services.serviceKnowledge import _ragLimits as _ragLimitsHelper +from modules.serviceCenter.services.serviceKnowledge import ragLimits -_FILES_DEFAULTS = _ragLimitsHelper.FILES_LIMITS_DEFAULT +_FILES_DEFAULTS = ragLimits.FILES_LIMITS_DEFAULT MAX_ITEMS_DEFAULT = _FILES_DEFAULTS["maxItems"] MAX_BYTES_DEFAULT = _FILES_DEFAULTS["maxBytes"] MAX_FILE_SIZE_DEFAULT = _FILES_DEFAULTS["maxFileSize"] @@ -44,7 +44,7 @@ MAX_AGE_DAYS_DEFAULT = 365 def _resolveDataSourceLimits(dsId: str, ds: Dict[str, Any]) -> Dict[str, int]: """Return explicit RAG-limit overrides stored on the DataSource (or {}).""" - return _ragLimitsHelper.getStoredOverrides(ds, "files") + return ragLimits.getStoredOverrides(ds, "files") FOLDER_MIME = "application/vnd.google-apps.folder" @@ -224,7 +224,7 @@ async def bootstrapGdrive( async def _resolveDependencies(connectionId: str): from modules.interfaces.interfaceDbApp import getRootInterface from modules.auth import TokenManager - from modules.connectors.providerGoogle.connectorGoogle import GoogleConnector + from modules.connectors.connectorProviderGoogle import GoogleConnector from modules.serviceCenter import getService from modules.serviceCenter.context import ServiceCenterContext from modules.security.rootAccess import getRootUser diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py index 96f9cecf..150fe839 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py +++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncGmail.py @@ -217,7 +217,7 @@ async def bootstrapGmail( adapter, connection, knowledgeService = await _resolveDependencies(connectionId) if googleGetFn is None: - from modules.connectors.providerGoogle.connectorGoogle import _googleGet as _defaultGet + from modules.connectors.connectorProviderGoogle import googleGet as _defaultGet token = getattr(adapter, "_token", "") @@ -277,7 +277,7 @@ async def bootstrapGmail( async def _resolveDependencies(connectionId: str): from modules.interfaces.interfaceDbApp import getRootInterface from modules.auth import TokenManager - from modules.connectors.providerGoogle.connectorGoogle import GoogleConnector + from modules.connectors.connectorProviderGoogle import GoogleConnector from modules.serviceCenter import getService from modules.serviceCenter.context import ServiceCenterContext from modules.security.rootAccess import getRootUser diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncKdrive.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncKdrive.py index 1235ed18..1c50070e 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncKdrive.py +++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncKdrive.py @@ -27,9 +27,9 @@ from modules.serviceCenter.services.serviceKnowledge.subWalkerHelpers import ( logger = logging.getLogger(__name__) -from modules.serviceCenter.services.serviceKnowledge import _ragLimits as _ragLimitsHelper +from modules.serviceCenter.services.serviceKnowledge import ragLimits -_FILES_DEFAULTS = _ragLimitsHelper.FILES_LIMITS_DEFAULT +_FILES_DEFAULTS = ragLimits.FILES_LIMITS_DEFAULT MAX_ITEMS_DEFAULT = _FILES_DEFAULTS["maxItems"] MAX_BYTES_DEFAULT = _FILES_DEFAULTS["maxBytes"] MAX_FILE_SIZE_DEFAULT = _FILES_DEFAULTS["maxFileSize"] @@ -39,7 +39,7 @@ SKIP_MIME_PREFIXES_DEFAULT = ("video/", "audio/") def _resolveDataSourceLimits(dsId: str, ds: Dict[str, Any]) -> Dict[str, int]: """Return explicit RAG-limit overrides stored on the DataSource (or {}).""" - return _ragLimitsHelper.getStoredOverrides(ds, "files") + return ragLimits.getStoredOverrides(ds, "files") @dataclass @@ -191,7 +191,7 @@ async def bootstrapKdrive( async def _resolveDependencies(connectionId: str): from modules.interfaces.interfaceDbApp import getRootInterface from modules.auth import TokenManager - from modules.connectors.providerInfomaniak.connectorInfomaniak import InfomaniakConnector + from modules.connectors.connectorProviderInfomaniak import InfomaniakConnector from modules.serviceCenter import getService from modules.serviceCenter.context import ServiceCenterContext from modules.security.rootAccess import getRootUser diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncOutlook.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncOutlook.py index e676b156..c27a5039 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncOutlook.py +++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncOutlook.py @@ -21,6 +21,8 @@ from dataclasses import dataclass, field from typing import Any, Dict, List, Optional from modules.serviceCenter.services.serviceKnowledge.subTextClean import cleanEmailBody +import base64 +from datetime import datetime, timezone, timedelta from modules.serviceCenter.services.serviceKnowledge.subWalkerHelpers import ( WalkerTimeout, extractWithTimeout, @@ -234,7 +236,7 @@ async def bootstrapOutlook( async def _resolveDependencies(connectionId: str): from modules.interfaces.interfaceDbApp import getRootInterface from modules.auth import TokenManager - from modules.connectors.providerMsft.connectorMsft import MsftConnector + from modules.connectors.connectorProviderMsft import MsftConnector from modules.serviceCenter import getService from modules.serviceCenter.context import ServiceCenterContext from modules.security.rootAccess import getRootUser @@ -321,7 +323,6 @@ async def _ingestFolder( # Keep header-based age filter in Graph itself to avoid shipping ancient # messages we'd discard client-side. if limits.maxAgeDays: - from datetime import datetime, timezone, timedelta cutoff = datetime.now(timezone.utc) - timedelta(days=limits.maxAgeDays) cutoffIso = cutoff.strftime("%Y-%m-%dT%H:%M:%SZ") @@ -362,9 +363,9 @@ async def _ingestFolder( if not nextLink: break # Strip Graph base so adapter._graphGet accepts the relative path. - from modules.connectors.providerMsft.connectorMsft import _stripGraphBase + from modules.connectors.connectorProviderMsft import stripGraphBase - endpoint = _stripGraphBase(nextLink) + endpoint = stripGraphBase(nextLink) async def _ingestMessage( @@ -504,7 +505,6 @@ async def _ingestAttachments( from modules.serviceCenter.services.serviceExtraction.subRegistry import ( ExtractorRegistry, ChunkerRegistry, ) - import base64 page = await adapter._graphGet(f"me/messages/{messageId}/attachments") if not isinstance(page, dict) or "error" in page: diff --git a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncSharepoint.py b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncSharepoint.py index 6f69d171..86d61f60 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncSharepoint.py +++ b/modules/serviceCenter/services/serviceKnowledge/subConnectorSyncSharepoint.py @@ -30,9 +30,9 @@ from modules.serviceCenter.services.serviceKnowledge.subWalkerHelpers import ( logger = logging.getLogger(__name__) -from modules.serviceCenter.services.serviceKnowledge import _ragLimits as _ragLimitsHelper +from modules.serviceCenter.services.serviceKnowledge import ragLimits -_FILES_DEFAULTS = _ragLimitsHelper.FILES_LIMITS_DEFAULT +_FILES_DEFAULTS = ragLimits.FILES_LIMITS_DEFAULT MAX_ITEMS_DEFAULT = _FILES_DEFAULTS["maxItems"] MAX_BYTES_DEFAULT = _FILES_DEFAULTS["maxBytes"] MAX_FILE_SIZE_DEFAULT = _FILES_DEFAULTS["maxFileSize"] @@ -48,7 +48,7 @@ def _resolveDataSourceLimits(dsId: str, ds: Dict[str, Any]) -> Dict[str, int]: defaults. Used to merge per-DataSource user settings on top of the walker's runtime limits. """ - return _ragLimitsHelper.getStoredOverrides(ds, "files") + return ragLimits.getStoredOverrides(ds, "files") @dataclass @@ -225,7 +225,7 @@ async def _resolveDependencies(connectionId: str): """ from modules.interfaces.interfaceDbApp import getRootInterface from modules.auth import TokenManager - from modules.connectors.providerMsft.connectorMsft import MsftConnector + from modules.connectors.connectorProviderMsft import MsftConnector from modules.serviceCenter import getService from modules.serviceCenter.context import ServiceCenterContext from modules.security.rootAccess import getRootUser diff --git a/modules/serviceCenter/services/serviceKnowledge/subFeatureBootstrap.py b/modules/serviceCenter/services/serviceKnowledge/subFeatureBootstrap.py index 88a59408..4d58933c 100644 --- a/modules/serviceCenter/services/serviceKnowledge/subFeatureBootstrap.py +++ b/modules/serviceCenter/services/serviceKnowledge/subFeatureBootstrap.py @@ -29,7 +29,7 @@ def _loadRagEnabledFds(featureInstanceId: str, featureDataSourceIds: Optional[Li Returns dicts with resolved flags so downstream code can read them directly. """ 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 getEffectiveFlagFds rootIf = getRootInterface() diff --git a/modules/serviceCenter/services/serviceKnowledge/udbNodes.py b/modules/serviceCenter/services/serviceKnowledge/udbNodes.py index d0678e99..d46292ce 100644 --- a/modules/serviceCenter/services/serviceKnowledge/udbNodes.py +++ b/modules/serviceCenter/services/serviceKnowledge/udbNodes.py @@ -411,7 +411,7 @@ class _FdsFamilyNode(UdbNode): return flag in ("neutralize", "ragIndexEnabled") def canEdit(self, context: Any, rootIf: Any) -> bool: - return _isFeatureAdmin(rootIf, str(context.user.id), self.featureInstanceId) + return isFeatureAdmin(rootIf, str(context.user.id), self.featureInstanceId) def getEffectiveFlag(self, flag, allDs, allFds, mode="aggregate") -> Any: if not self.supportsFlag(flag): @@ -427,7 +427,7 @@ class _FdsFamilyNode(UdbNode): def setFlag(self, flag, value, rootIf) -> List[str]: if not self.supportsFlag(flag): raise ValueError(f"FDS does not support flag {flag!r}") - from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + from modules.datamodels.datamodelFeatures import FeatureDataSource from modules.serviceCenter.services.serviceKnowledge._inheritFlags import ( cascadeResetDescendantsFds, ) @@ -489,7 +489,7 @@ class FdsWorkspaceNode(_FdsFamilyNode): table aggregate stays 'mixed' because some field children still read True from the list while others inherit the new value. """ - from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + from modules.datamodels.datamodelFeatures import FeatureDataSource allFds = rootIf.db.getRecordset( FeatureDataSource, recordFilter={"featureInstanceId": self.featureInstanceId}, @@ -657,7 +657,7 @@ class FdsFieldNode(UdbNode): return flag == "neutralize" def canEdit(self, context: Any, rootIf: Any) -> bool: - return _isFeatureAdmin(rootIf, str(context.user.id), self.featureInstanceId) + return isFeatureAdmin(rootIf, str(context.user.id), self.featureInstanceId) def getEffectiveFlag(self, flag, allDs, allFds, mode="aggregate") -> Any: if flag != "neutralize": @@ -681,7 +681,7 @@ class FdsFieldNode(UdbNode): def setFlag(self, flag, value, rootIf) -> List[str]: if flag != "neutralize": raise ValueError(f"FdsFieldNode does not support flag {flag!r}") - from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + from modules.datamodels.datamodelFeatures import FeatureDataSource # Resolve or auto-create the underlying table-record FDS so we # have somewhere to persist the neutralizeFields entry. rec = self.tableRec @@ -753,15 +753,15 @@ def _findOrCreateDs(rootIf: Any, connectionId: str, sourceType: str, """ from modules.datamodels.datamodelDataSource import DataSource from modules.datamodels.datamodelUam import UserConnection - from modules.serviceCenter.services.serviceKnowledge._inheritFlags import _normalisePath + from modules.serviceCenter.services.serviceKnowledge._inheritFlags import normalisePath - normPath = _normalisePath(path) + normPath = normalisePath(path) existing = rootIf.db.getRecordset(DataSource, recordFilter={ "connectionId": connectionId, "sourceType": sourceType, }) or [] for rec in existing: - if _normalisePath(rec.get("path")) == normPath: + if normalisePath(rec.get("path")) == normPath: return rec conn = rootIf.db.getRecord(UserConnection, connectionId) @@ -791,7 +791,7 @@ def _findOrCreateDs(rootIf: Any, connectionId: str, sourceType: str, return stub.model_dump() -def _isFeatureAdmin(rootIf: Any, userId: str, featureInstanceId: str) -> bool: +def isFeatureAdmin(rootIf: Any, userId: str, featureInstanceId: str) -> bool: """Return True iff the user holds a `*-admin` role on this feature instance. Convention: feature-specific admin role labels end with `-admin` @@ -846,7 +846,7 @@ def _findOrCreateTableFds(rootIf: Any, featureInstanceId: str, tableName: str, coordinate; its `objectKey`/`label` are filled from the RBAC catalog so list endpoints still render it correctly. """ - from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + from modules.datamodels.datamodelFeatures import FeatureDataSource existing = rootIf.db.getRecordset(FeatureDataSource, recordFilter={ "featureInstanceId": featureInstanceId, "tableName": tableName, @@ -939,7 +939,7 @@ def buildNodeForKey(key: str, context: Any, rootIf: Any) -> Optional[UdbNode]: if rootIf is None: rootIf = getRootInterface() from modules.datamodels.datamodelDataSource import DataSource - from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + from modules.datamodels.datamodelFeatures import FeatureDataSource kind, parts = _decode(key) @@ -1007,26 +1007,26 @@ def buildNodeForKey(key: str, context: Any, rootIf: Any) -> Optional[UdbNode]: def _findDsByCoord(rootIf: Any, connectionId: str, sourceType: Optional[str], path: str) -> Optional[Dict[str, Any]]: from modules.datamodels.datamodelDataSource import DataSource - from modules.serviceCenter.services.serviceKnowledge._inheritFlags import _normalisePath + from modules.serviceCenter.services.serviceKnowledge._inheritFlags import normalisePath rf = {"connectionId": connectionId} if sourceType is not None: rf["sourceType"] = sourceType records = rootIf.db.getRecordset(DataSource, recordFilter=rf) or [] - norm = _normalisePath(path) + norm = normalisePath(path) if sourceType is None: # connection-root: any record with path='/' on this connection for r in records: - if _normalisePath(r.get("path")) == "/": + if normalisePath(r.get("path")) == "/": return r return None for r in records: - if _normalisePath(r.get("path")) == norm: + if normalisePath(r.get("path")) == norm: return r return None def _loadAllFds(rootIf: Any, featureInstanceId: str) -> List[Dict[str, Any]]: - from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource + from modules.datamodels.datamodelFeatures import FeatureDataSource return rootIf.db.getRecordset( FeatureDataSource, recordFilter={"featureInstanceId": featureInstanceId} ) or [] diff --git a/modules/serviceCenter/services/serviceSharepoint/mainServiceSharepoint.py b/modules/serviceCenter/services/serviceSharepoint/mainServiceSharepoint.py index 4fd1fb36..8456dc52 100644 --- a/modules/serviceCenter/services/serviceSharepoint/mainServiceSharepoint.py +++ b/modules/serviceCenter/services/serviceSharepoint/mainServiceSharepoint.py @@ -7,6 +7,7 @@ import aiohttp import asyncio import time from typing import Dict, Any, List, Optional, Callable +from datetime import datetime, timedelta, timezone logger = logging.getLogger(__name__) @@ -585,7 +586,6 @@ class SharepointService: async def getFolderUsageAnalytics(self, siteId: str, driveId: str, itemId: str, startDateTime: Optional[str] = None, endDateTime: Optional[str] = None, interval: str = "day") -> Dict[str, Any]: """Get usage analytics for a folder or file.""" try: - from datetime import datetime, timedelta, timezone if not endDateTime: endDateTime = datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z') diff --git a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py index 0d3ae954..1eaebf56 100644 --- a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py +++ b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py @@ -27,6 +27,7 @@ from modules.interfaces.interfaceDbSubscription import ( InvalidTransitionError, ) from modules.shared.i18nRegistry import t +from datetime import datetime, timezone logger = logging.getLogger(__name__) @@ -742,7 +743,7 @@ class SubscriptionService: def _notifyEnterpriseInvoice(mandateId: str, subRecord: Dict[str, Any]) -> None: """Send enterprise invoice email to mandate admins.""" try: - from modules.shared.notifyMandateAdmins import notifyMandateAdmins + from modules.system.notifyMandateAdmins import notifyMandateAdmins rawHtml = _buildEnterpriseInvoiceHtml(subRecord) flatPrice = subRecord.get("enterpriseFlatPriceCHF") or 0 @@ -778,7 +779,6 @@ def _buildEnterpriseInvoiceHtml(subRecord: Dict[str, Any]) -> str: def _fmtDate(ts: Optional[float]) -> str: if not ts: return "—" - from datetime import datetime, timezone return datetime.fromtimestamp(ts, tz=timezone.utc).strftime("%d.%m.%Y") detailRows = "" @@ -845,7 +845,7 @@ def _notifySubscriptionChange( platformUrl: str = "", ) -> None: try: - from modules.shared.notifyMandateAdmins import notifyMandateAdmins + from modules.system.notifyMandateAdmins import notifyMandateAdmins planLabel = (plan.title or plan.planKey) if plan else "\u2014" platformHint = f"Plattform: {platformUrl}" if platformUrl else "" @@ -1036,117 +1036,20 @@ def _buildCancelSummaryHtml(subRecord: Dict[str, Any], platformUrl: str = "") -> # ============================================================================ -# Exception Classes +# Exception Classes (defined in shared, re-exported here for backward compat) # ============================================================================ -SUBSCRIPTION_USER_ACTION_UPGRADE = "UPGRADE_SUBSCRIPTION" -SUBSCRIPTION_USER_ACTION_REACTIVATE = "REACTIVATE_SUBSCRIPTION" -SUBSCRIPTION_USER_ACTION_ADD_PAYMENT = "ADD_PAYMENT_METHOD" - -SUBSCRIPTION_REASONS = { - "SUBSCRIPTION_INACTIVE", - "SUBSCRIPTION_PAYMENT_REQUIRED", - "SUBSCRIPTION_PAYMENT_PENDING", - "SUBSCRIPTION_EXPIRED", -} - - -def _subscriptionReasonForStatus(status: SubscriptionStatusEnum) -> str: - if status == SubscriptionStatusEnum.PENDING: - return "SUBSCRIPTION_PAYMENT_PENDING" - if status == SubscriptionStatusEnum.PAST_DUE: - return "SUBSCRIPTION_PAYMENT_REQUIRED" - if status == SubscriptionStatusEnum.EXPIRED: - return "SUBSCRIPTION_EXPIRED" - return "SUBSCRIPTION_INACTIVE" - - -def _subscriptionUserActionForStatus(status: SubscriptionStatusEnum) -> str: - if status in (SubscriptionStatusEnum.PAST_DUE, SubscriptionStatusEnum.PENDING): - return SUBSCRIPTION_USER_ACTION_ADD_PAYMENT - return SUBSCRIPTION_USER_ACTION_UPGRADE - - -class SubscriptionInactiveException(Exception): - def __init__(self, status: SubscriptionStatusEnum, mandateId: str = "", message: Optional[str] = None): - self.status = status - self.mandateId = mandateId - self.reason = _subscriptionReasonForStatus(status) - self.userAction = _subscriptionUserActionForStatus(status) - self.message = message or t( - "Kein aktives Abonnement für diesen Mandanten. Bitte wählen Sie einen Plan unter Billing." - ) - super().__init__(self.message) - - def toClientDict(self) -> Dict[str, Any]: - out: Dict[str, Any] = { - "error": self.reason, "message": self.message, - "userAction": self.userAction, "subscriptionUiPath": "/admin/billing?tab=subscription", - } - if self.mandateId: - out["mandateId"] = self.mandateId - return out - - -SUBSCRIPTION_USER_ACTION_CONTACT_ADMIN = "CONTACT_ADMIN" - - -def _subscriptionLimitsHint() -> str: - return " " + t( - "Details zu Ihrem Abonnement, den enthaltenen Limits und Upgrade-Optionen: " - "Menü «Administration» → «Billing» → Registerkarte «Abonnement»." - ) - - -def _enterpriseLimitsHint() -> str: - return " " + t( - "Ihr Enterprise-Abonnement wird vom Plattform-Administrator verwaltet. " - "Bitte kontaktieren Sie den Administrator für eine Anpassung der Limiten." - ) - - -class SubscriptionCapacityException(Exception): - def __init__(self, resourceType: str, currentCount: int, maxAllowed: int, - message: Optional[str] = None, isEnterprise: bool = False): - self.resourceType = resourceType - self.currentCount = currentCount - self.maxAllowed = maxAllowed - self.isEnterprise = isEnterprise - hint = _enterpriseLimitsHint() if isEnterprise else _subscriptionLimitsHint() - if message is not None: - self.message = message - elif resourceType == "users": - self.message = t( - "Mit dem aktuellen Abonnement sind für diesen Mandanten höchstens {maxAllowed} " - "Benutzer zulässig (derzeit {currentCount}). " - "Ohne Planwechsel können keine weiteren Benutzer hinzugefügt werden." - ).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint - elif resourceType == "featureInstances": - self.message = t( - "Es sind höchstens {maxAllowed} aktive Module erlaubt (derzeit {currentCount}). " - "Bitte Abonnement erweitern oder ein Modul entfernen." - ).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint - elif resourceType == "dataVolumeMB": - self.message = t( - "Das im Abonnement enthaltene Datenvolumen ({maxAllowed} MB) reicht nicht " - "(aktuell ca. {currentCount} MB). Bitte Speicher-Limit oder Plan anpassen." - ).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint - else: - self.message = t( - "Abonnement-Limit überschritten (Ressource «{resourceType}»: " - "aktuell {currentCount}, erlaubt {maxAllowed})." - ).format(resourceType=resourceType, currentCount=currentCount, maxAllowed=maxAllowed) + hint - super().__init__(self.message) - - def toClientDict(self) -> Dict[str, Any]: - action = SUBSCRIPTION_USER_ACTION_CONTACT_ADMIN if self.isEnterprise else SUBSCRIPTION_USER_ACTION_UPGRADE - return { - "error": f"SUBSCRIPTION_{self.resourceType.upper()}_LIMIT", - "currentCount": self.currentCount, "maxAllowed": self.maxAllowed, - "message": self.message, "userAction": action, - "subscriptionUiPath": "/admin/billing?tab=subscription", - } - +from modules.shared.serviceExceptions import ( + SubscriptionInactiveException, + SubscriptionCapacityException, + SUBSCRIPTION_USER_ACTION_UPGRADE, + SUBSCRIPTION_USER_ACTION_REACTIVATE, + SUBSCRIPTION_USER_ACTION_ADD_PAYMENT, + SUBSCRIPTION_USER_ACTION_CONTACT_ADMIN, + SUBSCRIPTION_REASONS, + _subscriptionReasonForStatus, + _subscriptionUserActionForStatus, +) SubscriptionService.SubscriptionInactiveException = SubscriptionInactiveException SubscriptionService.SubscriptionCapacityException = SubscriptionCapacityException diff --git a/modules/serviceCenter/services/serviceWeb/mainServiceWeb.py b/modules/serviceCenter/services/serviceWeb/mainServiceWeb.py index c4e24947..3445839e 100644 --- a/modules/serviceCenter/services/serviceWeb/mainServiceWeb.py +++ b/modules/serviceCenter/services/serviceWeb/mainServiceWeb.py @@ -925,7 +925,6 @@ Return ONLY valid JSON, no additional text: Returns: List of processed crawl results """ - import time results = [] # Handle list of results diff --git a/modules/shared/__init__.py b/modules/shared/__init__.py index 64b042b8..6d67ce5c 100644 --- a/modules/shared/__init__.py +++ b/modules/shared/__init__.py @@ -11,9 +11,6 @@ from . import attributeUtils from . import frontendTypes from . import configuration from . import eventManagement -from . import auditLogger from . import debugLogger from . import progressLogger from . import callbackRegistry -from . import jsonContinuation -from . import dbMultiTenantOptimizations diff --git a/modules/shared/configuration.py b/modules/shared/configuration.py index 15646962..fc7578f2 100644 --- a/modules/shared/configuration.py +++ b/modules/shared/configuration.py @@ -18,7 +18,6 @@ from pathlib import Path from cryptography.fernet import Fernet from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC -# audit_logger imported lazily to avoid circular import # Set up basic logging for configuration loading logging.basicConfig( @@ -201,15 +200,11 @@ class Configuration: if key.endswith("_SECRET"): # Log audit event for secret key access try: - from modules.shared.auditLogger import audit_logger - audit_logger.logKeyAccess( - userId=user_id, - mandateId="system", - keyName=key, - action="decode" + logging.getLogger("audit.fallback").info( + "AUDIT | %s | %s | system | key | %s | Key: %s", + time.time(), user_id, "decode", key ) except Exception: - # Don't fail if audit logging fails pass if value.startswith("{") and value.endswith("}"): @@ -478,15 +473,11 @@ def encryptValue(value: str, envType: str = None, userId: str = "system", keyNam # Log audit event for encryption try: - from modules.shared.auditLogger import audit_logger - audit_logger.logKeyAccess( - userId=userId, - mandateId="system", - keyName=keyName, - action="encrypt" + logging.getLogger("audit.fallback").info( + "AUDIT | %s | %s | system | key | %s | Key: %s", + time.time(), userId, "encrypt", keyName ) except Exception: - # Don't fail if audit logging fails pass return encryptedValue @@ -568,15 +559,11 @@ def decryptValue(encryptedValue: str, userId: str = "system", keyName: str = "un # Log audit event for decryption try: - from modules.shared.auditLogger import audit_logger - audit_logger.logKeyAccess( - userId=userId, - mandateId="system", - keyName=keyName, - action="decrypt" + logging.getLogger("audit.fallback").info( + "AUDIT | %s | %s | system | key | %s | Key: %s", + time.time(), userId, "decrypt", keyName ) except Exception: - # Don't fail if audit logging fails pass # Populate cache so subsequent reads of the same ciphertext don't diff --git a/modules/connectors/_httpResilience.py b/modules/shared/httpResilience.py similarity index 100% rename from modules/connectors/_httpResilience.py rename to modules/shared/httpResilience.py diff --git a/modules/shared/i18nRegistry.py b/modules/shared/i18nRegistry.py index cb2d070f..a72dcd9c 100644 --- a/modules/shared/i18nRegistry.py +++ b/modules/shared/i18nRegistry.py @@ -1,12 +1,14 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. """ -Gateway i18n registry: t(), @i18nModel, boot-sync, in-memory cache. +Gateway i18n registry: t(), @i18nModel, runtime translation cache. All UI-visible texts in the gateway (HTTPException details, model labels, API messages) are tagged with t() and registered at import time. -At boot, the registry is synced to the xx base set in the DB. At runtime, t() returns the cached translation for the current request language. + +Boot-time DB sync and label discovery live in i18nBootSync.py (called by app.py). +This module has ZERO dependencies on other platform-core modules outside shared/. """ from __future__ import annotations @@ -316,7 +318,7 @@ def setLanguage(lang: str): _CURRENT_LANGUAGE.set(lang) -def _getLanguage() -> str: +def getCurrentLanguage() -> str: """Get the language for the current request context.""" return _CURRENT_LANGUAGE.get() @@ -337,579 +339,3 @@ def normalizePrimaryLanguageTag(tag: str, fallback: str = "de") -> str: return primary return fallback - -# --------------------------------------------------------------------------- -# Boot: scan route files for routeApiMsg("…") calls → register eagerly -# --------------------------------------------------------------------------- - -_ROUTE_API_MSG_RE = None # compiled lazily - -def _scanRouteApiMsgKeys(): - """Scan all gateway route/feature Python files for routeApiMsg("…") calls - and register the keys in _REGISTRY so they appear in the boot DB sync. - """ - import re - from pathlib import Path - - global _ROUTE_API_MSG_RE - if _ROUTE_API_MSG_RE is None: - _ROUTE_API_MSG_RE = re.compile( - r"""routeApiMsg\(\s*(['"])((?:\\.|(?!\1).)+)\1""", - ) - - gatewayRoot = Path(__file__).resolve().parents[1] - scanDirs = [gatewayRoot / "routes", gatewayRoot / "features"] - - _ctxRe = re.compile(r'''apiRouteContext\(\s*['"]([^'"]+)['"]\s*\)''') - - for scanDir in scanDirs: - if not scanDir.is_dir(): - continue - for pyFile in scanDir.rglob("*.py"): - try: - src = pyFile.read_text(encoding="utf-8", errors="replace") - except OSError: - continue - ctxMatch = _ctxRe.search(src) - if not ctxMatch: - continue - ctx = f"api.{ctxMatch.group(1)}" - for m in _ROUTE_API_MSG_RE.finditer(src): - key = m.group(2).replace("\\'", "'").replace('\\"', '"') - if key and key not in _REGISTRY: - _REGISTRY[key] = _I18nRegistryEntry(context=ctx, value="") - - logger.info("i18n route scan: %d api.* keys in registry after scan", - sum(1 for e in _REGISTRY.values() if e.context.startswith("api."))) - - -def _registerNavLabels(): - """Register all navigation labels from NAVIGATION_SECTIONS as i18n keys. - - Called at boot before DB sync so that nav labels appear in the xx base set - and can be translated via the Admin UI. - """ - try: - from modules.system.mainSystem import NAVIGATION_SECTIONS - except ImportError: - logger.warning("i18n: could not import NAVIGATION_SECTIONS for nav label registration") - return - - count = 0 - for section in NAVIGATION_SECTIONS: - title = section.get("title", "") - if title and title not in _REGISTRY: - _REGISTRY[title] = _I18nRegistryEntry(context="nav", value="") - count += 1 - - for item in section.get("items", []): - label = item.get("label", "") - if label and label not in _REGISTRY: - _REGISTRY[label] = _I18nRegistryEntry(context="nav", value="") - count += 1 - - for subgroup in section.get("subgroups", []): - sgTitle = subgroup.get("title", "") - if sgTitle and sgTitle not in _REGISTRY: - _REGISTRY[sgTitle] = _I18nRegistryEntry(context="nav", value="") - count += 1 - for item in subgroup.get("items", []): - label = item.get("label", "") - if label and label not in _REGISTRY: - _REGISTRY[label] = _I18nRegistryEntry(context="nav", value="") - count += 1 - - logger.info("i18n nav labels: registered %d nav keys", count) - - -def _registerFeatureUiLabels(): - """Register FEATURE_LABEL and UI_OBJECTS labels from all feature modules (German i18n keys).""" - try: - from modules.system import mainSystem as _mainSystem - _fl = getattr(_mainSystem, "FEATURE_LABEL", None) - if isinstance(_fl, str) and _fl and _fl not in _REGISTRY: - _REGISTRY[_fl] = _I18nRegistryEntry(context="nav", value="") - except ImportError: - pass - - _featureModulePaths = ( - "modules.features.trustee.mainTrustee", - "modules.features.graphicalEditor.mainGraphicalEditor", - "modules.features.commcoach.mainCommcoach", - "modules.features.teamsbot.mainTeamsbot", - "modules.features.workspace.mainWorkspace", - "modules.features.realEstate.mainRealEstate", - "modules.features.neutralization.mainNeutralization", - ) - added = 0 - for modPath in _featureModulePaths: - try: - mod = __import__(modPath, fromlist=["FEATURE_LABEL", "UI_OBJECTS"]) - except ImportError: - continue - fl = getattr(mod, "FEATURE_LABEL", None) - if isinstance(fl, str) and fl and fl not in _REGISTRY: - _REGISTRY[fl] = _I18nRegistryEntry(context="nav", value="") - added += 1 - for uiObj in getattr(mod, "UI_OBJECTS", []) or []: - base = _extractRegistrySourceText(uiObj.get("label")) - if base and base not in _REGISTRY: - _REGISTRY[base] = _I18nRegistryEntry(context="nav", value="") - added += 1 - logger.info("i18n feature UI labels: %d new keys (nav context)", added) - - -def _registerRbacLabels(): - """Register DATA_OBJECTS, RESOURCE_OBJECTS labels and TEMPLATE_ROLES descriptions - from all feature modules and system module as i18n keys. - - context mapping: - - DATA_OBJECTS → rbac.data - - RESOURCE_OBJECTS → rbac.resource - - TEMPLATE_ROLES[].description (xx source) → rbac.role - - QUICK_ACTIONS[].label/description (xx source) → rbac.quickaction - - QUICK_ACTION_CATEGORIES[].label (xx source) → rbac.quickaction - """ - _systemModule = "modules.system.mainSystem" - _featureModulePaths = ( - _systemModule, - "modules.features.trustee.mainTrustee", - "modules.features.graphicalEditor.mainGraphicalEditor", - "modules.features.commcoach.mainCommcoach", - "modules.features.teamsbot.mainTeamsbot", - "modules.features.workspace.mainWorkspace", - "modules.features.realEstate.mainRealEstate", - "modules.features.neutralization.mainNeutralization", - ) - - added = 0 - for modPath in _featureModulePaths: - try: - mod = __import__(modPath, fromlist=[ - "DATA_OBJECTS", "RESOURCE_OBJECTS", "TEMPLATE_ROLES", - "QUICK_ACTIONS", "QUICK_ACTION_CATEGORIES", - ]) - except ImportError: - continue - - for dataObj in getattr(mod, "DATA_OBJECTS", []) or []: - key = _extractRegistrySourceText(dataObj.get("label")) - if key and key not in _REGISTRY: - _REGISTRY[key] = _I18nRegistryEntry(context="rbac.data", value="") - added += 1 - - for resObj in getattr(mod, "RESOURCE_OBJECTS", []) or []: - key = _extractRegistrySourceText(resObj.get("label")) - if key and key not in _REGISTRY: - _REGISTRY[key] = _I18nRegistryEntry(context="rbac.resource", value="") - added += 1 - - for role in getattr(mod, "TEMPLATE_ROLES", []) or []: - key = _extractRegistrySourceText(role.get("description")) - if key and key not in _REGISTRY: - _REGISTRY[key] = _I18nRegistryEntry(context="rbac.role", value="") - added += 1 - - for qa in getattr(mod, "QUICK_ACTIONS", []) or []: - for field in ("label", "description"): - key = _extractRegistrySourceText(qa.get(field)) - if key and key not in _REGISTRY: - _REGISTRY[key] = _I18nRegistryEntry(context="rbac.quickaction", value="") - added += 1 - - for cat in getattr(mod, "QUICK_ACTION_CATEGORIES", []) or []: - key = _extractRegistrySourceText(cat.get("label")) - if key and key not in _REGISTRY: - _REGISTRY[key] = _I18nRegistryEntry(context="rbac.quickaction", value="") - added += 1 - - logger.info("i18n rbac labels: %d new keys (rbac.* context)", added) - - -def _registerServiceCenterLabels(): - """Register service-center category labels and bootstrap role descriptions.""" - added = 0 - - try: - from modules.serviceCenter.registry import IMPORTABLE_SERVICES - for svc in IMPORTABLE_SERVICES.values(): - key = _extractRegistrySourceText(svc.get("label")) - if key and key not in _REGISTRY: - _REGISTRY[key] = _I18nRegistryEntry(context="service", value="") - added += 1 - except ImportError: - pass - - _bootstrapRoleDescriptions = [ - "Administrator - Benutzer und Ressourcen im Mandanten verwalten", - "Benutzer - Standard-Benutzer mit Zugriff auf eigene Datensätze", - "Betrachter - Nur-Lese-Zugriff auf Gruppen-Datensätze", - "System-Administrator - Vollständiger administrativer Zugriff über alle Mandanten", - ] - for desc in _bootstrapRoleDescriptions: - if desc not in _REGISTRY: - _REGISTRY[desc] = _I18nRegistryEntry(context="rbac.role", value="") - added += 1 - - logger.info("i18n service/bootstrap labels: %d new keys", added) - - -def _registerNodeLabels(): - """Register all graph-editor node labels, descriptions, parameter descriptions, - output labels, port descriptions, category labels, and entry-point titles.""" - added = 0 - - def _reg(key: str, ctx: str): - nonlocal added - if key and key not in _REGISTRY: - _REGISTRY[key] = _I18nRegistryEntry(context=ctx, value="") - added += 1 - - try: - from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES - for nd in STATIC_NODE_TYPES: - _reg(_extractRegistrySourceText(nd.get("label")), "node.label") - _reg(_extractRegistrySourceText(nd.get("description")), "node.desc") - - for param in nd.get("parameters", []) or []: - _reg(_extractRegistrySourceText(param.get("description")), "node.param") - _reg(_extractRegistrySourceText(param.get("label")), "node.param") - - outLabels = nd.get("outputLabels") - if isinstance(outLabels, dict): - sourceList = outLabels.get("xx") or next(iter(outLabels.values()), []) - if not isinstance(sourceList, list): - sourceList = [] - for lbl in sourceList: - _reg(lbl, "node.output") - elif isinstance(outLabels, list): - for lbl in outLabels: - _reg(lbl, "node.output") - except ImportError: - pass - - try: - from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG - for schema in PORT_TYPE_CATALOG.values(): - for field in getattr(schema, "fields", []) or []: - desc = getattr(field, "description", None) - if desc: - _reg(_extractRegistrySourceText(desc if isinstance(desc, (str, dict)) else None), "port.desc") - except ImportError: - pass - - _nodeCategoryLabels = [ - "Trigger", "Eingabe/Mensch", "Ablauf", "Daten", "KI", - "Datei", "E-Mail", "SharePoint", "ClickUp", "Treuhand", - ] - for lbl in _nodeCategoryLabels: - _reg(lbl, "node.category") - - _entryPointTitles = ["Jetzt ausführen", "Start"] - for lbl in _entryPointTitles: - _reg(lbl, "node.entry") - - logger.info("i18n node labels: %d new keys (node.*/port.* context)", added) - - -def _registerAccountingConnectorLabels(): - """Register all accounting connector configField labels (label) at boot time. - - Connector ``getRequiredConfigFields()`` is normally invoked lazily at first - request, which is too late for the boot-sync. We discover the connectors - here so their ``t()`` calls register the keys before they are written to the - ``xx`` set and AI-translated for every active language set. - """ - added = 0 - try: - from modules.features.trustee.accounting.accountingRegistry import getAccountingRegistry - except ImportError: - logger.debug("i18n accounting connectors: registry not importable") - return - - try: - registry = getAccountingRegistry() - except Exception as e: - logger.warning("i18n accounting connectors: registry init failed: %s", e) - return - - for connectorType, connector in (registry._connectors or {}).items(): - try: - for field in connector.getRequiredConfigFields(): - key = getattr(field, "label", "") or "" - if not isinstance(key, str) or not key: - continue - if key not in _REGISTRY: - _REGISTRY[key] = _I18nRegistryEntry( - context=f"connector.accounting.{connectorType}", - value="", - ) - added += 1 - except Exception as e: - logger.warning( - "i18n accounting connector %s: failed to read fields: %s", - connectorType, e, - ) - - logger.info("i18n accounting connector labels: %d new keys", added) - - -def _registerDatamodelOptionLabels(): - """Register all frontend_options labels from Pydantic datamodels and subscription plans.""" - added = 0 - - def _reg(key: str, ctx: str): - nonlocal added - if key and key not in _REGISTRY: - _REGISTRY[key] = _I18nRegistryEntry(context=ctx, value="") - added += 1 - - _datamodelModules = ( - "modules.datamodels.datamodelRbac", - "modules.datamodels.datamodelChat", - "modules.datamodels.datamodelMessaging", - "modules.datamodels.datamodelNotification", - "modules.datamodels.datamodelUam", - "modules.datamodels.datamodelFiles", - "modules.datamodels.datamodelDataSource", - "modules.datamodels.datamodelFeatureDataSource", - "modules.datamodels.datamodelUiLanguage", - "modules.datamodels.datamodelViews", - "modules.features.trustee.datamodelFeatureTrustee", - "modules.features.neutralization.datamodelFeatureNeutralizer", - ) - - for modPath in _datamodelModules: - try: - mod = __import__(modPath, fromlist=["__all__"]) - except ImportError: - continue - for attrName in dir(mod): - cls = getattr(mod, attrName, None) - if not isinstance(cls, type) or not issubclass(cls, BaseModel): - continue - for fieldName, fieldInfo in cls.model_fields.items(): - extra = (fieldInfo.json_schema_extra or {}) if hasattr(fieldInfo, "json_schema_extra") else {} - if not isinstance(extra, dict): - continue - options = extra.get("frontend_options") - if not isinstance(options, list): - continue - ctx = f"option.{cls.__name__}.{fieldName}" - for opt in options: - if isinstance(opt, dict): - _reg(_extractRegistrySourceText(opt.get("label")), ctx) - - try: - from modules.datamodels.datamodelSubscription import BUILTIN_PLANS - for plan in BUILTIN_PLANS.values(): - _reg(_extractRegistrySourceText(getattr(plan, "title", None)), "subscription.title") - _reg(_extractRegistrySourceText(getattr(plan, "description", None)), "subscription.desc") - except (ImportError, AttributeError): - pass - - logger.info("i18n datamodel option labels: %d new keys", added) - - -# --------------------------------------------------------------------------- -# Boot: sync registry to DB -# --------------------------------------------------------------------------- - -async def syncRegistryToDb(): - """Boot hook: write all registered keys into UiLanguageSet(xx). - - 1. Scans route files for routeApiMsg("…") to eagerly register api.* keys. - 2. Registers navigation labels as nav.* keys. - 3. Registers feature UI labels (FEATURE_LABEL, UI_OBJECTS). - 4. Registers RBAC labels (DATA/RESOURCE/ROLE/QuickAction). - 5. Merges with existing UI keys (context="ui"), only touches gateway keys. - """ - _scanRouteApiMsgKeys() - _registerNavLabels() - _registerFeatureUiLabels() - _registerRbacLabels() - _registerServiceCenterLabels() - _registerNodeLabels() - _registerDatamodelOptionLabels() - _registerAccountingConnectorLabels() - - if not _REGISTRY: - logger.info("i18n registry: no keys to sync (empty registry)") - return - - from modules.datamodels.datamodelUiLanguage import UiLanguageSet - from modules.shared.configuration import APP_CONFIG - from modules.connectors.connectorDbPostgre import getCachedConnector - from modules.shared.timeUtils import getUtcTimestamp - - db = getCachedConnector( - dbHost=APP_CONFIG.get("DB_HOST", "localhost"), - dbDatabase="poweron_management", - dbUser=APP_CONFIG.get("DB_USER"), - dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"), - dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), - userId="__i18n_boot__", - ) - - rows = db.getRecordset(UiLanguageSet, recordFilter={"id": "xx"}) - - gatewayEntries = [ - {"context": entry.context, "key": key, "value": entry.value} - for key, entry in _REGISTRY.items() - ] - gatewayKeys = set(_REGISTRY.keys()) - - if not rows: - now = getUtcTimestamp() - rec = { - "id": "xx", - "label": "Basisset (Meta)", - "entries": gatewayEntries, - "status": "complete", - "isDefault": True, - "sysCreatedAt": now, - "sysCreatedBy": "__i18n_boot__", - "sysModifiedAt": now, - "sysModifiedBy": "__i18n_boot__", - } - db.recordCreate(UiLanguageSet, rec) - logger.info("i18n boot-sync: created xx set with %d gateway keys", len(gatewayEntries)) - return - - row = dict(rows[0]) - existingEntries: List[dict] = row.get("entries") or [] - if not isinstance(existingEntries, list): - existingEntries = [] - - uiEntries = [e for e in existingEntries if e.get("context", "") == "ui"] - - oldGatewayEntries = [ - e for e in existingEntries - if e.get("context", "") != "ui" - ] - oldGatewayByKey = {e["key"]: e for e in oldGatewayEntries} - - added = 0 - updated = 0 - removed = 0 - - newGatewayEntries: List[dict] = [] - for key, entry in _REGISTRY.items(): - newEntry = {"context": entry.context, "key": key, "value": entry.value} - old = oldGatewayByKey.get(key) - if old is None: - added += 1 - elif old.get("context") != entry.context or old.get("value") != entry.value: - updated += 1 - newGatewayEntries.append(newEntry) - - removed = len(set(oldGatewayByKey.keys()) - gatewayKeys) - - mergedEntries = uiEntries + newGatewayEntries - - if added == 0 and updated == 0 and removed == 0: - logger.info("i18n boot-sync: xx set up-to-date (%d gateway + %d ui keys)", len(newGatewayEntries), len(uiEntries)) - return - - now = getUtcTimestamp() - row["entries"] = mergedEntries - if "keys" in row: - del row["keys"] - row["sysModifiedAt"] = now - row["sysModifiedBy"] = "__i18n_boot__" - db.recordModify(UiLanguageSet, "xx", row) - - logger.info( - "i18n boot-sync: xx updated (+%d added, ~%d updated, -%d removed, total=%d gateway + %d ui)", - added, updated, removed, len(newGatewayEntries), len(uiEntries), - ) - - -# --------------------------------------------------------------------------- -# Boot: load translation cache -# --------------------------------------------------------------------------- - -async def loadCache(): - """Boot hook: load all UiLanguageSets into the in-memory cache. - - Also persistently repairs placeholder mismatches in the DB: - if an entry's value has placeholder *names* that differ from the - source key (typical AI translation mishap, e.g. ``{konten}`` -> - ``{accounts}``), the source names are restored positionally and the - row is written back to the DB. Idempotent and safe -- only mutates - when the placeholder count matches and the names actually differ. - - After this, t() lookups are O(1) dict access with no DB calls. - """ - from modules.datamodels.datamodelUiLanguage import UiLanguageSet - from modules.shared.configuration import APP_CONFIG - from modules.connectors.connectorDbPostgre import getCachedConnector - - db = getCachedConnector( - dbHost=APP_CONFIG.get("DB_HOST", "localhost"), - dbDatabase="poweron_management", - dbUser=APP_CONFIG.get("DB_USER"), - dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"), - dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), - userId="__i18n_cache__", - ) - - rows = db.getRecordset(UiLanguageSet) - _CACHE.clear() - - repairedTotal = 0 - persistedLanguages = 0 - for row in rows: - code = row.get("id", "") - if code == "xx": - continue - entries = row.get("entries") - if not isinstance(entries, list): - continue - langDict: Dict[str, str] = {} - repairedInLang = 0 - # Walk a mutable copy so we can write the corrected entries back to - # the row without re-reading from the DB. - for entry in entries: - key = entry.get("key", "") - val = entry.get("value", "") - if not key or not val: - continue - fixed, changed = _enforceSourcePlaceholders(key, val) - if changed: - entry["value"] = fixed - repairedInLang += 1 - langDict[key] = fixed - if langDict: - _CACHE[code] = langDict - if repairedInLang: - repairedTotal += repairedInLang - try: - rowToSave = dict(row) - rowToSave["entries"] = entries - if "keys" in rowToSave: - del rowToSave["keys"] - db.recordModify(UiLanguageSet, code, rowToSave) - persistedLanguages += 1 - logger.info( - "i18n boot repair: fixed and persisted %d placeholder mismatches in language '%s'", - repairedInLang, code, - ) - except Exception as ex: - # Persistence is best-effort -- the in-memory cache is - # already correct (langDict above contains the fixed - # values), so the UI works either way. Log and move on. - logger.warning( - "i18n boot repair: in-memory fixed %d entries in '%s' but DB persist failed: %s", - repairedInLang, code, ex, - ) - - logger.info( - "i18n cache loaded: %d languages, %d total keys%s", - len(_CACHE), sum(len(v) for v in _CACHE.values()), - ( - f" (boot-repaired {repairedTotal} placeholders, " - f"persisted to {persistedLanguages} language sets)" - if repairedTotal else "" - ), - ) diff --git a/modules/shared/jsonContinuation-logic.md b/modules/shared/jsonContinuation-logic.md deleted file mode 100644 index b7e93cb4..00000000 --- a/modules/shared/jsonContinuation-logic.md +++ /dev/null @@ -1,164 +0,0 @@ -# JSON Continuation Context Module - -Ein Python-Modul zur Generierung von Kontextinformationen für abgeschnittene JSON-Strings, um AI-Modellen die Fortsetzung zu ermöglichen. - -## Problem - -Wenn eine AI-Antwort als JSON abgeschnitten wird (z.B. Token-Limit erreicht), muss die nächste Iteration wissen: -- **Wo** der JSON abgeschnitten wurde -- **Was** bereits generiert wurde -- **Was** als nächstes geliefert werden soll - -## Lösung: Drei Kontexte - -### 1. Overlap Context -- Zeigt das **innerste Objekt/Array-Element**, das den Cut-Punkt enthält -- Wird verwendet, um den abgeschnittenen Teil mit dem neuen Teil zu **mergen** -- Exakt so wie im Original-String (für String-Matching beim Merge) - -### 2. Hierarchy Context -- Zeigt die **hierarchische Struktur** vom Root bis zum Cut-Punkt -- Mit **Budget-Logik**: Näher am Cut = vollständige Werte, weiter weg = `"..."` Platzhalter -- Gibt der AI den Kontext der gesamten JSON-Struktur - -### 3. Complete Part (NEU) -- Der **vollständige, valide JSON** bis zum Cut-Punkt -- Alle offenen Strukturen werden geschlossen (`}`, `]`, `"`) -- Unvollständige Keys werden entfernt -- Kann direkt als valides JSON geparst werden - -## Installation - -```bash -# Keine externen Abhängigkeiten erforderlich -cp json_continuation.py /your/project/ -``` - -## Modulkonstanten - -```python -# Diese Konstanten können vor dem Import angepasst werden -BUDGET_LIMIT: int = 500 # Zeichen-Budget für Datenwerte -OVERLAP_MAX_CHARS: int = 1000 # Max Zeichen für Overlap Context -``` - -## Verwendung - -### Grundlegende Verwendung - -```python -from json_continuation import extract_continuation_contexts - -truncated_json = '''{"customers": [ - {"id": 1, "name": "John"}, - {"id": 2, "name": "Jane", "email": "jane@exa''' - -overlap, hierarchy, complete = extract_continuation_contexts(truncated_json) - -print("Overlap Context:") -print(overlap) -# {"id": 2, "name": "Jane", "email": "jane@exa - -print("Hierarchy Context:") -print(hierarchy) -# {"customers": [...structure with budget logic...] - -print("Complete Part (valid JSON):") -print(complete) -# {"customers": [{"id": 1, "name": "John"}, {"id": 2, "name": "Jane", "email": "jane@exa"}]} - -import json -parsed = json.loads(complete) # ✓ Funktioniert! -``` - -### Mit Dictionary-Interface - -```python -from json_continuation import get_contexts - -contexts = get_contexts(truncated_json) - -print(contexts['overlap']) -print(contexts['hierarchy']) -print(contexts['complete_part']) -``` - -### Konstanten anpassen - -```python -import json_continuation - -# Budget anpassen bevor Funktionen aufgerufen werden -json_continuation.BUDGET_LIMIT = 200 -json_continuation.OVERLAP_MAX_CHARS = 500 - -overlap, hierarchy, complete = json_continuation.extract_continuation_contexts(truncated_json) -``` - -## Rückgabewerte - -| Rückgabe | Typ | Beschreibung | -|----------|-----|--------------| -| `overlap` | str | Innerstes Element mit Cut-Punkt (für Merge) | -| `hierarchy` | str | Volle Struktur mit Budget-Logik | -| `complete_part` | str | Valides JSON mit geschlossenen Strukturen | - -## Beispiele - -### Verschachtelte Objekte - -```python -json_str = '{"user": {"profile": {"bio": "Hello Wor' - -overlap, hierarchy, complete = extract_continuation_contexts(json_str) - -# Overlap: {"bio": "Hello Wor -# Hierarchy: {"user": {"profile": {"bio": "Hello Wor -# Complete: {"user": {"profile": {"bio": "Hello Wor"}}} ← Valides JSON! -``` - -### Array von Objekten mit unvollständigem Key - -```python -json_str = '''{ - "items": [ - {"id": 1, "name": "First"}, - {"id": 2, "name": "Second"}, - {"id": 3, "name": "Third", "add''' - -overlap, hierarchy, complete = extract_continuation_contexts(json_str) - -# Complete entfernt den unvollständigen Key "add": -# {"items": [{"id": 1, ...}, {"id": 2, ...}, {"id": 3, "name": "Third"}]} -``` - -## Budget-Logik - -Die Budget-Logik funktioniert wie folgt: - -1. **Sammeln**: Alle String-Werte werden mit ihrer Position gesammelt -2. **Sortieren**: Nach Entfernung zum Cut-Punkt (näher = höhere Priorität) -3. **Zuweisen**: Budget wird von hinten nach vorne aufgebraucht -4. **Ersetzen**: Werte außerhalb des Budgets werden durch `"..."` ersetzt - -## Tests ausführen - -```bash -python -m unittest test_json_continuation -v -``` - -## API Referenz - -### `extract_continuation_contexts(truncated_json: str) -> Tuple[str, str, str]` - -Hauptfunktion. Gibt `(overlap, hierarchy, complete_part)` zurück. - -### `get_contexts(truncated_json: str) -> dict` - -Convenience-Funktion. Gibt Dictionary mit Keys `'overlap'`, `'hierarchy'`, `'complete_part'` zurück. - -### Modulkonstanten - -- `BUDGET_LIMIT`: int (default: 500) - Zeichen-Budget für Hierarchy-Context -- `OVERLAP_MAX_CHARS`: int (default: 1000) - Max Zeichen für Overlap-Context - diff --git a/modules/shared/jsonUtils.py b/modules/shared/jsonUtils.py index 37c1ae36..ea3c0200 100644 --- a/modules/shared/jsonUtils.py +++ b/modules/shared/jsonUtils.py @@ -865,8 +865,10 @@ def buildContinuationContext( allSections: List[Dict[str, Any]], lastRawResponse: Optional[str] = None, useCaseId: Optional[str] = None, - templateStructure: Optional[str] = None -) -> "ContinuationContext": + templateStructure: Optional[str] = None, + continuationContextClass=None, + getContextsFn=None +): """ Build context information from accumulated sections for continuation prompt. @@ -877,12 +879,12 @@ def buildContinuationContext( lastRawResponse: Raw JSON response from last iteration (can be broken/incomplete) useCaseId: Optional use case ID to determine expected JSON structure templateStructure: JSON structure template from initial prompt (MUST be identical) + continuationContextClass: Pydantic model class to construct the result (e.g. ContinuationContext) + getContextsFn: Function to extract continuation contexts from JSON string (from jsonContinuation) Returns: - ContinuationContext: Pydantic model with all continuation context information + Instance of continuationContextClass if provided, otherwise a plain dict """ - # Lazy import to avoid circular dependency - from modules.datamodels.datamodelAi import ContinuationContext section_count = len(allSections) # Build summary of delivered data (per-section counts) @@ -1010,10 +1012,8 @@ def buildContinuationContext( overlap_context = "" hierarchy_context = "" - if lastRawResponse: + if lastRawResponse and getContextsFn is not None: try: - from modules.shared.jsonContinuation import getContexts - # Normalize JSON string normalized = stripCodeFences(normalizeJsonText(lastRawResponse)).strip() if normalized: @@ -1026,7 +1026,7 @@ def buildContinuationContext( if startIdx >= 0: jsonContent = normalized[startIdx:] - contexts = getContexts(jsonContent) + contexts = getContextsFn(jsonContent) # Store all contexts from centralized module last_complete_part = contexts.completePart @@ -1036,8 +1036,7 @@ def buildContinuationContext( except Exception as e: logger.warning(f"Error extracting JSON continuation contexts: {e}", exc_info=True) - # Return ContinuationContext Pydantic model - return ContinuationContext( + contextData = dict( section_count=section_count, delivered_summary=delivered_summary, template_structure=templateStructure, @@ -1047,6 +1046,9 @@ def buildContinuationContext( overlap_context=overlap_context, hierarchy_context=hierarchy_context ) + if continuationContextClass is not None: + return continuationContextClass(**contextData) + return contextData def parseJsonWithModel(jsonString: str, modelClass: Type[T]) -> T: """ diff --git a/modules/shared/serviceExceptions.py b/modules/shared/serviceExceptions.py new file mode 100644 index 00000000..2aa94d95 --- /dev/null +++ b/modules/shared/serviceExceptions.py @@ -0,0 +1,146 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Shared service exception classes. + +Centralises the three cross-layer exception types so that both the +serviceCenter layer and the workflows/interfaces layers can import them +from one place without creating circular dependencies. +""" + +from typing import Dict, Any, Optional + +from modules.shared.i18nRegistry import t +from modules.datamodels.datamodelSubscription import SubscriptionStatusEnum + + +# ============================================================================ +# Subscription action / reason constants +# ============================================================================ + +SUBSCRIPTION_USER_ACTION_UPGRADE = "UPGRADE_SUBSCRIPTION" +SUBSCRIPTION_USER_ACTION_REACTIVATE = "REACTIVATE_SUBSCRIPTION" +SUBSCRIPTION_USER_ACTION_ADD_PAYMENT = "ADD_PAYMENT_METHOD" +SUBSCRIPTION_USER_ACTION_CONTACT_ADMIN = "CONTACT_ADMIN" + +SUBSCRIPTION_REASONS = { + "SUBSCRIPTION_INACTIVE", + "SUBSCRIPTION_PAYMENT_REQUIRED", + "SUBSCRIPTION_PAYMENT_PENDING", + "SUBSCRIPTION_EXPIRED", +} + + +# ============================================================================ +# Subscription helper functions +# ============================================================================ + +def _subscriptionReasonForStatus(status: SubscriptionStatusEnum) -> str: + if status == SubscriptionStatusEnum.PENDING: + return "SUBSCRIPTION_PAYMENT_PENDING" + if status == SubscriptionStatusEnum.PAST_DUE: + return "SUBSCRIPTION_PAYMENT_REQUIRED" + if status == SubscriptionStatusEnum.EXPIRED: + return "SUBSCRIPTION_EXPIRED" + return "SUBSCRIPTION_INACTIVE" + + +def _subscriptionUserActionForStatus(status: SubscriptionStatusEnum) -> str: + if status in (SubscriptionStatusEnum.PAST_DUE, SubscriptionStatusEnum.PENDING): + return SUBSCRIPTION_USER_ACTION_ADD_PAYMENT + return SUBSCRIPTION_USER_ACTION_UPGRADE + + +def _subscriptionLimitsHint() -> str: + return " " + t( + "Details zu Ihrem Abonnement, den enthaltenen Limits und Upgrade-Optionen: " + "Menü «Administration» → «Billing» → Registerkarte «Abonnement»." + ) + + +def _enterpriseLimitsHint() -> str: + return " " + t( + "Ihr Enterprise-Abonnement wird vom Plattform-Administrator verwaltet. " + "Bitte kontaktieren Sie den Administrator für eine Anpassung der Limiten." + ) + + +# ============================================================================ +# Exception classes +# ============================================================================ + +class SubscriptionInactiveException(Exception): + def __init__(self, status: SubscriptionStatusEnum, mandateId: str = "", message: Optional[str] = None): + self.status = status + self.mandateId = mandateId + self.reason = _subscriptionReasonForStatus(status) + self.userAction = _subscriptionUserActionForStatus(status) + self.message = message or t( + "Kein aktives Abonnement für diesen Mandanten. Bitte wählen Sie einen Plan unter Billing." + ) + super().__init__(self.message) + + def toClientDict(self) -> Dict[str, Any]: + out: Dict[str, Any] = { + "error": self.reason, "message": self.message, + "userAction": self.userAction, "subscriptionUiPath": "/admin/billing?tab=subscription", + } + if self.mandateId: + out["mandateId"] = self.mandateId + return out + + +class SubscriptionCapacityException(Exception): + def __init__(self, resourceType: str, currentCount: int, maxAllowed: int, + message: Optional[str] = None, isEnterprise: bool = False): + self.resourceType = resourceType + self.currentCount = currentCount + self.maxAllowed = maxAllowed + self.isEnterprise = isEnterprise + hint = _enterpriseLimitsHint() if isEnterprise else _subscriptionLimitsHint() + if message is not None: + self.message = message + elif resourceType == "users": + self.message = t( + "Mit dem aktuellen Abonnement sind für diesen Mandanten höchstens {maxAllowed} " + "Benutzer zulässig (derzeit {currentCount}). " + "Ohne Planwechsel können keine weiteren Benutzer hinzugefügt werden." + ).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint + elif resourceType == "featureInstances": + self.message = t( + "Es sind höchstens {maxAllowed} aktive Module erlaubt (derzeit {currentCount}). " + "Bitte Abonnement erweitern oder ein Modul entfernen." + ).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint + elif resourceType == "dataVolumeMB": + self.message = t( + "Das im Abonnement enthaltene Datenvolumen ({maxAllowed} MB) reicht nicht " + "(aktuell ca. {currentCount} MB). Bitte Speicher-Limit oder Plan anpassen." + ).format(maxAllowed=maxAllowed, currentCount=currentCount) + hint + else: + self.message = t( + "Abonnement-Limit überschritten (Ressource «{resourceType}»: " + "aktuell {currentCount}, erlaubt {maxAllowed})." + ).format(resourceType=resourceType, currentCount=currentCount, maxAllowed=maxAllowed) + hint + super().__init__(self.message) + + def toClientDict(self) -> Dict[str, Any]: + action = SUBSCRIPTION_USER_ACTION_CONTACT_ADMIN if self.isEnterprise else SUBSCRIPTION_USER_ACTION_UPGRADE + return { + "error": f"SUBSCRIPTION_{self.resourceType.upper()}_LIMIT", + "currentCount": self.currentCount, "maxAllowed": self.maxAllowed, + "message": self.message, "userAction": action, + "subscriptionUiPath": "/admin/billing?tab=subscription", + } + + +class BillingContextError(Exception): + """Raised when billing context is incomplete (missing mandateId, user, etc.). + + This is a FAIL-SAFE error: AI calls MUST NOT proceed without valid billing context. + Acts like a 0 CHF credit card pre-authorization check - validates that billing + CAN be recorded before any expensive AI operation starts. + """ + + def __init__(self, message: str = None): + self.message = message or "Billing context incomplete - AI call blocked" + super().__init__(self.message) diff --git a/modules/shared/workflowState.py b/modules/shared/workflowState.py new file mode 100644 index 00000000..069645b9 --- /dev/null +++ b/modules/shared/workflowState.py @@ -0,0 +1,47 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Workflow State +Shared utilities for workflow state management and validation. +""" + +import logging +from typing import Any + +logger = logging.getLogger(__name__) + + +class WorkflowStoppedException(Exception): + """Exception raised when a workflow is stopped by the user.""" + pass + + +def checkWorkflowStopped(services: Any) -> None: + """ + Check if workflow has been stopped by user and raise exception if so. + + Args: + services: Services object with workflow and interfaceDbChat for fresh status check + + Raises: + WorkflowStoppedException: If workflow status is "stopped" + """ + workflow = getattr(services, 'workflow', None) + if not workflow or not hasattr(workflow, 'id') or workflow.id is None: + return + + try: + # Get the current workflow status from the database to avoid stale data + currentWorkflow = services.interfaceDbChat.getWorkflow(workflow.id) + if currentWorkflow and currentWorkflow.status == "stopped": + logger.info("Workflow stopped by user, aborting operation") + raise WorkflowStoppedException("Workflow was stopped by user") + except WorkflowStoppedException: + # Re-raise the stop signal immediately + raise + except Exception as e: + # If we can't get the current status due to other database issues, fall back to the in-memory object + logger.warning(f"Could not check current workflow status from database: {str(e)}") + if workflow and workflow.status == "stopped": + logger.info("Workflow stopped by user (from in-memory object), aborting operation") + raise WorkflowStoppedException("Workflow was stopped by user") diff --git a/modules/system/databaseHealth.py b/modules/system/databaseHealth.py index 80a19ac9..111cc592 100644 --- a/modules/system/databaseHealth.py +++ b/modules/system/databaseHealth.py @@ -6,9 +6,12 @@ Database health utilities — table statistics and orphan detection/cleanup. All functions are intended for SysAdmin use only (access control in the route layer). """ +import datetime +import decimal import logging -import time import threading +import time +import uuid from dataclasses import dataclass, asdict from typing import Dict, List, Optional, Set @@ -16,8 +19,8 @@ import psycopg2 import psycopg2.extras from modules.shared.configuration import APP_CONFIG -from modules.shared.dbRegistry import getRegisteredDatabases -from modules.shared.fkRegistry import getFkRelationships, FkRelationship +from modules.dbHelpers.dbRegistry import getRegisteredDatabases +from modules.dbHelpers.fkRegistry import getFkRelationships, FkRelationship logger = logging.getLogger(__name__) @@ -92,7 +95,7 @@ class OrphanCleanupRefused(Exception): # Low-level DB helpers (read-only, lightweight connections) # --------------------------------------------------------------------------- -def _getConnection(dbName: str): +def getConnection(dbName: str): """Open a psycopg2 connection to the given registered database.""" registeredDbs = getRegisteredDatabases() configPrefix = registeredDbs.get(dbName) @@ -133,7 +136,7 @@ def _getTableStats(dbFilter: Optional[str] = None) -> List[dict]: results: List[dict] = [] for dbName in sorted(registeredDbs): try: - conn = _getConnection(dbName) + conn = getConnection(dbName) try: with conn.cursor() as cur: cur.execute(""" @@ -309,7 +312,7 @@ def _scanOrphans(dbFilter: Optional[str] = None) -> List[dict]: def _ensureConn(dbName: str): if dbName not in connCache: - connCache[dbName] = _getConnection(dbName) + connCache[dbName] = getConnection(dbName) return connCache[dbName] def _existingTables(dbName: str) -> Set[str]: @@ -473,14 +476,14 @@ def _cleanOrphans(db: str, table: str, column: str, force: bool = False) -> int: f"excluded from orphan deletion." ) - conn = _getConnection(rel.sourceDb) + conn = getConnection(rel.sourceDb) targetConn = None try: if rel.sourceDb == rel.targetDb: targetRowCount = _countRows(conn, rel.targetTable) parentIds: Optional[Set[str]] = None else: - targetConn = _getConnection(rel.targetDb) + targetConn = getConnection(rel.targetDb) targetRowCount = _countRows(targetConn, rel.targetTable) parentIds = _loadParentIds(targetConn, rel.targetTable, rel.targetColumn) @@ -690,7 +693,7 @@ def _listOrphans( safeLimit = max(1, min(int(limit), 10000)) - sourceConn = _getConnection(rel.sourceDb) + sourceConn = getConnection(rel.sourceDb) targetConn = None try: sourceColumns = _loadPhysicalColumns(sourceConn, rel.sourceTable) @@ -715,7 +718,7 @@ def _listOrphans( """, (safeLimit,)) rows = cur.fetchall() else: - targetConn = _getConnection(rel.targetDb) + targetConn = getConnection(rel.targetDb) targetColumns = _loadPhysicalColumns(targetConn, rel.targetTable) if rel.targetColumn not in targetColumns: return [] @@ -751,7 +754,7 @@ def _listOrphans( out: List[dict] = [] for row in rows: - rowDict = {k: _jsonSafe(v) for k, v in dict(row).items()} + rowDict = {k: jsonSafe(v) for k, v in dict(row).items()} out.append(asdict(OrphanRecord( sourceDb=rel.sourceDb, sourceTable=rel.sourceTable, @@ -766,12 +769,8 @@ def _listOrphans( return out -def _jsonSafe(v): +def jsonSafe(v): """Coerce psycopg2 row values into JSON-serialisable primitives.""" - import datetime - import decimal - import uuid - if v is None or isinstance(v, (str, int, float, bool)): return v if isinstance(v, (datetime.datetime, datetime.date, datetime.time)): @@ -781,9 +780,9 @@ def _jsonSafe(v): if isinstance(v, uuid.UUID): return str(v) if isinstance(v, (list, tuple)): - return [_jsonSafe(x) for x in v] + return [jsonSafe(x) for x in v] if isinstance(v, dict): - return {str(k): _jsonSafe(val) for k, val in v.items()} + return {str(k): jsonSafe(val) for k, val in v.items()} if isinstance(v, (bytes, bytearray, memoryview)): try: return bytes(v).decode("utf-8", errors="replace") @@ -806,9 +805,9 @@ def _discoverLegacyTables(dbFilter: Optional[str] = None) -> List[dict]: Returns a list of dicts: {db, table, rowCount, sizeBytes}. """ from modules.datamodels.datamodelBase import MODEL_REGISTRY - from modules.shared.fkRegistry import _ensureModelsLoaded + from modules.dbHelpers.fkRegistry import ensureModelsLoaded - _ensureModelsLoaded() + ensureModelsLoaded() registeredDbs = getRegisteredDatabases() results: List[dict] = [] @@ -816,7 +815,7 @@ def _discoverLegacyTables(dbFilter: Optional[str] = None) -> List[dict]: if dbFilter and dbName != dbFilter: continue try: - conn = _getConnection(dbName) + conn = getConnection(dbName) except Exception as e: logger.warning("Legacy scan: cannot connect to %s: %s", dbName, e) continue @@ -855,15 +854,15 @@ def _dropLegacyTable(dbName: str, tableName: str) -> dict: Raises ValueError if the table is model-backed (safety guard). """ from modules.datamodels.datamodelBase import MODEL_REGISTRY - from modules.shared.fkRegistry import _ensureModelsLoaded + from modules.dbHelpers.fkRegistry import ensureModelsLoaded - _ensureModelsLoaded() + ensureModelsLoaded() if tableName in MODEL_REGISTRY: raise ValueError( f"Table '{dbName}.{tableName}' is backed by a Pydantic model and cannot be dropped via legacy cleanup." ) - conn = _getConnection(dbName) + conn = getConnection(dbName) try: with conn.cursor() as cur: cur.execute(""" diff --git a/modules/system/databaseMigration.py b/modules/system/databaseMigration.py index 2607eb6b..4227529e 100644 --- a/modules/system/databaseMigration.py +++ b/modules/system/databaseMigration.py @@ -14,6 +14,7 @@ All functions are intended for SysAdmin use only (access control in the route la import json import logging +import os from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Set, Tuple @@ -21,10 +22,10 @@ import psycopg2 import psycopg2.extras from modules.shared.configuration import APP_CONFIG -from modules.shared.dbRegistry import getRegisteredDatabases -from modules.shared.fkRegistry import getFkRelationships +from modules.dbHelpers.dbRegistry import getRegisteredDatabases +from modules.dbHelpers.fkRegistry import getFkRelationships from modules.datamodels.datamodelBase import MODEL_REGISTRY -from modules.system.databaseHealth import _getConnection, _jsonSafe +from modules.system.databaseHealth import getConnection, jsonSafe logger = logging.getLogger(__name__) @@ -58,7 +59,7 @@ def _getAvailableDatabases() -> List[dict]: continue entry: dict = {"name": dbName, "tableCount": 0, "recordCount": 0} try: - conn = _getConnection(dbName) + conn = getConnection(dbName) try: with conn.cursor() as cur: cur.execute(""" @@ -137,7 +138,7 @@ def _getModelTablesForDb(dbName: str, physicalTables: List[str]) -> List[str]: def _exportSingleDb(dbName: str) -> dict: - conn = _getConnection(dbName) + conn = getConnection(dbName) excluded = _EXCLUDED_TABLES.get(dbName, set()) try: allTables = _listTables(conn) @@ -178,7 +179,7 @@ def _listTables(conn) -> List[str]: def _readTableRows(conn, tableName: str) -> List[dict]: with conn.cursor() as cur: cur.execute(f'SELECT * FROM "{tableName}"') - return [{k: _jsonSafe(v) for k, v in dict(row).items()} for row in cur.fetchall()] + return [{k: jsonSafe(v) for k, v in dict(row).items()} for row in cur.fetchall()] # --------------------------------------------------------------------------- @@ -195,8 +196,6 @@ def streamExportGenerator(databases: List[str], instanceLabel: str = ""): The output format is identical to the non-streaming _exportDatabases(): {"meta": {...}, "databases": {"dbName": {"tables": {"tbl": [rows]}, ...}}} """ - import json - registeredDbs = getRegisteredDatabases() validDbs = [db for db in databases if db in registeredDbs] @@ -219,7 +218,7 @@ def streamExportGenerator(databases: List[str], instanceLabel: str = ""): excluded = _EXCLUDED_TABLES.get(dbName, set()) conn = None try: - conn = _getConnection(dbName) + conn = getConnection(dbName) allTables = _listTables(conn) modelTables = _getModelTablesForDb(dbName, allTables) @@ -255,7 +254,7 @@ def streamExportGenerator(databases: List[str], instanceLabel: str = ""): if not firstRow: yield ',' firstRow = False - safeRow = {k: _jsonSafe(v) for k, v in dict(row).items()} + safeRow = {k: jsonSafe(v) for k, v in dict(row).items()} yield json.dumps(safeRow, ensure_ascii=False, default=str) rowCount += 1 @@ -379,7 +378,7 @@ def _loadLiveSystemObjectIds() -> Dict[str, str]: return {} result: Dict[str, str] = {} - conn = _getConnection("poweron_app") + conn = getConnection("poweron_app") try: with conn.cursor() as cur: cur.execute("""SELECT id FROM "Mandate" WHERE "name" = 'root' AND "isSystem" = true LIMIT 1""") @@ -528,7 +527,7 @@ def _importDatabases(payload: dict, mode: str) -> dict: tables = dbData.get("tables", {}) dbResult: Dict[str, int] = {} - conn = _getConnection(dbName) + conn = getConnection(dbName) try: conn.autocommit = False existingTables = set(_listTables(conn)) @@ -649,12 +648,10 @@ def _insertRows( def _pgSafe(v: Any) -> Any: """Convert Python values to psycopg2-compatible types.""" - import json as _json - if v is None or isinstance(v, (str, int, float, bool)): return v if isinstance(v, (dict, list)): - return _json.dumps(v) + return json.dumps(v) return str(v) @@ -856,7 +853,7 @@ def _importSingleDb(payload: dict, dbName: str, mode: str, protectedIds: List[st if dbCreated: warnings.append(f"Datenbank '{dbName}' wurde neu erstellt") - conn = _getConnection(dbName) + conn = getConnection(dbName) try: existingTables = set(_listTables(conn)) conn.rollback() @@ -1150,8 +1147,6 @@ def _streamSplitToFiles( Returns ``{dbName: {tableName: filePath}}``. """ - import os - remapSet = set(remap.keys()) if remap else set() dbFiles: Dict[str, Dict[str, str]] = {} writers: Dict[Tuple[str, str], Any] = {} @@ -1236,7 +1231,7 @@ def _importSingleDbFromFiles( if dbCreated: warnings.append(f"Datenbank '{dbName}' wurde neu erstellt") - conn = _getConnection(dbName) + conn = getConnection(dbName) try: existingTables = set(_listTables(conn)) conn.rollback() diff --git a/modules/shared/gdprDeletion.py b/modules/system/gdprDeletion.py similarity index 100% rename from modules/shared/gdprDeletion.py rename to modules/system/gdprDeletion.py diff --git a/modules/system/i18nBootSync.py b/modules/system/i18nBootSync.py new file mode 100644 index 00000000..e60aa4d3 --- /dev/null +++ b/modules/system/i18nBootSync.py @@ -0,0 +1,563 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +i18n boot-time logic: label discovery, DB sync, and cache loading. + +Called once at app startup (from app.py). This module MAY import from +system, features, serviceCenter, connectors — it runs after all modules +are importable and is never imported at module-level by datamodels or shared. +""" + +from __future__ import annotations + +import logging +import re +from pathlib import Path +from typing import Any, Dict, List, Type + +from pydantic import BaseModel + +from modules.shared.i18nRegistry import ( + _CACHE, + _CURRENT_LANGUAGE, + _enforceSourcePlaceholders, + _extractRegistrySourceText, + _I18nRegistryEntry, + _REGISTRY, + t, +) + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Boot: scan route files for routeApiMsg("…") calls → register eagerly +# --------------------------------------------------------------------------- + +_ROUTE_API_MSG_RE = None # compiled lazily + + +def _scanRouteApiMsgKeys(): + """Scan all gateway route/feature Python files for routeApiMsg("…") calls + and register the keys in _REGISTRY so they appear in the boot DB sync. + """ + global _ROUTE_API_MSG_RE + if _ROUTE_API_MSG_RE is None: + _ROUTE_API_MSG_RE = re.compile( + r"""routeApiMsg\(\s*(['"])((?:\\.|(?!\1).)+)\1""", + ) + + gatewayRoot = Path(__file__).resolve().parents[1] + scanDirs = [gatewayRoot / "routes", gatewayRoot / "features"] + + _ctxRe = re.compile(r'''apiRouteContext\(\s*['"]([^'"]+)['"]\s*\)''') + + for scanDir in scanDirs: + if not scanDir.is_dir(): + continue + for pyFile in scanDir.rglob("*.py"): + try: + src = pyFile.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + ctxMatch = _ctxRe.search(src) + if not ctxMatch: + continue + ctx = f"api.{ctxMatch.group(1)}" + for m in _ROUTE_API_MSG_RE.finditer(src): + key = m.group(2).replace("\\'", "'").replace('\\"', '"') + if key and key not in _REGISTRY: + _REGISTRY[key] = _I18nRegistryEntry(context=ctx, value="") + + logger.info("i18n route scan: %d api.* keys in registry after scan", + sum(1 for e in _REGISTRY.values() if e.context.startswith("api."))) + + +def _registerNavLabels(): + """Register all navigation labels from NAVIGATION_SECTIONS as i18n keys.""" + try: + from modules.system.mainSystem import NAVIGATION_SECTIONS + except ImportError: + logger.warning("i18n: could not import NAVIGATION_SECTIONS for nav label registration") + return + + count = 0 + for section in NAVIGATION_SECTIONS: + title = section.get("title", "") + if title and title not in _REGISTRY: + _REGISTRY[title] = _I18nRegistryEntry(context="nav", value="") + count += 1 + + for item in section.get("items", []): + label = item.get("label", "") + if label and label not in _REGISTRY: + _REGISTRY[label] = _I18nRegistryEntry(context="nav", value="") + count += 1 + + for subgroup in section.get("subgroups", []): + sgTitle = subgroup.get("title", "") + if sgTitle and sgTitle not in _REGISTRY: + _REGISTRY[sgTitle] = _I18nRegistryEntry(context="nav", value="") + count += 1 + for item in subgroup.get("items", []): + label = item.get("label", "") + if label and label not in _REGISTRY: + _REGISTRY[label] = _I18nRegistryEntry(context="nav", value="") + count += 1 + + logger.info("i18n nav labels: registered %d nav keys", count) + + +def _registerFeatureUiLabels(): + """Register FEATURE_LABEL and UI_OBJECTS labels from all feature modules.""" + try: + from modules.system import mainSystem as _mainSystem + _fl = getattr(_mainSystem, "FEATURE_LABEL", None) + if isinstance(_fl, str) and _fl and _fl not in _REGISTRY: + _REGISTRY[_fl] = _I18nRegistryEntry(context="nav", value="") + except ImportError: + pass + + _featureModulePaths = ( + "modules.features.trustee.mainTrustee", + "modules.features.graphicalEditor.mainGraphicalEditor", + "modules.features.commcoach.mainCommcoach", + "modules.features.teamsbot.mainTeamsbot", + "modules.features.workspace.mainWorkspace", + "modules.features.realEstate.mainRealEstate", + "modules.features.neutralization.mainNeutralization", + ) + added = 0 + for modPath in _featureModulePaths: + try: + mod = __import__(modPath, fromlist=["FEATURE_LABEL", "UI_OBJECTS"]) + except ImportError: + continue + fl = getattr(mod, "FEATURE_LABEL", None) + if isinstance(fl, str) and fl and fl not in _REGISTRY: + _REGISTRY[fl] = _I18nRegistryEntry(context="nav", value="") + added += 1 + for uiObj in getattr(mod, "UI_OBJECTS", []) or []: + base = _extractRegistrySourceText(uiObj.get("label")) + if base and base not in _REGISTRY: + _REGISTRY[base] = _I18nRegistryEntry(context="nav", value="") + added += 1 + logger.info("i18n feature UI labels: %d new keys (nav context)", added) + + +def _registerRbacLabels(): + """Register DATA_OBJECTS, RESOURCE_OBJECTS labels and TEMPLATE_ROLES descriptions.""" + _featureModulePaths = ( + "modules.system.mainSystem", + "modules.features.trustee.mainTrustee", + "modules.features.graphicalEditor.mainGraphicalEditor", + "modules.features.commcoach.mainCommcoach", + "modules.features.teamsbot.mainTeamsbot", + "modules.features.workspace.mainWorkspace", + "modules.features.realEstate.mainRealEstate", + "modules.features.neutralization.mainNeutralization", + ) + + added = 0 + for modPath in _featureModulePaths: + try: + mod = __import__(modPath, fromlist=[ + "DATA_OBJECTS", "RESOURCE_OBJECTS", "TEMPLATE_ROLES", + "QUICK_ACTIONS", "QUICK_ACTION_CATEGORIES", + ]) + except ImportError: + continue + + for dataObj in getattr(mod, "DATA_OBJECTS", []) or []: + key = _extractRegistrySourceText(dataObj.get("label")) + if key and key not in _REGISTRY: + _REGISTRY[key] = _I18nRegistryEntry(context="rbac.data", value="") + added += 1 + + for resObj in getattr(mod, "RESOURCE_OBJECTS", []) or []: + key = _extractRegistrySourceText(resObj.get("label")) + if key and key not in _REGISTRY: + _REGISTRY[key] = _I18nRegistryEntry(context="rbac.resource", value="") + added += 1 + + for role in getattr(mod, "TEMPLATE_ROLES", []) or []: + key = _extractRegistrySourceText(role.get("description")) + if key and key not in _REGISTRY: + _REGISTRY[key] = _I18nRegistryEntry(context="rbac.role", value="") + added += 1 + + for qa in getattr(mod, "QUICK_ACTIONS", []) or []: + for field in ("label", "description"): + key = _extractRegistrySourceText(qa.get(field)) + if key and key not in _REGISTRY: + _REGISTRY[key] = _I18nRegistryEntry(context="rbac.quickaction", value="") + added += 1 + + for cat in getattr(mod, "QUICK_ACTION_CATEGORIES", []) or []: + key = _extractRegistrySourceText(cat.get("label")) + if key and key not in _REGISTRY: + _REGISTRY[key] = _I18nRegistryEntry(context="rbac.quickaction", value="") + added += 1 + + logger.info("i18n rbac labels: %d new keys (rbac.* context)", added) + + +def _registerServiceCenterLabels(): + """Register service-center category labels and bootstrap role descriptions.""" + added = 0 + + try: + from modules.serviceCenter.registry import IMPORTABLE_SERVICES + for svc in IMPORTABLE_SERVICES.values(): + key = _extractRegistrySourceText(svc.get("label")) + if key and key not in _REGISTRY: + _REGISTRY[key] = _I18nRegistryEntry(context="service", value="") + added += 1 + except ImportError: + pass + + _bootstrapRoleDescriptions = [ + "Administrator - Benutzer und Ressourcen im Mandanten verwalten", + "Benutzer - Standard-Benutzer mit Zugriff auf eigene Datensätze", + "Betrachter - Nur-Lese-Zugriff auf Gruppen-Datensätze", + "System-Administrator - Vollständiger administrativer Zugriff über alle Mandanten", + ] + for desc in _bootstrapRoleDescriptions: + if desc not in _REGISTRY: + _REGISTRY[desc] = _I18nRegistryEntry(context="rbac.role", value="") + added += 1 + + logger.info("i18n service/bootstrap labels: %d new keys", added) + + +def _registerNodeLabels(): + """Register all graph-editor node labels, descriptions, parameter descriptions, + output labels, port descriptions, category labels, and entry-point titles.""" + added = 0 + + def _reg(key: str, ctx: str): + nonlocal added + if key and key not in _REGISTRY: + _REGISTRY[key] = _I18nRegistryEntry(context=ctx, value="") + added += 1 + + try: + from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES + for nd in STATIC_NODE_TYPES: + _reg(_extractRegistrySourceText(nd.get("label")), "node.label") + _reg(_extractRegistrySourceText(nd.get("description")), "node.desc") + + for param in nd.get("parameters", []) or []: + _reg(_extractRegistrySourceText(param.get("description")), "node.param") + _reg(_extractRegistrySourceText(param.get("label")), "node.param") + + outLabels = nd.get("outputLabels") + if isinstance(outLabels, dict): + sourceList = outLabels.get("xx") or next(iter(outLabels.values()), []) + if not isinstance(sourceList, list): + sourceList = [] + for lbl in sourceList: + _reg(lbl, "node.output") + elif isinstance(outLabels, list): + for lbl in outLabels: + _reg(lbl, "node.output") + except ImportError: + pass + + try: + from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG + for schema in PORT_TYPE_CATALOG.values(): + for field in getattr(schema, "fields", []) or []: + desc = getattr(field, "description", None) + if desc: + _reg(_extractRegistrySourceText(desc if isinstance(desc, (str, dict)) else None), "port.desc") + except ImportError: + pass + + _nodeCategoryLabels = [ + "Trigger", "Eingabe/Mensch", "Ablauf", "Daten", "KI", + "Datei", "E-Mail", "SharePoint", "ClickUp", "Treuhand", + ] + for lbl in _nodeCategoryLabels: + _reg(lbl, "node.category") + + _entryPointTitles = ["Jetzt ausführen", "Start"] + for lbl in _entryPointTitles: + _reg(lbl, "node.entry") + + logger.info("i18n node labels: %d new keys (node.*/port.* context)", added) + + +def _registerAccountingConnectorLabels(): + """Register all accounting connector configField labels at boot time.""" + added = 0 + try: + from modules.features.trustee.accounting.accountingRegistry import getAccountingRegistry + except ImportError: + logger.debug("i18n accounting connectors: registry not importable") + return + + try: + registry = getAccountingRegistry() + except Exception as e: + logger.warning("i18n accounting connectors: registry init failed: %s", e) + return + + for connectorType, connector in (registry._connectors or {}).items(): + try: + for field in connector.getRequiredConfigFields(): + key = getattr(field, "label", "") or "" + if not isinstance(key, str) or not key: + continue + if key not in _REGISTRY: + _REGISTRY[key] = _I18nRegistryEntry( + context=f"connector.accounting.{connectorType}", + value="", + ) + added += 1 + except Exception as e: + logger.warning( + "i18n accounting connector %s: failed to read fields: %s", + connectorType, e, + ) + + logger.info("i18n accounting connector labels: %d new keys", added) + + +def _registerDatamodelOptionLabels(): + """Register all frontend_options labels from Pydantic datamodels and subscription plans.""" + added = 0 + + def _reg(key: str, ctx: str): + nonlocal added + if key and key not in _REGISTRY: + _REGISTRY[key] = _I18nRegistryEntry(context=ctx, value="") + added += 1 + + _datamodelModules = ( + "modules.datamodels.datamodelRbac", + "modules.datamodels.datamodelChat", + "modules.datamodels.datamodelMessaging", + "modules.datamodels.datamodelNotification", + "modules.datamodels.datamodelUam", + "modules.datamodels.datamodelFiles", + "modules.datamodels.datamodelDataSource", + "modules.datamodels.datamodelFeatures", + "modules.datamodels.datamodelUiLanguage", + "modules.datamodels.datamodelViews", + "modules.features.trustee.datamodelFeatureTrustee", + "modules.features.neutralization.datamodelFeatureNeutralizer", + ) + + for modPath in _datamodelModules: + try: + mod = __import__(modPath, fromlist=["__all__"]) + except ImportError: + continue + for attrName in dir(mod): + cls = getattr(mod, attrName, None) + if not isinstance(cls, type) or not issubclass(cls, BaseModel): + continue + for fieldName, fieldInfo in cls.model_fields.items(): + extra = (fieldInfo.json_schema_extra or {}) if hasattr(fieldInfo, "json_schema_extra") else {} + if not isinstance(extra, dict): + continue + options = extra.get("frontend_options") + if not isinstance(options, list): + continue + ctx = f"option.{cls.__name__}.{fieldName}" + for opt in options: + if isinstance(opt, dict): + _reg(_extractRegistrySourceText(opt.get("label")), ctx) + + try: + from modules.datamodels.datamodelSubscription import BUILTIN_PLANS + for plan in BUILTIN_PLANS.values(): + _reg(_extractRegistrySourceText(getattr(plan, "title", None)), "subscription.title") + _reg(_extractRegistrySourceText(getattr(plan, "description", None)), "subscription.desc") + except (ImportError, AttributeError): + pass + + logger.info("i18n datamodel option labels: %d new keys", added) + + +# --------------------------------------------------------------------------- +# Public boot API (called by app.py) +# --------------------------------------------------------------------------- + +async def syncRegistryToDb(): + """Boot hook: discover all i18n keys and write them into UiLanguageSet(xx).""" + _scanRouteApiMsgKeys() + _registerNavLabels() + _registerFeatureUiLabels() + _registerRbacLabels() + _registerServiceCenterLabels() + _registerNodeLabels() + _registerDatamodelOptionLabels() + _registerAccountingConnectorLabels() + + if not _REGISTRY: + logger.info("i18n registry: no keys to sync (empty registry)") + return + + from modules.datamodels.datamodelUiLanguage import UiLanguageSet + from modules.shared.configuration import APP_CONFIG + from modules.connectors.connectorDbPostgre import getCachedConnector + from modules.shared.timeUtils import getUtcTimestamp + + db = getCachedConnector( + dbHost=APP_CONFIG.get("DB_HOST", "localhost"), + dbDatabase="poweron_management", + dbUser=APP_CONFIG.get("DB_USER"), + dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"), + dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), + userId="__i18n_boot__", + ) + + rows = db.getRecordset(UiLanguageSet, recordFilter={"id": "xx"}) + + gatewayEntries = [ + {"context": entry.context, "key": key, "value": entry.value} + for key, entry in _REGISTRY.items() + ] + gatewayKeys = set(_REGISTRY.keys()) + + if not rows: + now = getUtcTimestamp() + rec = { + "id": "xx", + "label": "Basisset (Meta)", + "entries": gatewayEntries, + "status": "complete", + "isDefault": True, + "sysCreatedAt": now, + "sysCreatedBy": "__i18n_boot__", + "sysModifiedAt": now, + "sysModifiedBy": "__i18n_boot__", + } + db.recordCreate(UiLanguageSet, rec) + logger.info("i18n boot-sync: created xx set with %d gateway keys", len(gatewayEntries)) + return + + row = dict(rows[0]) + existingEntries: List[dict] = row.get("entries") or [] + if not isinstance(existingEntries, list): + existingEntries = [] + + uiEntries = [e for e in existingEntries if e.get("context", "") == "ui"] + + oldGatewayEntries = [ + e for e in existingEntries + if e.get("context", "") != "ui" + ] + oldGatewayByKey = {e["key"]: e for e in oldGatewayEntries} + + added = 0 + updated = 0 + removed = 0 + + newGatewayEntries: List[dict] = [] + for key, entry in _REGISTRY.items(): + newEntry = {"context": entry.context, "key": key, "value": entry.value} + old = oldGatewayByKey.get(key) + if old is None: + added += 1 + elif old.get("context") != entry.context or old.get("value") != entry.value: + updated += 1 + newGatewayEntries.append(newEntry) + + removed = len(set(oldGatewayByKey.keys()) - gatewayKeys) + + mergedEntries = uiEntries + newGatewayEntries + + if added == 0 and updated == 0 and removed == 0: + logger.info("i18n boot-sync: xx set up-to-date (%d gateway + %d ui keys)", len(newGatewayEntries), len(uiEntries)) + return + + now = getUtcTimestamp() + row["entries"] = mergedEntries + if "keys" in row: + del row["keys"] + row["sysModifiedAt"] = now + row["sysModifiedBy"] = "__i18n_boot__" + db.recordModify(UiLanguageSet, "xx", row) + + logger.info( + "i18n boot-sync: xx updated (+%d added, ~%d updated, -%d removed, total=%d gateway + %d ui)", + added, updated, removed, len(newGatewayEntries), len(uiEntries), + ) + + +async def loadCache(): + """Boot hook: load all UiLanguageSets into the in-memory translation cache. + + Also persistently repairs placeholder mismatches in the DB. + After this, t() lookups are O(1) dict access with no DB calls. + """ + from modules.datamodels.datamodelUiLanguage import UiLanguageSet + from modules.shared.configuration import APP_CONFIG + from modules.connectors.connectorDbPostgre import getCachedConnector + + db = getCachedConnector( + dbHost=APP_CONFIG.get("DB_HOST", "localhost"), + dbDatabase="poweron_management", + dbUser=APP_CONFIG.get("DB_USER"), + dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"), + dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), + userId="__i18n_cache__", + ) + + rows = db.getRecordset(UiLanguageSet) + _CACHE.clear() + + repairedTotal = 0 + persistedLanguages = 0 + for row in rows: + code = row.get("id", "") + if code == "xx": + continue + entries = row.get("entries") + if not isinstance(entries, list): + continue + langDict: Dict[str, str] = {} + repairedInLang = 0 + for entry in entries: + key = entry.get("key", "") + val = entry.get("value", "") + if not key or not val: + continue + fixed, changed = _enforceSourcePlaceholders(key, val) + if changed: + entry["value"] = fixed + repairedInLang += 1 + langDict[key] = fixed + if langDict: + _CACHE[code] = langDict + if repairedInLang: + repairedTotal += repairedInLang + try: + rowToSave = dict(row) + rowToSave["entries"] = entries + if "keys" in rowToSave: + del rowToSave["keys"] + db.recordModify(UiLanguageSet, code, rowToSave) + persistedLanguages += 1 + logger.info( + "i18n boot repair: fixed and persisted %d placeholder mismatches in language '%s'", + repairedInLang, code, + ) + except Exception as ex: + logger.warning( + "i18n boot repair: in-memory fixed %d entries in '%s' but DB persist failed: %s", + repairedInLang, code, ex, + ) + + logger.info( + "i18n cache loaded: %d languages, %d total keys%s", + len(_CACHE), sum(len(v) for v in _CACHE.values()), + ( + f" (boot-repaired {repairedTotal} placeholders, " + f"persisted to {persistedLanguages} language sets)" + if repairedTotal else "" + ), + ) diff --git a/modules/shared/notifyMandateAdmins.py b/modules/system/notifyMandateAdmins.py similarity index 100% rename from modules/shared/notifyMandateAdmins.py rename to modules/system/notifyMandateAdmins.py diff --git a/modules/workflows/automation2/executionEngine.py b/modules/workflows/automation2/executionEngine.py index 8efe9339..f8d95f1c 100644 --- a/modules/workflows/automation2/executionEngine.py +++ b/modules/workflows/automation2/executionEngine.py @@ -2,6 +2,7 @@ # Main execution engine for automation2 graphs. import asyncio +import json import logging import time import uuid @@ -30,8 +31,7 @@ from modules.workflows.automation2.executors import ( ) from modules.features.graphicalEditor.portTypes import normalizeToSchema, wrapTransit, unwrapTransit from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES -from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionInactiveException as _SubscriptionInactiveException -from modules.serviceCenter.services.serviceBilling.mainServiceBilling import BillingContextError as _BillingContextError +from modules.shared.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError from modules.workflows.automation2.graphicalEditorRunFileLogger import ( GraphicalEditorRunFileLogger, graphical_editor_run_file_logging_enabled, @@ -440,8 +440,7 @@ def _substituteFeatureInstancePlaceholders( concrete UUIDs (pre-baked by ``_copyTemplateWorkflows``) are left untouched because the placeholder literal ``{{featureInstanceId}}`` will not match. """ - import json as _json - raw = _json.dumps(graph) + raw = json.dumps(graph) if "{{featureInstanceId}}" not in raw: return graph replaced = raw.replace("{{featureInstanceId}}", targetFeatureInstanceId) @@ -450,7 +449,7 @@ def _substituteFeatureInstancePlaceholders( raw.count("{{featureInstanceId}}"), targetFeatureInstanceId, ) - return _json.loads(replaced) + return json.loads(replaced) async def _run_post_loop_done_nodes( diff --git a/modules/workflows/automation2/executors/actionNodeExecutor.py b/modules/workflows/automation2/executors/actionNodeExecutor.py index 5783b108..20fed58a 100644 --- a/modules/workflows/automation2/executors/actionNodeExecutor.py +++ b/modules/workflows/automation2/executors/actionNodeExecutor.py @@ -12,14 +12,14 @@ import binascii import json import logging import re +import time from typing import Any, Dict, Optional from modules.features.graphicalEditor.portTypes import ( _normalizeError, normalizeToSchema, ) -from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionInactiveException as _SubscriptionInactiveException -from modules.serviceCenter.services.serviceBilling.mainServiceBilling import BillingContextError as _BillingContextError +from modules.shared.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError from modules.workflows.automation2.executors.inputExecutor import PauseForHumanTaskError from modules.workflows.methods.methodContext.actions.extractContent import ( PRESENTATION_KIND, @@ -132,7 +132,7 @@ def _looks_like_ascii_base64_payload(s: str) -> bool: return bool(re.fullmatch(r"[A-Za-z0-9+/]+=*", t)) and len(t) % 4 == 0 -def _coerce_document_data_to_bytes(raw: Any) -> Optional[bytes]: +def coerceDocumentDataToBytes(raw: Any) -> Optional[bytes]: """Normalize documentData for DB file persistence. ActionDocument conventions (see methodFile.create): binary bodies are carried as ASCII @@ -624,8 +624,7 @@ class ActionNodeExecutor: raise PauseForEmailWaitError(runId=runId, nodeId=nodeId, waitConfig=waitConfig) # 6. Create progress parent so nested actions have a hierarchy - import time as _time - nodeOperationId = f"node_{nodeId}_{context.get('_runId', 'x')}_{int(_time.time())}" + nodeOperationId = f"node_{nodeId}_{context.get('_runId', 'x')}_{int(time.time())}" chatService = getattr(self.services, "chat", None) if chatService: try: @@ -675,7 +674,7 @@ class ActionNodeExecutor: continue rawData = getattr(d, "documentData", None) if hasattr(d, "documentData") else (dumped.get("documentData") if isinstance(dumped, dict) else None) - rawBytes = _coerce_document_data_to_bytes(rawData) + rawBytes = coerceDocumentDataToBytes(rawData) if isinstance(dumped, dict) and rawBytes: try: from modules.interfaces.interfaceDbManagement import getInterface as _getMgmtInterface diff --git a/modules/workflows/automation2/executors/flowExecutor.py b/modules/workflows/automation2/executors/flowExecutor.py index 00ede971..3da89a87 100644 --- a/modules/workflows/automation2/executors/flowExecutor.py +++ b/modules/workflows/automation2/executors/flowExecutor.py @@ -2,6 +2,7 @@ # Flow control node executor (ifElse, switch, loop, merge). import logging +from datetime import datetime from typing import Any, Dict, List, Optional from modules.features.graphicalEditor.conditionOperators import apply_condition_operator, resolve_value_kind @@ -160,7 +161,6 @@ class FlowExecutor: s = str(v).strip() if not s: return None - from datetime import datetime for fmt in ("%Y-%m-%d", "%d.%m.%Y", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"): try: diff --git a/modules/workflows/automation2/graphUtils.py b/modules/workflows/automation2/graphUtils.py index b31dd7bb..9130f023 100644 --- a/modules/workflows/automation2/graphUtils.py +++ b/modules/workflows/automation2/graphUtils.py @@ -1,7 +1,10 @@ # Copyright (c) 2025 Patrick Motsch # Graph parsing, validation, and topological sort for automation2. +import json import logging +import re +from collections import deque from typing import Dict, List, Any, Tuple, Set, Optional logger = logging.getLogger(__name__) @@ -53,8 +56,6 @@ def getLoopBodyNodeIds(loopNodeId: str, connectionMap: Dict[str, List[Tuple[str, Edges vom Rumpf zurück in den Loop-Knoten (gleicher Eingang wie der Hauptfluss) beenden die Expansion am Loop-Knoten — der Loop-Knoten selbst ist nie Teil des Rumpfes. """ - from collections import deque - body: Set[str] = set() rev: Dict[str, List[Tuple[str, int, int]]] = {} for tgt, pairs in connectionMap.items(): @@ -105,8 +106,6 @@ def getLoopPrimaryInputSource( def getLoopDoneNodeIds(loopNodeId: str, connectionMap: Dict[str, List[Tuple[str, int, int]]]) -> Set[str]: """Nodes reachable from flow.loop output port 1 (runs once after all iterations).""" - from collections import deque - done: Set[str] = set() rev: Dict[str, List[Tuple[str, int, int]]] = {} for tgt, pairs in connectionMap.items(): @@ -297,7 +296,6 @@ def topoSort(nodes: List[Dict], connectionMap: Dict[str, List[Tuple[str, int, in order: List[Dict] = [] def bfs(startIds: List[str]) -> None: - from collections import deque q = deque(startIds) for nid in startIds: visited.add(nid) @@ -430,9 +428,6 @@ def resolveParameterReferences( When ``consumer_node_id`` and ``input_sources`` are set, refs to the wired upstream switch use that connection's output port (per-branch payload). """ - import json - import re - if isinstance(value, dict): # Phase-5 Schicht-4: typed-ref envelopes (FeatureInstanceRef etc.) on # disk get unwrapped to their canonical primitive (e.g. ``id``) so diff --git a/modules/workflows/methods/_actionSignatureValidator.py b/modules/workflows/methods/_actionSignatureValidator.py index 942ccb8a..25be8175 100644 --- a/modules/workflows/methods/_actionSignatureValidator.py +++ b/modules/workflows/methods/_actionSignatureValidator.py @@ -44,7 +44,7 @@ def _isKnownType(typeName: str) -> bool: return typeName in PRIMITIVE_TYPES or typeName in PORT_TYPE_CATALOG -def _validateTypeRef(typeStr: str) -> List[str]: +def validateTypeRef(typeStr: str) -> List[str]: """ Validate a single type reference string (the value of `type` on a WorkflowActionParameter or `outputType` on a WorkflowActionDefinition). @@ -81,7 +81,7 @@ def _validateActionParameter( ) -> List[str]: """Validate a single parameter; returns prefixed error messages.""" out: List[str] = [] - for err in _validateTypeRef(param.type): + for err in validateTypeRef(param.type): out.append(f"{actionId}.{paramName}: {err}") return out @@ -98,7 +98,7 @@ def _validateActionDefinition( outputType = actionDef.outputType if outputType not in _ALLOWED_GENERIC_OUTPUTS: - for err in _validateTypeRef(outputType): + for err in validateTypeRef(outputType): errors.append(f"{actionId}.: {err}") return errors @@ -171,7 +171,7 @@ __all__ = [ "_validateActionsDict", "_validateActionDefinition", "_validateActionParameter", - "_validateTypeRef", + "validateTypeRef", "_formatValidationReport", "_logValidationReport", ] diff --git a/modules/workflows/methods/methodAi/actions/consolidate.py b/modules/workflows/methods/methodAi/actions/consolidate.py index 7483507e..0dced074 100644 --- a/modules/workflows/methods/methodAi/actions/consolidate.py +++ b/modules/workflows/methods/methodAi/actions/consolidate.py @@ -7,8 +7,7 @@ from typing import Any, Dict, List from modules.datamodels.datamodelAi import AiCallOptions, AiCallRequest, OperationTypeEnum from modules.datamodels.datamodelChat import ActionResult -from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionInactiveException -from modules.serviceCenter.services.serviceBilling.mainServiceBilling import BillingContextError +from modules.shared.serviceExceptions import SubscriptionInactiveException, BillingContextError logger = logging.getLogger(__name__) diff --git a/modules/workflows/methods/methodAi/actions/generateCode.py b/modules/workflows/methods/methodAi/actions/generateCode.py index bc7a5a64..66a1d0bf 100644 --- a/modules/workflows/methods/methodAi/actions/generateCode.py +++ b/modules/workflows/methods/methodAi/actions/generateCode.py @@ -2,14 +2,14 @@ # All rights reserved. import logging +import re import time from typing import Dict, Any, Optional, List from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelExtraction import ContentPart from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum from modules.datamodels.datamodelWorkflow import AiResponse, DocumentData -from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionInactiveException -from modules.serviceCenter.services.serviceBilling.mainServiceBilling import BillingContextError +from modules.shared.serviceExceptions import SubscriptionInactiveException, BillingContextError logger = logging.getLogger(__name__) @@ -99,7 +99,6 @@ async def generateCode(self, parameters: Dict[str, Any]) -> ActionResult: if aiResponse.metadata and aiResponse.metadata.filename: docName = aiResponse.metadata.filename elif aiResponse.metadata and aiResponse.metadata.title: - import re sanitized = re.sub(r"[^a-zA-Z0-9._-]", "_", aiResponse.metadata.title) sanitized = re.sub(r"_+", "_", sanitized).strip("_") if sanitized: diff --git a/modules/workflows/methods/methodAi/actions/generateDocument.py b/modules/workflows/methods/methodAi/actions/generateDocument.py index 5a1ff0eb..2006ba96 100644 --- a/modules/workflows/methods/methodAi/actions/generateDocument.py +++ b/modules/workflows/methods/methodAi/actions/generateDocument.py @@ -2,14 +2,14 @@ # All rights reserved. import logging +import re import time from typing import Dict, Any, Optional, List from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelExtraction import ContentPart from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum from modules.datamodels.datamodelWorkflow import AiResponse, DocumentData -from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionInactiveException -from modules.serviceCenter.services.serviceBilling.mainServiceBilling import BillingContextError +from modules.shared.serviceExceptions import SubscriptionInactiveException, BillingContextError logger = logging.getLogger(__name__) @@ -113,7 +113,6 @@ async def generateDocument(self, parameters: Dict[str, Any]) -> ActionResult: if aiResponse.metadata and aiResponse.metadata.filename: docName = aiResponse.metadata.filename elif aiResponse.metadata and aiResponse.metadata.title: - import re sanitized = re.sub(r"[^a-zA-Z0-9._-]", "_", aiResponse.metadata.title) sanitized = re.sub(r"_+", "_", sanitized).strip("_") if sanitized: diff --git a/modules/workflows/methods/methodAi/actions/process.py b/modules/workflows/methods/methodAi/actions/process.py index 62955b12..47774eb1 100644 --- a/modules/workflows/methods/methodAi/actions/process.py +++ b/modules/workflows/methods/methodAi/actions/process.py @@ -10,8 +10,7 @@ from typing import Dict, Any, List, Optional from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, ProcessingModeEnum from modules.datamodels.datamodelExtraction import ContentPart -from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionInactiveException -from modules.serviceCenter.services.serviceBilling.mainServiceBilling import BillingContextError +from modules.shared.serviceExceptions import SubscriptionInactiveException, BillingContextError logger = logging.getLogger(__name__) diff --git a/modules/workflows/methods/methodAi/actions/webResearch.py b/modules/workflows/methods/methodAi/actions/webResearch.py index e32f8e65..0dfdeeab 100644 --- a/modules/workflows/methods/methodAi/actions/webResearch.py +++ b/modules/workflows/methods/methodAi/actions/webResearch.py @@ -8,8 +8,7 @@ import json from typing import Dict, Any from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.serviceCenter import ServiceCenterContext, getService, can_access_service -from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionInactiveException -from modules.serviceCenter.services.serviceBilling.mainServiceBilling import BillingContextError +from modules.shared.serviceExceptions import SubscriptionInactiveException, BillingContextError logger = logging.getLogger(__name__) diff --git a/modules/workflows/methods/methodBase.py b/modules/workflows/methods/methodBase.py index 5a766563..57670f61 100644 --- a/modules/workflows/methods/methodBase.py +++ b/modules/workflows/methods/methodBase.py @@ -1,8 +1,9 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. -from typing import Dict, List, Optional, Any -from datetime import datetime, UTC import logging +import re +from datetime import datetime, UTC +from typing import Dict, List, Optional, Any from functools import wraps @@ -305,7 +306,6 @@ class MethodBase: raise ValueError(f"String length must be <= {rules['max']}") if 'pattern' in rules: - import re if not re.match(rules['pattern'], str(value)): raise ValueError(f"Value does not match required pattern: {rules['pattern']}") @@ -478,7 +478,6 @@ class MethodBase: # Clean base name (remove special characters, spaces) clean_base = base_name.lower().replace(' ', '_').replace('-', '_') # Remove any non-alphanumeric characters except underscores - import re clean_base = re.sub(r'[^a-z0-9_]', '', clean_base) # Add action name if provided diff --git a/modules/workflows/methods/methodContext/actions/extractContent.py b/modules/workflows/methods/methodContext/actions/extractContent.py index 52d07b34..44309888 100644 --- a/modules/workflows/methods/methodContext/actions/extractContent.py +++ b/modules/workflows/methods/methodContext/actions/extractContent.py @@ -12,8 +12,8 @@ internally when ``_runContext`` enables image uploads. Older ``kind: context.extractContent.handover.v1`` is legacy-only (merge/tests), not produced here.""" -import base64 as _b64 -import binascii as _binascii +import base64 +import binascii import copy import csv import json @@ -69,8 +69,6 @@ def _apply_content_filter(payload: Dict[str, Any], content_filter: str) -> Dict[ - noImages: blacklist — every typeGroup except image (wider than textOnly; future non-image types are retained). """ - import copy - if content_filter == "all": return payload result = copy.deepcopy(payload) @@ -1239,8 +1237,8 @@ def _persist_extracted_image_parts( continue raw_s = raw_data.strip() if isinstance(raw_data, str) else "" try: - img_bytes = _b64.b64decode(raw_s, validate=True) if raw_s else b"" - except (_binascii.Error, TypeError, ValueError): + img_bytes = base64.b64decode(raw_s, validate=True) if raw_s else b"" + except (binascii.Error, TypeError, ValueError): new_parts.append(p) continue if not img_bytes: @@ -1288,7 +1286,7 @@ def _persist_extracted_image_parts( return content_extracted_serial, artifacts -def _one_file_bucket(ec: ContentExtracted, source_file_name: str) -> Dict[str, Any]: +def oneFileBucket(ec: ContentExtracted, source_file_name: str) -> Dict[str, Any]: parts_ser = _serialize_parts(ec.parts) ud = getattr(ec, "udm", None) @@ -1401,7 +1399,7 @@ def _load_image_bytes_by_file_id(services: Any, file_id: str) -> Optional[bytes] def _inline_runs_from_presentation_lines(lines: List[Any]) -> List[Dict[str, Any]]: """Map presentation ``lines`` to inline runs, preserving line order with explicit breaks.""" - from modules.serviceCenter.services.serviceGeneration.subDocumentUtility import _parseInlineRuns + from modules.serviceCenter.services.serviceGeneration.subDocumentUtility import parseInlineRuns runs: List[Dict[str, Any]] = [] first = True @@ -1412,7 +1410,7 @@ def _inline_runs_from_presentation_lines(lines: List[Any]) -> List[Dict[str, Any piece = str(ln) if ln is not None else "" if not piece: continue - runs.extend(_parseInlineRuns(piece)) + runs.extend(parseInlineRuns(piece)) return runs if runs else [{"type": "text", "value": ""}] @@ -1539,7 +1537,7 @@ def presentation_envelopes_to_document_json( services: Any = None, ) -> Dict[str, Any]: """Map presentation envelope(s) to ``renderReport`` ``extractedContent`` (documents/sections).""" - from modules.serviceCenter.services.serviceGeneration.subDocumentUtility import _parseInlineRuns + from modules.serviceCenter.services.serviceGeneration.subDocumentUtility import parseInlineRuns envelopes = normalize_presentation_envelopes(raw) if not envelopes: @@ -1576,7 +1574,7 @@ def presentation_envelopes_to_document_json( "id": _next_id(), "content_type": "paragraph", "order": order, - "elements": [{"content": {"inlineRuns": _parseInlineRuns(t)}}], + "elements": [{"content": {"inlineRuns": parseInlineRuns(t)}}], }) def _resolve_image_file_id(slot: Dict[str, Any]) -> Optional[str]: @@ -1633,7 +1631,7 @@ def presentation_envelopes_to_document_json( "elements": [{ "content": { "altText": str(name), - "base64Data": _b64.b64encode(blob).decode("ascii"), + "base64Data": base64.b64encode(blob).decode("ascii"), "fileId": str(fid), "fileName": str(name), "mimeType": mime, diff --git a/modules/workflows/methods/methodContext/actions/neutralizeData.py b/modules/workflows/methods/methodContext/actions/neutralizeData.py index 8efc7954..5bd1eb34 100644 --- a/modules/workflows/methods/methodContext/actions/neutralizeData.py +++ b/modules/workflows/methods/methodContext/actions/neutralizeData.py @@ -1,7 +1,7 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. -import base64 as _b64 +import base64 import logging import time from typing import Any, Dict @@ -10,7 +10,7 @@ from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.datamodels.datamodelDocref import coerceDocumentReferenceList from modules.datamodels.datamodelExtraction import ContentExtracted, ContentPart -from .extractContent import _one_file_bucket +from .extractContent import oneFileBucket logger = logging.getLogger(__name__) @@ -50,7 +50,7 @@ async def _neutralize_one_content_extracted( prog, f"Checking image part {len(neutralized_parts) + 1}", ) - _img_bytes = _b64.b64decode(str(part.data)) + _img_bytes = base64.b64decode(str(part.data)) _img_result = await svc.services.neutralization.processImageAsync(_img_bytes, f"part_{part.id}") if _img_result.get("status") == "ok": neutralized_parts.append(part) @@ -227,7 +227,7 @@ async def neutralizeData(self, parameters: Dict[str, Any]) -> ActionResult: chat_doc_slot=i, chat_documents_len=max(len(chat_documents), 1), ) - new_files[fk] = _one_file_bucket(ce_out, str(bucket.get("sourceFileName") or fk)) + new_files[fk] = oneFileBucket(ce_out, str(bucket.get("sourceFileName") or fk)) bundle["files"] = new_files original_filename = getattr(chat_doc, "fileName", f"neutralized_bundle_{workflow_id}.json") diff --git a/modules/workflows/methods/methodFile/actions/create.py b/modules/workflows/methods/methodFile/actions/create.py index 9342767f..cc5550ca 100644 --- a/modules/workflows/methods/methodFile/actions/create.py +++ b/modules/workflows/methods/methodFile/actions/create.py @@ -13,7 +13,7 @@ import re from modules.datamodels.datamodelChat import ActionResult, ActionDocument from modules.shared.i18nRegistry import normalizePrimaryLanguageTag -from modules.workflows.automation2.executors.actionNodeExecutor import _coerce_document_data_to_bytes +from modules.workflows.automation2.executors.actionNodeExecutor import coerceDocumentDataToBytes from modules.workflows.methods.methodAi._common import is_image_action_document_list from modules.workflows.methods.methodContext.actions.extractContent import ( presentation_envelopes_to_document_json, @@ -139,7 +139,7 @@ def _get_management_interface(services) -> Optional[Any]: def _load_image_bytes_from_action_doc(doc: dict, services) -> Optional[bytes]: raw = doc.get("documentData") - blob = _coerce_document_data_to_bytes(raw) + blob = coerceDocumentDataToBytes(raw) if blob: return blob fid = doc.get("fileId") diff --git a/modules/workflows/methods/methodTrustee/actions/extractFromFiles.py b/modules/workflows/methods/methodTrustee/actions/extractFromFiles.py index 37dd133d..fa677e2b 100644 --- a/modules/workflows/methods/methodTrustee/actions/extractFromFiles.py +++ b/modules/workflows/methods/methodTrustee/actions/extractFromFiles.py @@ -7,19 +7,19 @@ Output: ActionResult with one ActionDocument per file: { documentType, extracted """ import asyncio -import json -import logging -import uuid import csv import io +import json +import logging +import re +import uuid from datetime import datetime, timezone from typing import Dict, Any, List, Optional, Tuple from modules.datamodels.datamodelChat import ActionResult, ActionDocument, ChatDocument, ChatMessage from modules.datamodels.datamodelDocref import DocumentReferenceList, DocumentItemReference from modules.datamodels.datamodelAi import AiCallOptions, AiCallRequest, OperationTypeEnum -from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionInactiveException as _SubscriptionInactiveException -from modules.serviceCenter.services.serviceBilling.mainServiceBilling import BillingContextError as _BillingContextError +from modules.shared.serviceExceptions import SubscriptionInactiveException as _SubscriptionInactiveException, BillingContextError as _BillingContextError logger = logging.getLogger(__name__) @@ -219,8 +219,6 @@ def _parseCsvToRecords(csvContent: str) -> List[Dict[str, Any]]: def _estimateBankTransactionLineCount(rawText: str) -> int: """Estimate how many transaction rows exist in bank statement OCR text.""" - import re - lines = (rawText or "").splitlines() datePattern = re.compile(r"\b(\d{2}[./-]\d{2}[./-]\d{2,4}|\d{4}-\d{2}-\d{2})\b") amountPattern = re.compile(r"[-+]?\d{1,3}(?:[ '\u00A0]\d{3})*(?:[.,]\d{2})\b") diff --git a/modules/workflows/methods/methodTrustee/actions/processDocuments.py b/modules/workflows/methods/methodTrustee/actions/processDocuments.py index a5c9ce74..ab738a14 100644 --- a/modules/workflows/methods/methodTrustee/actions/processDocuments.py +++ b/modules/workflows/methods/methodTrustee/actions/processDocuments.py @@ -15,6 +15,7 @@ syncToAccounting (via DataRef on documents[0]). import json import logging +import re from datetime import datetime, timezone from typing import Dict, Any, List, Optional @@ -37,7 +38,6 @@ def _extractAccountNumber(value) -> Optional[str]: """Extract the leading numeric account number from AI output like '6200 Fahrzeugaufwand' -> '6200'.""" if not value or not isinstance(value, str): return None - import re match = re.match(r"(\d+)", value.strip()) return match.group(1) if match else value.strip() or None @@ -64,7 +64,6 @@ def _normaliseRef(value: Any) -> Optional[str]: raw = _cleanStr(value) if not raw: return None - import re return re.sub(r"[^A-Z0-9]", "", raw.upper()) or None @@ -114,7 +113,6 @@ def _normaliseCompany(value: Any) -> Optional[str]: raw = _cleanStr(value) if not raw: return None - import re cleaned = re.sub(r"[^A-Z0-9]", "", raw.upper()) return cleaned or None diff --git a/modules/workflows/methods/methodTrustee/actions/queryData.py b/modules/workflows/methods/methodTrustee/actions/queryData.py index 9b2e3e10..b30c9390 100644 --- a/modules/workflows/methods/methodTrustee/actions/queryData.py +++ b/modules/workflows/methods/methodTrustee/actions/queryData.py @@ -20,7 +20,7 @@ This action does NOT trigger an external sync — use import json import logging import re -from datetime import datetime as _dt, timezone as _tz +from datetime import datetime, timezone from typing import Any, Dict, List, Optional from modules.datamodels.datamodelChat import ActionResult @@ -33,7 +33,7 @@ def _isoToTs(isoDate: Optional[str]) -> Optional[float]: if not isoDate: return None try: - return _dt.strptime(isoDate.strip()[:10], "%Y-%m-%d").replace(tzinfo=_tz.utc).timestamp() + return datetime.strptime(isoDate.strip()[:10], "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp() except (ValueError, AttributeError): return None @@ -43,7 +43,7 @@ def _tsToIso(ts) -> Optional[str]: if ts is None: return None try: - return _dt.fromtimestamp(float(ts), tz=_tz.utc).strftime("%Y-%m-%d") + return datetime.fromtimestamp(float(ts), tz=timezone.utc).strftime("%Y-%m-%d") except (ValueError, TypeError, OSError): return None diff --git a/modules/workflows/methods/methodTrustee/actions/refreshAccountingData.py b/modules/workflows/methods/methodTrustee/actions/refreshAccountingData.py index 0d6e737c..817d229a 100644 --- a/modules/workflows/methods/methodTrustee/actions/refreshAccountingData.py +++ b/modules/workflows/methods/methodTrustee/actions/refreshAccountingData.py @@ -8,7 +8,7 @@ Checks lastSyncAt to avoid redundant imports unless forceRefresh is set. import json import logging import time -from datetime import datetime as _dt, timezone as _tz +from datetime import datetime, timezone from typing import Dict, Any, Optional from modules.datamodels.datamodelChat import ActionResult @@ -21,7 +21,7 @@ def _isoToTs(isoDate: Optional[str]) -> Optional[float]: if not isoDate: return None try: - return _dt.strptime(isoDate.strip()[:10], "%Y-%m-%d").replace(tzinfo=_tz.utc).timestamp() + return datetime.strptime(isoDate.strip()[:10], "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp() except (ValueError, AttributeError): return None @@ -31,7 +31,7 @@ def _tsToIso(ts) -> Optional[str]: if ts is None: return None try: - return _dt.fromtimestamp(float(ts), tz=_tz.utc).strftime("%Y-%m-%d") + return datetime.fromtimestamp(float(ts), tz=timezone.utc).strftime("%Y-%m-%d") except (ValueError, TypeError, OSError): return None @@ -208,7 +208,7 @@ def _exportAccountingData(trusteeInterface, featureInstanceId: str, dateFrom: st balances = trusteeInterface.db.getRecordset(TrusteeDataAccountBalance, recordFilter=baseFilter) or [] - currentYear = _dt.now(tz=_tz.utc).year + currentYear = datetime.now(tz=timezone.utc).year accountSummary = _buildAccountSummary(accountMap, balances, currentYear) entries = trusteeInterface.db.getRecordset(TrusteeDataJournalEntry, recordFilter=baseFilter) or [] diff --git a/modules/workflows/processing/adaptive/contentValidator.py b/modules/workflows/processing/adaptive/contentValidator.py index e8ba106b..15e1dc65 100644 --- a/modules/workflows/processing/adaptive/contentValidator.py +++ b/modules/workflows/processing/adaptive/contentValidator.py @@ -4,6 +4,7 @@ # Content validation for adaptive Dynamic mode # Generic, document-aware validation system +import io import logging import json import base64 @@ -583,7 +584,6 @@ class ContentValidator: if formatExt == "csv": import csv - import io try: reader = csv.reader(io.StringIO(content)) rows = list(reader) diff --git a/modules/workflows/processing/core/actionExecutor.py b/modules/workflows/processing/core/actionExecutor.py index 3d4ed7fc..1a162922 100644 --- a/modules/workflows/processing/core/actionExecutor.py +++ b/modules/workflows/processing/core/actionExecutor.py @@ -4,6 +4,7 @@ # Action execution functionality for workflows import logging +import time from typing import Dict, Any, List from modules.datamodels.datamodelChat import ActionResult, ActionItem, TaskStep from modules.datamodels.datamodelChat import ChatWorkflow @@ -119,7 +120,6 @@ class ActionExecutor: logger.error(f"Error getting task operation ID: {str(e)}") # Create action operationId entry - Action is child of Task - import time actionOperationId = f"action_{action.execMethod}_{action.execAction}_{workflow.id}_{taskNum}_{actionNum}_{int(time.time())}" try: diff --git a/modules/workflows/processing/core/messageCreator.py b/modules/workflows/processing/core/messageCreator.py index e0c49a52..cb8e344f 100644 --- a/modules/workflows/processing/core/messageCreator.py +++ b/modules/workflows/processing/core/messageCreator.py @@ -4,6 +4,7 @@ # Generic message creation for all workflow phases import logging +import re from typing import Dict, Any, Optional, List from modules.datamodels.datamodelChat import TaskPlan, TaskStep, ActionResult, ReviewResult from modules.datamodels.datamodelChat import ChatWorkflow @@ -338,7 +339,6 @@ class MessageCreator: if not label or not isinstance(label, str): return 0 - import re pattern = rf'{prefix}(\d+)' match = re.search(pattern, label) return int(match.group(1)) if match else 0 diff --git a/modules/workflows/processing/shared/stateTools.py b/modules/workflows/processing/shared/stateTools.py index 70259b3c..c1614b69 100644 --- a/modules/workflows/processing/shared/stateTools.py +++ b/modules/workflows/processing/shared/stateTools.py @@ -2,47 +2,9 @@ # All rights reserved. """ State Tools -Shared utilities for workflow state management and validation. +Re-exports from modules.shared.workflowState for backward compatibility. """ -import logging -from typing import Any - -logger = logging.getLogger(__name__) - - -class WorkflowStoppedException(Exception): - """Exception raised when a workflow is stopped by the user.""" - pass - - -def checkWorkflowStopped(services: Any) -> None: - """ - Check if workflow has been stopped by user and raise exception if so. - - Args: - services: Services object with workflow and interfaceDbChat for fresh status check - - Raises: - WorkflowStoppedException: If workflow status is "stopped" - """ - workflow = getattr(services, 'workflow', None) - if not workflow or not hasattr(workflow, 'id') or workflow.id is None: - return - - try: - # Get the current workflow status from the database to avoid stale data - currentWorkflow = services.interfaceDbChat.getWorkflow(workflow.id) - if currentWorkflow and currentWorkflow.status == "stopped": - logger.info("Workflow stopped by user, aborting operation") - raise WorkflowStoppedException("Workflow was stopped by user") - except WorkflowStoppedException: - # Re-raise the stop signal immediately - raise - except Exception as e: - # If we can't get the current status due to other database issues, fall back to the in-memory object - logger.warning(f"Could not check current workflow status from database: {str(e)}") - if workflow and workflow.status == "stopped": - logger.info("Workflow stopped by user (from in-memory object), aborting operation") - raise WorkflowStoppedException("Workflow was stopped by user") +from modules.shared.workflowState import checkWorkflowStopped, WorkflowStoppedException +__all__ = ["checkWorkflowStopped", "WorkflowStoppedException"] diff --git a/modules/workflows/processing/workflowProcessor.py b/modules/workflows/processing/workflowProcessor.py index 99d8fd63..7f63fc62 100644 --- a/modules/workflows/processing/workflowProcessor.py +++ b/modules/workflows/processing/workflowProcessor.py @@ -5,6 +5,8 @@ import logging import json +import time +import traceback from typing import Dict, Any, Optional, List, TYPE_CHECKING from modules.datamodels import datamodelChat from modules.datamodels.datamodelChat import TaskStep, TaskContext, TaskPlan, ActionResult, ActionDocument, ChatDocument, ChatMessage @@ -48,8 +50,6 @@ class WorkflowProcessor: async def generateTaskPlan(self, userInput: str, workflow: ChatWorkflow) -> TaskPlan: """Generate a high-level task plan for the workflow""" - import time - # Init progress logger operationId = f"taskPlan_{workflow.id}_{int(time.time())}" @@ -111,8 +111,6 @@ class WorkflowProcessor: async def executeTask(self, taskStep: TaskStep, workflow: ChatWorkflow, context: TaskContext) -> datamodelChat.ChatTaskResult: """Execute a task step using the appropriate mode""" - import time - # Get task index from workflow state taskIndex = workflow.getTaskIndex() @@ -511,7 +509,6 @@ class WorkflowProcessor: return result except Exception as e: - import traceback errorDetails = f"{type(e).__name__}: {str(e)}" logger.error(f"Error in fastPathExecute: {errorDetails}") logger.debug(f"Fast path error traceback:\n{traceback.format_exc()}") diff --git a/modules/workflows/scheduler/mainScheduler.py b/modules/workflows/scheduler/mainScheduler.py index 0dce2ec5..ef89f821 100644 --- a/modules/workflows/scheduler/mainScheduler.py +++ b/modules/workflows/scheduler/mainScheduler.py @@ -11,6 +11,8 @@ Replaces subAutomation2Schedule with v1-style incremental sync patterns: import asyncio import logging +import threading +import time from typing import Any, Dict, Optional from modules.shared.eventManagement import eventManager @@ -268,12 +270,9 @@ class WorkflowScheduler: def _delayedSync(self) -> None: """Delayed sync (5s) in case DB was not ready at startup.""" - import threading - eventUser = self._eventUser def _run(): - import time time.sleep(5) try: self._syncScheduledWorkflows() diff --git a/scripts/build_ui_language_seed_json.py b/scripts/build_ui_language_seed_json.py deleted file mode 100644 index e610ea11..00000000 --- a/scripts/build_ui_language_seed_json.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Build ui_language_seed.json from frontend_nyla locale TS files (one-off / CI).""" - -from __future__ import annotations - -import json -import re -from pathlib import Path - -_REPO = Path(__file__).resolve().parents[2] -_SRC = _REPO / "frontend_nyla" / "src" / "locales" -_OUT = _REPO / "gateway" / "modules" / "migration" / "seedData" / "ui_language_seed.json" - - -def _unescape_ts_single_quoted(raw: str) -> str: - out: list[str] = [] - i = 0 - while i < len(raw): - c = raw[i] - if c == "\\" and i + 1 < len(raw): - n = raw[i + 1] - if n == "n": - out.append("\n") - i += 2 - continue - if n == "r": - out.append("\r") - i += 2 - continue - if n == "t": - out.append("\t") - i += 2 - continue - out.append(n) - i += 2 - continue - out.append(c) - i += 1 - return "".join(out) - - -def _parse_locale(path: Path) -> dict[str, str]: - text = path.read_text(encoding="utf-8") - mapping: dict[str, str] = {} - line_re = re.compile( - r"^\s*'((?:\\.|[^'])*)':\s*'((?:\\.|[^'])*)'\s*,?\s*(//.*)?$" - ) - for line in text.splitlines(): - m = line_re.match(line.strip()) - if not m: - continue - key = _unescape_ts_single_quoted(m.group(1)) - val = _unescape_ts_single_quoted(m.group(2)) - mapping[key] = val - return mapping - - -def main() -> None: - deMap = _parse_locale(_SRC / "de.ts") - enMap = _parse_locale(_SRC / "en.ts") - frMap = _parse_locale(_SRC / "fr.ts") - - dePlain = {v: v for v in deMap.values()} - enPlain: dict[str, str] = {} - frPlain: dict[str, str] = {} - for dotKey, germanText in deMap.items(): - if dotKey in enMap: - enPlain[germanText] = enMap[dotKey] - if dotKey in frMap: - frPlain[germanText] = frMap[dotKey] - - payload = [ - { - "id": "de", - "label": "Deutsch", - "keys": dePlain, - "status": "complete", - "isDefault": True, - }, - { - "id": "en", - "label": "English", - "keys": enPlain, - "status": "complete", - "isDefault": False, - }, - { - "id": "fr", - "label": "Français", - "keys": frPlain, - "status": "complete", - "isDefault": False, - }, - ] - _OUT.parent.mkdir(parents=True, exist_ok=True) - _OUT.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") - print("Wrote", _OUT, "keys de/en/fr", len(dePlain), len(enPlain), len(frPlain)) - - -if __name__ == "__main__": - main() diff --git a/scripts/exportDbSchemaFromModels.py b/scripts/exportDbSchemaFromModels.py index be715c80..dc3e4ab8 100644 --- a/scripts/exportDbSchemaFromModels.py +++ b/scripts/exportDbSchemaFromModels.py @@ -51,13 +51,13 @@ def _buildCompleteTableToDbMap() -> Dict[str, str]: More reliable than fkRegistry._buildTableToDbMap() for the schema script because it catches ALL tables, not just FK targets. """ - from modules.shared.dbRegistry import getRegisteredDatabases - from modules.system.databaseHealth import _getConnection + from modules.dbHelpers.dbRegistry import getRegisteredDatabases + from modules.system.databaseHealth import getConnection mapping: Dict[str, str] = {} for dbName in getRegisteredDatabases(): try: - conn = _getConnection(dbName) + conn = getConnection(dbName) try: with conn.cursor() as cur: cur.execute(""" @@ -154,7 +154,7 @@ def _resolveTypeName(annotation) -> str: def _renderMarkdown(schema: Dict[str, List[dict]]) -> str: """Render the schema as markdown.""" - from modules.shared.dbRegistry import getRegisteredDatabases + from modules.dbHelpers.dbRegistry import getRegisteredDatabases registeredDbs = getRegisteredDatabases() now = datetime.now().strftime("%Y-%m-%d %H:%M") diff --git a/tests/functional/test12_json_split_merge.py b/tests/functional/test12_json_split_merge.py index 368066c4..6e10c58c 100644 --- a/tests/functional/test12_json_split_merge.py +++ b/tests/functional/test12_json_split_merge.py @@ -21,7 +21,7 @@ if _gateway_path not in sys.path: # Import JSON merger from workflow tools from modules.serviceCenter.services.serviceAi.subJsonMerger import ModularJsonMerger, JsonMergeLogger -from modules.shared.jsonContinuation import getContexts +from modules.datamodels.jsonContinuation import getContexts class JsonSplitMergeTester12: diff --git a/tests/functional/test13_json_completion_cuts.py b/tests/functional/test13_json_completion_cuts.py index 4ff05014..494678fc 100644 --- a/tests/functional/test13_json_completion_cuts.py +++ b/tests/functional/test13_json_completion_cuts.py @@ -19,7 +19,7 @@ if _gateway_path not in sys.path: sys.path.insert(0, _gateway_path) # Import JSON continuation module -from modules.shared.jsonContinuation import getContexts +from modules.datamodels.jsonContinuation import getContexts class JsonCompletionTester13: diff --git a/tests/functional/test14_json_continuation_context.py b/tests/functional/test14_json_continuation_context.py index 805e2ae7..ae7ea00e 100644 --- a/tests/functional/test14_json_continuation_context.py +++ b/tests/functional/test14_json_continuation_context.py @@ -18,7 +18,7 @@ if _gateway_path not in sys.path: sys.path.insert(0, _gateway_path) # Import jsonContinuation -from modules.shared.jsonContinuation import getContexts +from modules.datamodels.jsonContinuation import getContexts class JsonContinuationContextTester14: diff --git a/tests/integration/rbac/test_rbac_database.py b/tests/integration/rbac/test_rbac_database.py index fc444411..dbf56dd3 100644 --- a/tests/integration/rbac/test_rbac_database.py +++ b/tests/integration/rbac/test_rbac_database.py @@ -10,6 +10,7 @@ import psycopg2 import pytest from modules.connectors.connectorDbPostgre import DatabaseConnector from modules.datamodels.datamodelUam import User, AccessLevel, UserPermissions +from modules.interfaces.interfaceRbac import buildRbacWhereClause def _dbConfig(): @@ -112,7 +113,7 @@ class TestRbacDatabaseFiltering: roleLabels=["sysadmin"], ) - whereClause = db.buildRbacWhereClause(permissions, user, "SomeTable") + whereClause = buildRbacWhereClause(permissions, user, "SomeTable", db) # ALL access should return None (no filtering) assert whereClause is None @@ -134,7 +135,7 @@ class TestRbacDatabaseFiltering: roleLabels=["user"], ) - whereClause = db.buildRbacWhereClause(permissions, user, "SomeTable") + whereClause = buildRbacWhereClause(permissions, user, "SomeTable", db) assert whereClause is not None assert whereClause["condition"] == '"sysCreatedBy" = %s' @@ -158,8 +159,8 @@ class TestRbacDatabaseFiltering: roleLabels=["admin"], ) - whereClause = db.buildRbacWhereClause( - permissions, user, "SomeTable", mandateId=mandate_id + whereClause = buildRbacWhereClause( + permissions, user, "SomeTable", db, mandateId=mandate_id ) assert whereClause is not None @@ -183,7 +184,7 @@ class TestRbacDatabaseFiltering: roleLabels=["viewer"], ) - whereClause = db.buildRbacWhereClause(permissions, user, "SomeTable") + whereClause = buildRbacWhereClause(permissions, user, "SomeTable", db) assert whereClause is not None assert whereClause["condition"] == "1 = 0" # Always false @@ -206,7 +207,7 @@ class TestRbacDatabaseFiltering: roleLabels=["user"], ) - whereClause = db.buildRbacWhereClause(permissions, user, "UserInDB") + whereClause = buildRbacWhereClause(permissions, user, "UserInDB", db) # UserInDB with MY access should filter by id field assert whereClause is not None @@ -271,8 +272,8 @@ class TestRbacDatabaseFiltering: roleLabels=["admin"], ) - whereClause = db.buildRbacWhereClause( - permissions, user, "UserConnection", mandateId=testMandateId + whereClause = buildRbacWhereClause( + permissions, user, "UserConnection", db, mandateId=testMandateId ) assert whereClause is not None diff --git a/tests/unit/services/test_featureDataAgent_schema.py b/tests/unit/services/test_featureDataAgent_schema.py index 2b84e08c..0e852c70 100644 --- a/tests/unit/services/test_featureDataAgent_schema.py +++ b/tests/unit/services/test_featureDataAgent_schema.py @@ -39,7 +39,7 @@ from modules.serviceCenter.services.serviceAgent.featureDataAgent import ( @pytest.fixture(scope="module", autouse=True) def _ensureModels(): - fkRegistry._ensureModelsLoaded() + fkRegistry.ensureModelsLoaded() def _trusteeAccountBalanceObj(): diff --git a/tests/unit/services/test_queryValidator.py b/tests/unit/services/test_queryValidator.py index 40c8f444..ed3235f0 100644 --- a/tests/unit/services/test_queryValidator.py +++ b/tests/unit/services/test_queryValidator.py @@ -27,7 +27,7 @@ from modules.serviceCenter.services.serviceAgent.queryValidator import QueryVali @pytest.fixture(scope="module", autouse=True) def _ensureModels(): - fkRegistry._ensureModelsLoaded() + fkRegistry.ensureModelsLoaded() @pytest.fixture() diff --git a/tests/unit/services/test_trusteeOntology.py b/tests/unit/services/test_trusteeOntology.py index 887f69a4..c9945ea4 100644 --- a/tests/unit/services/test_trusteeOntology.py +++ b/tests/unit/services/test_trusteeOntology.py @@ -40,7 +40,7 @@ from modules.shared import fkRegistry @pytest.fixture(scope="module", autouse=True) def _ensureModels(): - fkRegistry._ensureModelsLoaded() + fkRegistry.ensureModelsLoaded() # ---------------------------------------------------------------------------