elimination of technical issues (imports)
This commit is contained in:
parent
10f172e950
commit
bc7c6fe27c
238 changed files with 4940 additions and 18886 deletions
58
app.py
58
app.py
|
|
@ -302,7 +302,7 @@ async def lifespan(app: FastAPI):
|
||||||
logger.info("Application is starting up")
|
logger.info("Application is starting up")
|
||||||
|
|
||||||
# Validate FK metadata on all Pydantic models (fail-fast, no silent fallbacks)
|
# Validate FK metadata on all Pydantic models (fail-fast, no silent fallbacks)
|
||||||
from modules.shared.fkRegistry import validateFkTargets
|
from modules.dbHelpers.fkRegistry import validateFkTargets
|
||||||
fkErrors = validateFkTargets()
|
fkErrors = validateFkTargets()
|
||||||
if fkErrors:
|
if fkErrors:
|
||||||
for err in fkErrors:
|
for err in fkErrors:
|
||||||
|
|
@ -342,7 +342,7 @@ async def lifespan(app: FastAPI):
|
||||||
|
|
||||||
# Sync gateway i18n registry to DB and load translation cache
|
# Sync gateway i18n registry to DB and load translation cache
|
||||||
try:
|
try:
|
||||||
from modules.shared.i18nRegistry import syncRegistryToDb, loadCache
|
from modules.system.i18nBootSync import syncRegistryToDb, loadCache
|
||||||
await syncRegistryToDb()
|
await syncRegistryToDb()
|
||||||
await loadCache()
|
await loadCache()
|
||||||
logger.info("i18n registry sync + cache load completed")
|
logger.info("i18n registry sync + cache load completed")
|
||||||
|
|
@ -376,6 +376,34 @@ async def lifespan(app: FastAPI):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not initialize feature containers: {e}")
|
logger.warning(f"Could not initialize feature containers: {e}")
|
||||||
|
|
||||||
|
# Bootstrap Stripe prices for paid plans (composition root — upward import allowed here)
|
||||||
|
try:
|
||||||
|
from modules.serviceCenter.services.serviceSubscription.stripeBootstrap import bootstrapStripePrices
|
||||||
|
bootstrapStripePrices()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Stripe price bootstrap failed: {e}")
|
||||||
|
|
||||||
|
# Bootstrap MIME map into ComponentObjects (composition root — upward import allowed here)
|
||||||
|
try:
|
||||||
|
from modules.serviceCenter.services.serviceExtraction.subRegistry import ExtractorRegistry
|
||||||
|
from modules.interfaces.interfaceDbManagement import ComponentObjects
|
||||||
|
_mimeRegistry = ExtractorRegistry()
|
||||||
|
_extensionToMime = _mimeRegistry.getExtensionToMimeMap()
|
||||||
|
_textMimes: set = set()
|
||||||
|
_seen: set = set()
|
||||||
|
for _ext in _mimeRegistry._map.values():
|
||||||
|
_eid = id(_ext)
|
||||||
|
if _eid in _seen:
|
||||||
|
continue
|
||||||
|
_seen.add(_eid)
|
||||||
|
_mimes = _ext.getSupportedMimeTypes()
|
||||||
|
if any(m.startswith("text/") for m in _mimes):
|
||||||
|
_textMimes.update(_mimes)
|
||||||
|
_textMimes.update({"application/json", "application/xml", "application/javascript", "application/sql", "application/x-yaml", "application/x-toml"})
|
||||||
|
ComponentObjects.setMimeMap(_extensionToMime, _textMimes)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"MIME map bootstrap failed: {e}")
|
||||||
|
|
||||||
# --- Init Managers ---
|
# --- Init Managers ---
|
||||||
import asyncio
|
import asyncio
|
||||||
try:
|
try:
|
||||||
|
|
@ -400,7 +428,7 @@ async def lifespan(app: FastAPI):
|
||||||
eventManager.start()
|
eventManager.start()
|
||||||
|
|
||||||
# Register audit log cleanup scheduler
|
# Register audit log cleanup scheduler
|
||||||
from modules.shared.auditLogger import registerAuditLogCleanupScheduler
|
from modules.dbHelpers.auditLogger import registerAuditLogCleanupScheduler
|
||||||
registerAuditLogCleanupScheduler()
|
registerAuditLogCleanupScheduler()
|
||||||
|
|
||||||
# Register enterprise subscription auto-renewal scheduler
|
# Register enterprise subscription auto-renewal scheduler
|
||||||
|
|
@ -431,6 +459,26 @@ async def lifespan(app: FastAPI):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"KnowledgeIngestionConsumer registration failed (non-critical): {e}")
|
logger.warning(f"KnowledgeIngestionConsumer registration failed (non-critical): {e}")
|
||||||
|
|
||||||
|
# Install force-exit handler AFTER uvicorn has registered its own SIGINT
|
||||||
|
# handler. Uvicorn's default timeout-graceful-shutdown is None (wait
|
||||||
|
# forever), so frontend polling keep-alive connections block the process.
|
||||||
|
# This wraps uvicorn's handler: on Ctrl+C, start a 3s timer that calls
|
||||||
|
# os._exit() if the graceful shutdown hasn't completed by then.
|
||||||
|
import signal as _sig
|
||||||
|
import threading as _thr
|
||||||
|
_prevSigint = _sig.getsignal(_sig.SIGINT)
|
||||||
|
|
||||||
|
def _onSigint(signum, frame):
|
||||||
|
_t = _thr.Timer(3.0, lambda: os._exit(0))
|
||||||
|
_t.daemon = True
|
||||||
|
_t.start()
|
||||||
|
if callable(_prevSigint) and _prevSigint not in (_sig.SIG_DFL, _sig.SIG_IGN):
|
||||||
|
_prevSigint(signum, frame)
|
||||||
|
else:
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
|
||||||
|
_sig.signal(_sig.SIGINT, _onSigint)
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# --- Shutdown sequence (protected against CancelledError) ---
|
# --- Shutdown sequence (protected against CancelledError) ---
|
||||||
|
|
@ -474,7 +522,7 @@ async def lifespan(app: FastAPI):
|
||||||
|
|
||||||
# 5. Close shared HTTP sessions (ResilientHttp) to avoid TCP keepalive hang
|
# 5. Close shared HTTP sessions (ResilientHttp) to avoid TCP keepalive hang
|
||||||
try:
|
try:
|
||||||
from modules.connectors._httpResilience import closeAllResilientHttp
|
from modules.shared.httpResilience import closeAllResilientHttp
|
||||||
await closeAllResilientHttp()
|
await closeAllResilientHttp()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Closing HTTP sessions failed: {e}")
|
logger.warning(f"Closing HTTP sessions failed: {e}")
|
||||||
|
|
@ -655,8 +703,6 @@ app.include_router(connectionsRouter)
|
||||||
from modules.routes.routeRagInventory import router as ragInventoryRouter
|
from modules.routes.routeRagInventory import router as ragInventoryRouter
|
||||||
app.include_router(ragInventoryRouter)
|
app.include_router(ragInventoryRouter)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
from modules.routes.routeTableViews import router as tableViewsRouter
|
from modules.routes.routeTableViews import router as tableViewsRouter
|
||||||
app.include_router(tableViewsRouter)
|
app.include_router(tableViewsRouter)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,15 +11,15 @@ IMPORTANT: Model Registration Requirements
|
||||||
- If duplicate displayNames are detected during registration, an error will be raised
|
- If duplicate displayNames are detected during registration, an error will be raised
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import re as _re
|
import re
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from typing import List, Dict, Any, Optional, AsyncGenerator, Union
|
from typing import List, Dict, Any, Optional, AsyncGenerator, Union
|
||||||
from modules.datamodels.datamodelAi import AiModel, AiModelCall, AiModelResponse
|
from modules.datamodels.datamodelAi import AiModel, AiModelCall, AiModelResponse
|
||||||
|
|
||||||
|
|
||||||
_RETRY_AFTER_PATTERN = _re.compile(
|
_RETRY_AFTER_PATTERN = re.compile(
|
||||||
r"(?:try again in|retry after)\s+(\d+(?:\.\d+)?)\s*s", _re.IGNORECASE
|
r"(?:try again in|retry after)\s+(\d+(?:\.\d+)?)\s*s", re.IGNORECASE
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
|
import base64
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import httpx
|
import httpx
|
||||||
|
|
@ -655,9 +656,8 @@ class AiAnthropic(BaseConnectorAi):
|
||||||
base64Data = parts[1]
|
base64Data = parts[1]
|
||||||
|
|
||||||
_SUPPORTED = {"image/jpeg", "image/png", "image/gif", "image/webp"}
|
_SUPPORTED = {"image/jpeg", "image/png", "image/gif", "image/webp"}
|
||||||
import base64 as _b64
|
|
||||||
try:
|
try:
|
||||||
rawHead = _b64.b64decode(base64Data[:32])
|
rawHead = base64.b64decode(base64Data[:32])
|
||||||
if rawHead[:3] == b"\xff\xd8\xff":
|
if rawHead[:3] == b"\xff\xd8\xff":
|
||||||
mimeType = "image/jpeg"
|
mimeType = "image/jpeg"
|
||||||
elif rawHead[:8] == b"\x89PNG\r\n\x1a\n":
|
elif rawHead[:8] == b"\x89PNG\r\n\x1a\n":
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
import logging
|
import logging
|
||||||
import json as _json
|
import json
|
||||||
import httpx
|
import httpx
|
||||||
from typing import List, Dict, Any, AsyncGenerator, Union
|
from typing import List, Dict, Any, AsyncGenerator, Union
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
@ -274,7 +274,7 @@ class AiMistral(BaseConnectorAi):
|
||||||
bodyStr = body.decode()
|
bodyStr = body.decode()
|
||||||
if response.status_code == 429:
|
if response.status_code == 429:
|
||||||
try:
|
try:
|
||||||
errorMsg = _json.loads(bodyStr).get("error", {}).get("message", "Rate limit exceeded")
|
errorMsg = json.loads(bodyStr).get("error", {}).get("message", "Rate limit exceeded")
|
||||||
except (ValueError, KeyError):
|
except (ValueError, KeyError):
|
||||||
errorMsg = f"Rate limit exceeded for {model.name}"
|
errorMsg = f"Rate limit exceeded for {model.name}"
|
||||||
raise RateLimitExceededException(f"Rate limit exceeded for {model.name}: {errorMsg}")
|
raise RateLimitExceededException(f"Rate limit exceeded for {model.name}: {errorMsg}")
|
||||||
|
|
@ -287,8 +287,8 @@ class AiMistral(BaseConnectorAi):
|
||||||
if data.strip() == "[DONE]":
|
if data.strip() == "[DONE]":
|
||||||
break
|
break
|
||||||
try:
|
try:
|
||||||
chunk = _json.loads(data)
|
chunk = json.loads(data)
|
||||||
except _json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
import logging
|
import logging
|
||||||
import json as _json
|
import json
|
||||||
import httpx
|
import httpx
|
||||||
from typing import List, Dict, Any, AsyncGenerator, Union
|
from typing import List, Dict, Any, AsyncGenerator, Union
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
@ -477,7 +477,7 @@ class AiOpenai(BaseConnectorAi):
|
||||||
bodyStr = body.decode()
|
bodyStr = body.decode()
|
||||||
if response.status_code == 429:
|
if response.status_code == 429:
|
||||||
try:
|
try:
|
||||||
errorMsg = _json.loads(bodyStr).get("error", {}).get("message", "Rate limit exceeded")
|
errorMsg = json.loads(bodyStr).get("error", {}).get("message", "Rate limit exceeded")
|
||||||
except (ValueError, KeyError):
|
except (ValueError, KeyError):
|
||||||
errorMsg = f"Rate limit exceeded for {model.name}"
|
errorMsg = f"Rate limit exceeded for {model.name}"
|
||||||
raise RateLimitExceededException(f"Rate limit exceeded for {model.name}: {errorMsg}")
|
raise RateLimitExceededException(f"Rate limit exceeded for {model.name}: {errorMsg}")
|
||||||
|
|
@ -490,8 +490,8 @@ class AiOpenai(BaseConnectorAi):
|
||||||
if data.strip() == "[DONE]":
|
if data.strip() == "[DONE]":
|
||||||
break
|
break
|
||||||
try:
|
try:
|
||||||
chunk = _json.loads(data)
|
chunk = json.loads(data)
|
||||||
except _json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
delta = chunk.get("choices", [{}])[0].get("delta", {})
|
||||||
|
|
|
||||||
|
|
@ -437,7 +437,7 @@ def requireSysAdmin(currentUser: User = Depends(getCurrentUser)) -> User:
|
||||||
|
|
||||||
# Audit for all SysAdmin actions
|
# Audit for all SysAdmin actions
|
||||||
try:
|
try:
|
||||||
from modules.shared.auditLogger import audit_logger
|
from modules.dbHelpers.auditLogger import audit_logger
|
||||||
audit_logger.logSecurityEvent(
|
audit_logger.logSecurityEvent(
|
||||||
userId=str(currentUser.id),
|
userId=str(currentUser.id),
|
||||||
mandateId="system",
|
mandateId="system",
|
||||||
|
|
@ -483,7 +483,7 @@ def requirePlatformAdmin(currentUser: User = Depends(getCurrentUser)) -> User:
|
||||||
|
|
||||||
# Audit for all Platform-Admin actions
|
# Audit for all Platform-Admin actions
|
||||||
try:
|
try:
|
||||||
from modules.shared.auditLogger import audit_logger
|
from modules.dbHelpers.auditLogger import audit_logger
|
||||||
audit_logger.logSecurityEvent(
|
audit_logger.logSecurityEvent(
|
||||||
userId=str(currentUser.id),
|
userId=str(currentUser.id),
|
||||||
mandateId="system",
|
mandateId="system",
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ _MFA_INTERVAL = 30
|
||||||
_MFA_VALID_WINDOW = 1
|
_MFA_VALID_WINDOW = 1
|
||||||
|
|
||||||
|
|
||||||
def _getMfaIssuer() -> str:
|
def getMfaIssuer() -> str:
|
||||||
"""Build the TOTP issuer name, e.g. 'PowerOn' or 'PowerOn (Dev)'."""
|
"""Build the TOTP issuer name, e.g. 'PowerOn' or 'PowerOn (Dev)'."""
|
||||||
envType = (APP_CONFIG.get("APP_ENV_TYPE") or "").strip().lower()
|
envType = (APP_CONFIG.get("APP_ENV_TYPE") or "").strip().lower()
|
||||||
if envType in ("prod", ""):
|
if envType in ("prod", ""):
|
||||||
|
|
@ -44,11 +44,11 @@ def _encryptSecret(plainSecret: str, userId: str = "system") -> str:
|
||||||
return encryptValue(plainSecret, userId=userId, keyName="mfa_secret")
|
return encryptValue(plainSecret, userId=userId, keyName="mfa_secret")
|
||||||
|
|
||||||
|
|
||||||
def _decryptSecret(encryptedSecret: str, userId: str = "system") -> str:
|
def decryptSecret(encryptedSecret: str, userId: str = "system") -> str:
|
||||||
return decryptValue(encryptedSecret, userId=userId, keyName="mfa_secret")
|
return decryptValue(encryptedSecret, userId=userId, keyName="mfa_secret")
|
||||||
|
|
||||||
|
|
||||||
def _buildTotp(plainSecret: str) -> pyotp.TOTP:
|
def buildTotp(plainSecret: str) -> pyotp.TOTP:
|
||||||
return pyotp.TOTP(plainSecret, digits=_MFA_DIGITS, interval=_MFA_INTERVAL)
|
return pyotp.TOTP(plainSecret, digits=_MFA_DIGITS, interval=_MFA_INTERVAL)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -61,8 +61,8 @@ def generateSetup(userId: str, username: str) -> dict:
|
||||||
"""
|
"""
|
||||||
plain = _generateSecret()
|
plain = _generateSecret()
|
||||||
encrypted = _encryptSecret(plain, userId=userId)
|
encrypted = _encryptSecret(plain, userId=userId)
|
||||||
totp = _buildTotp(plain)
|
totp = buildTotp(plain)
|
||||||
uri = totp.provisioning_uri(name=username, issuer_name=_getMfaIssuer())
|
uri = totp.provisioning_uri(name=username, issuer_name=getMfaIssuer())
|
||||||
return {
|
return {
|
||||||
"encryptedSecret": encrypted,
|
"encryptedSecret": encrypted,
|
||||||
"provisioningUri": uri,
|
"provisioningUri": uri,
|
||||||
|
|
@ -72,8 +72,8 @@ def generateSetup(userId: str, username: str) -> dict:
|
||||||
def confirmSetup(encryptedSecret: str, code: str, userId: str = "system") -> bool:
|
def confirmSetup(encryptedSecret: str, code: str, userId: str = "system") -> bool:
|
||||||
"""Verify a TOTP code against an encrypted secret (enrolment confirmation)."""
|
"""Verify a TOTP code against an encrypted secret (enrolment confirmation)."""
|
||||||
try:
|
try:
|
||||||
plain = _decryptSecret(encryptedSecret, userId=userId)
|
plain = decryptSecret(encryptedSecret, userId=userId)
|
||||||
totp = _buildTotp(plain)
|
totp = buildTotp(plain)
|
||||||
return totp.verify(code, valid_window=_MFA_VALID_WINDOW)
|
return totp.verify(code, valid_window=_MFA_VALID_WINDOW)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("MFA confirmSetup failed for userId=%s", userId)
|
logger.exception("MFA confirmSetup failed for userId=%s", userId)
|
||||||
|
|
@ -83,8 +83,8 @@ def confirmSetup(encryptedSecret: str, code: str, userId: str = "system") -> boo
|
||||||
def verifyCode(encryptedSecret: str, code: str, userId: str = "system") -> bool:
|
def verifyCode(encryptedSecret: str, code: str, userId: str = "system") -> bool:
|
||||||
"""Verify a TOTP code during login."""
|
"""Verify a TOTP code during login."""
|
||||||
try:
|
try:
|
||||||
plain = _decryptSecret(encryptedSecret, userId=userId)
|
plain = decryptSecret(encryptedSecret, userId=userId)
|
||||||
totp = _buildTotp(plain)
|
totp = buildTotp(plain)
|
||||||
return totp.verify(code, valid_window=_MFA_VALID_WINDOW)
|
return totp.verify(code, valid_window=_MFA_VALID_WINDOW)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("MFA verifyCode failed for userId=%s", userId)
|
logger.exception("MFA verifyCode failed for userId=%s", userId)
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import logging
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
from modules.datamodels.datamodelUam import UserConnection, AuthAuthority
|
from modules.datamodels.datamodelUam import UserConnection, AuthAuthority
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
from modules.shared.timeUtils import getUtcTimestamp
|
||||||
from modules.shared.auditLogger import audit_logger
|
from modules.dbHelpers.auditLogger import audit_logger
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
import contextvars
|
import contextvars
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import math
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
import psycopg2
|
import psycopg2
|
||||||
|
|
@ -8,6 +11,7 @@ import psycopg2.extras
|
||||||
import psycopg2.pool
|
import psycopg2.pool
|
||||||
import logging
|
import logging
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import List, Dict, Any, Optional, Union, get_origin, get_args, Type
|
from typing import List, Dict, Any, Optional, Union, get_origin, get_args, Type
|
||||||
import uuid
|
import uuid
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
@ -16,8 +20,6 @@ import threading
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
from modules.shared.timeUtils import getUtcTimestamp
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
from modules.datamodels.datamodelBase import PowerOnModel
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
from modules.datamodels.datamodelUam import User, AccessLevel, UserPermissions
|
|
||||||
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -149,7 +151,6 @@ def getModelFields(model_class) -> Dict[str, str]:
|
||||||
|
|
||||||
def parseRecordFields(record: Dict[str, Any], fields: Dict[str, str], context: str = "") -> None:
|
def parseRecordFields(record: Dict[str, Any], fields: Dict[str, str], context: str = "") -> None:
|
||||||
"""Parse record fields in-place: numeric typing, vector parsing, JSONB deserialization."""
|
"""Parse record fields in-place: numeric typing, vector parsing, JSONB deserialization."""
|
||||||
import json as _json
|
|
||||||
|
|
||||||
for fieldName, fieldType in fields.items():
|
for fieldName, fieldType in fields.items():
|
||||||
if fieldName not in record:
|
if fieldName not in record:
|
||||||
|
|
@ -177,10 +178,10 @@ def parseRecordFields(record: Dict[str, Any], fields: Dict[str, str], context: s
|
||||||
elif fieldType == "JSONB" and value is not None:
|
elif fieldType == "JSONB" and value is not None:
|
||||||
try:
|
try:
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
record[fieldName] = _json.loads(value)
|
record[fieldName] = json.loads(value)
|
||||||
elif not isinstance(value, (dict, list)):
|
elif not isinstance(value, (dict, list)):
|
||||||
record[fieldName] = _json.loads(str(value))
|
record[fieldName] = json.loads(str(value))
|
||||||
except (_json.JSONDecodeError, TypeError, ValueError):
|
except (json.JSONDecodeError, TypeError, ValueError):
|
||||||
logger.warning(f"Could not parse JSONB field {fieldName}, keeping as string ({context})")
|
logger.warning(f"Could not parse JSONB field {fieldName}, keeping as string ({context})")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -995,8 +996,6 @@ class DatabaseConnector:
|
||||||
|
|
||||||
# Handle JSONB fields - ensure proper JSON format for PostgreSQL
|
# Handle JSONB fields - ensure proper JSON format for PostgreSQL
|
||||||
elif col in fields and fields[col] == "JSONB" and value is not None:
|
elif col in fields and fields[col] == "JSONB" and value is not None:
|
||||||
import json
|
|
||||||
|
|
||||||
if isinstance(value, (dict, list)):
|
if isinstance(value, (dict, list)):
|
||||||
value = json.dumps(value)
|
value = json.dumps(value)
|
||||||
elif isinstance(value, str):
|
elif isinstance(value, str):
|
||||||
|
|
@ -1173,25 +1172,6 @@ class DatabaseConnector:
|
||||||
logger.error(f"Error removing initial ID for table {table}: {e}")
|
logger.error(f"Error removing initial ID for table {table}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def buildRbacWhereClause(
|
|
||||||
self,
|
|
||||||
permissions: UserPermissions,
|
|
||||||
currentUser: User,
|
|
||||||
table: str,
|
|
||||||
mandateId: Optional[str] = None,
|
|
||||||
featureInstanceId: Optional[str] = None,
|
|
||||||
) -> Optional[Dict[str, Any]]:
|
|
||||||
"""Delegate to interfaceRbac.buildRbacWhereClause (tests and call sites use connector as entry)."""
|
|
||||||
from modules.interfaces.interfaceRbac import buildRbacWhereClause as _buildRbacWhereClause
|
|
||||||
|
|
||||||
return _buildRbacWhereClause(
|
|
||||||
permissions,
|
|
||||||
currentUser,
|
|
||||||
table,
|
|
||||||
self,
|
|
||||||
mandateId=mandateId,
|
|
||||||
featureInstanceId=featureInstanceId,
|
|
||||||
)
|
|
||||||
|
|
||||||
def updateContext(self, userId: str) -> None:
|
def updateContext(self, userId: str) -> None:
|
||||||
"""Updates the context of the database connector.
|
"""Updates the context of the database connector.
|
||||||
|
|
@ -1412,18 +1392,17 @@ class DatabaseConnector:
|
||||||
isDateVal = bool(fromVal and re.match(r'^\d{4}-\d{2}-\d{2}$', str(fromVal))) or \
|
isDateVal = bool(fromVal and re.match(r'^\d{4}-\d{2}-\d{2}$', str(fromVal))) or \
|
||||||
bool(toVal and re.match(r'^\d{4}-\d{2}-\d{2}$', str(toVal)))
|
bool(toVal and re.match(r'^\d{4}-\d{2}-\d{2}$', str(toVal)))
|
||||||
if isNumericCol and isDateVal:
|
if isNumericCol and isDateVal:
|
||||||
from datetime import datetime as _dt, timezone as _tz
|
|
||||||
if fromVal and toVal:
|
if fromVal and toVal:
|
||||||
fromTs = _dt.strptime(str(fromVal), '%Y-%m-%d').replace(tzinfo=_tz.utc).timestamp()
|
fromTs = datetime.strptime(str(fromVal), '%Y-%m-%d').replace(tzinfo=timezone.utc).timestamp()
|
||||||
toTs = _dt.strptime(str(toVal), '%Y-%m-%d').replace(hour=23, minute=59, second=59, tzinfo=_tz.utc).timestamp()
|
toTs = datetime.strptime(str(toVal), '%Y-%m-%d').replace(hour=23, minute=59, second=59, tzinfo=timezone.utc).timestamp()
|
||||||
where_parts.append(f'"{key}" >= %s AND "{key}" <= %s')
|
where_parts.append(f'"{key}" >= %s AND "{key}" <= %s')
|
||||||
values.extend([fromTs, toTs])
|
values.extend([fromTs, toTs])
|
||||||
elif fromVal:
|
elif fromVal:
|
||||||
fromTs = _dt.strptime(str(fromVal), '%Y-%m-%d').replace(tzinfo=_tz.utc).timestamp()
|
fromTs = datetime.strptime(str(fromVal), '%Y-%m-%d').replace(tzinfo=timezone.utc).timestamp()
|
||||||
where_parts.append(f'"{key}" >= %s')
|
where_parts.append(f'"{key}" >= %s')
|
||||||
values.append(fromTs)
|
values.append(fromTs)
|
||||||
else:
|
else:
|
||||||
toTs = _dt.strptime(str(toVal), '%Y-%m-%d').replace(hour=23, minute=59, second=59, tzinfo=_tz.utc).timestamp()
|
toTs = datetime.strptime(str(toVal), '%Y-%m-%d').replace(hour=23, minute=59, second=59, tzinfo=timezone.utc).timestamp()
|
||||||
where_parts.append(f'"{key}" <= %s')
|
where_parts.append(f'"{key}" <= %s')
|
||||||
values.append(toTs)
|
values.append(toTs)
|
||||||
elif isNumericCol:
|
elif isNumericCol:
|
||||||
|
|
@ -1498,7 +1477,6 @@ class DatabaseConnector:
|
||||||
If pagination is None, returns all records (no LIMIT/OFFSET).
|
If pagination is None, returns all records (no LIMIT/OFFSET).
|
||||||
"""
|
"""
|
||||||
from modules.datamodels.datamodelPagination import PaginationParams
|
from modules.datamodels.datamodelPagination import PaginationParams
|
||||||
import math
|
|
||||||
|
|
||||||
table = model_class.__name__
|
table = model_class.__name__
|
||||||
|
|
||||||
|
|
@ -1540,9 +1518,6 @@ class DatabaseConnector:
|
||||||
if fieldFilter and isinstance(fieldFilter, list):
|
if fieldFilter and isinstance(fieldFilter, list):
|
||||||
records = [{f: r[f] for f in fieldFilter if f in r} for r in records]
|
records = [{f: r[f] for f in fieldFilter if f in r} for r in records]
|
||||||
|
|
||||||
from modules.routes.routeHelpers import enrichRowsWithFkLabels
|
|
||||||
enrichRowsWithFkLabels(records, model_class)
|
|
||||||
|
|
||||||
pageSize = pagination.pageSize if pagination else max(totalItems, 1)
|
pageSize = pagination.pageSize if pagination else max(totalItems, 1)
|
||||||
totalPages = math.ceil(totalItems / pageSize) if totalItems > 0 else 0
|
totalPages = math.ceil(totalItems / pageSize) if totalItems > 0 else 0
|
||||||
|
|
||||||
|
|
@ -1578,7 +1553,6 @@ class DatabaseConnector:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
if pagination:
|
if pagination:
|
||||||
import copy
|
|
||||||
pagination = copy.deepcopy(pagination)
|
pagination = copy.deepcopy(pagination)
|
||||||
if pagination.filters and column in pagination.filters:
|
if pagination.filters and column in pagination.filters:
|
||||||
pagination.filters.pop(column, None)
|
pagination.filters.pop(column, None)
|
||||||
|
|
@ -1812,7 +1786,6 @@ class DatabaseConnector:
|
||||||
single inserts produce identical on-disk values (timestamps as floats,
|
single inserts produce identical on-disk values (timestamps as floats,
|
||||||
enums as strings, vectors as pgvector text, JSONB as JSON strings).
|
enums as strings, vectors as pgvector text, JSONB as JSON strings).
|
||||||
"""
|
"""
|
||||||
import json as _json
|
|
||||||
out = []
|
out = []
|
||||||
for col in columns:
|
for col in columns:
|
||||||
value = record.get(col)
|
value = record.get(col)
|
||||||
|
|
@ -1829,16 +1802,16 @@ class DatabaseConnector:
|
||||||
value = f"[{','.join(str(v) for v in value)}]"
|
value = f"[{','.join(str(v) for v in value)}]"
|
||||||
elif col in fields and fields[col] == "JSONB" and value is not None:
|
elif col in fields and fields[col] == "JSONB" and value is not None:
|
||||||
if isinstance(value, (dict, list)):
|
if isinstance(value, (dict, list)):
|
||||||
value = _json.dumps(value)
|
value = json.dumps(value)
|
||||||
elif isinstance(value, str):
|
elif isinstance(value, str):
|
||||||
try:
|
try:
|
||||||
_json.loads(value)
|
json.loads(value)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
value = _json.dumps(value)
|
value = json.dumps(value)
|
||||||
elif hasattr(value, "model_dump"):
|
elif hasattr(value, "model_dump"):
|
||||||
value = _json.dumps(value.model_dump())
|
value = json.dumps(value.model_dump())
|
||||||
else:
|
else:
|
||||||
value = _json.dumps(value)
|
value = json.dumps(value)
|
||||||
out.append(value)
|
out.append(value)
|
||||||
return tuple(out)
|
return tuple(out)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,13 @@ Path convention (leading slash, no trailing slash except root):
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional, Union
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
from modules.connectors.connectorProviderBase import (
|
from modules.connectors.connectorProviderBase import (
|
||||||
ProviderConnector,
|
ProviderConnector,
|
||||||
|
|
@ -24,11 +27,11 @@ from modules.connectors.connectorProviderBase import (
|
||||||
DownloadResult,
|
DownloadResult,
|
||||||
)
|
)
|
||||||
from modules.datamodels.datamodelDataSource import ExternalEntry
|
from modules.datamodels.datamodelDataSource import ExternalEntry
|
||||||
from modules.serviceCenter.services.serviceClickup.mainServiceClickup import ClickupService
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# type metadata for ExternalEntry.metadata["cuType"]
|
_CLICKUP_API_BASE = "https://api.clickup.com/api/v2"
|
||||||
|
|
||||||
_CU_TEAM = "team"
|
_CU_TEAM = "team"
|
||||||
_CU_SPACE = "space"
|
_CU_SPACE = "space"
|
||||||
_CU_FOLDER = "folder"
|
_CU_FOLDER = "folder"
|
||||||
|
|
@ -45,14 +48,118 @@ def _norm(path: str) -> str:
|
||||||
return p
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def clickupAuthorizationHeader(token: str) -> str:
|
||||||
|
"""ClickUp: personal tokens are `pk_...` without Bearer; OAuth uses Bearer."""
|
||||||
|
t = (token or "").strip()
|
||||||
|
if t.startswith("pk_"):
|
||||||
|
return t
|
||||||
|
return f"Bearer {t}"
|
||||||
|
|
||||||
|
|
||||||
|
class ClickupApiClient:
|
||||||
|
"""Low-level ClickUp REST API v2 client. Pure HTTP — no service dependencies."""
|
||||||
|
|
||||||
|
def __init__(self, accessToken: str):
|
||||||
|
self.accessToken = accessToken
|
||||||
|
|
||||||
|
async def _request(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
params: Optional[Dict[str, Any]] = None,
|
||||||
|
json_body: Optional[Dict[str, Any]] = None,
|
||||||
|
data: Optional[aiohttp.FormData] = None,
|
||||||
|
) -> Union[Dict[str, Any], List[Any], bytes, None]:
|
||||||
|
if not self.accessToken:
|
||||||
|
return {"error": "Access token is not set."}
|
||||||
|
url = f"{_CLICKUP_API_BASE}/{path.lstrip('/')}"
|
||||||
|
headers: Dict[str, str] = {
|
||||||
|
"Authorization": clickupAuthorizationHeader(self.accessToken),
|
||||||
|
}
|
||||||
|
if json_body is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
|
||||||
|
timeout = aiohttp.ClientTimeout(total=60)
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
kwargs: Dict[str, Any] = {"headers": headers, "params": params}
|
||||||
|
if json_body is not None:
|
||||||
|
kwargs["json"] = json_body
|
||||||
|
if data is not None:
|
||||||
|
kwargs["data"] = data
|
||||||
|
async with session.request(method.upper(), url, **kwargs) as resp:
|
||||||
|
if resp.status == 204:
|
||||||
|
return {}
|
||||||
|
text = await resp.text()
|
||||||
|
if resp.status >= 400:
|
||||||
|
log = logger.warning if resp.status == 404 else logger.error
|
||||||
|
log(f"ClickUp API {method} {url} -> {resp.status}: {text[:500]}")
|
||||||
|
return {"error": f"HTTP {resp.status}", "body": text}
|
||||||
|
if not text:
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
return json.loads(text)
|
||||||
|
except Exception:
|
||||||
|
return {"raw": text}
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return {"error": f"ClickUp API timeout: {path}"}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"ClickUp API error: {e}")
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
async def getAuthorizedTeams(self) -> Dict[str, Any]:
|
||||||
|
return await self._request("GET", "/team")
|
||||||
|
|
||||||
|
async def getSpaces(self, teamId: str) -> Dict[str, Any]:
|
||||||
|
return await self._request("GET", f"/team/{teamId}/space")
|
||||||
|
|
||||||
|
async def getFolders(self, spaceId: str) -> Dict[str, Any]:
|
||||||
|
return await self._request("GET", f"/space/{spaceId}/folder")
|
||||||
|
|
||||||
|
async def getFolderlessLists(self, spaceId: str) -> Dict[str, Any]:
|
||||||
|
return await self._request("GET", f"/space/{spaceId}/list")
|
||||||
|
|
||||||
|
async def getListsInFolder(self, folderId: str) -> Dict[str, Any]:
|
||||||
|
return await self._request("GET", f"/folder/{folderId}/list")
|
||||||
|
|
||||||
|
async def getTasksInList(self, listId: str, *, page: int = 0) -> Dict[str, Any]:
|
||||||
|
params: Dict[str, Any] = {"page": page, "subtasks": "true", "include_closed": "false"}
|
||||||
|
return await self._request("GET", f"/list/{listId}/task", params=params)
|
||||||
|
|
||||||
|
async def getTask(self, taskId: str) -> Dict[str, Any]:
|
||||||
|
params = {"include_subtasks": "true"}
|
||||||
|
return await self._request("GET", f"/task/{taskId}", params=params)
|
||||||
|
|
||||||
|
async def searchTeamTasks(self, teamId: str, *, query: str, page: int = 0) -> Dict[str, Any]:
|
||||||
|
params = {"query": query, "page": page}
|
||||||
|
return await self._request("GET", f"/team/{teamId}/task", params=params)
|
||||||
|
|
||||||
|
async def uploadTaskAttachment(self, taskId: str, fileBytes: bytes, fileName: str) -> Dict[str, Any]:
|
||||||
|
if not self.accessToken:
|
||||||
|
return {"error": "Access token is not set."}
|
||||||
|
url = f"{_CLICKUP_API_BASE}/task/{taskId}/attachment"
|
||||||
|
headers = {"Authorization": clickupAuthorizationHeader(self.accessToken)}
|
||||||
|
formData = aiohttp.FormData()
|
||||||
|
formData.add_field("attachment", fileBytes, filename=fileName, content_type="application/octet-stream")
|
||||||
|
timeout = aiohttp.ClientTimeout(total=120)
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession(timeout=timeout) as session:
|
||||||
|
async with session.post(url, headers=headers, data=formData) as resp:
|
||||||
|
text = await resp.text()
|
||||||
|
if resp.status >= 400:
|
||||||
|
return {"error": f"HTTP {resp.status}", "body": text}
|
||||||
|
return json.loads(text) if text else {}
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": str(e)}
|
||||||
|
|
||||||
|
|
||||||
class ClickupListsAdapter(ServiceAdapter):
|
class ClickupListsAdapter(ServiceAdapter):
|
||||||
"""Maps ClickUp hierarchy + list tasks to browse/download/upload/search."""
|
"""Maps ClickUp hierarchy + list tasks to browse/download/upload/search."""
|
||||||
|
|
||||||
def __init__(self, access_token: str):
|
def __init__(self, access_token: str):
|
||||||
self._token = access_token
|
self._token = access_token
|
||||||
# Minimal service instance for API calls (no ServiceCenter context)
|
self._svc = ClickupApiClient(access_token)
|
||||||
self._svc = ClickupService(context=None, get_service=lambda _: None)
|
|
||||||
self._svc.setAccessToken(access_token)
|
|
||||||
|
|
||||||
async def browse(
|
async def browse(
|
||||||
self,
|
self,
|
||||||
|
|
@ -3,14 +3,17 @@
|
||||||
"""Google ProviderConnector -- Drive and Gmail via Google OAuth."""
|
"""Google ProviderConnector -- Drive and Gmail via Google OAuth."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from modules.connectors.connectorProviderBase import ProviderConnector, ServiceAdapter, DownloadResult
|
from modules.connectors.connectorProviderBase import ProviderConnector, ServiceAdapter, DownloadResult
|
||||||
from modules.connectors._httpResilience import ResilientHttp
|
from modules.shared.httpResilience import ResilientHttp
|
||||||
from modules.datamodels.datamodelDataSource import ExternalEntry
|
from modules.datamodels.datamodelDataSource import ExternalEntry
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -29,8 +32,6 @@ def _parseGoogleDateRange(text: Optional[str]) -> tuple:
|
||||||
Supports two ISO dates, a single ISO date (~31 day window) or a YYYY-MM
|
Supports two ISO dates, a single ISO date (~31 day window) or a YYYY-MM
|
||||||
month pattern. Returns RFC3339 UTC strings (timeMin, timeMax) or (None, None).
|
month pattern. Returns RFC3339 UTC strings (timeMin, timeMax) or (None, None).
|
||||||
"""
|
"""
|
||||||
import re
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
if not text:
|
if not text:
|
||||||
return (None, None)
|
return (None, None)
|
||||||
|
|
||||||
|
|
@ -58,7 +59,7 @@ def _parseGoogleDateRange(text: Optional[str]) -> tuple:
|
||||||
return (None, None)
|
return (None, None)
|
||||||
|
|
||||||
|
|
||||||
async def _googleGet(token: str, url: str) -> Dict[str, Any]:
|
async def googleGet(token: str, url: str) -> Dict[str, Any]:
|
||||||
headers = {"Authorization": f"Bearer {token}"}
|
headers = {"Authorization": f"Bearer {token}"}
|
||||||
return await _http.getJson(url, headers=headers)
|
return await _http.getJson(url, headers=headers)
|
||||||
|
|
||||||
|
|
@ -92,7 +93,7 @@ class DriveAdapter(ServiceAdapter):
|
||||||
pageSize = max(1, min(int(limit or 100), 1000))
|
pageSize = max(1, min(int(limit or 100), 1000))
|
||||||
url = f"{_DRIVE_BASE}/files?q={query}&fields={fields}&pageSize={pageSize}&orderBy=folder,name"
|
url = f"{_DRIVE_BASE}/files?q={query}&fields={fields}&pageSize={pageSize}&orderBy=folder,name"
|
||||||
|
|
||||||
result = await _googleGet(self._token, url)
|
result = await googleGet(self._token, url)
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
_raiseGoogleError(result, "Google Drive browse")
|
_raiseGoogleError(result, "Google Drive browse")
|
||||||
|
|
||||||
|
|
@ -184,7 +185,7 @@ class DriveAdapter(ServiceAdapter):
|
||||||
if pageToken:
|
if pageToken:
|
||||||
params["pageToken"] = pageToken
|
params["pageToken"] = pageToken
|
||||||
url = f"{_DRIVE_BASE}/files?{urllib.parse.urlencode(params)}"
|
url = f"{_DRIVE_BASE}/files?{urllib.parse.urlencode(params)}"
|
||||||
result = await _googleGet(self._token, url)
|
result = await googleGet(self._token, url)
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
if not entries:
|
if not entries:
|
||||||
_raiseGoogleError(result, "Google Drive search")
|
_raiseGoogleError(result, "Google Drive search")
|
||||||
|
|
@ -228,7 +229,7 @@ class GmailAdapter(ServiceAdapter):
|
||||||
|
|
||||||
if not cleanPath:
|
if not cleanPath:
|
||||||
url = f"{_GMAIL_BASE}/users/me/labels"
|
url = f"{_GMAIL_BASE}/users/me/labels"
|
||||||
result = await _googleGet(self._token, url)
|
result = await googleGet(self._token, url)
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
_raiseGoogleError(result, "Gmail labels")
|
_raiseGoogleError(result, "Gmail labels")
|
||||||
_SYSTEM_LABELS = {"INBOX", "SENT", "DRAFT", "TRASH", "SPAM", "STARRED", "IMPORTANT"}
|
_SYSTEM_LABELS = {"INBOX", "SENT", "DRAFT", "TRASH", "SPAM", "STARRED", "IMPORTANT"}
|
||||||
|
|
@ -281,7 +282,7 @@ class GmailAdapter(ServiceAdapter):
|
||||||
if not ref:
|
if not ref:
|
||||||
return None
|
return None
|
||||||
r = ref.strip()
|
r = ref.strip()
|
||||||
result = await _googleGet(self._token, f"{_GMAIL_BASE}/users/me/labels")
|
result = await googleGet(self._token, f"{_GMAIL_BASE}/users/me/labels")
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
_raiseGoogleError(result, "Gmail labels")
|
_raiseGoogleError(result, "Gmail labels")
|
||||||
labels = result.get("labels", [])
|
labels = result.get("labels", [])
|
||||||
|
|
@ -319,7 +320,7 @@ class GmailAdapter(ServiceAdapter):
|
||||||
if pageToken:
|
if pageToken:
|
||||||
p["pageToken"] = pageToken
|
p["pageToken"] = pageToken
|
||||||
url = f"{_GMAIL_BASE}/users/me/messages?{urllib.parse.urlencode(p)}"
|
url = f"{_GMAIL_BASE}/users/me/messages?{urllib.parse.urlencode(p)}"
|
||||||
result = await _googleGet(self._token, url)
|
result = await googleGet(self._token, url)
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
if not msgIds:
|
if not msgIds:
|
||||||
_raiseGoogleError(result, "Gmail list messages")
|
_raiseGoogleError(result, "Gmail list messages")
|
||||||
|
|
@ -350,7 +351,7 @@ class GmailAdapter(ServiceAdapter):
|
||||||
f"{_GMAIL_BASE}/users/me/messages/{msgId}"
|
f"{_GMAIL_BASE}/users/me/messages/{msgId}"
|
||||||
f"?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=Date"
|
f"?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=Date"
|
||||||
)
|
)
|
||||||
detail = await _googleGet(self._token, detailUrl)
|
detail = await googleGet(self._token, detailUrl)
|
||||||
if "error" in detail:
|
if "error" in detail:
|
||||||
return ExternalEntry(name=f"Message {msgId}", path=f"{pathPrefix}/{msgId}", isFolder=False,
|
return ExternalEntry(name=f"Message {msgId}", path=f"{pathPrefix}/{msgId}", isFolder=False,
|
||||||
metadata={"id": msgId})
|
metadata={"id": msgId})
|
||||||
|
|
@ -371,15 +372,13 @@ class GmailAdapter(ServiceAdapter):
|
||||||
|
|
||||||
async def download(self, path: str) -> DownloadResult:
|
async def download(self, path: str) -> DownloadResult:
|
||||||
"""Download a Gmail message as RFC 822 EML via format=raw."""
|
"""Download a Gmail message as RFC 822 EML via format=raw."""
|
||||||
import base64
|
|
||||||
import re
|
|
||||||
cleanPath = (path or "").strip("/")
|
cleanPath = (path or "").strip("/")
|
||||||
msgId = cleanPath.split("/")[-1] if cleanPath else ""
|
msgId = cleanPath.split("/")[-1] if cleanPath else ""
|
||||||
if not msgId:
|
if not msgId:
|
||||||
return DownloadResult()
|
return DownloadResult()
|
||||||
|
|
||||||
url = f"{_GMAIL_BASE}/users/me/messages/{msgId}?format=raw"
|
url = f"{_GMAIL_BASE}/users/me/messages/{msgId}?format=raw"
|
||||||
result = await _googleGet(self._token, url)
|
result = await googleGet(self._token, url)
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
return DownloadResult()
|
return DownloadResult()
|
||||||
|
|
||||||
|
|
@ -390,7 +389,7 @@ class GmailAdapter(ServiceAdapter):
|
||||||
emlBytes = base64.urlsafe_b64decode(rawB64)
|
emlBytes = base64.urlsafe_b64decode(rawB64)
|
||||||
|
|
||||||
metaUrl = f"{_GMAIL_BASE}/users/me/messages/{msgId}?format=metadata&metadataHeaders=Subject"
|
metaUrl = f"{_GMAIL_BASE}/users/me/messages/{msgId}?format=metadata&metadataHeaders=Subject"
|
||||||
meta = await _googleGet(self._token, metaUrl)
|
meta = await googleGet(self._token, metaUrl)
|
||||||
subject = msgId
|
subject = msgId
|
||||||
if "error" not in meta:
|
if "error" not in meta:
|
||||||
for h in meta.get("payload", {}).get("headers", []):
|
for h in meta.get("payload", {}).get("headers", []):
|
||||||
|
|
@ -469,7 +468,7 @@ class CalendarAdapter(ServiceAdapter):
|
||||||
cleanPath = (path or "").strip("/")
|
cleanPath = (path or "").strip("/")
|
||||||
if not cleanPath:
|
if not cleanPath:
|
||||||
url = f"{_CALENDAR_BASE}/users/me/calendarList?maxResults=250"
|
url = f"{_CALENDAR_BASE}/users/me/calendarList?maxResults=250"
|
||||||
result = await _googleGet(self._token, url)
|
result = await googleGet(self._token, url)
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
_raiseGoogleError(result, "Google Calendar list")
|
_raiseGoogleError(result, "Google Calendar list")
|
||||||
calendars = result.get("items", [])
|
calendars = result.get("items", [])
|
||||||
|
|
@ -504,7 +503,7 @@ class CalendarAdapter(ServiceAdapter):
|
||||||
timeMin, timeMax = _parseGoogleDateRange(filter)
|
timeMin, timeMax = _parseGoogleDateRange(filter)
|
||||||
if timeMin and timeMax:
|
if timeMin and timeMax:
|
||||||
url += f"&timeMin={quote(timeMin, safe='')}&timeMax={quote(timeMax, safe='')}"
|
url += f"&timeMin={quote(timeMin, safe='')}&timeMax={quote(timeMax, safe='')}"
|
||||||
result = await _googleGet(self._token, url)
|
result = await googleGet(self._token, url)
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
_raiseGoogleError(result, "Google Calendar events")
|
_raiseGoogleError(result, "Google Calendar events")
|
||||||
events = result.get("items", [])
|
events = result.get("items", [])
|
||||||
|
|
@ -534,7 +533,7 @@ class CalendarAdapter(ServiceAdapter):
|
||||||
return DownloadResult()
|
return DownloadResult()
|
||||||
calendarId, eventId = cleanPath.split("/", 1)
|
calendarId, eventId = cleanPath.split("/", 1)
|
||||||
url = f"{_CALENDAR_BASE}/calendars/{quote(calendarId, safe='')}/events/{quote(eventId, safe='')}"
|
url = f"{_CALENDAR_BASE}/calendars/{quote(calendarId, safe='')}/events/{quote(eventId, safe='')}"
|
||||||
ev = await _googleGet(self._token, url)
|
ev = await googleGet(self._token, url)
|
||||||
if "error" in ev:
|
if "error" in ev:
|
||||||
logger.warning(f"Google Calendar event fetch failed: {ev['error']}")
|
logger.warning(f"Google Calendar event fetch failed: {ev['error']}")
|
||||||
return DownloadResult()
|
return DownloadResult()
|
||||||
|
|
@ -573,7 +572,7 @@ class CalendarAdapter(ServiceAdapter):
|
||||||
f"{_CALENDAR_BASE}/calendars/{quote(calendarId, safe='')}/events"
|
f"{_CALENDAR_BASE}/calendars/{quote(calendarId, safe='')}/events"
|
||||||
f"?q={quote(query, safe='')}&maxResults={effectiveLimit}&singleEvents=true"
|
f"?q={quote(query, safe='')}&maxResults={effectiveLimit}&singleEvents=true"
|
||||||
)
|
)
|
||||||
result = await _googleGet(self._token, url)
|
result = await googleGet(self._token, url)
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
_raiseGoogleError(result, "Google Calendar search")
|
_raiseGoogleError(result, "Google Calendar search")
|
||||||
return [
|
return [
|
||||||
|
|
@ -629,7 +628,7 @@ class ContactsAdapter(ServiceAdapter):
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
url = f"{_PEOPLE_BASE}/contactGroups?pageSize=200"
|
url = f"{_PEOPLE_BASE}/contactGroups?pageSize=200"
|
||||||
result = await _googleGet(self._token, url)
|
result = await googleGet(self._token, url)
|
||||||
if "error" not in result:
|
if "error" not in result:
|
||||||
for grp in result.get("contactGroups", []):
|
for grp in result.get("contactGroups", []):
|
||||||
name = grp.get("formattedName") or grp.get("name") or ""
|
name = grp.get("formattedName") or grp.get("name") or ""
|
||||||
|
|
@ -659,7 +658,7 @@ class ContactsAdapter(ServiceAdapter):
|
||||||
f"{_PEOPLE_BASE}/people/me/connections"
|
f"{_PEOPLE_BASE}/people/me/connections"
|
||||||
f"?pageSize={min(effectiveLimit, 1000)}&personFields={self._PERSON_FIELDS}"
|
f"?pageSize={min(effectiveLimit, 1000)}&personFields={self._PERSON_FIELDS}"
|
||||||
)
|
)
|
||||||
result = await _googleGet(self._token, url)
|
result = await googleGet(self._token, url)
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
_raiseGoogleError(result, "Google People connections")
|
_raiseGoogleError(result, "Google People connections")
|
||||||
people = result.get("connections", [])
|
people = result.get("connections", [])
|
||||||
|
|
@ -669,7 +668,7 @@ class ContactsAdapter(ServiceAdapter):
|
||||||
f"{_PEOPLE_BASE}/{quote(groupResource, safe='/')}"
|
f"{_PEOPLE_BASE}/{quote(groupResource, safe='/')}"
|
||||||
f"?maxMembers={min(effectiveLimit, 1000)}"
|
f"?maxMembers={min(effectiveLimit, 1000)}"
|
||||||
)
|
)
|
||||||
grpResult = await _googleGet(self._token, grpUrl)
|
grpResult = await googleGet(self._token, grpUrl)
|
||||||
if "error" in grpResult:
|
if "error" in grpResult:
|
||||||
_raiseGoogleError(grpResult, "Google contactGroup detail")
|
_raiseGoogleError(grpResult, "Google contactGroup detail")
|
||||||
memberResourceNames = grpResult.get("memberResourceNames") or []
|
memberResourceNames = grpResult.get("memberResourceNames") or []
|
||||||
|
|
@ -681,7 +680,7 @@ class ContactsAdapter(ServiceAdapter):
|
||||||
chunk = memberResourceNames[i : i + chunkSize]
|
chunk = memberResourceNames[i : i + chunkSize]
|
||||||
params = "&".join(f"resourceNames={quote(rn, safe='/')}" for rn in chunk)
|
params = "&".join(f"resourceNames={quote(rn, safe='/')}" for rn in chunk)
|
||||||
batchUrl = f"{_PEOPLE_BASE}/people:batchGet?{params}&personFields={self._PERSON_FIELDS}"
|
batchUrl = f"{_PEOPLE_BASE}/people:batchGet?{params}&personFields={self._PERSON_FIELDS}"
|
||||||
batchResult = await _googleGet(self._token, batchUrl)
|
batchResult = await googleGet(self._token, batchUrl)
|
||||||
if "error" in batchResult:
|
if "error" in batchResult:
|
||||||
logger.warning(f"Google People batchGet failed: {batchResult['error']}")
|
logger.warning(f"Google People batchGet failed: {batchResult['error']}")
|
||||||
continue
|
continue
|
||||||
|
|
@ -717,7 +716,7 @@ class ContactsAdapter(ServiceAdapter):
|
||||||
if not personSuffix:
|
if not personSuffix:
|
||||||
return DownloadResult()
|
return DownloadResult()
|
||||||
url = f"{_PEOPLE_BASE}/people/{quote(personSuffix, safe='')}?personFields={self._PERSON_FIELDS}"
|
url = f"{_PEOPLE_BASE}/people/{quote(personSuffix, safe='')}?personFields={self._PERSON_FIELDS}"
|
||||||
person = await _googleGet(self._token, url)
|
person = await googleGet(self._token, url)
|
||||||
if "error" in person:
|
if "error" in person:
|
||||||
logger.warning(f"Google People fetch failed: {person['error']}")
|
logger.warning(f"Google People fetch failed: {person['error']}")
|
||||||
return DownloadResult()
|
return DownloadResult()
|
||||||
|
|
@ -746,7 +745,7 @@ class ContactsAdapter(ServiceAdapter):
|
||||||
f"?query={quote(query, safe='')}&pageSize={min(effectiveLimit, 30)}"
|
f"?query={quote(query, safe='')}&pageSize={min(effectiveLimit, 30)}"
|
||||||
f"&readMask={self._PERSON_FIELDS}"
|
f"&readMask={self._PERSON_FIELDS}"
|
||||||
)
|
)
|
||||||
result = await _googleGet(self._token, url)
|
result = await googleGet(self._token, url)
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
_raiseGoogleError(result, "Google Contacts search")
|
_raiseGoogleError(result, "Google Contacts search")
|
||||||
entries: List[ExternalEntry] = []
|
entries: List[ExternalEntry] = []
|
||||||
|
|
@ -770,7 +769,6 @@ class ContactsAdapter(ServiceAdapter):
|
||||||
|
|
||||||
|
|
||||||
def _googleSafeFileName(name: str) -> str:
|
def _googleSafeFileName(name: str) -> str:
|
||||||
import re
|
|
||||||
return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name or "")[:80].strip(". ")
|
return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name or "")[:80].strip(". ")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -790,7 +788,6 @@ def _googleIcsDateTime(value: Optional[str]) -> Optional[str]:
|
||||||
"""Convert a Google Calendar dateTime/date string to RFC 5545 format (UTC)."""
|
"""Convert a Google Calendar dateTime/date string to RFC 5545 format (UTC)."""
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
from datetime import datetime, timezone
|
|
||||||
try:
|
try:
|
||||||
if "T" not in value:
|
if "T" not in value:
|
||||||
dt = datetime.strptime(value, "%Y-%m-%d")
|
dt = datetime.strptime(value, "%Y-%m-%d")
|
||||||
|
|
@ -806,7 +803,6 @@ def _googleIcsDateTime(value: Optional[str]) -> Optional[str]:
|
||||||
|
|
||||||
def _googleEventToIcs(event: Dict[str, Any]) -> bytes:
|
def _googleEventToIcs(event: Dict[str, Any]) -> bytes:
|
||||||
"""Build a minimal RFC 5545 VCALENDAR/VEVENT for a Google Calendar event."""
|
"""Build a minimal RFC 5545 VCALENDAR/VEVENT for a Google Calendar event."""
|
||||||
from datetime import datetime, timezone
|
|
||||||
uid = event.get("iCalUID") or event.get("id") or "unknown@poweron"
|
uid = event.get("iCalUID") or event.get("id") or "unknown@poweron"
|
||||||
summary = _googleIcsEscape(event.get("summary") or "")
|
summary = _googleIcsEscape(event.get("summary") or "")
|
||||||
location = _googleIcsEscape(event.get("location") or "")
|
location = _googleIcsEscape(event.get("location") or "")
|
||||||
|
|
@ -45,7 +45,7 @@ from modules.connectors.connectorProviderBase import (
|
||||||
ServiceAdapter,
|
ServiceAdapter,
|
||||||
DownloadResult,
|
DownloadResult,
|
||||||
)
|
)
|
||||||
from modules.connectors._httpResilience import ResilientHttp
|
from modules.shared.httpResilience import ResilientHttp
|
||||||
from modules.datamodels.datamodelDataSource import ExternalEntry
|
from modules.datamodels.datamodelDataSource import ExternalEntry
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -6,14 +6,17 @@ All ServiceAdapters share the same OAuth access token obtained from the
|
||||||
UserConnection (authority=msft).
|
UserConnection (authority=msft).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import asyncio
|
import asyncio
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
|
|
||||||
from modules.connectors.connectorProviderBase import ProviderConnector, ServiceAdapter, DownloadResult
|
from modules.connectors.connectorProviderBase import ProviderConnector, ServiceAdapter, DownloadResult
|
||||||
from modules.connectors._httpResilience import ResilientHttp
|
from modules.shared.httpResilience import ResilientHttp
|
||||||
from modules.datamodels.datamodelDataSource import ExternalEntry
|
from modules.datamodels.datamodelDataSource import ExternalEntry
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -79,7 +82,7 @@ async def _handleResponse(resp: aiohttp.ClientResponse) -> Dict[str, Any]:
|
||||||
return {"error": f"{resp.status}: {errorText}"}
|
return {"error": f"{resp.status}: {errorText}"}
|
||||||
|
|
||||||
|
|
||||||
def _stripGraphBase(url: str) -> str:
|
def stripGraphBase(url: str) -> str:
|
||||||
"""Convert an absolute Graph URL (used by @odata.nextLink) into the
|
"""Convert an absolute Graph URL (used by @odata.nextLink) into the
|
||||||
relative endpoint that ``_makeGraphCall`` expects."""
|
relative endpoint that ``_makeGraphCall`` expects."""
|
||||||
if not url:
|
if not url:
|
||||||
|
|
@ -176,7 +179,7 @@ class SharepointAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
if effectiveLimit is not None and len(items) >= effectiveLimit:
|
if effectiveLimit is not None and len(items) >= effectiveLimit:
|
||||||
break
|
break
|
||||||
nextLink = result.get("@odata.nextLink")
|
nextLink = result.get("@odata.nextLink")
|
||||||
endpoint = _stripGraphBase(nextLink) if nextLink else None
|
endpoint = stripGraphBase(nextLink) if nextLink else None
|
||||||
|
|
||||||
entries = [_graphItemToExternalEntry(item, path) for item in items]
|
entries = [_graphItemToExternalEntry(item, path) for item in items]
|
||||||
if filter:
|
if filter:
|
||||||
|
|
@ -257,7 +260,7 @@ class SharepointAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
if effectiveLimit is not None and len(items) >= effectiveLimit:
|
if effectiveLimit is not None and len(items) >= effectiveLimit:
|
||||||
break
|
break
|
||||||
nextLink = result.get("@odata.nextLink")
|
nextLink = result.get("@odata.nextLink")
|
||||||
endpoint = _stripGraphBase(nextLink) if nextLink else None
|
endpoint = stripGraphBase(nextLink) if nextLink else None
|
||||||
entries = [_graphItemToExternalEntry(item) for item in items]
|
entries = [_graphItemToExternalEntry(item) for item in items]
|
||||||
if effectiveLimit is not None:
|
if effectiveLimit is not None:
|
||||||
entries = entries[: max(1, effectiveLimit)]
|
entries = entries[: max(1, effectiveLimit)]
|
||||||
|
|
@ -278,8 +281,6 @@ def _parseDateRange(filterStr: Optional[str]) -> tuple:
|
||||||
(treated as a ~31 day window), or a YYYY-MM month pattern. Returns
|
(treated as a ~31 day window), or a YYYY-MM month pattern. Returns
|
||||||
(startDateTime, endDateTime) ISO strings, or (None, None) if not parseable.
|
(startDateTime, endDateTime) ISO strings, or (None, None) if not parseable.
|
||||||
"""
|
"""
|
||||||
import re
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
if not filterStr:
|
if not filterStr:
|
||||||
return (None, None)
|
return (None, None)
|
||||||
isoMatch = re.findall(r'\d{4}-\d{2}-\d{2}(?:T[\d:]+)?', filterStr)
|
isoMatch = re.findall(r'\d{4}-\d{2}-\d{2}(?:T[\d:]+)?', filterStr)
|
||||||
|
|
@ -368,7 +369,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
if not nextLink:
|
if not nextLink:
|
||||||
endpoint = None
|
endpoint = None
|
||||||
else:
|
else:
|
||||||
endpoint = _stripGraphBase(nextLink)
|
endpoint = stripGraphBase(nextLink)
|
||||||
|
|
||||||
# Guarantee Inbox is present (well-known name, locale-independent)
|
# Guarantee Inbox is present (well-known name, locale-independent)
|
||||||
if not any((f.get("displayName") or "").lower() in ("inbox", "posteingang") for f in folders):
|
if not any((f.get("displayName") or "").lower() in ("inbox", "posteingang") for f in folders):
|
||||||
|
|
@ -445,7 +446,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
if len(messages) >= effectiveLimit:
|
if len(messages) >= effectiveLimit:
|
||||||
break
|
break
|
||||||
nextLink = result.get("@odata.nextLink")
|
nextLink = result.get("@odata.nextLink")
|
||||||
endpoint = _stripGraphBase(nextLink) if nextLink else None
|
endpoint = stripGraphBase(nextLink) if nextLink else None
|
||||||
entries = [
|
entries = [
|
||||||
ExternalEntry(
|
ExternalEntry(
|
||||||
name=m.get("subject", "(no subject)"),
|
name=m.get("subject", "(no subject)"),
|
||||||
|
|
@ -470,7 +471,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
|
|
||||||
async def download(self, path: str) -> DownloadResult:
|
async def download(self, path: str) -> DownloadResult:
|
||||||
"""Download a mail message as RFC 822 EML via Graph API $value endpoint."""
|
"""Download a mail message as RFC 822 EML via Graph API $value endpoint."""
|
||||||
import re
|
|
||||||
messageId = path.strip("/").split("/")[-1]
|
messageId = path.strip("/").split("/")[-1]
|
||||||
|
|
||||||
meta = await self._graphGet(f"me/messages/{messageId}?$select=subject")
|
meta = await self._graphGet(f"me/messages/{messageId}?$select=subject")
|
||||||
|
|
@ -572,7 +572,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
attachments: Optional[List[Dict]] = None,
|
attachments: Optional[List[Dict]] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Send an email via Microsoft Graph. bodyType: 'Text' or 'HTML'."""
|
"""Send an email via Microsoft Graph. bodyType: 'Text' or 'HTML'."""
|
||||||
import json
|
|
||||||
message = self._buildMessage(to, subject, body, bodyType, cc, attachments)
|
message = self._buildMessage(to, subject, body, bodyType, cc, attachments)
|
||||||
payload = json.dumps({"message": message, "saveToSentItems": True}).encode("utf-8")
|
payload = json.dumps({"message": message, "saveToSentItems": True}).encode("utf-8")
|
||||||
result = await self._graphPost("me/sendMail", payload)
|
result = await self._graphPost("me/sendMail", payload)
|
||||||
|
|
@ -587,7 +586,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
attachments: Optional[List[Dict]] = None,
|
attachments: Optional[List[Dict]] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Create a draft email in the user's Drafts folder via Microsoft Graph."""
|
"""Create a draft email in the user's Drafts folder via Microsoft Graph."""
|
||||||
import json
|
|
||||||
message = self._buildMessage(to, subject, body, bodyType, cc, attachments)
|
message = self._buildMessage(to, subject, body, bodyType, cc, attachments)
|
||||||
payload = json.dumps(message).encode("utf-8")
|
payload = json.dumps(message).encode("utf-8")
|
||||||
result = await self._graphPost("me/messages", payload)
|
result = await self._graphPost("me/messages", payload)
|
||||||
|
|
@ -617,7 +615,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
Preserves the conversation thread and the ``AW:`` prefix in Outlook --
|
Preserves the conversation thread and the ``AW:`` prefix in Outlook --
|
||||||
unlike sendMail() which creates a brand-new conversation.
|
unlike sendMail() which creates a brand-new conversation.
|
||||||
"""
|
"""
|
||||||
import json
|
|
||||||
endpointAction = "replyAll" if replyAll else "reply"
|
endpointAction = "replyAll" if replyAll else "reply"
|
||||||
payload = json.dumps({"comment": comment}).encode("utf-8")
|
payload = json.dumps({"comment": comment}).encode("utf-8")
|
||||||
result = await self._graphPost(f"me/messages/{messageId}/{endpointAction}", payload)
|
result = await self._graphPost(f"me/messages/{messageId}/{endpointAction}", payload)
|
||||||
|
|
@ -629,7 +626,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
self, messageId: str, to: List[str], comment: str = "",
|
self, messageId: str, to: List[str], comment: str = "",
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Forward an existing message to new recipients."""
|
"""Forward an existing message to new recipients."""
|
||||||
import json
|
|
||||||
payload = json.dumps({
|
payload = json.dumps({
|
||||||
"comment": comment,
|
"comment": comment,
|
||||||
"toRecipients": [{"emailAddress": {"address": addr}} for addr in to],
|
"toRecipients": [{"emailAddress": {"address": addr}} for addr in to],
|
||||||
|
|
@ -644,7 +640,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
replyAll: bool = False,
|
replyAll: bool = False,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Create a reply-draft (in the Drafts folder) that the user can edit before sending."""
|
"""Create a reply-draft (in the Drafts folder) that the user can edit before sending."""
|
||||||
import json
|
|
||||||
endpointAction = "createReplyAll" if replyAll else "createReply"
|
endpointAction = "createReplyAll" if replyAll else "createReply"
|
||||||
payload = json.dumps({"comment": comment}).encode("utf-8") if comment else b"{}"
|
payload = json.dumps({"comment": comment}).encode("utf-8") if comment else b"{}"
|
||||||
result = await self._graphPost(f"me/messages/{messageId}/{endpointAction}", payload)
|
result = await self._graphPost(f"me/messages/{messageId}/{endpointAction}", payload)
|
||||||
|
|
@ -656,7 +651,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
self, messageId: str, to: Optional[List[str]] = None, comment: str = "",
|
self, messageId: str, to: Optional[List[str]] = None, comment: str = "",
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Create a forward-draft (in the Drafts folder) that the user can edit before sending."""
|
"""Create a forward-draft (in the Drafts folder) that the user can edit before sending."""
|
||||||
import json
|
|
||||||
body: Dict[str, Any] = {}
|
body: Dict[str, Any] = {}
|
||||||
if comment:
|
if comment:
|
||||||
body["comment"] = comment
|
body["comment"] = comment
|
||||||
|
|
@ -727,7 +721,7 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
"childFolderCount": f.get("childFolderCount", 0),
|
"childFolderCount": f.get("childFolderCount", 0),
|
||||||
})
|
})
|
||||||
nextLink = result.get("@odata.nextLink")
|
nextLink = result.get("@odata.nextLink")
|
||||||
endpoint = _stripGraphBase(nextLink) if nextLink else None
|
endpoint = stripGraphBase(nextLink) if nextLink else None
|
||||||
return folders
|
return folders
|
||||||
|
|
||||||
async def _resolveFolderId(self, folderRef: str) -> Optional[str]:
|
async def _resolveFolderId(self, folderRef: str) -> Optional[str]:
|
||||||
|
|
@ -764,7 +758,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
self, messageId: str, destinationFolder: str,
|
self, messageId: str, destinationFolder: str,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Move a message to another folder (well-known name, displayName, or folder id)."""
|
"""Move a message to another folder (well-known name, displayName, or folder id)."""
|
||||||
import json
|
|
||||||
destId = await self._resolveFolderId(destinationFolder)
|
destId = await self._resolveFolderId(destinationFolder)
|
||||||
if not destId:
|
if not destId:
|
||||||
return {"error": f"Folder not found: '{destinationFolder}'. Use listMailFolders to inspect available folders."}
|
return {"error": f"Folder not found: '{destinationFolder}'. Use listMailFolders to inspect available folders."}
|
||||||
|
|
@ -778,7 +771,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
self, messageId: str, destinationFolder: str,
|
self, messageId: str, destinationFolder: str,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Copy a message into another folder (original stays in place)."""
|
"""Copy a message into another folder (original stays in place)."""
|
||||||
import json
|
|
||||||
destId = await self._resolveFolderId(destinationFolder)
|
destId = await self._resolveFolderId(destinationFolder)
|
||||||
if not destId:
|
if not destId:
|
||||||
return {"error": f"Folder not found: '{destinationFolder}'. Use listMailFolders to inspect available folders."}
|
return {"error": f"Folder not found: '{destinationFolder}'. Use listMailFolders to inspect available folders."}
|
||||||
|
|
@ -818,7 +810,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
|
|
||||||
async def markMailAsRead(self, messageId: str) -> Dict[str, Any]:
|
async def markMailAsRead(self, messageId: str) -> Dict[str, Any]:
|
||||||
"""Mark a message as read (sets ``isRead=true``)."""
|
"""Mark a message as read (sets ``isRead=true``)."""
|
||||||
import json
|
|
||||||
payload = json.dumps({"isRead": True}).encode("utf-8")
|
payload = json.dumps({"isRead": True}).encode("utf-8")
|
||||||
result = await self._graphPatch(f"me/messages/{messageId}", payload)
|
result = await self._graphPatch(f"me/messages/{messageId}", payload)
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
|
|
@ -827,7 +818,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
|
|
||||||
async def markMailAsUnread(self, messageId: str) -> Dict[str, Any]:
|
async def markMailAsUnread(self, messageId: str) -> Dict[str, Any]:
|
||||||
"""Mark a message as unread (sets ``isRead=false``)."""
|
"""Mark a message as unread (sets ``isRead=false``)."""
|
||||||
import json
|
|
||||||
payload = json.dumps({"isRead": False}).encode("utf-8")
|
payload = json.dumps({"isRead": False}).encode("utf-8")
|
||||||
result = await self._graphPatch(f"me/messages/{messageId}", payload)
|
result = await self._graphPatch(f"me/messages/{messageId}", payload)
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
|
|
@ -845,7 +835,6 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
``"notFlagged"`` -- the three values Microsoft Graph recognises for
|
``"notFlagged"`` -- the three values Microsoft Graph recognises for
|
||||||
``followupFlag.flagStatus``.
|
``followupFlag.flagStatus``.
|
||||||
"""
|
"""
|
||||||
import json
|
|
||||||
if flagStatus not in ("flagged", "complete", "notFlagged"):
|
if flagStatus not in ("flagged", "complete", "notFlagged"):
|
||||||
return {"error": f"Invalid flagStatus '{flagStatus}'. Use one of: flagged, complete, notFlagged."}
|
return {"error": f"Invalid flagStatus '{flagStatus}'. Use one of: flagged, complete, notFlagged."}
|
||||||
payload = json.dumps({"flag": {"flagStatus": flagStatus}}).encode("utf-8")
|
payload = json.dumps({"flag": {"flagStatus": flagStatus}}).encode("utf-8")
|
||||||
|
|
@ -952,7 +941,7 @@ class OneDriveAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
if effectiveLimit is not None and len(items) >= effectiveLimit:
|
if effectiveLimit is not None and len(items) >= effectiveLimit:
|
||||||
break
|
break
|
||||||
nextLink = result.get("@odata.nextLink")
|
nextLink = result.get("@odata.nextLink")
|
||||||
endpoint = _stripGraphBase(nextLink) if nextLink else None
|
endpoint = stripGraphBase(nextLink) if nextLink else None
|
||||||
|
|
||||||
entries = [_graphItemToExternalEntry(item, path) for item in items]
|
entries = [_graphItemToExternalEntry(item, path) for item in items]
|
||||||
if filter:
|
if filter:
|
||||||
|
|
@ -1003,7 +992,7 @@ class OneDriveAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
if effectiveLimit is not None and len(items) >= effectiveLimit:
|
if effectiveLimit is not None and len(items) >= effectiveLimit:
|
||||||
break
|
break
|
||||||
nextLink = result.get("@odata.nextLink")
|
nextLink = result.get("@odata.nextLink")
|
||||||
endpoint = _stripGraphBase(nextLink) if nextLink else None
|
endpoint = stripGraphBase(nextLink) if nextLink else None
|
||||||
entries = [_graphItemToExternalEntry(item) for item in items]
|
entries = [_graphItemToExternalEntry(item) for item in items]
|
||||||
if effectiveLimit is not None:
|
if effectiveLimit is not None:
|
||||||
entries = entries[: max(1, effectiveLimit)]
|
entries = entries[: max(1, effectiveLimit)]
|
||||||
|
|
@ -1099,7 +1088,7 @@ class CalendarAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
if len(events) >= effectiveLimit:
|
if len(events) >= effectiveLimit:
|
||||||
break
|
break
|
||||||
nextLink = result.get("@odata.nextLink")
|
nextLink = result.get("@odata.nextLink")
|
||||||
endpoint = _stripGraphBase(nextLink) if nextLink else None
|
endpoint = stripGraphBase(nextLink) if nextLink else None
|
||||||
|
|
||||||
return [
|
return [
|
||||||
ExternalEntry(
|
ExternalEntry(
|
||||||
|
|
@ -1296,7 +1285,7 @@ class ContactsAdapter(_GraphApiMixin, ServiceAdapter):
|
||||||
if len(contacts) >= effectiveLimit:
|
if len(contacts) >= effectiveLimit:
|
||||||
break
|
break
|
||||||
nextLink = result.get("@odata.nextLink")
|
nextLink = result.get("@odata.nextLink")
|
||||||
endpoint = _stripGraphBase(nextLink) if nextLink else None
|
endpoint = stripGraphBase(nextLink) if nextLink else None
|
||||||
|
|
||||||
return [
|
return [
|
||||||
ExternalEntry(
|
ExternalEntry(
|
||||||
|
|
@ -1448,7 +1437,6 @@ def _matchFilter(entry: ExternalEntry, pattern: str) -> bool:
|
||||||
|
|
||||||
def _safeFileName(name: str) -> str:
|
def _safeFileName(name: str) -> str:
|
||||||
"""Strip path-unsafe characters and trim length so the result is a usable file name."""
|
"""Strip path-unsafe characters and trim length so the result is a usable file name."""
|
||||||
import re
|
|
||||||
return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name or "")[:80].strip(". ")
|
return re.sub(r'[<>:"/\\|?*\x00-\x1f]', "_", name or "")[:80].strip(". ")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1478,7 +1466,6 @@ def _icsDateTime(value: Optional[str]) -> Optional[str]:
|
||||||
"""Convert an ISO datetime string to an RFC 5545 DATE-TIME value (UTC)."""
|
"""Convert an ISO datetime string to an RFC 5545 DATE-TIME value (UTC)."""
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
from datetime import datetime, timezone
|
|
||||||
try:
|
try:
|
||||||
normalized = value.replace("Z", "+00:00") if value.endswith("Z") else value
|
normalized = value.replace("Z", "+00:00") if value.endswith("Z") else value
|
||||||
dt = datetime.fromisoformat(normalized)
|
dt = datetime.fromisoformat(normalized)
|
||||||
|
|
@ -1491,7 +1478,6 @@ def _icsDateTime(value: Optional[str]) -> Optional[str]:
|
||||||
|
|
||||||
def _eventToIcs(event: Dict[str, Any]) -> bytes:
|
def _eventToIcs(event: Dict[str, Any]) -> bytes:
|
||||||
"""Build a minimal RFC 5545 VCALENDAR/VEVENT for a Graph event payload."""
|
"""Build a minimal RFC 5545 VCALENDAR/VEVENT for a Graph event payload."""
|
||||||
from datetime import datetime, timezone
|
|
||||||
uid = event.get("iCalUId") or event.get("id") or "unknown@poweron"
|
uid = event.get("iCalUId") or event.get("id") or "unknown@poweron"
|
||||||
summary = _icsEscape(event.get("subject") or "")
|
summary = _icsEscape(event.get("subject") or "")
|
||||||
location = _icsEscape((event.get("location") or {}).get("displayName") or "")
|
location = _icsEscape((event.get("location") or {}).get("displayName") or "")
|
||||||
|
|
@ -44,31 +44,31 @@ class ConnectorResolver:
|
||||||
if ConnectorResolver._providerRegistry:
|
if ConnectorResolver._providerRegistry:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
from modules.connectors.providerMsft.connectorMsft import MsftConnector
|
from modules.connectors.connectorProviderMsft import MsftConnector
|
||||||
ConnectorResolver._providerRegistry["msft"] = MsftConnector
|
ConnectorResolver._providerRegistry["msft"] = MsftConnector
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning("MsftConnector not available")
|
logger.warning("MsftConnector not available")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from modules.connectors.providerGoogle.connectorGoogle import GoogleConnector
|
from modules.connectors.connectorProviderGoogle import GoogleConnector
|
||||||
ConnectorResolver._providerRegistry["google"] = GoogleConnector
|
ConnectorResolver._providerRegistry["google"] = GoogleConnector
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.debug("GoogleConnector not available (stub)")
|
logger.debug("GoogleConnector not available (stub)")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from modules.connectors.providerFtp.connectorFtp import FtpConnector
|
from modules.connectors.connectorProviderFtp import FtpConnector
|
||||||
ConnectorResolver._providerRegistry["local:ftp"] = FtpConnector
|
ConnectorResolver._providerRegistry["local:ftp"] = FtpConnector
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.debug("FtpConnector not available (stub)")
|
logger.debug("FtpConnector not available (stub)")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from modules.connectors.providerClickup.connectorClickup import ClickupConnector
|
from modules.connectors.connectorProviderClickup import ClickupConnector
|
||||||
ConnectorResolver._providerRegistry["clickup"] = ClickupConnector
|
ConnectorResolver._providerRegistry["clickup"] = ClickupConnector
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning("ClickupConnector not available")
|
logger.warning("ClickupConnector not available")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from modules.connectors.providerInfomaniak.connectorInfomaniak import InfomaniakConnector
|
from modules.connectors.connectorProviderInfomaniak import InfomaniakConnector
|
||||||
ConnectorResolver._providerRegistry["infomaniak"] = InfomaniakConnector
|
ConnectorResolver._providerRegistry["infomaniak"] = InfomaniakConnector
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning("InfomaniakConnector not available")
|
logger.warning("InfomaniakConnector not available")
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from typing import Optional
|
||||||
import logging
|
import logging
|
||||||
import aiohttp
|
import aiohttp
|
||||||
from modules.datamodels.datamodelTickets import TicketBase, TicketFieldAttribute
|
from modules.datamodels.datamodelTickets import TicketBase, TicketFieldAttribute
|
||||||
from modules.serviceCenter.services.serviceClickup.mainServiceClickup import clickup_authorization_header
|
from modules.connectors.connectorProviderClickup import clickupAuthorizationHeader
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -31,7 +31,7 @@ class ConnectorTicketClickup(TicketBase):
|
||||||
|
|
||||||
def _headers(self) -> dict:
|
def _headers(self) -> dict:
|
||||||
return {
|
return {
|
||||||
"Authorization": clickup_authorization_header(self.apiToken),
|
"Authorization": clickupAuthorizationHeader(self.apiToken),
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ from google.cloud import speech
|
||||||
from google.cloud import translate_v2 as translate
|
from google.cloud import translate_v2 as translate
|
||||||
from google.cloud import texttospeech
|
from google.cloud import texttospeech
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
from modules.shared.voiceCatalog import getDefaultVoice as _catalogDefaultVoice
|
from modules.shared.voiceCatalog import getDefaultVoice
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -1097,7 +1097,7 @@ class ConnectorGoogleSpeech:
|
||||||
voice exists, in which case the caller omits `name` and Google
|
voice exists, in which case the caller omits `name` and Google
|
||||||
auto-selects based on languageCode + ssml_gender.
|
auto-selects based on languageCode + ssml_gender.
|
||||||
"""
|
"""
|
||||||
return _catalogDefaultVoice(languageCode)
|
return getDefaultVoice(languageCode)
|
||||||
|
|
||||||
async def getAvailableVoices(self, languageCode: Optional[str] = None) -> Dict[str, Any]:
|
async def getAvailableVoices(self, languageCode: Optional[str] = None) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
|
||||||
# All rights reserved.
|
|
||||||
"""ClickUp provider connector."""
|
|
||||||
|
|
||||||
from .connectorClickup import ClickupConnector
|
|
||||||
|
|
||||||
__all__ = ["ClickupConnector"]
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
|
||||||
# All rights reserved.
|
|
||||||
"""FTP/SFTP Provider Connector stub."""
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
|
||||||
# All rights reserved.
|
|
||||||
"""Google Provider Connector -- 1 Connection : n Services (Drive, Gmail)."""
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
|
||||||
# All rights reserved.
|
|
||||||
"""Infomaniak Provider Connector -- 1 Connection : n Services (kDrive, Mail)."""
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
|
||||||
# All rights reserved.
|
|
||||||
"""Microsoft Provider Connector -- 1 Connection : n Services (SharePoint, Outlook, Teams, OneDrive)."""
|
|
||||||
|
|
@ -13,4 +13,5 @@ from . import datamodelSecurity as security
|
||||||
from . import datamodelChat as chat
|
from . import datamodelChat as chat
|
||||||
from . import datamodelFiles as files
|
from . import datamodelFiles as files
|
||||||
from . import datamodelVoice as voice
|
from . import datamodelVoice as voice
|
||||||
from . import datamodelUtils as utils
|
from . import datamodelUtils as utils
|
||||||
|
from . import jsonContinuation
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
|
||||||
# All rights reserved.
|
|
||||||
"""FeatureDataSource model for exposing feature instance data to the AI workspace.
|
|
||||||
|
|
||||||
A FeatureDataSource links a FeatureInstance table (DATA_OBJECT) to a workspace
|
|
||||||
so the agent can query structured feature data (e.g. TrusteePosition rows).
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from modules.datamodels.datamodelBase import PowerOnModel
|
|
||||||
from modules.shared.i18nRegistry import i18nModel
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
|
|
||||||
@i18nModel("Feature-Datenquelle")
|
|
||||||
class FeatureDataSource(PowerOnModel):
|
|
||||||
"""Feature-Instanz-Tabelle als Datenquelle im AI-Workspace."""
|
|
||||||
id: str = Field(
|
|
||||||
default_factory=lambda: str(uuid.uuid4()),
|
|
||||||
description="Primary key",
|
|
||||||
json_schema_extra={"label": "ID"},
|
|
||||||
)
|
|
||||||
featureInstanceId: str = Field(
|
|
||||||
description="FK to FeatureInstance",
|
|
||||||
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
|
|
||||||
)
|
|
||||||
featureCode: str = Field(
|
|
||||||
description="Feature code (e.g. trustee, commcoach)",
|
|
||||||
json_schema_extra={"label": "Feature", "fk_target": {"db": "poweron_app", "table": "Feature", "column": "code", "labelField": "code"}},
|
|
||||||
)
|
|
||||||
tableName: str = Field(
|
|
||||||
description="Table name from DATA_OBJECTS meta (e.g. TrusteePosition)",
|
|
||||||
json_schema_extra={"label": "Tabelle"},
|
|
||||||
)
|
|
||||||
objectKey: str = Field(
|
|
||||||
description="RBAC object key (e.g. data.feature.trustee.TrusteePosition)",
|
|
||||||
json_schema_extra={"label": "Objekt-Schluessel"},
|
|
||||||
)
|
|
||||||
label: str = Field(
|
|
||||||
description="User-visible label",
|
|
||||||
json_schema_extra={"label": "Bezeichnung"},
|
|
||||||
)
|
|
||||||
mandateId: str = Field(
|
|
||||||
default="",
|
|
||||||
description="Mandate scope (set automatically from featureInstance.mandateId on create).",
|
|
||||||
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
|
|
||||||
)
|
|
||||||
neutralize: Optional[bool] = Field(
|
|
||||||
default=None,
|
|
||||||
description=(
|
|
||||||
"Three-state neutralization flag with cascade-inherit semantics. "
|
|
||||||
"None = inherit; True/False = explicit. Cascade-reset on parent toggle."
|
|
||||||
),
|
|
||||||
json_schema_extra={"label": "Neutralisieren", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False},
|
|
||||||
)
|
|
||||||
ragIndexEnabled: Optional[bool] = Field(
|
|
||||||
default=None,
|
|
||||||
description=(
|
|
||||||
"Three-state RAG-indexing flag with cascade-inherit semantics. "
|
|
||||||
"None = inherit; True/False = explicit. Cascade-reset on parent toggle."
|
|
||||||
),
|
|
||||||
json_schema_extra={"label": "RAG-Indexierung", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False},
|
|
||||||
)
|
|
||||||
neutralizeFields: Optional[List[str]] = Field(
|
|
||||||
default=None,
|
|
||||||
description="Column names whose values are replaced with placeholders before AI processing",
|
|
||||||
json_schema_extra={"label": "Zu neutralisierende Felder", "frontend_type": "multiselect", "frontend_readonly": False, "frontend_required": False},
|
|
||||||
)
|
|
||||||
recordFilter: Optional[Dict[str, str]] = Field(
|
|
||||||
default=None,
|
|
||||||
description="Record-level filter applied when querying this table, e.g. {'sessionId': 'abc-123'}",
|
|
||||||
json_schema_extra={"label": "Datensatzfilter"},
|
|
||||||
)
|
|
||||||
settings: Optional[Dict[str, Any]] = Field(
|
|
||||||
default=None,
|
|
||||||
description=(
|
|
||||||
"FeatureDataSource-scoped settings (JSON). Currently used keys: "
|
|
||||||
"ragLimits.{maxBytes,maxFileSize,maxItems,maxDepth}. "
|
|
||||||
"Mirror of DataSource.settings so the UDB settings modal can target both."
|
|
||||||
),
|
|
||||||
json_schema_extra={"label": "Einstellungen", "frontend_type": "json", "frontend_readonly": True, "frontend_required": False},
|
|
||||||
)
|
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""Feature models: Feature, FeatureInstance."""
|
"""Feature models: Feature definitions, instances, data sources, and shared feature types."""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any, List
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from modules.datamodels.datamodelBase import PowerOnModel
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
from modules.shared.i18nRegistry import i18nModel
|
from modules.shared.i18nRegistry import i18nModel
|
||||||
from modules.datamodels.datamodelUtils import TextMultilingual
|
from modules.datamodels.datamodelUtils import TextMultilingual
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Feature & FeatureInstance
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@i18nModel("Feature")
|
@i18nModel("Feature")
|
||||||
class Feature(PowerOnModel):
|
class Feature(PowerOnModel):
|
||||||
"""Feature-Definition (global, z.B. 'trustee', 'commcoach'). Verfuegbare Funktionalitaeten der Plattform."""
|
"""Feature-Definition (global, z.B. 'trustee', 'commcoach'). Verfuegbare Funktionalitaeten der Plattform."""
|
||||||
|
|
@ -71,3 +75,147 @@ class FeatureInstance(PowerOnModel):
|
||||||
description="Instance-specific configuration (JSONB). Structure depends on featureCode.",
|
description="Instance-specific configuration (JSONB). Structure depends on featureCode.",
|
||||||
json_schema_extra={"label": "Konfiguration", "frontend_type": "json", "frontend_readonly": False, "frontend_required": False}
|
json_schema_extra={"label": "Konfiguration", "frontend_type": "json", "frontend_readonly": False, "frontend_required": False}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# FeatureDataSource
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@i18nModel("Feature-Datenquelle")
|
||||||
|
class FeatureDataSource(PowerOnModel):
|
||||||
|
"""Feature-Instanz-Tabelle als Datenquelle im AI-Workspace."""
|
||||||
|
id: str = Field(
|
||||||
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
description="Primary key",
|
||||||
|
json_schema_extra={"label": "ID"},
|
||||||
|
)
|
||||||
|
featureInstanceId: str = Field(
|
||||||
|
description="FK to FeatureInstance",
|
||||||
|
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"}},
|
||||||
|
)
|
||||||
|
featureCode: str = Field(
|
||||||
|
description="Feature code (e.g. trustee, commcoach)",
|
||||||
|
json_schema_extra={"label": "Feature", "fk_target": {"db": "poweron_app", "table": "Feature", "column": "code", "labelField": "code"}},
|
||||||
|
)
|
||||||
|
tableName: str = Field(
|
||||||
|
description="Table name from DATA_OBJECTS meta (e.g. TrusteePosition)",
|
||||||
|
json_schema_extra={"label": "Tabelle"},
|
||||||
|
)
|
||||||
|
objectKey: str = Field(
|
||||||
|
description="RBAC object key (e.g. data.feature.trustee.TrusteePosition)",
|
||||||
|
json_schema_extra={"label": "Objekt-Schluessel"},
|
||||||
|
)
|
||||||
|
label: str = Field(
|
||||||
|
description="User-visible label",
|
||||||
|
json_schema_extra={"label": "Bezeichnung"},
|
||||||
|
)
|
||||||
|
mandateId: str = Field(
|
||||||
|
default="",
|
||||||
|
description="Mandate scope (set automatically from featureInstance.mandateId on create).",
|
||||||
|
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"}},
|
||||||
|
)
|
||||||
|
neutralize: Optional[bool] = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"Three-state neutralization flag with cascade-inherit semantics. "
|
||||||
|
"None = inherit; True/False = explicit. Cascade-reset on parent toggle."
|
||||||
|
),
|
||||||
|
json_schema_extra={"label": "Neutralisieren", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False},
|
||||||
|
)
|
||||||
|
ragIndexEnabled: Optional[bool] = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"Three-state RAG-indexing flag with cascade-inherit semantics. "
|
||||||
|
"None = inherit; True/False = explicit. Cascade-reset on parent toggle."
|
||||||
|
),
|
||||||
|
json_schema_extra={"label": "RAG-Indexierung", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False},
|
||||||
|
)
|
||||||
|
neutralizeFields: Optional[List[str]] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Column names whose values are replaced with placeholders before AI processing",
|
||||||
|
json_schema_extra={"label": "Zu neutralisierende Felder", "frontend_type": "multiselect", "frontend_readonly": False, "frontend_required": False},
|
||||||
|
)
|
||||||
|
recordFilter: Optional[Dict[str, str]] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Record-level filter applied when querying this table, e.g. {'sessionId': 'abc-123'}",
|
||||||
|
json_schema_extra={"label": "Datensatzfilter"},
|
||||||
|
)
|
||||||
|
settings: Optional[Dict[str, Any]] = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"FeatureDataSource-scoped settings (JSON). Currently used keys: "
|
||||||
|
"ragLimits.{maxBytes,maxFileSize,maxItems,maxDepth}. "
|
||||||
|
"Mirror of DataSource.settings so the UDB settings modal can target both."
|
||||||
|
),
|
||||||
|
json_schema_extra={"label": "Einstellungen", "frontend_type": "json", "frontend_readonly": True, "frontend_required": False},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DataNeutralizerAttributes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@i18nModel("Neutralisiertes Datenattribut")
|
||||||
|
class DataNeutralizerAttributes(PowerOnModel):
|
||||||
|
"""Zuordnung Originaltext zu Platzhalter fuer neutralisierte Daten."""
|
||||||
|
id: str = Field(
|
||||||
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
description="Unique ID of the attribute mapping (used as UID in neutralized files)",
|
||||||
|
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
|
||||||
|
)
|
||||||
|
mandateId: str = Field(
|
||||||
|
description="ID of the mandate this attribute belongs to",
|
||||||
|
json_schema_extra={
|
||||||
|
"label": "Mandanten-ID",
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_readonly": True,
|
||||||
|
"frontend_required": True,
|
||||||
|
"fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
featureInstanceId: str = Field(
|
||||||
|
description="ID of the feature instance this attribute belongs to",
|
||||||
|
json_schema_extra={
|
||||||
|
"label": "Feature-Instanz-ID",
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_readonly": True,
|
||||||
|
"frontend_required": True,
|
||||||
|
"fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
userId: str = Field(
|
||||||
|
description="ID of the user who created this attribute",
|
||||||
|
json_schema_extra={
|
||||||
|
"label": "Benutzer-ID",
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_readonly": True,
|
||||||
|
"frontend_required": True,
|
||||||
|
"fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
originalText: str = Field(
|
||||||
|
description="Original text that was neutralized",
|
||||||
|
json_schema_extra={"label": "Originaltext", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
|
||||||
|
)
|
||||||
|
fileId: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="ID of the file this attribute belongs to",
|
||||||
|
json_schema_extra={
|
||||||
|
"label": "Datei-ID",
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_readonly": True,
|
||||||
|
"frontend_required": False,
|
||||||
|
"fk_target": {"db": "poweron_management", "table": "FileItem", "labelField": "fileName"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
patternType: str = Field(
|
||||||
|
description="Type of pattern that matched (email, phone, name, etc.)",
|
||||||
|
json_schema_extra={"label": "Mustertyp", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AutoWorkflow — re-exported from canonical location (datamodelWorkflowAutomation)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
from modules.datamodels.datamodelWorkflowAutomation import AutoWorkflow # noqa: F401
|
||||||
|
|
|
||||||
551
modules/datamodels/datamodelPortTypes.py
Normal file
551
modules/datamodels/datamodelPortTypes.py
Normal file
|
|
@ -0,0 +1,551 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""Port type catalog and primitive types for the Graphical Editor workflow system."""
|
||||||
|
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from modules.shared.i18nRegistry import t
|
||||||
|
|
||||||
|
|
||||||
|
class PortField(BaseModel):
|
||||||
|
model_config = ConfigDict(populate_by_name=True)
|
||||||
|
|
||||||
|
name: str
|
||||||
|
type: str # str, int, bool, List[str], List[Document], Dict[str,Any], ConnectionRef, …
|
||||||
|
description: str = ""
|
||||||
|
required: bool = True
|
||||||
|
enumValues: Optional[List[str]] = None
|
||||||
|
# Marks this field as the discriminator for a Ref-Schema (e.g. ConnectionRef.authority,
|
||||||
|
# FeatureInstanceRef.featureCode). Pickers/validators use it to filter compatible
|
||||||
|
# producers by sub-type. Type must be "str" when discriminator is True.
|
||||||
|
discriminator: bool = False
|
||||||
|
# Surfaces this field at the top of the DataPicker list as the most common pick.
|
||||||
|
recommended: bool = False
|
||||||
|
# Human DataPicker title (camelCase JSON for frontend). Omit for technical paths-only.
|
||||||
|
picker_label: Optional[str] = Field(default=None, serialization_alias="pickerLabel")
|
||||||
|
# For List[T] fields: segment between parent and inner field (iteration / one list item).
|
||||||
|
picker_item_label: Optional[str] = Field(default=None, serialization_alias="pickerItemLabel")
|
||||||
|
|
||||||
|
|
||||||
|
class PortSchema(BaseModel):
|
||||||
|
name: str # e.g. "EmailDraft", "AiResult", "Transit"
|
||||||
|
fields: List[PortField]
|
||||||
|
# Declarative flag for the engine: when True, the executor attaches
|
||||||
|
# connection provenance ({id, authority, label}) onto the output. Replaces
|
||||||
|
# hard-coded schema lists in actionNodeExecutor._attachConnectionProvenance.
|
||||||
|
carriesConnectionProvenance: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PORT_TYPE_CATALOG
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# Refs (handles to external resources, pickable by user)
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
"ConnectionRef": PortSchema(name="ConnectionRef", fields=[
|
||||||
|
PortField(name="id", type="str", description="UserConnection.id (UUID)"),
|
||||||
|
PortField(name="authority", type="str", discriminator=True,
|
||||||
|
description="Auth-Provider-Code: msft | clickup | google | …"),
|
||||||
|
PortField(name="label", type="str", required=False, description="Anzeigename"),
|
||||||
|
]),
|
||||||
|
"FeatureInstanceRef": PortSchema(name="FeatureInstanceRef", fields=[
|
||||||
|
PortField(name="id", type="str", description="FeatureInstance.id (UUID)"),
|
||||||
|
PortField(name="featureCode", type="str", discriminator=True,
|
||||||
|
description="Feature-Modul-Code: trustee | redmine | clickup | sharepoint | …"),
|
||||||
|
PortField(name="label", type="str", required=False, description="Anzeigename"),
|
||||||
|
PortField(name="mandateId", type="str", required=False, description="Zugehöriger Mandant"),
|
||||||
|
]),
|
||||||
|
"ClickUpListRef": PortSchema(name="ClickUpListRef", fields=[
|
||||||
|
PortField(name="listId", type="str", description="ClickUp-Listen-ID"),
|
||||||
|
PortField(name="name", type="str", required=False, description="Listenname"),
|
||||||
|
PortField(name="spaceId", type="str", required=False, description="Space-ID"),
|
||||||
|
PortField(name="groupId", type="str", required=False, description="Gruppen-ID für die Gruppierungszuordnung"),
|
||||||
|
PortField(name="connection", type="ConnectionRef", required=False,
|
||||||
|
description="ClickUp-Verbindung"),
|
||||||
|
]),
|
||||||
|
"PromptTemplateRef": PortSchema(name="PromptTemplateRef", fields=[
|
||||||
|
PortField(name="id", type="str", description="Prompt-Template-ID"),
|
||||||
|
PortField(name="name", type="str", required=False, description="Anzeigename"),
|
||||||
|
PortField(name="version", type="str", required=False, description="Version / Tag"),
|
||||||
|
]),
|
||||||
|
"SharePointFolderRef": PortSchema(name="SharePointFolderRef", fields=[
|
||||||
|
PortField(name="siteUrl", type="str", required=False, description="SharePoint Site"),
|
||||||
|
PortField(name="driveId", type="str", required=False, description="Drive ID"),
|
||||||
|
PortField(name="folderPath", type="str", required=False, description="Ordnerpfad"),
|
||||||
|
PortField(name="label", type="str", required=False, description="Kurzlabel für Picker"),
|
||||||
|
]),
|
||||||
|
"SharePointFileRef": PortSchema(name="SharePointFileRef", fields=[
|
||||||
|
PortField(name="siteUrl", type="str", required=False, description="SharePoint Site"),
|
||||||
|
PortField(name="driveId", type="str", required=False, description="Drive ID"),
|
||||||
|
PortField(name="filePath", type="str", required=False, description="Dateipfad"),
|
||||||
|
PortField(name="fileName", type="str", required=False, description="Dateiname"),
|
||||||
|
PortField(name="label", type="str", required=False, description="Kurzlabel"),
|
||||||
|
]),
|
||||||
|
"Document": PortSchema(name="Document", fields=[
|
||||||
|
PortField(name="id", type="str", required=False, description="Dokument-/Datei-ID"),
|
||||||
|
PortField(name="name", type="str", required=False, description="Anzeigename"),
|
||||||
|
PortField(name="mimeType", type="str", required=False, description="MIME-Typ"),
|
||||||
|
PortField(name="sizeBytes", type="int", required=False, description="Grösse"),
|
||||||
|
PortField(name="downloadUrl", type="str", required=False, description="Download-URL"),
|
||||||
|
PortField(name="filePath", type="str", required=False, description="Logischer Pfad"),
|
||||||
|
]),
|
||||||
|
"FileItem": PortSchema(name="FileItem", fields=[
|
||||||
|
PortField(name="id", type="str", required=False, description="Datei-ID"),
|
||||||
|
PortField(name="name", type="str", required=False, description="Name"),
|
||||||
|
PortField(name="path", type="str", required=False, description="Pfad"),
|
||||||
|
PortField(name="mimeType", type="str", required=False, description="MIME"),
|
||||||
|
PortField(name="sizeBytes", type="int", required=False, description="Grösse"),
|
||||||
|
]),
|
||||||
|
"EmailItem": PortSchema(name="EmailItem", fields=[
|
||||||
|
PortField(name="id", type="str", required=False, description="Message-ID"),
|
||||||
|
PortField(name="subject", type="str", required=False, description="Betreff"),
|
||||||
|
PortField(name="fromAddress", type="str", required=False, description="Absender"),
|
||||||
|
PortField(name="toAddresses", type="List[str]", required=False, description="Empfänger"),
|
||||||
|
PortField(name="receivedAt", type="str", required=False, description="Empfangen am"),
|
||||||
|
PortField(name="hasAttachments", type="bool", required=False, description="Hat Anhänge"),
|
||||||
|
PortField(name="bodyPreview", type="str", required=False, description="Vorschau"),
|
||||||
|
]),
|
||||||
|
"TaskItem": PortSchema(name="TaskItem", fields=[
|
||||||
|
PortField(name="id", type="str", required=False, description="Task-ID"),
|
||||||
|
PortField(name="title", type="str", required=False, description="Titel"),
|
||||||
|
PortField(name="status", type="str", required=False, description="Status"),
|
||||||
|
PortField(name="assignee", type="str", required=False, description="Assignee"),
|
||||||
|
PortField(name="dueDate", type="str", required=False, description="Fälligkeit"),
|
||||||
|
PortField(name="listId", type="str", required=False, description="ClickUp-Liste"),
|
||||||
|
]),
|
||||||
|
"QueryResult": PortSchema(name="QueryResult", fields=[
|
||||||
|
PortField(name="rows", type="List[Any]", description="Ergebniszeilen"),
|
||||||
|
PortField(name="columns", type="List[str]", required=False, description="Spaltennamen"),
|
||||||
|
PortField(name="count", type="int", required=False, description="Zeilenanzahl"),
|
||||||
|
]),
|
||||||
|
"UdmPage": PortSchema(name="UdmPage", fields=[
|
||||||
|
PortField(name="pageNumber", type="int", required=False, description="Seitennummer"),
|
||||||
|
PortField(name="blocks", type="List[Any]", required=False, description="ContentBlocks"),
|
||||||
|
]),
|
||||||
|
"UdmBlock": PortSchema(name="UdmBlock", fields=[
|
||||||
|
PortField(name="kind", type="str", required=False, description="Block-Typ"),
|
||||||
|
PortField(name="text", type="str", required=False, description="Textinhalt"),
|
||||||
|
PortField(name="children", type="List[Any]", required=False, description="Unterblöcke"),
|
||||||
|
]),
|
||||||
|
"DocumentList": PortSchema(name="DocumentList", carriesConnectionProvenance=True, fields=[
|
||||||
|
PortField(name="documents", type="List[Document]",
|
||||||
|
description="Dokumente aus vorherigen Schritten", recommended=True),
|
||||||
|
PortField(name="connection", type="ConnectionRef", required=False,
|
||||||
|
description="Verbindung, mit der die Liste erzeugt wurde"),
|
||||||
|
PortField(name="source", type="SharePointFolderRef", required=False,
|
||||||
|
description="Herkunftsordner / Quelle"),
|
||||||
|
PortField(name="count", type="int", required=False,
|
||||||
|
description="Anzahl Dokumente"),
|
||||||
|
]),
|
||||||
|
"FileList": PortSchema(name="FileList", carriesConnectionProvenance=True, fields=[
|
||||||
|
PortField(name="files", type="List[FileItem]",
|
||||||
|
description="Dateiliste"),
|
||||||
|
PortField(name="connection", type="ConnectionRef", required=False,
|
||||||
|
description="Verbindung"),
|
||||||
|
PortField(name="source", type="SharePointFolderRef", required=False,
|
||||||
|
description="Listen-Kontext"),
|
||||||
|
PortField(name="count", type="int", required=False,
|
||||||
|
description="Anzahl Dateien"),
|
||||||
|
]),
|
||||||
|
"EmailDraft": PortSchema(name="EmailDraft", carriesConnectionProvenance=True, fields=[
|
||||||
|
PortField(name="subject", type="str",
|
||||||
|
description="Betreff"),
|
||||||
|
PortField(name="body", type="str",
|
||||||
|
description="Inhalt"),
|
||||||
|
PortField(name="to", type="List[str]",
|
||||||
|
description="Empfänger"),
|
||||||
|
PortField(name="cc", type="List[str]", required=False,
|
||||||
|
description="CC"),
|
||||||
|
PortField(name="attachments", type="List[Document]", required=False,
|
||||||
|
description="Anhänge"),
|
||||||
|
PortField(name="connection", type="ConnectionRef", required=False,
|
||||||
|
description="Outlook-/Graph-Verbindung"),
|
||||||
|
]),
|
||||||
|
"EmailList": PortSchema(name="EmailList", carriesConnectionProvenance=True, fields=[
|
||||||
|
PortField(name="emails", type="List[EmailItem]",
|
||||||
|
description="E-Mails"),
|
||||||
|
PortField(name="connection", type="ConnectionRef", required=False,
|
||||||
|
description="Verbindung"),
|
||||||
|
PortField(name="count", type="int", required=False,
|
||||||
|
description="Anzahl"),
|
||||||
|
]),
|
||||||
|
"TaskList": PortSchema(name="TaskList", carriesConnectionProvenance=True, fields=[
|
||||||
|
PortField(name="tasks", type="List[TaskItem]",
|
||||||
|
description="Aufgaben"),
|
||||||
|
PortField(name="connection", type="ConnectionRef", required=False,
|
||||||
|
description="Verbindung"),
|
||||||
|
PortField(name="listId", type="str", required=False,
|
||||||
|
description="ClickUp-Listen-ID"),
|
||||||
|
PortField(name="count", type="int", required=False,
|
||||||
|
description="Anzahl"),
|
||||||
|
]),
|
||||||
|
"TaskResult": PortSchema(name="TaskResult", fields=[
|
||||||
|
PortField(name="success", type="bool",
|
||||||
|
description="Erfolg"),
|
||||||
|
PortField(name="taskId", type="str",
|
||||||
|
description="Aufgaben-ID"),
|
||||||
|
PortField(name="task", type="Dict",
|
||||||
|
description="Aufgabendaten"),
|
||||||
|
]),
|
||||||
|
"FormPayload": PortSchema(name="FormPayload", fields=[
|
||||||
|
PortField(name="payload", type="Dict[str,Any]",
|
||||||
|
description="Formulardaten"),
|
||||||
|
]),
|
||||||
|
"AiResult": PortSchema(name="AiResult", fields=[
|
||||||
|
PortField(name="prompt", type="str",
|
||||||
|
description="Prompt",
|
||||||
|
picker_label=t("Eingabe (Prompt des Schritts)"),
|
||||||
|
),
|
||||||
|
PortField(name="response", type="str",
|
||||||
|
description=(
|
||||||
|
"Antworttext (Modell-Fließtext o. ä.; Bilder liegen in documents, nicht hier)."
|
||||||
|
),
|
||||||
|
recommended=True,
|
||||||
|
picker_label=t("Ausgabetext (Modell)"),
|
||||||
|
),
|
||||||
|
PortField(name="responseData", type="Dict", required=False,
|
||||||
|
description="Strukturierte Antwort (nur bei JSON-Ausgabe)",
|
||||||
|
picker_label=t("Strukturierte Antwortdaten")),
|
||||||
|
PortField(name="context", type="str",
|
||||||
|
description="Kontext",
|
||||||
|
picker_label=t("Eingabe-Kontext")),
|
||||||
|
PortField(name="documents", type="List[Document]",
|
||||||
|
description=(
|
||||||
|
"Erzeugte oder mitgegebene Dateien (z. B. Bilder); documentData = Nutzlast pro Eintrag."
|
||||||
|
),
|
||||||
|
picker_label=t("Alle Ausgabe-Dateien (Liste)"),
|
||||||
|
picker_item_label=t("je Datei"),
|
||||||
|
),
|
||||||
|
PortField(name="data", type="Dict", required=False,
|
||||||
|
description=(
|
||||||
|
"Internes Payload-Objekt (entspricht ``ActionResult.data``-Semantik). "
|
||||||
|
"Wird vom Executor gesetzt und enthält denselben Inhalt wie ``response`` "
|
||||||
|
"in strukturierter Form; primär für nachgelagerte Kontext-Nodes."
|
||||||
|
),
|
||||||
|
picker_label=t("Technische Detaildaten (data)")),
|
||||||
|
PortField(name="imageDocumentsOnly", type="List[Document]", required=False,
|
||||||
|
description="Nur Bild-bezogene Einträge aus documents.",
|
||||||
|
picker_label=t("Nur Bilder (Liste)")),
|
||||||
|
]),
|
||||||
|
"BoolResult": PortSchema(name="BoolResult", fields=[
|
||||||
|
PortField(name="result", type="bool",
|
||||||
|
description="Ergebnis"),
|
||||||
|
PortField(name="reason", type="str", required=False,
|
||||||
|
description="Begründung"),
|
||||||
|
]),
|
||||||
|
"TextResult": PortSchema(name="TextResult", fields=[
|
||||||
|
PortField(name="text", type="str",
|
||||||
|
description="Text",
|
||||||
|
picker_label=t("Text (Schrittausgabe)")),
|
||||||
|
]),
|
||||||
|
"LoopItem": PortSchema(name="LoopItem", fields=[
|
||||||
|
PortField(name="currentItem", type="Any",
|
||||||
|
description="Aktuelles Element"),
|
||||||
|
PortField(name="currentIndex", type="int",
|
||||||
|
description="Aktueller Index"),
|
||||||
|
PortField(name="items", type="List[Any]",
|
||||||
|
description="Alle Elemente"),
|
||||||
|
PortField(name="count", type="int",
|
||||||
|
description="Gesamtanzahl"),
|
||||||
|
]),
|
||||||
|
"AggregateResult": PortSchema(name="AggregateResult", fields=[
|
||||||
|
PortField(name="items", type="List[Any]",
|
||||||
|
description="Gesammelte Elemente"),
|
||||||
|
PortField(name="count", type="int",
|
||||||
|
description="Anzahl"),
|
||||||
|
]),
|
||||||
|
"MergeResult": PortSchema(name="MergeResult", fields=[
|
||||||
|
PortField(name="inputs", type="Dict[int,Any]",
|
||||||
|
description="Eingaben nach Port"),
|
||||||
|
PortField(name="first", type="Any",
|
||||||
|
description="Erstes verfügbares"),
|
||||||
|
PortField(name="merged", type="Dict",
|
||||||
|
description="Zusammengeführte Daten"),
|
||||||
|
]),
|
||||||
|
"ContextBranch": PortSchema(name="ContextBranch", fields=[
|
||||||
|
PortField(name="items", type="List[Any]",
|
||||||
|
description="Schleifen-fertige Elemente aus dem (gefilterten) Kontext",
|
||||||
|
recommended=True,
|
||||||
|
picker_label=t("Gefilterte Elemente")),
|
||||||
|
PortField(name="data", type="Dict", required=False,
|
||||||
|
description="Gefilterter Presentation-Umschlag oder Eingabe-Spiegel",
|
||||||
|
picker_label=t("Kontext (data)")),
|
||||||
|
PortField(name="filterApplied", type="bool", required=False,
|
||||||
|
description="True wenn ein Kontext-Inhaltsfilter angewendet wurde"),
|
||||||
|
PortField(name="contentType", type="str", required=False,
|
||||||
|
description="Angewendeter Inhaltstyp-Filter (z. B. image)"),
|
||||||
|
PortField(name="match", type="int", required=False,
|
||||||
|
description="Aktiver Ausgangs-Index (Fall oder Sonst)"),
|
||||||
|
]),
|
||||||
|
"ActionDocument": PortSchema(name="ActionDocument", fields=[
|
||||||
|
PortField(name="documentName", type="str",
|
||||||
|
description="Dokumentname",
|
||||||
|
picker_label=t("Dateiname")),
|
||||||
|
PortField(name="documentData", type="Any",
|
||||||
|
description="Inhalt / Rohdaten (z.B. JSON-String, Bytes)",
|
||||||
|
picker_label=t("Dateiinhalt (JSON, Text oder Bild)"),
|
||||||
|
recommended=True),
|
||||||
|
PortField(name="mimeType", type="str",
|
||||||
|
description="MIME-Typ",
|
||||||
|
picker_label=t("Dateityp (MIME)")),
|
||||||
|
PortField(name="fileId", type="str", required=False,
|
||||||
|
description="Persistierte FileItem.id (vom Engine ergänzt)"),
|
||||||
|
PortField(name="fileName", type="str", required=False,
|
||||||
|
description="Persistierter Dateiname (vom Engine ergänzt)"),
|
||||||
|
]),
|
||||||
|
"ActionResult": PortSchema(name="ActionResult", fields=[
|
||||||
|
PortField(name="success", type="bool",
|
||||||
|
description="Erfolg"),
|
||||||
|
PortField(name="error", type="str", required=False,
|
||||||
|
description="Fehler"),
|
||||||
|
# `documents` is populated for every action that returns ActionResult
|
||||||
|
# (see datamodelChat.ActionResult.documents and actionNodeExecutor.out).
|
||||||
|
# Without it in the catalog the DataPicker cannot offer downstream
|
||||||
|
# bindings like `processDocuments → documents → *` for syncToAccounting.
|
||||||
|
PortField(name="documents", type="List[ActionDocument]", required=False,
|
||||||
|
description=(
|
||||||
|
"Dokumentliste für Actions mit echten Artefakt-Dokumenten. "
|
||||||
|
"Beim Knoten „Inhalt extrahieren“ fehlt dieses Feld in der Knotenausgabe."
|
||||||
|
),
|
||||||
|
picker_label=t("Alle Ausgabe-Dokumente"),
|
||||||
|
picker_item_label=t("je Dokument"),
|
||||||
|
),
|
||||||
|
PortField(name="data", type="Dict", required=False,
|
||||||
|
description=(
|
||||||
|
"Strukturierter Inhalt. Bei **context.extractContent**: **Presentation**-Root "
|
||||||
|
"(`schemaVersion`, `kind`, `fileOrder`, `files`) plus **`_meta`** — ohne "
|
||||||
|
"zusätzliches `response`/`contentExtracted`-Duplikat."
|
||||||
|
),
|
||||||
|
picker_label=t("Technische Detaildaten (data)")),
|
||||||
|
# Mirror AiResult primary text fields so DataPicker / primaryTextRef behave the same
|
||||||
|
PortField(name="prompt", type="str", required=False,
|
||||||
|
description="Optional: auslösender Prompt / Schrittname",
|
||||||
|
picker_label=t("Auslöser / Prompt (falls vorhanden)")),
|
||||||
|
PortField(name="response", type="str", required=False,
|
||||||
|
description=(
|
||||||
|
"Fließtext wo die Action einen liefert. Bei **„Inhalt extrahieren“** absichtlich leer — "
|
||||||
|
"Inhalt liegt in ``data``.``files``."
|
||||||
|
),
|
||||||
|
recommended=True,
|
||||||
|
picker_label=t("Nur Fließtext (gesamt)")),
|
||||||
|
PortField(name="context", type="str", required=False,
|
||||||
|
description="Optional: Eingabe-Kontext",
|
||||||
|
picker_label=t("Mitgegebener Kontext")),
|
||||||
|
PortField(name="imageDocumentsOnly", type="List[ActionDocument]", required=False,
|
||||||
|
description=(
|
||||||
|
"Nur Bild-bezogene Einträge. Bei „Inhalt extrahieren“: synthetische "
|
||||||
|
"Einträge mit ``fileId`` aus persistierten Extrakt-Bildern (kein separates JSON-Dokument)."
|
||||||
|
),
|
||||||
|
picker_label=t("Nur Bilder (Liste)")),
|
||||||
|
PortField(name="responseData", type="Dict", required=False,
|
||||||
|
description="Optional: strukturierte Zusatzdaten",
|
||||||
|
picker_label=t("Strukturierte Zusatzdaten")),
|
||||||
|
PortField(name="presentation", type="Dict", required=False,
|
||||||
|
description=(
|
||||||
|
"Selten: Top-Level-Spiegel von Präsentationsdaten andere Actions. "
|
||||||
|
"Bei „Inhalt extrahieren“ liegt alles direkt unter ``data`` (kein zusätzlicher Spiegel)."
|
||||||
|
),
|
||||||
|
picker_label=t("Presentation (Top-Level-Spiegel)")),
|
||||||
|
PortField(name="presentationSummary", type="Dict", required=False,
|
||||||
|
description=(
|
||||||
|
"Kompakte Metadaten zu ``presentation`` (Debugging / traces)."
|
||||||
|
),
|
||||||
|
picker_label=t("Presentation-Zusammenfassung")),
|
||||||
|
PortField(name="presentationConfig", type="Dict", required=False,
|
||||||
|
description=(
|
||||||
|
"Optional: Debugging-Konfiguration; bei Extract liegt die Primärquelle in ``validationMetadata`` des JSON-Dokuments."
|
||||||
|
),
|
||||||
|
picker_label=t("Presentation-Konfiguration")),
|
||||||
|
]),
|
||||||
|
"Transit": PortSchema(name="Transit", fields=[]),
|
||||||
|
"UdmDocument": PortSchema(name="UdmDocument", carriesConnectionProvenance=True, fields=[
|
||||||
|
PortField(name="id", type="str", description="Dokument-ID"),
|
||||||
|
PortField(name="sourceType", type="str", description="Quellformat (pdf, docx, …)"),
|
||||||
|
PortField(name="sourcePath", type="str", description="Quellpfad"),
|
||||||
|
PortField(name="children", type="List[Any]", description="StructuralNodes / Seiten"),
|
||||||
|
PortField(name="connection", type="ConnectionRef", required=False,
|
||||||
|
description="Optionale Verbindungsreferenz"),
|
||||||
|
PortField(name="source", type="SharePointFileRef", required=False,
|
||||||
|
description="Optionale Datei-Herkunft"),
|
||||||
|
]),
|
||||||
|
"UdmNodeList": PortSchema(name="UdmNodeList", fields=[
|
||||||
|
PortField(name="nodes", type="List[Any]", description="UDM StructuralNodes oder ContentBlocks"),
|
||||||
|
PortField(name="count", type="int", description="Anzahl"),
|
||||||
|
]),
|
||||||
|
"ConsolidateResult": PortSchema(name="ConsolidateResult", fields=[
|
||||||
|
PortField(name="result", type="Any", description="Konsolidiertes Ergebnis"),
|
||||||
|
PortField(name="mode", type="str", description="Konsolidierungsmodus"),
|
||||||
|
PortField(name="count", type="int", description="Anzahl verarbeiteter Elemente"),
|
||||||
|
]),
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# Shared sub-types (used inside Result schemas)
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
"ProcessError": PortSchema(name="ProcessError", fields=[
|
||||||
|
PortField(name="documentId", type="str", required=False,
|
||||||
|
description="Betroffenes Dokument (falls zuordbar)"),
|
||||||
|
PortField(name="stage", type="str",
|
||||||
|
description="Pipeline-Stufe: extract | parse | sync | validate | …"),
|
||||||
|
PortField(name="message", type="str", description="Fehlermeldung"),
|
||||||
|
PortField(name="code", type="str", required=False, description="Fehler-Code"),
|
||||||
|
]),
|
||||||
|
"JournalLine": PortSchema(name="JournalLine", fields=[
|
||||||
|
PortField(name="id", type="str", required=False, description="Buchungszeilen-ID"),
|
||||||
|
PortField(name="bookingDate", type="str", description="Buchungsdatum (ISO)"),
|
||||||
|
PortField(name="account", type="str", description="Konto"),
|
||||||
|
PortField(name="contraAccount", type="str", required=False, description="Gegenkonto"),
|
||||||
|
PortField(name="amount", type="float", description="Betrag"),
|
||||||
|
PortField(name="currency", type="str", required=False, description="Währung"),
|
||||||
|
PortField(name="text", type="str", required=False, description="Buchungstext"),
|
||||||
|
PortField(name="reference", type="str", required=False, description="Beleg-Referenz"),
|
||||||
|
]),
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# Trustee Action Results
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
"TrusteeRefreshResult": PortSchema(name="TrusteeRefreshResult", fields=[
|
||||||
|
PortField(name="syncCounts", type="Dict[str,int]",
|
||||||
|
description="Tabellen → Anzahl synchronisierter Datensätze"),
|
||||||
|
PortField(name="oldestBookingDate", type="str", required=False,
|
||||||
|
description="Ältestes Buchungsdatum (ISO)"),
|
||||||
|
PortField(name="newestBookingDate", type="str", required=False,
|
||||||
|
description="Neuestes Buchungsdatum (ISO)"),
|
||||||
|
PortField(name="durationMs", type="int", required=False,
|
||||||
|
description="Dauer in Millisekunden"),
|
||||||
|
PortField(name="featureInstance", type="FeatureInstanceRef", required=False,
|
||||||
|
description="Trustee-Instanz"),
|
||||||
|
PortField(name="errors", type="List[ProcessError]", required=False,
|
||||||
|
description="Fehler-Liste"),
|
||||||
|
]),
|
||||||
|
"TrusteeProcessResult": PortSchema(name="TrusteeProcessResult", fields=[
|
||||||
|
PortField(name="documents", type="List[Document]",
|
||||||
|
description="Verarbeitete Dokumente mit angereicherten Daten"),
|
||||||
|
PortField(name="processedCount", type="int", required=False,
|
||||||
|
description="Anzahl erfolgreich verarbeiteter Dokumente"),
|
||||||
|
PortField(name="failedCount", type="int", required=False,
|
||||||
|
description="Anzahl fehlgeschlagener Dokumente"),
|
||||||
|
PortField(name="featureInstance", type="FeatureInstanceRef", required=False,
|
||||||
|
description="Trustee-Instanz"),
|
||||||
|
PortField(name="errors", type="List[ProcessError]", required=False,
|
||||||
|
description="Fehler-Liste"),
|
||||||
|
]),
|
||||||
|
"TrusteeSyncResult": PortSchema(name="TrusteeSyncResult", fields=[
|
||||||
|
PortField(name="syncedCount", type="int",
|
||||||
|
description="Erfolgreich in das Buchhaltungssystem übertragene Datensätze"),
|
||||||
|
PortField(name="failedCount", type="int", required=False,
|
||||||
|
description="Fehlgeschlagene Übertragungen"),
|
||||||
|
PortField(name="journalLines", type="List[JournalLine]", required=False,
|
||||||
|
description="Erzeugte Buchungszeilen"),
|
||||||
|
PortField(name="featureInstance", type="FeatureInstanceRef", required=False,
|
||||||
|
description="Ziel-Trustee-Instanz"),
|
||||||
|
PortField(name="errors", type="List[ProcessError]", required=False,
|
||||||
|
description="Fehler-Liste"),
|
||||||
|
]),
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# Redmine Action Results
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
"RedmineTicket": PortSchema(name="RedmineTicket", fields=[
|
||||||
|
PortField(name="id", type="str", description="Ticket-ID"),
|
||||||
|
PortField(name="subject", type="str", description="Betreff"),
|
||||||
|
PortField(name="description", type="str", required=False, description="Beschreibung"),
|
||||||
|
PortField(name="status", type="str", description="Status-Name"),
|
||||||
|
PortField(name="tracker", type="str", required=False,
|
||||||
|
description="Tracker (Bug, Feature, Task, …)"),
|
||||||
|
PortField(name="priority", type="str", required=False, description="Priorität"),
|
||||||
|
PortField(name="assignee", type="str", required=False, description="Zugewiesen an"),
|
||||||
|
PortField(name="author", type="str", required=False, description="Autor"),
|
||||||
|
PortField(name="project", type="str", required=False, description="Projekt"),
|
||||||
|
PortField(name="createdOn", type="str", required=False, description="Erstellt (ISO)"),
|
||||||
|
PortField(name="updatedOn", type="str", required=False, description="Aktualisiert (ISO)"),
|
||||||
|
PortField(name="dueDate", type="str", required=False, description="Fälligkeitsdatum"),
|
||||||
|
PortField(name="featureInstance", type="FeatureInstanceRef", required=False,
|
||||||
|
description="Redmine-Instanz"),
|
||||||
|
]),
|
||||||
|
"RedmineTicketList": PortSchema(name="RedmineTicketList", fields=[
|
||||||
|
PortField(name="tickets", type="List[RedmineTicket]", description="Ticket-Liste"),
|
||||||
|
PortField(name="count", type="int", required=False, description="Anzahl Tickets"),
|
||||||
|
PortField(name="filters", type="Dict[str,Any]", required=False,
|
||||||
|
description="Angewendete Filter"),
|
||||||
|
PortField(name="featureInstance", type="FeatureInstanceRef", required=False,
|
||||||
|
description="Redmine-Instanz"),
|
||||||
|
]),
|
||||||
|
"RedmineRelationList": PortSchema(name="RedmineRelationList", fields=[
|
||||||
|
PortField(name="relations", type="List[Any]", description="Relationen"),
|
||||||
|
PortField(name="count", type="int", required=False, description="Anzahl in dieser Seite"),
|
||||||
|
PortField(name="totalMatched", type="int", required=False,
|
||||||
|
description="Gesamtanzahl nach Filter"),
|
||||||
|
PortField(name="offset", type="int", required=False, description="Pagination-Offset"),
|
||||||
|
PortField(name="hasMore", type="bool", required=False, description="Weitere Seiten verfügbar"),
|
||||||
|
]),
|
||||||
|
"RedmineStats": PortSchema(name="RedmineStats", fields=[
|
||||||
|
PortField(name="kpis", type="Dict[str,Any]",
|
||||||
|
description="Key Performance Indicators"),
|
||||||
|
PortField(name="throughput", type="Dict[str,Any]", required=False,
|
||||||
|
description="Durchsatz pro Zeitraum"),
|
||||||
|
PortField(name="statusDistribution", type="Dict[str,int]", required=False,
|
||||||
|
description="Tickets pro Status"),
|
||||||
|
PortField(name="backlog", type="Dict[str,Any]", required=False,
|
||||||
|
description="Backlog-Statistik"),
|
||||||
|
PortField(name="featureInstance", type="FeatureInstanceRef", required=False,
|
||||||
|
description="Redmine-Instanz"),
|
||||||
|
]),
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# ClickUp / SharePoint / Email helper results
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
"TaskAttachmentRef": PortSchema(name="TaskAttachmentRef", fields=[
|
||||||
|
PortField(name="taskId", type="str", description="Aufgaben-ID"),
|
||||||
|
PortField(name="attachmentId", type="str", required=False, description="Attachment-ID"),
|
||||||
|
PortField(name="fileName", type="str", required=False, description="Dateiname"),
|
||||||
|
PortField(name="url", type="str", required=False, description="Download-URL"),
|
||||||
|
]),
|
||||||
|
"AttachmentSpec": PortSchema(name="AttachmentSpec", fields=[
|
||||||
|
PortField(name="source", type="str",
|
||||||
|
description="Quellart: path | document | url",
|
||||||
|
enumValues=["path", "document", "url"]),
|
||||||
|
PortField(name="ref", type="str",
|
||||||
|
description="Referenzwert (Pfad / Document.id / URL)"),
|
||||||
|
PortField(name="fileName", type="str", required=False,
|
||||||
|
description="Override-Dateiname"),
|
||||||
|
PortField(name="mimeType", type="str", required=False, description="MIME-Override"),
|
||||||
|
]),
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# Expressions (replace string-typed condition / cron params)
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
"CronExpression": PortSchema(name="CronExpression", fields=[
|
||||||
|
PortField(name="expression", type="str",
|
||||||
|
description="Cron-Ausdruck (5 oder 6 Felder)"),
|
||||||
|
PortField(name="timezone", type="str", required=False,
|
||||||
|
description="IANA Timezone (z.B. Europe/Zurich)"),
|
||||||
|
]),
|
||||||
|
"ConditionExpression": PortSchema(name="ConditionExpression", fields=[
|
||||||
|
PortField(name="expression", type="str", description="Boolescher Ausdruck"),
|
||||||
|
PortField(name="syntax", type="str", required=False,
|
||||||
|
description="jmespath | jsonlogic | python | template",
|
||||||
|
enumValues=["jmespath", "jsonlogic", "python", "template"]),
|
||||||
|
]),
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# Semantic primitives (give meaning to scalar str values)
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
"DateTime": PortSchema(name="DateTime", fields=[
|
||||||
|
PortField(name="iso", type="str", description="ISO-8601 Datum/Zeit"),
|
||||||
|
PortField(name="timezone", type="str", required=False,
|
||||||
|
description="IANA Timezone"),
|
||||||
|
]),
|
||||||
|
"Url": PortSchema(name="Url", fields=[
|
||||||
|
PortField(name="url", type="str", description="Vollständige URL"),
|
||||||
|
PortField(name="label", type="str", required=False, description="Anzeigename"),
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Primitives accepted as PortField.type in addition to catalog schema names.
|
||||||
|
PRIMITIVE_TYPES: frozenset = frozenset({
|
||||||
|
"str", "int", "bool", "float", "Any", "Dict", "List",
|
||||||
|
})
|
||||||
|
|
@ -24,7 +24,7 @@ from modules.datamodels.datamodelBilling import BillingTransaction
|
||||||
from modules.datamodels.datamodelSubscription import MandateSubscription
|
from modules.datamodels.datamodelSubscription import MandateSubscription
|
||||||
from modules.datamodels.datamodelUiLanguage import UiLanguageSet
|
from modules.datamodels.datamodelUiLanguage import UiLanguageSet
|
||||||
from modules.datamodels.datamodelRbac import Role
|
from modules.datamodels.datamodelRbac import Role
|
||||||
from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutralizerAttributes
|
from modules.datamodels.datamodelFeatures import DataNeutralizerAttributes
|
||||||
from modules.shared.i18nRegistry import i18nModel
|
from modules.shared.i18nRegistry import i18nModel
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -243,7 +243,7 @@ class RoleView(Role):
|
||||||
# Automation Workflow — dashboard view with synthesized fields
|
# Automation Workflow — dashboard view with synthesized fields
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow
|
from modules.datamodels.datamodelFeatures import AutoWorkflow
|
||||||
|
|
||||||
|
|
||||||
@i18nModel("Workflow (Ansicht)")
|
@i18nModel("Workflow (Ansicht)")
|
||||||
|
|
|
||||||
579
modules/datamodels/datamodelWorkflowAutomation.py
Normal file
579
modules/datamodels/datamodelWorkflowAutomation.py
Normal file
|
|
@ -0,0 +1,579 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""Workflow Automation models: AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask.
|
||||||
|
|
||||||
|
Canonical location for all workflow-engine data models used across the platform.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Dict, Any, List, Optional
|
||||||
|
from pydantic import Field
|
||||||
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
|
from modules.shared.i18nRegistry import i18nModel
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Enums
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class AutoWorkflowStatus(str, Enum):
|
||||||
|
DRAFT = "draft"
|
||||||
|
PUBLISHED = "published"
|
||||||
|
ARCHIVED = "archived"
|
||||||
|
|
||||||
|
|
||||||
|
class AutoRunStatus(str, Enum):
|
||||||
|
RUNNING = "running"
|
||||||
|
PAUSED = "paused"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class AutoStepStatus(str, Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
RUNNING = "running"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
SKIPPED = "skipped"
|
||||||
|
|
||||||
|
|
||||||
|
class AutoTaskStatus(str, Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
EXPIRED = "expired"
|
||||||
|
|
||||||
|
|
||||||
|
class AutoTemplateScope(str, Enum):
|
||||||
|
USER = "user"
|
||||||
|
INSTANCE = "instance"
|
||||||
|
MANDATE = "mandate"
|
||||||
|
SYSTEM = "system"
|
||||||
|
|
||||||
|
|
||||||
|
GRAPHICAL_EDITOR_DATABASE = "poweron_graphicaleditor"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AutoWorkflow
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@i18nModel("Workflow")
|
||||||
|
class AutoWorkflow(PowerOnModel):
|
||||||
|
id: str = Field(
|
||||||
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
description="Primary key",
|
||||||
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"},
|
||||||
|
)
|
||||||
|
mandateId: str = Field(
|
||||||
|
description="Mandate ID",
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_readonly": True,
|
||||||
|
"frontend_required": False,
|
||||||
|
"label": "Mandanten-ID",
|
||||||
|
"fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
featureInstanceId: str = Field(
|
||||||
|
description="Feature instance ID (GE owner instance / RBAC scope)",
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_readonly": True,
|
||||||
|
"frontend_required": False,
|
||||||
|
"label": "Feature-Instanz-ID",
|
||||||
|
"fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
targetFeatureInstanceId: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Target feature instance for execution data scope. NULL for templates, mandatory for non-templates.",
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "select",
|
||||||
|
"frontend_readonly": False,
|
||||||
|
"frontend_required": False,
|
||||||
|
"label": "Ziel-Instanz",
|
||||||
|
"fk_target": {"db": "poweron_app", "table": "FeatureInstance", "labelField": "label"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
label: str = Field(
|
||||||
|
description="User-friendly workflow name",
|
||||||
|
json_schema_extra={"frontend_type": "text", "frontend_required": True, "label": "Bezeichnung"},
|
||||||
|
)
|
||||||
|
description: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Workflow description",
|
||||||
|
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Beschreibung"},
|
||||||
|
)
|
||||||
|
tags: List[str] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Tags for categorization",
|
||||||
|
json_schema_extra={"frontend_type": "tags", "frontend_required": False, "label": "Tags"},
|
||||||
|
)
|
||||||
|
isTemplate: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="Whether this workflow is a template",
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "checkbox",
|
||||||
|
"frontend_required": False,
|
||||||
|
"label": "Ist Vorlage",
|
||||||
|
"frontend_format_labels": ["Ja", "-", "Nein"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
templateSourceId: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="ID of the template this workflow was created from",
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_readonly": True,
|
||||||
|
"frontend_required": False,
|
||||||
|
"label": "Vorlagen-Quelle",
|
||||||
|
"fk_target": {
|
||||||
|
"db": "poweron_graphicaleditor",
|
||||||
|
"table": "AutoWorkflow",
|
||||||
|
"labelField": "label",
|
||||||
|
"softFk": True,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
templateScope: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Template scope: user, instance, mandate, system (AutoTemplateScope)",
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "select",
|
||||||
|
"frontend_required": False,
|
||||||
|
"label": "Vorlagen-Bereich",
|
||||||
|
"frontend_options": [
|
||||||
|
{"value": "user", "label": "Meine"},
|
||||||
|
{"value": "instance", "label": "Instanz"},
|
||||||
|
{"value": "mandate", "label": "Mandant"},
|
||||||
|
{"value": "system", "label": "System"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
sharedReadOnly: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description="If true, shared template is read-only for non-owners",
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "checkbox",
|
||||||
|
"frontend_required": False,
|
||||||
|
"label": "Freigabe nur-lesen",
|
||||||
|
"frontend_format_labels": ["Ja", "-", "Nein"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
currentVersionId: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="ID of the currently published AutoVersion",
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_readonly": True,
|
||||||
|
"frontend_required": False,
|
||||||
|
"label": "Aktuelle Version",
|
||||||
|
"fk_target": {"db": "poweron_graphicaleditor", "table": "AutoVersion", "labelField": "versionNumber"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
active: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description="Whether workflow is active",
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "checkbox",
|
||||||
|
"frontend_required": False,
|
||||||
|
"label": "Aktiv",
|
||||||
|
"frontend_format_labels": ["Ja", "-", "Nein"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
eventId: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Scheduler event ID for incremental sync",
|
||||||
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Event-ID"},
|
||||||
|
)
|
||||||
|
notifyOnFailure: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description="Send notification (in-app + email) when a run fails",
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "checkbox",
|
||||||
|
"frontend_required": False,
|
||||||
|
"label": "Bei Fehler benachrichtigen",
|
||||||
|
"frontend_format_labels": ["Ja", "-", "Nein"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
graph: Dict[str, Any] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="Graph with nodes and connections (legacy; prefer AutoVersion.graph)",
|
||||||
|
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Graph"},
|
||||||
|
)
|
||||||
|
invocations: List[Dict[str, Any]] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Entry points / starts (manual, form, schedule, webhook, ...)",
|
||||||
|
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Starts / Einstiegspunkte"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AutoVersion
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@i18nModel("Workflow-Version")
|
||||||
|
class AutoVersion(PowerOnModel):
|
||||||
|
id: str = Field(
|
||||||
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
description="Primary key",
|
||||||
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"},
|
||||||
|
)
|
||||||
|
workflowId: str = Field(
|
||||||
|
description="FK -> AutoWorkflow",
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_readonly": True,
|
||||||
|
"frontend_required": True,
|
||||||
|
"label": "Workflow-ID",
|
||||||
|
"fk_target": {"db": "poweron_graphicaleditor", "table": "AutoWorkflow", "labelField": "label"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
versionNumber: int = Field(
|
||||||
|
default=1,
|
||||||
|
description="Incrementing version number",
|
||||||
|
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Version"},
|
||||||
|
)
|
||||||
|
status: str = Field(
|
||||||
|
default=AutoWorkflowStatus.DRAFT.value,
|
||||||
|
description="Version status: draft, published, archived",
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "select",
|
||||||
|
"frontend_required": False,
|
||||||
|
"label": "Status",
|
||||||
|
"frontend_options": [
|
||||||
|
{"value": "draft", "label": "Entwurf"},
|
||||||
|
{"value": "published", "label": "Veröffentlicht"},
|
||||||
|
{"value": "archived", "label": "Archiviert"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
graph: Dict[str, Any] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="Graph with nodes and connections (incl. node parameters)",
|
||||||
|
json_schema_extra={"frontend_type": "textarea", "frontend_required": True, "label": "Graph"},
|
||||||
|
)
|
||||||
|
invocations: List[Dict[str, Any]] = Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="Entry points / starts for this version",
|
||||||
|
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Einstiegspunkte"},
|
||||||
|
)
|
||||||
|
publishedAt: Optional[float] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Timestamp when version was published",
|
||||||
|
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Veröffentlicht am"},
|
||||||
|
)
|
||||||
|
publishedBy: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="User ID who published this version",
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_readonly": True,
|
||||||
|
"frontend_required": False,
|
||||||
|
"label": "Veröffentlicht von",
|
||||||
|
"fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AutoRun
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@i18nModel("Workflow-Ausführung")
|
||||||
|
class AutoRun(PowerOnModel):
|
||||||
|
id: str = Field(
|
||||||
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
description="Primary key",
|
||||||
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"},
|
||||||
|
)
|
||||||
|
workflowId: str = Field(
|
||||||
|
description="Workflow ID",
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_readonly": True,
|
||||||
|
"frontend_required": True,
|
||||||
|
"label": "Workflow-ID",
|
||||||
|
"fk_target": {"db": "poweron_graphicaleditor", "table": "AutoWorkflow", "labelField": "label"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
label: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Human-readable run label, set at creation from workflow name or caller",
|
||||||
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Bezeichnung"},
|
||||||
|
)
|
||||||
|
mandateId: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Mandate ID for cross-feature querying",
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_readonly": True,
|
||||||
|
"frontend_required": False,
|
||||||
|
"label": "Mandanten-ID",
|
||||||
|
"fk_target": {"db": "poweron_app", "table": "Mandate", "labelField": "label"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
ownerId: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="User ID who triggered this run",
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_readonly": True,
|
||||||
|
"frontend_required": False,
|
||||||
|
"label": "Auslöser",
|
||||||
|
"fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
versionId: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="AutoVersion ID used for this run",
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_readonly": True,
|
||||||
|
"frontend_required": False,
|
||||||
|
"label": "Versions-ID",
|
||||||
|
"fk_target": {"db": "poweron_graphicaleditor", "table": "AutoVersion", "labelField": "versionNumber"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
status: str = Field(
|
||||||
|
default=AutoRunStatus.RUNNING.value,
|
||||||
|
description="Status: running, paused, completed, failed, cancelled",
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "select",
|
||||||
|
"frontend_required": False,
|
||||||
|
"label": "Status",
|
||||||
|
"frontend_options": [
|
||||||
|
{"value": "running", "label": "Läuft"},
|
||||||
|
{"value": "paused", "label": "Pausiert"},
|
||||||
|
{"value": "completed", "label": "Abgeschlossen"},
|
||||||
|
{"value": "failed", "label": "Fehlgeschlagen"},
|
||||||
|
{"value": "cancelled", "label": "Abgebrochen"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
trigger: Dict[str, Any] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="Trigger info (type, entryPointId, payload, etc.)",
|
||||||
|
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Auslöser"},
|
||||||
|
)
|
||||||
|
startedAt: Optional[float] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Run start timestamp",
|
||||||
|
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Gestartet am"},
|
||||||
|
)
|
||||||
|
completedAt: Optional[float] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Run completion timestamp",
|
||||||
|
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Abgeschlossen am"},
|
||||||
|
)
|
||||||
|
nodeOutputs: Dict[str, Any] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="Outputs from executed nodes",
|
||||||
|
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Node-Ausgaben"},
|
||||||
|
)
|
||||||
|
currentNodeId: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Node ID when paused (human task / email wait)",
|
||||||
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Aktueller Knoten"},
|
||||||
|
)
|
||||||
|
resumeContext: Dict[str, Any] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="Context for resume (connectionMap, inputSources, etc.)",
|
||||||
|
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Wiederaufnahme-Kontext"},
|
||||||
|
)
|
||||||
|
error: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Error message if failed",
|
||||||
|
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False, "label": "Fehler"},
|
||||||
|
)
|
||||||
|
costTokens: int = Field(
|
||||||
|
default=0,
|
||||||
|
description="Total tokens consumed by AI nodes",
|
||||||
|
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Verbrauchte Tokens"},
|
||||||
|
)
|
||||||
|
costCredits: float = Field(
|
||||||
|
default=0.0,
|
||||||
|
description="Total credits consumed",
|
||||||
|
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Verbrauchte Credits"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AutoStepLog
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@i18nModel("Schritt-Protokoll")
|
||||||
|
class AutoStepLog(PowerOnModel):
|
||||||
|
id: str = Field(
|
||||||
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
description="Primary key",
|
||||||
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"},
|
||||||
|
)
|
||||||
|
runId: str = Field(
|
||||||
|
description="FK -> AutoRun",
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_readonly": True,
|
||||||
|
"frontend_required": True,
|
||||||
|
"label": "Lauf-ID",
|
||||||
|
"fk_target": {"db": "poweron_graphicaleditor", "table": "AutoRun", "labelField": "label"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
nodeId: str = Field(
|
||||||
|
description="Node ID in the graph",
|
||||||
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Knoten-ID"},
|
||||||
|
)
|
||||||
|
nodeType: str = Field(
|
||||||
|
description="Node type (e.g. ai.chat, email.send)",
|
||||||
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Knotentyp"},
|
||||||
|
)
|
||||||
|
status: str = Field(
|
||||||
|
default=AutoStepStatus.PENDING.value,
|
||||||
|
description="Step status: pending, running, completed, failed, skipped",
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "select",
|
||||||
|
"frontend_required": False,
|
||||||
|
"label": "Status",
|
||||||
|
"frontend_options": [
|
||||||
|
{"value": "pending", "label": "Wartend"},
|
||||||
|
{"value": "running", "label": "Läuft"},
|
||||||
|
{"value": "completed", "label": "Abgeschlossen"},
|
||||||
|
{"value": "failed", "label": "Fehlgeschlagen"},
|
||||||
|
{"value": "skipped", "label": "Übersprungen"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
inputSnapshot: Dict[str, Any] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="Snapshot of inputs at execution time",
|
||||||
|
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Eingabe-Snapshot"},
|
||||||
|
)
|
||||||
|
output: Dict[str, Any] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="Node output",
|
||||||
|
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Ausgabe"},
|
||||||
|
)
|
||||||
|
error: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Error message if step failed",
|
||||||
|
json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False, "label": "Fehler"},
|
||||||
|
)
|
||||||
|
startedAt: Optional[float] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Step start timestamp",
|
||||||
|
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Gestartet am"},
|
||||||
|
)
|
||||||
|
completedAt: Optional[float] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Step completion timestamp",
|
||||||
|
json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Abgeschlossen am"},
|
||||||
|
)
|
||||||
|
durationMs: Optional[int] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Execution duration in milliseconds",
|
||||||
|
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Dauer (ms)"},
|
||||||
|
)
|
||||||
|
tokensUsed: int = Field(
|
||||||
|
default=0,
|
||||||
|
description="Tokens consumed by this step",
|
||||||
|
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Verbrauchte Tokens"},
|
||||||
|
)
|
||||||
|
retryCount: int = Field(
|
||||||
|
default=0,
|
||||||
|
description="Number of retries executed",
|
||||||
|
json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Wiederholungen"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AutoTask
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@i18nModel("Aufgabe")
|
||||||
|
class AutoTask(PowerOnModel):
|
||||||
|
id: str = Field(
|
||||||
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
description="Primary key",
|
||||||
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"},
|
||||||
|
)
|
||||||
|
runId: str = Field(
|
||||||
|
description="FK -> AutoRun",
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_readonly": True,
|
||||||
|
"frontend_required": True,
|
||||||
|
"label": "Lauf-ID",
|
||||||
|
"fk_target": {"db": "poweron_graphicaleditor", "table": "AutoRun", "labelField": "label"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
workflowId: str = Field(
|
||||||
|
description="Workflow ID",
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_readonly": True,
|
||||||
|
"frontend_required": True,
|
||||||
|
"label": "Workflow-ID",
|
||||||
|
"fk_target": {"db": "poweron_graphicaleditor", "table": "AutoWorkflow", "labelField": "label"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
nodeId: str = Field(
|
||||||
|
description="Node ID in the graph",
|
||||||
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Knoten-ID"},
|
||||||
|
)
|
||||||
|
nodeType: str = Field(
|
||||||
|
description="Node type: form, approval, upload, comment, review, selection, confirmation",
|
||||||
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Knotentyp"},
|
||||||
|
)
|
||||||
|
config: Dict[str, Any] = Field(
|
||||||
|
default_factory=dict,
|
||||||
|
description="Node config (form schema, approval text, etc.)",
|
||||||
|
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Konfiguration"},
|
||||||
|
)
|
||||||
|
assigneeId: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description="User ID assigned to complete the task",
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "text",
|
||||||
|
"frontend_readonly": False,
|
||||||
|
"frontend_required": False,
|
||||||
|
"label": "Zugewiesen an",
|
||||||
|
"fk_target": {"db": "poweron_app", "table": "UserInDB", "labelField": "username"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
status: str = Field(
|
||||||
|
default=AutoTaskStatus.PENDING.value,
|
||||||
|
description="Status: pending, completed, cancelled, expired",
|
||||||
|
json_schema_extra={
|
||||||
|
"frontend_type": "select",
|
||||||
|
"frontend_required": False,
|
||||||
|
"label": "Status",
|
||||||
|
"frontend_options": [
|
||||||
|
{"value": "pending", "label": "Wartend"},
|
||||||
|
{"value": "completed", "label": "Abgeschlossen"},
|
||||||
|
{"value": "cancelled", "label": "Abgebrochen"},
|
||||||
|
{"value": "expired", "label": "Abgelaufen"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
result: Optional[Dict[str, Any]] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Task result (form data, approval decision, etc.)",
|
||||||
|
json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Ergebnis"},
|
||||||
|
)
|
||||||
|
expiresAt: Optional[float] = Field(
|
||||||
|
default=None,
|
||||||
|
description="Expiration timestamp for the task",
|
||||||
|
json_schema_extra={"frontend_type": "timestamp", "frontend_required": False, "label": "Läuft ab am"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Backward-compatible aliases
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Automation2Workflow = AutoWorkflow
|
||||||
|
Automation2WorkflowRun = AutoRun
|
||||||
|
Automation2HumanTask = AutoTask
|
||||||
|
|
@ -21,7 +21,7 @@ Modulkonstanten:
|
||||||
Maximale Zeichen für den Overlap Context
|
Maximale Zeichen für den Overlap Context
|
||||||
|
|
||||||
Verwendung:
|
Verwendung:
|
||||||
>>> from modules.shared.jsonContinuation import getContexts
|
>>> from modules.datamodels.jsonContinuation import getContexts
|
||||||
>>> jsonStr = '{"users": [{"name": "John", "bio": "Hello Wor'
|
>>> jsonStr = '{"users": [{"name": "John", "bio": "Hello Wor'
|
||||||
>>> contexts = getContexts(jsonStr)
|
>>> contexts = getContexts(jsonStr)
|
||||||
>>> print(contexts.overlapContext)
|
>>> print(contexts.overlapContext)
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
"""AI Audit Logger — records every AI provider call for compliance reporting.
|
"""AI Audit Logger — records every AI provider call for compliance reporting.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
from modules.shared.aiAuditLogger import aiAuditLogger
|
from modules.dbHelpers.aiAuditLogger import aiAuditLogger
|
||||||
aiAuditLogger.logAiCall(userId=..., mandateId=..., ...)
|
aiAuditLogger.logAiCall(userId=..., mandateId=..., ...)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -14,6 +14,7 @@ GDPR Requirements Addressed:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, Dict, Any
|
from typing import Optional, Dict, Any
|
||||||
|
|
||||||
|
|
@ -395,7 +396,6 @@ class AuditLogger:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from modules.datamodels.datamodelAudit import AuditLogEntry
|
from modules.datamodels.datamodelAudit import AuditLogEntry
|
||||||
import time
|
|
||||||
|
|
||||||
# Calculate cutoff timestamp
|
# Calculate cutoff timestamp
|
||||||
cutoffTimestamp = time.time() - (retentionDays * 24 * 60 * 60)
|
cutoffTimestamp = time.time() - (retentionDays * 24 * 60 * 60)
|
||||||
|
|
@ -7,7 +7,7 @@ Applies indexes, immutable triggers, and foreign key constraints
|
||||||
for the junction tables used in the multi-tenant mandate model.
|
for the junction tables used in the multi-tenant mandate model.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
from modules.shared.dbMultiTenantOptimizations import applyMultiTenantOptimizations
|
from modules.dbHelpers.dbMultiTenantOptimizations import applyMultiTenantOptimizations
|
||||||
|
|
||||||
# Call after database tables are created
|
# Call after database tables are created
|
||||||
applyMultiTenantOptimizations(dbConnector)
|
applyMultiTenantOptimizations(dbConnector)
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
Dynamic database registry — each interface self-registers its DB on import.
|
Dynamic database registry — each interface self-registers its DB on import.
|
||||||
|
|
||||||
Usage in any interfaceDb*.py / interfaceFeature*.py:
|
Usage in any interfaceDb*.py / interfaceFeature*.py:
|
||||||
from modules.shared.dbRegistry import registerDatabase
|
from modules.dbHelpers.dbRegistry import registerDatabase
|
||||||
registerDatabase("poweron_xyz")
|
registerDatabase("poweron_xyz")
|
||||||
"""
|
"""
|
||||||
|
|
||||||
196
modules/dbHelpers/fkLabelResolver.py
Normal file
196
modules/dbHelpers/fkLabelResolver.py
Normal file
|
|
@ -0,0 +1,196 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""
|
||||||
|
FK label resolution: resolve foreign-key IDs to human-readable labels.
|
||||||
|
|
||||||
|
Works with the fk_target annotations on Pydantic models (see fkRegistry.py)
|
||||||
|
to auto-build label resolvers for paginated record sets.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from functools import partial
|
||||||
|
from typing import Any, Callable, Dict, List, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Individual FK label resolvers (db, ids) -> {id: label}
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def resolveMandateLabels(db, ids: List[str]) -> Dict[str, Optional[str]]:
|
||||||
|
"""Resolve mandate IDs to labels. Returns None (not the ID!) for
|
||||||
|
unresolvable entries so the caller can distinguish "resolved" from "missing".
|
||||||
|
"""
|
||||||
|
from modules.datamodels.datamodelUam import Mandate
|
||||||
|
uniqueIds = list(set(ids))
|
||||||
|
records = db.getRecordset(Mandate, recordFilter={"id": uniqueIds}) or []
|
||||||
|
found: Dict[str, dict] = {}
|
||||||
|
for rec in records:
|
||||||
|
mid = rec.get("id", "")
|
||||||
|
found[mid] = rec
|
||||||
|
result: Dict[str, Optional[str]] = {}
|
||||||
|
for mid in ids:
|
||||||
|
m = found.get(mid)
|
||||||
|
label = (m.get("label") or m.get("name")) if m else None
|
||||||
|
if not label:
|
||||||
|
logger.debug("resolveMandateLabels: no label for id=%s (found=%s)", mid, m is not None)
|
||||||
|
result[mid] = label or None
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def resolveInstanceLabels(db, ids: List[str]) -> Dict[str, Optional[str]]:
|
||||||
|
"""Resolve feature-instance IDs to labels. Returns None for unresolvable."""
|
||||||
|
from modules.datamodels.datamodelFeatures import FeatureInstance
|
||||||
|
result: Dict[str, Optional[str]] = {}
|
||||||
|
for iid in ids:
|
||||||
|
records = db.getRecordset(FeatureInstance, recordFilter={"id": iid})
|
||||||
|
if records:
|
||||||
|
label = records[0].get("label") or None
|
||||||
|
result[iid] = label
|
||||||
|
else:
|
||||||
|
logger.debug("resolveInstanceLabels: no label for id=%s", iid)
|
||||||
|
result[iid] = None
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def resolveUserLabels(db, ids: List[str]) -> Dict[str, Optional[str]]:
|
||||||
|
"""Resolve user IDs to display names. Returns None for unresolvable."""
|
||||||
|
from modules.datamodels.datamodelUam import UserInDB as _UserInDB
|
||||||
|
uniqueIds = list(set(ids))
|
||||||
|
users = db.getRecordset(
|
||||||
|
_UserInDB,
|
||||||
|
recordFilter={"id": uniqueIds},
|
||||||
|
)
|
||||||
|
result: Dict[str, Optional[str]] = {}
|
||||||
|
found: Dict[str, dict] = {}
|
||||||
|
for u in (users or []):
|
||||||
|
uid = u.get("id", "")
|
||||||
|
found[uid] = u
|
||||||
|
for uid in ids:
|
||||||
|
u = found.get(uid)
|
||||||
|
if u:
|
||||||
|
result[uid] = u.get("displayName") or u.get("username") or u.get("email") or None
|
||||||
|
else:
|
||||||
|
result[uid] = None
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def resolveRoleLabels(db, ids: List[str]) -> Dict[str, Optional[str]]:
|
||||||
|
"""Resolve Role.id to roleLabel. Returns None for unresolvable."""
|
||||||
|
if not ids:
|
||||||
|
return {}
|
||||||
|
from modules.datamodels.datamodelRbac import Role as _Role
|
||||||
|
recs = db.getRecordset(
|
||||||
|
_Role,
|
||||||
|
recordFilter={"id": list(set(ids))},
|
||||||
|
) or []
|
||||||
|
out: Dict[str, Optional[str]] = {i: None for i in ids}
|
||||||
|
for r in recs:
|
||||||
|
rid = r.get("id")
|
||||||
|
if rid:
|
||||||
|
out[rid] = r.get("roleLabel") or None
|
||||||
|
for rid in ids:
|
||||||
|
if out.get(rid) is None:
|
||||||
|
logger.debug("resolveRoleLabels: no label for id=%s", rid)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Resolver registry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_BUILTIN_FK_RESOLVERS: Dict[str, Callable] = {
|
||||||
|
"Mandate": resolveMandateLabels,
|
||||||
|
"FeatureInstance": resolveInstanceLabels,
|
||||||
|
"UserInDB": resolveUserLabels,
|
||||||
|
"Role": resolveRoleLabels,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def buildLabelResolversFromModel(
|
||||||
|
modelClass: type,
|
||||||
|
db=None,
|
||||||
|
) -> Dict[str, Callable[[List[str]], Dict[str, str]]]:
|
||||||
|
"""
|
||||||
|
Auto-build labelResolvers dict from ``json_schema_extra.fk_target`` on a Pydantic model.
|
||||||
|
Maps field names to resolver functions when the target table has a registered builtin
|
||||||
|
resolver and ``fk_target.labelField`` is set (non-None).
|
||||||
|
|
||||||
|
When ``db`` is provided, the returned resolvers are pre-bound with partial(resolver, db)
|
||||||
|
so they can be called as resolver(ids).
|
||||||
|
"""
|
||||||
|
resolvers: Dict[str, Callable[[List[str]], Dict[str, str]]] = {}
|
||||||
|
for name, fieldInfo in modelClass.model_fields.items():
|
||||||
|
extra = fieldInfo.json_schema_extra
|
||||||
|
if not extra or not isinstance(extra, dict):
|
||||||
|
continue
|
||||||
|
tgt = extra.get("fk_target")
|
||||||
|
if not isinstance(tgt, dict):
|
||||||
|
continue
|
||||||
|
if tgt.get("labelField") is None:
|
||||||
|
continue
|
||||||
|
fkModel = tgt.get("table")
|
||||||
|
if fkModel and fkModel in _BUILTIN_FK_RESOLVERS:
|
||||||
|
fn = _BUILTIN_FK_RESOLVERS[fkModel]
|
||||||
|
resolvers[name] = partial(fn, db) if db else fn
|
||||||
|
return resolvers
|
||||||
|
|
||||||
|
|
||||||
|
def enrichRowsWithFkLabels(
|
||||||
|
rows: List[Dict[str, Any]],
|
||||||
|
modelClass: type = None,
|
||||||
|
*,
|
||||||
|
db=None,
|
||||||
|
labelResolvers: Optional[Dict[str, Callable[[List[str]], Dict[str, Optional[str]]]]] = None,
|
||||||
|
extraResolvers: Optional[Dict[str, Callable[[List[str]], Dict[str, Optional[str]]]]] = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Add ``{field}Label`` columns to each row for every FK field that has a
|
||||||
|
registered resolver.
|
||||||
|
|
||||||
|
``modelClass`` — if provided, resolvers are auto-built from ``fk_target``
|
||||||
|
annotations on the Pydantic model (via ``buildLabelResolversFromModel``).
|
||||||
|
Requires ``db`` to be passed.
|
||||||
|
|
||||||
|
``labelResolvers`` — explicit resolver map that overrides auto-built ones.
|
||||||
|
Each resolver has signature ``(ids: List[str]) -> Dict[str, Optional[str]]``.
|
||||||
|
|
||||||
|
``extraResolvers`` — merged on top of auto-built / explicit resolvers. Use
|
||||||
|
for ad-hoc fields that are not FK-annotated on the model (e.g.
|
||||||
|
``createdByUserId`` on billing transactions).
|
||||||
|
|
||||||
|
If a label cannot be resolved the ``{field}Label`` value is ``None``
|
||||||
|
(never the raw ID — that would reintroduce the silent-truncation bug).
|
||||||
|
"""
|
||||||
|
resolvers: Dict[str, Callable] = {}
|
||||||
|
|
||||||
|
if modelClass is not None and labelResolvers is None:
|
||||||
|
resolvers = buildLabelResolversFromModel(modelClass, db)
|
||||||
|
elif labelResolvers is not None:
|
||||||
|
resolvers = dict(labelResolvers)
|
||||||
|
|
||||||
|
if extraResolvers:
|
||||||
|
resolvers.update(extraResolvers)
|
||||||
|
|
||||||
|
if not resolvers or not rows:
|
||||||
|
return rows
|
||||||
|
|
||||||
|
for field, resolver in resolvers.items():
|
||||||
|
ids = list({str(r.get(field)) for r in rows if r.get(field)})
|
||||||
|
if not ids:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
labelMap = resolver(ids)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error("enrichRowsWithFkLabels: resolver for '%s' raised: %s", field, e)
|
||||||
|
labelMap = {}
|
||||||
|
|
||||||
|
labelKey = f"{field}Label"
|
||||||
|
for r in rows:
|
||||||
|
fkVal = r.get(field)
|
||||||
|
if fkVal:
|
||||||
|
r[labelKey] = labelMap.get(str(fkVal))
|
||||||
|
else:
|
||||||
|
r[labelKey] = None
|
||||||
|
|
||||||
|
return rows
|
||||||
|
|
@ -14,7 +14,7 @@ for the *target* side. By collecting all such declarations we know which DB
|
||||||
each table lives in — no extra registration step needed.
|
each table lives in — no extra registration step needed.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
from modules.shared.fkRegistry import getFkRelationships
|
from modules.dbHelpers.fkRegistry import getFkRelationships
|
||||||
rels = getFkRelationships()
|
rels = getFkRelationships()
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -32,7 +32,7 @@ logger = logging.getLogger(__name__)
|
||||||
_modelsLoaded = False
|
_modelsLoaded = False
|
||||||
|
|
||||||
|
|
||||||
def _ensureModelsLoaded() -> None:
|
def ensureModelsLoaded() -> None:
|
||||||
"""Import all datamodel modules so that __init_subclass__ fills MODEL_REGISTRY.
|
"""Import all datamodel modules so that __init_subclass__ fills MODEL_REGISTRY.
|
||||||
|
|
||||||
In a running server the interfaces import the datamodels automatically.
|
In a running server the interfaces import the datamodels automatically.
|
||||||
|
|
@ -98,7 +98,7 @@ def _buildTableToDbMap() -> Dict[str, str]:
|
||||||
2. For models still unmapped, query each registered database's
|
2. For models still unmapped, query each registered database's
|
||||||
catalog (information_schema) to find the table there.
|
catalog (information_schema) to find the table there.
|
||||||
"""
|
"""
|
||||||
_ensureModelsLoaded()
|
ensureModelsLoaded()
|
||||||
|
|
||||||
mapping: Dict[str, str] = {}
|
mapping: Dict[str, str] = {}
|
||||||
for modelCls in MODEL_REGISTRY.values():
|
for modelCls in MODEL_REGISTRY.values():
|
||||||
|
|
@ -117,7 +117,7 @@ def _buildTableToDbMap() -> Dict[str, str]:
|
||||||
unmapped = [name for name in MODEL_REGISTRY if name not in mapping]
|
unmapped = [name for name in MODEL_REGISTRY if name not in mapping]
|
||||||
if unmapped:
|
if unmapped:
|
||||||
try:
|
try:
|
||||||
from modules.shared.dbRegistry import getRegisteredDatabases
|
from modules.dbHelpers.dbRegistry import getRegisteredDatabases
|
||||||
_resolveUnmappedTablesFromCatalog(mapping, unmapped, getRegisteredDatabases())
|
_resolveUnmappedTablesFromCatalog(mapping, unmapped, getRegisteredDatabases())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not resolve unmapped tables from catalog: {e}")
|
logger.warning(f"Could not resolve unmapped tables from catalog: {e}")
|
||||||
|
|
@ -260,7 +260,7 @@ def validateFkTargets() -> List[str]:
|
||||||
Each ``fk_target`` must contain exactly ``db``, ``table``, and ``labelField``
|
Each ``fk_target`` must contain exactly ``db``, ``table``, and ``labelField``
|
||||||
(``labelField`` may be ``None``).
|
(``labelField`` may be ``None``).
|
||||||
"""
|
"""
|
||||||
_ensureModelsLoaded()
|
ensureModelsLoaded()
|
||||||
errors: List[str] = []
|
errors: List[str] = []
|
||||||
for tableName, modelCls in MODEL_REGISTRY.items():
|
for tableName, modelCls in MODEL_REGISTRY.items():
|
||||||
for fieldName, fieldInfo in modelCls.model_fields.items():
|
for fieldName, fieldInfo in modelCls.model_fields.items():
|
||||||
543
modules/dbHelpers/paginationHelpers.py
Normal file
543
modules/dbHelpers/paginationHelpers.py
Normal file
|
|
@ -0,0 +1,543 @@
|
||||||
|
# Copyright (c) 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
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""
|
"""
|
||||||
Demo Configs — Auto-Discovery Module
|
Demo Configs — Auto-Discovery Module
|
||||||
|
|
||||||
Scans this folder for Python files that contain subclasses of _BaseDemoConfig
|
Scans this folder for Python files that contain subclasses of BaseDemoConfig
|
||||||
and exposes them via getAvailableDemoConfigs().
|
and exposes them via getAvailableDemoConfigs().
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -11,14 +11,14 @@ import logging
|
||||||
import pkgutil
|
import pkgutil
|
||||||
from typing import Dict
|
from typing import Dict
|
||||||
|
|
||||||
from modules.demoConfigs._baseDemoConfig import _BaseDemoConfig
|
from modules.demoConfigs.baseDemoConfig import BaseDemoConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_configCache: Dict[str, _BaseDemoConfig] = {}
|
_configCache: Dict[str, BaseDemoConfig] = {}
|
||||||
|
|
||||||
|
|
||||||
def getAvailableDemoConfigs() -> Dict[str, _BaseDemoConfig]:
|
def getAvailableDemoConfigs() -> Dict[str, BaseDemoConfig]:
|
||||||
"""Return a dict of code -> instance for every discovered demo config."""
|
"""Return a dict of code -> instance for every discovered demo config."""
|
||||||
if _configCache:
|
if _configCache:
|
||||||
return _configCache
|
return _configCache
|
||||||
|
|
@ -32,7 +32,7 @@ def getAvailableDemoConfigs() -> Dict[str, _BaseDemoConfig]:
|
||||||
try:
|
try:
|
||||||
module = importlib.import_module(f"{package}.{moduleName}")
|
module = importlib.import_module(f"{package}.{moduleName}")
|
||||||
for name, obj in inspect.getmembers(module, inspect.isclass):
|
for name, obj in inspect.getmembers(module, inspect.isclass):
|
||||||
if issubclass(obj, _BaseDemoConfig) and obj is not _BaseDemoConfig:
|
if issubclass(obj, BaseDemoConfig) and obj is not BaseDemoConfig:
|
||||||
instance = obj()
|
instance = obj()
|
||||||
if instance.code:
|
if instance.code:
|
||||||
_configCache[instance.code] = instance
|
_configCache[instance.code] = instance
|
||||||
|
|
@ -43,7 +43,7 @@ def getAvailableDemoConfigs() -> Dict[str, _BaseDemoConfig]:
|
||||||
return _configCache
|
return _configCache
|
||||||
|
|
||||||
|
|
||||||
def getDemoConfigByCode(code: str) -> _BaseDemoConfig | None:
|
def getDemoConfigByCode(code: str) -> BaseDemoConfig | None:
|
||||||
"""Get a specific demo config by its code."""
|
"""Get a specific demo config by its code."""
|
||||||
configs = getAvailableDemoConfigs()
|
configs = getAvailableDemoConfigs()
|
||||||
return configs.get(code)
|
return configs.get(code)
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"""
|
"""
|
||||||
Base class for demo configurations.
|
Base class for demo configurations.
|
||||||
|
|
||||||
Each demo config file in this folder extends _BaseDemoConfig and provides
|
Each demo config file in this folder extends BaseDemoConfig and provides
|
||||||
idempotent load() and remove() methods for setting up / tearing down
|
idempotent load() and remove() methods for setting up / tearing down
|
||||||
a complete demo environment (mandates, users, features, test data, etc.).
|
a complete demo environment (mandates, users, features, test data, etc.).
|
||||||
|
|
||||||
|
|
@ -18,7 +18,7 @@ from typing import Any, Dict, List
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class _BaseDemoConfig(ABC):
|
class BaseDemoConfig(ABC):
|
||||||
"""Abstract base for demo configurations."""
|
"""Abstract base for demo configurations."""
|
||||||
|
|
||||||
code: str = ""
|
code: str = ""
|
||||||
|
|
@ -17,7 +17,7 @@ import logging
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Dict, Any, Optional, List
|
from typing import Dict, Any, Optional, List
|
||||||
|
|
||||||
from modules.demoConfigs._baseDemoConfig import _BaseDemoConfig
|
from modules.demoConfigs.baseDemoConfig import BaseDemoConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -57,7 +57,7 @@ _FEATURES_ALPINA = [
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class InvestorDemo2026(_BaseDemoConfig):
|
class InvestorDemo2026(BaseDemoConfig):
|
||||||
code = "investor-demo-2026"
|
code = "investor-demo-2026"
|
||||||
label = "Investor Demo April 2026"
|
label = "Investor Demo April 2026"
|
||||||
description = (
|
description = (
|
||||||
|
|
|
||||||
|
|
@ -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
|
Idempotent: ``load()`` skips anything that already exists; ``remove()`` deletes
|
||||||
mandate, user, seed data and imported workflow cleanly.
|
mandate, user, seed data and imported workflow cleanly.
|
||||||
|
|
||||||
Pattern: subclass of :class:`_BaseDemoConfig`, auto-discovered by
|
Pattern: subclass of :class:`BaseDemoConfig`, auto-discovered by
|
||||||
``demoConfigs/__init__.py``. See ``investorDemo2026.py`` for the reference
|
``demoConfigs/__init__.py``. See ``investorDemo2026.py`` for the reference
|
||||||
implementation we mirror here.
|
implementation we mirror here.
|
||||||
"""
|
"""
|
||||||
|
|
@ -27,7 +27,7 @@ from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from modules.demoConfigs._baseDemoConfig import _BaseDemoConfig
|
from modules.demoConfigs.baseDemoConfig import BaseDemoConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -59,7 +59,7 @@ _PILOT_WORKFLOW_FILE = "pwg-mietzinsbestaetigung-pilot.workflow.json"
|
||||||
_SEED_TRUSTEE_FILE = "_seedTrusteeData.json"
|
_SEED_TRUSTEE_FILE = "_seedTrusteeData.json"
|
||||||
|
|
||||||
|
|
||||||
class PwgDemo2026(_BaseDemoConfig):
|
class PwgDemo2026(BaseDemoConfig):
|
||||||
code = "pwg-demo-2026"
|
code = "pwg-demo-2026"
|
||||||
label = "PWG Pilot Demo (Mietzinsbestätigungen)"
|
label = "PWG Pilot Demo (Mietzinsbestätigungen)"
|
||||||
description = (
|
description = (
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -11,7 +11,7 @@ from typing import Dict, Any, List, Optional
|
||||||
|
|
||||||
from modules.datamodels.datamodelUam import User
|
from modules.datamodels.datamodelUam import User
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
from modules.shared.dbRegistry import registerDatabase
|
from modules.dbHelpers.dbRegistry import registerDatabase
|
||||||
from modules.shared.timeUtils import getIsoTimestamp, getUtcTimestamp
|
from modules.shared.timeUtils import getIsoTimestamp, getUtcTimestamp
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
from modules.shared.i18nRegistry import resolveText, t
|
from modules.shared.i18nRegistry import resolveText, t
|
||||||
|
|
|
||||||
|
|
@ -537,3 +537,73 @@ def _ensureAccessRulesForRole(rootInterface, roleId: str, ruleTemplates: List[Di
|
||||||
logger.debug(f"Created {createdCount} AccessRules for role {roleId}")
|
logger.debug(f"Created {createdCount} AccessRules for role {roleId}")
|
||||||
|
|
||||||
return createdCount
|
return createdCount
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Feature Lifecycle Hooks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def onMandateDelete(mandateId: str, instances: list) -> None:
|
||||||
|
"""Cascade-delete all commcoach data for deleted mandate."""
|
||||||
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
|
from modules.shared.configuration import APP_CONFIG
|
||||||
|
from modules.features.commcoach.datamodelCommcoach import (
|
||||||
|
TrainingModule, CoachingSession, CoachingMessage, CoachingTask,
|
||||||
|
CoachingScore, CoachingUserProfile, CoachingPersona,
|
||||||
|
ModulePersonaMapping, CoachingBadge,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
featureInstances = [
|
||||||
|
inst for inst in instances
|
||||||
|
if (inst.get("featureCode") if isinstance(inst, dict) else getattr(inst, "featureCode", "")) == FEATURE_CODE
|
||||||
|
]
|
||||||
|
if not featureInstances:
|
||||||
|
return
|
||||||
|
|
||||||
|
db = DatabaseConnector(
|
||||||
|
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
|
||||||
|
dbDatabase="poweron_commcoach",
|
||||||
|
dbUser=APP_CONFIG.get("DB_USER"),
|
||||||
|
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
|
||||||
|
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
|
||||||
|
userId=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
totalDeleted = 0
|
||||||
|
for inst in featureInstances:
|
||||||
|
instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None)
|
||||||
|
if not instId:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Models scoped by instanceId
|
||||||
|
for ModelClass in [
|
||||||
|
TrainingModule, CoachingSession, CoachingUserProfile,
|
||||||
|
ModulePersonaMapping, CoachingBadge,
|
||||||
|
]:
|
||||||
|
records = db.getRecordset(ModelClass, recordFilter={"instanceId": instId}) or []
|
||||||
|
for rec in records:
|
||||||
|
db.recordDelete(ModelClass, rec.get("id"))
|
||||||
|
totalDeleted += len(records)
|
||||||
|
|
||||||
|
# CoachingPersona: only delete mandate-scoped (not builtin with null mandateId)
|
||||||
|
records = db.getRecordset(CoachingPersona, recordFilter={"instanceId": instId, "mandateId": mandateId}) or []
|
||||||
|
for rec in records:
|
||||||
|
db.recordDelete(CoachingPersona, rec.get("id"))
|
||||||
|
totalDeleted += len(records)
|
||||||
|
|
||||||
|
# Models scoped by mandateId only (no instanceId)
|
||||||
|
for ModelClass in [CoachingTask, CoachingScore]:
|
||||||
|
records = db.getRecordset(ModelClass, recordFilter={"mandateId": mandateId}) or []
|
||||||
|
for rec in records:
|
||||||
|
db.recordDelete(ModelClass, rec.get("id"))
|
||||||
|
totalDeleted += len(records)
|
||||||
|
|
||||||
|
# CoachingMessage: scoped via sessionId (orphans cleaned up when sessions are deleted)
|
||||||
|
|
||||||
|
if totalDeleted:
|
||||||
|
logger.info(f"Cascade: deleted {totalDeleted} commcoach record(s) for mandate {mandateId}")
|
||||||
|
db.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to cascade-delete commcoach data for mandate {mandateId}: {e}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ Implements training module management, session streaming, tasks, and dashboard.
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import asyncio
|
import asyncio
|
||||||
import base64
|
import base64
|
||||||
import uuid
|
import uuid
|
||||||
|
|
@ -43,7 +44,7 @@ _activeProcessTasks: dict = {}
|
||||||
def _audit(context: RequestContext, action: str, resourceType: str = None, resourceId: str = None, details: str = ""):
|
def _audit(context: RequestContext, action: str, resourceType: str = None, resourceId: str = None, details: str = ""):
|
||||||
"""Log an audit event for CommCoach. Non-blocking, best-effort."""
|
"""Log an audit event for CommCoach. Non-blocking, best-effort."""
|
||||||
try:
|
try:
|
||||||
from modules.shared.auditLogger import audit_logger
|
from modules.dbHelpers.auditLogger import audit_logger
|
||||||
audit_logger.logEvent(
|
audit_logger.logEvent(
|
||||||
userId=str(context.user.id),
|
userId=str(context.user.id),
|
||||||
mandateId=str(context.mandateId) if context.mandateId else None,
|
mandateId=str(context.mandateId) if context.mandateId else None,
|
||||||
|
|
@ -941,24 +942,22 @@ async def listPersonas(
|
||||||
allPersonas = interface.getAllPersonas(instanceId)
|
allPersonas = interface.getAllPersonas(instanceId)
|
||||||
|
|
||||||
if mode == "filterValues":
|
if mode == "filterValues":
|
||||||
from modules.routes.routeHelpers import handleFilterValuesInMemory
|
from modules.dbHelpers.paginationHelpers import handleFilterValuesInMemory
|
||||||
if not column:
|
if not column:
|
||||||
raise HTTPException(status_code=400, detail=routeApiMsg("column parameter required"))
|
raise HTTPException(status_code=400, detail=routeApiMsg("column parameter required"))
|
||||||
return handleFilterValuesInMemory(allPersonas, column, pagination)
|
return handleFilterValuesInMemory(allPersonas, column, pagination)
|
||||||
if mode == "ids":
|
if mode == "ids":
|
||||||
from modules.routes.routeHelpers import handleIdsInMemory
|
from modules.dbHelpers.paginationHelpers import handleIdsInMemory
|
||||||
return handleIdsInMemory(allPersonas, pagination)
|
return handleIdsInMemory(allPersonas, pagination)
|
||||||
|
|
||||||
if pagination:
|
if pagination:
|
||||||
import json as _json
|
|
||||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict
|
from modules.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict
|
||||||
from modules.routes.routeHelpers import applyFiltersAndSort, paginateInMemory
|
from modules.dbHelpers.paginationHelpers import applyFiltersAndSort, paginateInMemory
|
||||||
paginationDict = _json.loads(pagination)
|
paginationDict = json.loads(pagination)
|
||||||
paginationDict = normalize_pagination_dict(paginationDict)
|
paginationDict = normalize_pagination_dict(paginationDict)
|
||||||
paginationParams = PaginationParams(**paginationDict)
|
paginationParams = PaginationParams(**paginationDict)
|
||||||
filtered = applyFiltersAndSort(allPersonas, paginationParams)
|
filtered = applyFiltersAndSort(allPersonas, paginationParams)
|
||||||
pageItems, totalItems = paginateInMemory(filtered, paginationParams)
|
pageItems, totalItems = paginateInMemory(filtered, paginationParams)
|
||||||
import math
|
|
||||||
return {
|
return {
|
||||||
"items": pageItems,
|
"items": pageItems,
|
||||||
"pagination": PaginationMetadata(
|
"pagination": PaginationMetadata(
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,14 @@ CommCoach Service - Coaching Orchestration.
|
||||||
Manages the coaching pipeline: message processing, AI calls, scoring, task extraction.
|
Manages the coaching pipeline: message processing, AI calls, scoring, task extraction.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import html
|
import html
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
import asyncio
|
import asyncio
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
|
|
||||||
from modules.datamodels.datamodelUam import User
|
from modules.datamodels.datamodelUam import User
|
||||||
|
|
@ -344,7 +347,6 @@ async def _generateAndEmitTts(sessionId: str, speechText: str, currentUser, mand
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
|
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
|
||||||
import base64
|
|
||||||
voiceInterface = getVoiceInterface(currentUser, mandateId)
|
voiceInterface = getVoiceInterface(currentUser, mandateId)
|
||||||
language, voiceName = getUserVoicePrefs(str(currentUser.id), mandateId)
|
language, voiceName = getUserVoicePrefs(str(currentUser.id), mandateId)
|
||||||
ttsResult = await voiceInterface.textToSpeech(
|
ttsResult = await voiceInterface.textToSpeech(
|
||||||
|
|
@ -377,7 +379,6 @@ async def _generateAndEmitTts(sessionId: str, speechText: str, currentUser, mand
|
||||||
|
|
||||||
def _resolveFileNameAndMime(title: str) -> tuple:
|
def _resolveFileNameAndMime(title: str) -> tuple:
|
||||||
"""Derive fileName and mimeType from a document title. Only appends .md if no known extension present."""
|
"""Derive fileName and mimeType from a document title. Only appends .md if no known extension present."""
|
||||||
import os
|
|
||||||
knownExtensions = {
|
knownExtensions = {
|
||||||
".md": "text/markdown", ".txt": "text/plain", ".html": "text/html",
|
".md": "text/markdown", ".txt": "text/plain", ".html": "text/html",
|
||||||
".htm": "text/html", ".pdf": "application/pdf", ".json": "application/json",
|
".htm": "text/html", ".pdf": "application/pdf", ".json": "application/json",
|
||||||
|
|
@ -1269,7 +1270,6 @@ class CommcoachService:
|
||||||
startedAt = session.get("startedAt")
|
startedAt = session.get("startedAt")
|
||||||
durationSeconds = 0
|
durationSeconds = 0
|
||||||
if startedAt:
|
if startedAt:
|
||||||
from datetime import datetime, timezone
|
|
||||||
start = datetime.fromtimestamp(startedAt, tz=timezone.utc)
|
start = datetime.fromtimestamp(startedAt, tz=timezone.utc)
|
||||||
end = datetime.now(timezone.utc)
|
end = datetime.now(timezone.utc)
|
||||||
durationSeconds = int((end - start).total_seconds())
|
durationSeconds = int((end - start).total_seconds())
|
||||||
|
|
@ -1335,8 +1335,6 @@ class CommcoachService:
|
||||||
if not profile:
|
if not profile:
|
||||||
profile = interface.getOrCreateProfile(self.userId, self.mandateId, self.instanceId)
|
profile = interface.getOrCreateProfile(self.userId, self.mandateId, self.instanceId)
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
lastSessionAt = profile.get("lastSessionAt")
|
lastSessionAt = profile.get("lastSessionAt")
|
||||||
currentStreak = profile.get("streakDays", 0)
|
currentStreak = profile.get("streakDays", 0)
|
||||||
longestStreak = profile.get("longestStreak", 0)
|
longestStreak = profile.get("longestStreak", 0)
|
||||||
|
|
@ -1381,7 +1379,7 @@ class CommcoachService:
|
||||||
|
|
||||||
from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface
|
from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
from modules.shared.notifyMandateAdmins import renderHtmlEmail, resolveMandateName
|
from modules.system.notifyMandateAdmins import renderHtmlEmail, resolveMandateName
|
||||||
|
|
||||||
rootInterface = getRootInterface()
|
rootInterface = getRootInterface()
|
||||||
user = rootInterface.getUser(self.userId)
|
user = rootInterface.getUser(self.userId)
|
||||||
|
|
@ -1428,7 +1426,6 @@ class CommcoachService:
|
||||||
for s in completedSessions:
|
for s in completedSessions:
|
||||||
startedAt = s.get("startedAt")
|
startedAt = s.get("startedAt")
|
||||||
if startedAt:
|
if startedAt:
|
||||||
from datetime import datetime, timezone
|
|
||||||
dt = datetime.fromtimestamp(startedAt, tz=timezone.utc)
|
dt = datetime.fromtimestamp(startedAt, tz=timezone.utc)
|
||||||
s["date"] = dt.strftime("%d.%m.%Y")
|
s["date"] = dt.strftime("%d.%m.%Y")
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ Handles system prompts, diagnostic question generation, session summarization, a
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Optional, Dict, Any, List, Tuple
|
from typing import Optional, Dict, Any, List, Tuple
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -208,7 +209,6 @@ Tool-Nutzung:
|
||||||
dateStr = ""
|
dateStr = ""
|
||||||
startedAt = retrievedSession.get("startedAt")
|
startedAt = retrievedSession.get("startedAt")
|
||||||
if startedAt:
|
if startedAt:
|
||||||
from datetime import datetime, timezone
|
|
||||||
dt = datetime.fromtimestamp(startedAt, tz=timezone.utc)
|
dt = datetime.fromtimestamp(startedAt, tz=timezone.utc)
|
||||||
dateStr = dt.strftime("%d.%m.%Y")
|
dateStr = dt.strftime("%d.%m.%Y")
|
||||||
prompt += f"\n\nVom Benutzer angefragte Session ({dateStr}):"
|
prompt += f"\n\nVom Benutzer angefragte Session ({dateStr}):"
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ CommCoach Context Retrieval.
|
||||||
Intent detection, retrieval strategies, and context assembly for intelligent session continuity.
|
Intent detection, retrieval strategies, and context assembly for intelligent session continuity.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import re
|
import re
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
@ -146,7 +147,6 @@ def searchSessionsByTopic(
|
||||||
keyTopics = []
|
keyTopics = []
|
||||||
if keyTopicsRaw:
|
if keyTopicsRaw:
|
||||||
try:
|
try:
|
||||||
import json
|
|
||||||
parsed = json.loads(keyTopicsRaw) if isinstance(keyTopicsRaw, str) else keyTopicsRaw
|
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 []
|
keyTopics = [t.lower() if isinstance(t, str) else str(t).lower() for t in parsed] if isinstance(parsed, list) else []
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,10 @@ CommCoach Export Service.
|
||||||
Generates Markdown and PDF exports for dossiers and sessions.
|
Generates Markdown and PDF exports for dossiers and sessions.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
|
import re
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
from datetime import datetime, timezone
|
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:
|
def _markdownToPdf(markdownText: str, title: str) -> bytes:
|
||||||
"""Convert markdown text to a styled PDF using reportlab. Raises on failure."""
|
"""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.lib.pagesizes import A4
|
||||||
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
|
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer
|
||||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||||
|
|
@ -217,12 +217,11 @@ def _escXml(text: str) -> str:
|
||||||
|
|
||||||
def _mdToXml(text: str) -> str:
|
def _mdToXml(text: str) -> str:
|
||||||
"""Convert markdown inline formatting to reportlab XML. Bold, italic, escape the rest."""
|
"""Convert markdown inline formatting to reportlab XML. Bold, italic, escape the rest."""
|
||||||
import re as _re
|
|
||||||
text = text.replace("&", "&").replace("<", "<").replace(">", ">")
|
text = text.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
text = _re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text)
|
text = re.sub(r'\*\*(.+?)\*\*', r'<b>\1</b>', text)
|
||||||
text = _re.sub(r'__(.+?)__', r'<b>\1</b>', text)
|
text = re.sub(r'__(.+?)__', r'<b>\1</b>', text)
|
||||||
text = _re.sub(r'\*(.+?)\*', r'<i>\1</i>', text)
|
text = re.sub(r'\*(.+?)\*', r'<i>\1</i>', text)
|
||||||
text = _re.sub(r'_(.+?)_', r'<i>\1</i>', text)
|
text = re.sub(r'_(.+?)_', r'<i>\1</i>', text)
|
||||||
return text
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,7 @@ async def _runDailyReminders():
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
from .datamodelCommcoach import CoachingUserProfile, TrainingModuleStatus
|
from .datamodelCommcoach import CoachingUserProfile, TrainingModuleStatus
|
||||||
from modules.interfaces.interfaceMessaging import getInterface as getMessagingInterface
|
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")
|
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
||||||
db = DatabaseConnector(
|
db = DatabaseConnector(
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ from modules.features.graphicalEditor.nodeAdapter import (
|
||||||
_adapterFromLegacyNode,
|
_adapterFromLegacyNode,
|
||||||
_isMethodBoundNode,
|
_isMethodBoundNode,
|
||||||
)
|
)
|
||||||
from modules.workflows.methods._actionSignatureValidator import _validateTypeRef
|
from modules.workflows.methods._actionSignatureValidator import validateTypeRef
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -91,14 +91,14 @@ def _validateAdapterAgainstAction(
|
||||||
f"action '{adapter.bindsAction}.{paramName}': missing 'type' on parameter"
|
f"action '{adapter.bindsAction}.{paramName}': missing 'type' on parameter"
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
for err in _validateTypeRef(typeRef):
|
for err in validateTypeRef(typeRef):
|
||||||
report.errors.append(
|
report.errors.append(
|
||||||
f"action '{adapter.bindsAction}.{paramName}': {err}"
|
f"action '{adapter.bindsAction}.{paramName}': {err}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Rule 4: Action outputType exists in catalog (or is a generic fire-and-forget type)
|
# Rule 4: Action outputType exists in catalog (or is a generic fire-and-forget type)
|
||||||
if outputType not in {"ActionResult", "Transit"}:
|
if outputType not in {"ActionResult", "Transit"}:
|
||||||
for err in _validateTypeRef(outputType):
|
for err in validateTypeRef(outputType):
|
||||||
report.errors.append(
|
report.errors.append(
|
||||||
f"action '{adapter.bindsAction}'.outputType: {err}"
|
f"action '{adapter.bindsAction}'.outputType: {err}"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,579 +1,25 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# 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
|
# All models and enums re-exported for backward compatibility.
|
||||||
from typing import Dict, Any, List, Optional
|
# Canonical location: modules.datamodels.datamodelWorkflowAutomation
|
||||||
from pydantic import BaseModel, Field
|
from modules.datamodels.datamodelWorkflowAutomation import ( # noqa: F401
|
||||||
from modules.datamodels.datamodelBase import PowerOnModel
|
AutoWorkflowStatus,
|
||||||
from modules.shared.i18nRegistry import i18nModel
|
AutoRunStatus,
|
||||||
import uuid
|
AutoStepStatus,
|
||||||
|
AutoTaskStatus,
|
||||||
|
AutoTemplateScope,
|
||||||
|
GRAPHICAL_EDITOR_DATABASE,
|
||||||
|
AutoWorkflow,
|
||||||
|
AutoVersion,
|
||||||
|
AutoRun,
|
||||||
|
AutoStepLog,
|
||||||
|
AutoTask,
|
||||||
|
Automation2Workflow,
|
||||||
|
Automation2WorkflowRun,
|
||||||
|
Automation2HumanTask,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Legacy alias
|
||||||
# ---------------------------------------------------------------------------
|
graphicalEditorDatabase = GRAPHICAL_EDITOR_DATABASE
|
||||||
# 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
|
|
||||||
|
|
|
||||||
|
|
@ -39,24 +39,22 @@ def _make_json_serializable(obj: Any, _depth: int = 0) -> Any:
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
from modules.datamodels.datamodelUam import User
|
from modules.datamodels.datamodelUam import User
|
||||||
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import (
|
from modules.datamodels.datamodelWorkflowAutomation import (
|
||||||
|
GRAPHICAL_EDITOR_DATABASE,
|
||||||
AutoWorkflow,
|
AutoWorkflow,
|
||||||
AutoVersion,
|
AutoVersion,
|
||||||
AutoRun,
|
AutoRun,
|
||||||
AutoStepLog,
|
AutoStepLog,
|
||||||
AutoTask,
|
AutoTask,
|
||||||
AutoWorkflow as Automation2Workflow,
|
|
||||||
AutoRun as Automation2WorkflowRun,
|
|
||||||
AutoTask as Automation2HumanTask,
|
|
||||||
)
|
)
|
||||||
from modules.features.graphicalEditor.entryPoints import invocations_synced_with_graph
|
from modules.features.graphicalEditor.entryPoints import invocations_synced_with_graph
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
from modules.shared.dbRegistry import registerDatabase
|
from modules.dbHelpers.dbRegistry import registerDatabase
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
graphicalEditorDatabase = "poweron_graphicaleditor"
|
graphicalEditorDatabase = GRAPHICAL_EDITOR_DATABASE
|
||||||
registerDatabase(graphicalEditorDatabase)
|
registerDatabase(graphicalEditorDatabase)
|
||||||
_CALLBACK_WORKFLOW_CHANGED = "graphicalEditor.workflow.changed"
|
_CALLBACK_WORKFLOW_CHANGED = "graphicalEditor.workflow.changed"
|
||||||
|
|
||||||
|
|
@ -524,7 +522,6 @@ class GraphicalEditorObjects:
|
||||||
return None
|
return None
|
||||||
existing = self.getVersions(workflowId)
|
existing = self.getVersions(workflowId)
|
||||||
nextNumber = max((v.get("versionNumber", 0) for v in existing), default=0) + 1
|
nextNumber = max((v.get("versionNumber", 0) for v in existing), default=0) + 1
|
||||||
import time
|
|
||||||
data = {
|
data = {
|
||||||
"id": str(uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
"workflowId": workflowId,
|
"workflowId": workflowId,
|
||||||
|
|
@ -546,7 +543,6 @@ class GraphicalEditorObjects:
|
||||||
for v in existing:
|
for v in existing:
|
||||||
if v.get("status") == "published" and v.get("id") != versionId:
|
if v.get("status") == "published" and v.get("id") != versionId:
|
||||||
self.db.recordModify(AutoVersion, v["id"], {"status": "archived"})
|
self.db.recordModify(AutoVersion, v["id"], {"status": "archived"})
|
||||||
import time
|
|
||||||
updated = self.db.recordModify(AutoVersion, versionId, {
|
updated = self.db.recordModify(AutoVersion, versionId, {
|
||||||
"status": "published",
|
"status": "published",
|
||||||
"publishedAt": time.time(),
|
"publishedAt": time.time(),
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@ GraphicalEditor Feature - n8n-style flow automation.
|
||||||
Minimal bootstrap for feature instance creation. Build from here.
|
Minimal bootstrap for feature instance creation. Build from here.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import uuid
|
||||||
from typing import Dict, List, Any, Optional
|
from typing import Dict, List, Any, Optional
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import t
|
from modules.shared.i18nRegistry import t
|
||||||
|
|
@ -119,11 +121,10 @@ def getGraphicalEditorServices(
|
||||||
|
|
||||||
_workflow = workflow
|
_workflow = workflow
|
||||||
if _workflow is None:
|
if _workflow is None:
|
||||||
import uuid as _uuid
|
|
||||||
_workflow = type(
|
_workflow = type(
|
||||||
"_Placeholder",
|
"_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(
|
ctx = ServiceCenterContext(
|
||||||
|
|
@ -209,6 +210,269 @@ def getFeatureDefinition() -> Dict[str, Any]:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Feature Lifecycle Hooks (called dynamically by core via loadFeatureMainModules)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def onMandateDelete(mandateId: str, instances: list) -> None:
|
||||||
|
"""Cascade-delete all AutoWorkflow data in the Greenfield DB for this mandate."""
|
||||||
|
from modules.datamodels.datamodelWorkflowAutomation import (
|
||||||
|
GRAPHICAL_EDITOR_DATABASE, AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask,
|
||||||
|
)
|
||||||
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
|
from modules.shared.configuration import APP_CONFIG
|
||||||
|
|
||||||
|
try:
|
||||||
|
geDb = DatabaseConnector(
|
||||||
|
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
|
||||||
|
dbDatabase=GRAPHICAL_EDITOR_DATABASE,
|
||||||
|
dbUser=APP_CONFIG.get("DB_USER"),
|
||||||
|
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
|
||||||
|
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
|
||||||
|
userId=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not geDb._ensureTableExists(AutoWorkflow):
|
||||||
|
return
|
||||||
|
|
||||||
|
geInstances = [
|
||||||
|
inst for inst in instances
|
||||||
|
if (inst.get("featureCode") if isinstance(inst, dict) else getattr(inst, "featureCode", "")) == "graphicalEditor"
|
||||||
|
]
|
||||||
|
|
||||||
|
totalDeleted = 0
|
||||||
|
for inst in geInstances:
|
||||||
|
instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None)
|
||||||
|
if not instId:
|
||||||
|
continue
|
||||||
|
|
||||||
|
workflows = geDb.getRecordset(AutoWorkflow, recordFilter={
|
||||||
|
"mandateId": mandateId,
|
||||||
|
"featureInstanceId": instId,
|
||||||
|
}) or []
|
||||||
|
|
||||||
|
for wf in workflows:
|
||||||
|
wfId = wf.get("id")
|
||||||
|
if not wfId:
|
||||||
|
continue
|
||||||
|
|
||||||
|
for v in geDb.getRecordset(AutoVersion, recordFilter={"workflowId": wfId}) or []:
|
||||||
|
geDb.recordDelete(AutoVersion, v.get("id"))
|
||||||
|
|
||||||
|
for run in geDb.getRecordset(AutoRun, recordFilter={"workflowId": wfId}) or []:
|
||||||
|
runId = run.get("id")
|
||||||
|
for sl in geDb.getRecordset(AutoStepLog, recordFilter={"runId": runId}) or []:
|
||||||
|
geDb.recordDelete(AutoStepLog, sl.get("id"))
|
||||||
|
geDb.recordDelete(AutoRun, runId)
|
||||||
|
|
||||||
|
for task in geDb.getRecordset(AutoTask, recordFilter={"workflowId": wfId}) or []:
|
||||||
|
geDb.recordDelete(AutoTask, task.get("id"))
|
||||||
|
|
||||||
|
geDb.recordDelete(AutoWorkflow, wfId)
|
||||||
|
totalDeleted += 1
|
||||||
|
|
||||||
|
if totalDeleted:
|
||||||
|
logger.info(f"Cascade: deleted {totalDeleted} AutoWorkflow(s) in Greenfield DB for mandate {mandateId}")
|
||||||
|
geDb.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to cascade-delete graphical editor data for mandate {mandateId}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def onBootstrap() -> None:
|
||||||
|
"""Seed system workflow templates and sync feature template workflows on boot."""
|
||||||
|
from modules.datamodels.datamodelWorkflowAutomation import GRAPHICAL_EDITOR_DATABASE, AutoWorkflow
|
||||||
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
|
from modules.shared.configuration import APP_CONFIG
|
||||||
|
|
||||||
|
try:
|
||||||
|
greenfieldDb = DatabaseConnector(
|
||||||
|
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
|
||||||
|
dbDatabase=GRAPHICAL_EDITOR_DATABASE,
|
||||||
|
dbUser=APP_CONFIG.get("DB_USER"),
|
||||||
|
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
|
||||||
|
)
|
||||||
|
greenfieldDb._ensureTableExists(AutoWorkflow)
|
||||||
|
|
||||||
|
# --- Seed system templates ---
|
||||||
|
existing = greenfieldDb.getRecordset(AutoWorkflow, recordFilter={
|
||||||
|
"isTemplate": True,
|
||||||
|
"templateScope": "system",
|
||||||
|
})
|
||||||
|
existingLabels = {r.get("label") if isinstance(r, dict) else getattr(r, "label", "") for r in (existing or [])}
|
||||||
|
|
||||||
|
templates = _buildSystemTemplates()
|
||||||
|
created = 0
|
||||||
|
for tpl in templates:
|
||||||
|
if tpl["label"] in existingLabels:
|
||||||
|
continue
|
||||||
|
tpl["id"] = str(uuid.uuid4())
|
||||||
|
greenfieldDb.recordCreate(AutoWorkflow, tpl)
|
||||||
|
created += 1
|
||||||
|
|
||||||
|
if created:
|
||||||
|
logger.info(f"Bootstrapped {created} system workflow template(s)")
|
||||||
|
|
||||||
|
# --- Sync feature template workflows ---
|
||||||
|
from modules.system.registry import loadFeatureMainModules
|
||||||
|
|
||||||
|
mainModules = loadFeatureMainModules()
|
||||||
|
templatesBySourceId: dict = {}
|
||||||
|
for featureCode, mod in mainModules.items():
|
||||||
|
getTemplateWorkflowsFn = getattr(mod, "getTemplateWorkflows", None)
|
||||||
|
if not getTemplateWorkflowsFn:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
featureTemplates = getTemplateWorkflowsFn() or []
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
for tpl in featureTemplates:
|
||||||
|
tplId = tpl.get("id")
|
||||||
|
if tplId:
|
||||||
|
templatesBySourceId[tplId] = tpl
|
||||||
|
|
||||||
|
if templatesBySourceId:
|
||||||
|
updated = 0
|
||||||
|
for sourceId, tpl in templatesBySourceId.items():
|
||||||
|
instances = greenfieldDb.getRecordset(AutoWorkflow, recordFilter={
|
||||||
|
"templateSourceId": sourceId,
|
||||||
|
"isTemplate": False,
|
||||||
|
})
|
||||||
|
if not instances:
|
||||||
|
continue
|
||||||
|
|
||||||
|
canonicalGraph = tpl.get("graph", {})
|
||||||
|
for inst in instances:
|
||||||
|
instId = inst.get("id") if isinstance(inst, dict) else getattr(inst, "id", None)
|
||||||
|
targetInstanceId = (
|
||||||
|
inst.get("targetFeatureInstanceId") if isinstance(inst, dict)
|
||||||
|
else getattr(inst, "targetFeatureInstanceId", None)
|
||||||
|
) or ""
|
||||||
|
|
||||||
|
graphJson = json.dumps(canonicalGraph)
|
||||||
|
graphJson = graphJson.replace("{{featureInstanceId}}", targetInstanceId)
|
||||||
|
newGraph = json.loads(graphJson)
|
||||||
|
|
||||||
|
existingGraph = inst.get("graph") if isinstance(inst, dict) else getattr(inst, "graph", None)
|
||||||
|
if isinstance(existingGraph, str):
|
||||||
|
try:
|
||||||
|
existingGraph = json.loads(existingGraph)
|
||||||
|
except Exception:
|
||||||
|
existingGraph = None
|
||||||
|
|
||||||
|
if existingGraph == newGraph:
|
||||||
|
continue
|
||||||
|
greenfieldDb.recordModify(AutoWorkflow, instId, {"graph": newGraph})
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
if updated:
|
||||||
|
logger.info(f"Synced {updated} workflow(s) with current feature templates")
|
||||||
|
|
||||||
|
greenfieldDb.close()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"GraphicalEditor bootstrap failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def onInstanceCreate(mandateId: str, instanceId: str, featureCode: str, templateWorkflows: list) -> int:
|
||||||
|
"""Create workflow instances from template definitions when a feature instance is created."""
|
||||||
|
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
|
||||||
|
from modules.security.rootAccess import getRootUser
|
||||||
|
from modules.shared.i18nRegistry import resolveText
|
||||||
|
|
||||||
|
rootUser = getRootUser()
|
||||||
|
geInterface = getGraphicalEditorInterface(rootUser, mandateId, instanceId)
|
||||||
|
|
||||||
|
copied = 0
|
||||||
|
for template in templateWorkflows:
|
||||||
|
templateId = template.get("id", "<no-id>")
|
||||||
|
try:
|
||||||
|
graphJson = json.dumps(template.get("graph", {}))
|
||||||
|
graphJson = graphJson.replace("{{featureInstanceId}}", instanceId)
|
||||||
|
graph = json.loads(graphJson)
|
||||||
|
|
||||||
|
label = resolveText(template.get("label"))
|
||||||
|
|
||||||
|
geInterface.createWorkflow({
|
||||||
|
"label": label,
|
||||||
|
"graph": graph,
|
||||||
|
"tags": template.get("tags", [f"feature:{featureCode}"]),
|
||||||
|
"isTemplate": False,
|
||||||
|
"templateSourceId": templateId,
|
||||||
|
"templateScope": "instance",
|
||||||
|
"active": True,
|
||||||
|
"targetFeatureInstanceId": instanceId,
|
||||||
|
"invocations": template.get("invocations", []),
|
||||||
|
})
|
||||||
|
copied += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"onInstanceCreate: failed to copy template '{templateId}': {e}")
|
||||||
|
|
||||||
|
return copied
|
||||||
|
|
||||||
|
|
||||||
|
def _buildSystemTemplates():
|
||||||
|
"""Build the graph definitions for platform system templates."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"label": "Personal Assistant: E-Mail-Antwort-Drafting",
|
||||||
|
"mandateId": None,
|
||||||
|
"featureInstanceId": None,
|
||||||
|
"isTemplate": True,
|
||||||
|
"templateScope": "system",
|
||||||
|
"sharedReadOnly": True,
|
||||||
|
"active": False,
|
||||||
|
"graph": {
|
||||||
|
"nodes": [
|
||||||
|
{"id": "n1", "type": "trigger.schedule", "x": 50, "y": 200, "title": "Täglicher Check", "parameters": {}},
|
||||||
|
{"id": "n2", "type": "email.checkEmail", "x": 300, "y": 200, "title": "Mailbox prüfen", "parameters": {}},
|
||||||
|
{"id": "n3", "type": "flow.loop", "x": 550, "y": 200, "title": "Pro E-Mail", "parameters": {"items": {"type": "ref", "nodeId": "n2", "path": ["emails"]}, "concurrency": 1}},
|
||||||
|
{"id": "n4", "type": "ai.prompt", "x": 800, "y": 200, "title": "Analyse: Antwort nötig?", "parameters": {}},
|
||||||
|
{"id": "n5", "type": "flow.ifElse", "x": 1050, "y": 200, "title": "Antwort nötig?", "parameters": {}},
|
||||||
|
{"id": "n6", "type": "ai.prompt", "x": 1300, "y": 100, "title": "Kontext abrufen & Antwort formulieren", "parameters": {}},
|
||||||
|
{"id": "n7", "type": "email.draftEmail", "x": 1550, "y": 100, "title": "Draft erstellen", "parameters": {}},
|
||||||
|
],
|
||||||
|
"connections": [
|
||||||
|
{"source": "n1", "target": "n2", "sourceOutput": 0, "targetInput": 0},
|
||||||
|
{"source": "n2", "target": "n3", "sourceOutput": 0, "targetInput": 0},
|
||||||
|
{"source": "n3", "target": "n4", "sourceOutput": 0, "targetInput": 0},
|
||||||
|
{"source": "n4", "target": "n5", "sourceOutput": 0, "targetInput": 0},
|
||||||
|
{"source": "n5", "target": "n6", "sourceOutput": 0, "targetInput": 0},
|
||||||
|
{"source": "n6", "target": "n7", "sourceOutput": 0, "targetInput": 0},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"invocations": [{"type": "schedule", "cronExpression": "0 8 * * 1-5"}],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Treuhand: PDF-Klassifizierung & Trustee-Import",
|
||||||
|
"mandateId": None,
|
||||||
|
"featureInstanceId": None,
|
||||||
|
"isTemplate": True,
|
||||||
|
"templateScope": "system",
|
||||||
|
"sharedReadOnly": True,
|
||||||
|
"active": False,
|
||||||
|
"graph": {
|
||||||
|
"nodes": [
|
||||||
|
{"id": "n1", "type": "trigger.schedule", "x": 50, "y": 200, "title": "Geplanter Import", "parameters": {}},
|
||||||
|
{"id": "n2", "type": "sharepoint.listFiles", "x": 300, "y": 200, "title": "SharePoint Ordner lesen", "parameters": {}},
|
||||||
|
{"id": "n3", "type": "flow.loop", "x": 550, "y": 200, "title": "Pro Dokument", "parameters": {"items": {"type": "ref", "nodeId": "n2", "path": ["files"]}, "concurrency": 1}},
|
||||||
|
{"id": "n4", "type": "sharepoint.readFile", "x": 800, "y": 200, "title": "PDF-Inhalt lesen", "parameters": {}},
|
||||||
|
{"id": "n5", "type": "ai.prompt", "x": 1050, "y": 200, "title": "Typ klassifizieren (Rechnung, Beleg, Bankauszug, Vertrag, etc.)", "parameters": {}},
|
||||||
|
{"id": "n6", "type": "trustee.extractFromFiles", "x": 1300, "y": 200, "title": "Dokument extrahieren", "parameters": {}},
|
||||||
|
{"id": "n7", "type": "trustee.processDocuments", "x": 1550, "y": 200, "title": "In Trustee einlesen", "parameters": {}},
|
||||||
|
],
|
||||||
|
"connections": [
|
||||||
|
{"source": "n1", "target": "n2", "sourceOutput": 0, "targetInput": 0},
|
||||||
|
{"source": "n2", "target": "n3", "sourceOutput": 0, "targetInput": 0},
|
||||||
|
{"source": "n3", "target": "n4", "sourceOutput": 0, "targetInput": 0},
|
||||||
|
{"source": "n4", "target": "n5", "sourceOutput": 0, "targetInput": 0},
|
||||||
|
{"source": "n5", "target": "n6", "sourceOutput": 0, "targetInput": 0},
|
||||||
|
{"source": "n6", "target": "n7", "sourceOutput": 0, "targetInput": 0},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"invocations": [{"type": "schedule", "cronExpression": "0 7 * * 1-5"}],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def getUiObjects() -> List[Dict[str, Any]]:
|
def getUiObjects() -> List[Dict[str, Any]]:
|
||||||
"""Return UI objects for RBAC catalog registration."""
|
"""Return UI objects for RBAC catalog registration."""
|
||||||
return UI_OBJECTS
|
return UI_OBJECTS
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,18 @@ output normalizers, and Transit helpers.
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import resolveText, t
|
from modules.shared.i18nRegistry import resolveText, t
|
||||||
|
from modules.datamodels.datamodelPortTypes import (
|
||||||
|
PortField,
|
||||||
|
PortSchema,
|
||||||
|
PORT_TYPE_CATALOG,
|
||||||
|
PRIMITIVE_TYPES,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -24,35 +31,6 @@ logger = logging.getLogger(__name__)
|
||||||
# Pydantic models
|
# 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):
|
class InputPortDef(BaseModel):
|
||||||
accepts: List[str] # list of accepted schema names
|
accepts: List[str] # list of accepted schema names
|
||||||
|
|
||||||
|
|
@ -70,523 +48,10 @@ class OutputPortDef(BaseModel):
|
||||||
return d
|
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
|
# 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]:
|
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:
|
def resolveSystemVariable(variable: str, context: Dict[str, Any]) -> Any:
|
||||||
"""Resolve a system variable name to its runtime value."""
|
"""Resolve a system variable name to its runtime value."""
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
mapping = {
|
mapping = {
|
||||||
"system.timestamp": lambda: int(now.timestamp() * 1000),
|
"system.timestamp": lambda: int(now.timestamp() * 1000),
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,14 @@ import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
|
import uuid
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Path, Query, Body, Request, HTTPException
|
from fastapi import APIRouter, Depends, Path, Query, Body, Request, HTTPException
|
||||||
from fastapi.responses import JSONResponse, StreamingResponse, Response
|
from fastapi.responses import JSONResponse, StreamingResponse, Response
|
||||||
from modules.auth import limiter, getRequestContext, RequestContext
|
from modules.auth import limiter, getRequestContext, RequestContext
|
||||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginationMetadata, normalize_pagination_dict
|
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.mainGraphicalEditor import getGraphicalEditorServices
|
||||||
from modules.features.graphicalEditor.nodeRegistry import getNodeTypesForApi
|
from modules.features.graphicalEditor.nodeRegistry import getNodeTypesForApi
|
||||||
|
|
@ -422,7 +423,6 @@ async def post_execute(
|
||||||
workflow_for_envelope = wf
|
workflow_for_envelope = wf
|
||||||
targetFeatureInstanceId = wf.get("targetFeatureInstanceId")
|
targetFeatureInstanceId = wf.get("targetFeatureInstanceId")
|
||||||
if not workflowId:
|
if not workflowId:
|
||||||
import uuid
|
|
||||||
workflowId = f"transient-{uuid.uuid4().hex[:12]}"
|
workflowId = f"transient-{uuid.uuid4().hex[:12]}"
|
||||||
logger.info("graphicalEditor execute: using transient workflowId=%s", workflowId)
|
logger.info("graphicalEditor execute: using transient workflowId=%s", workflowId)
|
||||||
|
|
||||||
|
|
@ -642,18 +642,18 @@ def get_templates(
|
||||||
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
|
iface = getGraphicalEditorInterface(context.user, mandateId, instanceId)
|
||||||
templates = iface.getTemplates(scope=scope)
|
templates = iface.getTemplates(scope=scope)
|
||||||
|
|
||||||
from modules.routes.routeHelpers import enrichRowsWithFkLabels
|
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
|
||||||
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow
|
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow
|
||||||
enrichRowsWithFkLabels(templates, AutoWorkflow)
|
enrichRowsWithFkLabels(templates, AutoWorkflow, db=iface.db)
|
||||||
|
|
||||||
if mode == "filterValues":
|
if mode == "filterValues":
|
||||||
if not column:
|
if not column:
|
||||||
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
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)
|
return handleFilterValuesInMemory(templates, column, pagination)
|
||||||
|
|
||||||
if mode == "ids":
|
if mode == "ids":
|
||||||
from modules.routes.routeHelpers import handleIdsInMemory
|
from modules.dbHelpers.paginationHelpers import handleIdsInMemory
|
||||||
return handleIdsInMemory(templates, pagination)
|
return handleIdsInMemory(templates, pagination)
|
||||||
|
|
||||||
paginationParams = None
|
paginationParams = None
|
||||||
|
|
@ -1411,11 +1411,11 @@ def get_workflows(
|
||||||
if mode == "filterValues":
|
if mode == "filterValues":
|
||||||
if not column:
|
if not column:
|
||||||
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
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)
|
return handleFilterValuesInMemory(enriched, column, pagination)
|
||||||
|
|
||||||
if mode == "ids":
|
if mode == "ids":
|
||||||
from modules.routes.routeHelpers import handleIdsInMemory
|
from modules.dbHelpers.paginationHelpers import handleIdsInMemory
|
||||||
return handleIdsInMemory(enriched, pagination)
|
return handleIdsInMemory(enriched, pagination)
|
||||||
|
|
||||||
paginationParams = None
|
paginationParams = None
|
||||||
|
|
|
||||||
|
|
@ -92,63 +92,8 @@ class DataNeutraliserConfig(PowerOnModel):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@i18nModel("Neutralisiertes Datenattribut")
|
# Re-exported from canonical location (moved to datamodels layer)
|
||||||
class DataNeutralizerAttributes(PowerOnModel):
|
from modules.datamodels.datamodelFeatures import DataNeutralizerAttributes # noqa: F401
|
||||||
"""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},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@i18nModel("Neutralisierungs-Snapshot")
|
@i18nModel("Neutralisierungs-Snapshot")
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ from modules.features.neutralization.datamodelFeatureNeutralizer import (
|
||||||
DataNeutralizationSnapshot,
|
DataNeutralizationSnapshot,
|
||||||
)
|
)
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
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.interfaces.interfaceRbac import getRecordsetWithRBAC
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
from modules.shared.timeUtils import getUtcTimestamp
|
||||||
|
|
|
||||||
|
|
@ -231,3 +231,51 @@ def _ensureAccessRulesForRole(rootInterface, roleId: str, ruleTemplates: List[Di
|
||||||
createdCount += 1
|
createdCount += 1
|
||||||
|
|
||||||
return createdCount
|
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}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
|
import base64
|
||||||
import logging
|
import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
from typing import Any, Dict, List, Optional
|
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]:
|
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.
|
"""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."""
|
Saves both original and neutralized files to user files (component storage) when available."""
|
||||||
import base64
|
|
||||||
name_lower = (filename or '').lower()
|
name_lower = (filename or '').lower()
|
||||||
mime_map = {
|
mime_map = {
|
||||||
'.pdf': 'application/pdf',
|
'.pdf': 'application/pdf',
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,11 @@ Mehrsprachig: DE, EN, FR, IT
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import base64
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import json
|
import json
|
||||||
|
import uuid
|
||||||
from typing import Dict, List, Any, Optional
|
from typing import Dict, List, Any, Optional
|
||||||
|
|
||||||
from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutraliserConfig, DataNeutralizerAttributes
|
from modules.features.neutralization.datamodelFeatureNeutralizer import DataNeutraliserConfig, DataNeutralizerAttributes
|
||||||
|
|
@ -120,12 +122,12 @@ class NeutralizationService:
|
||||||
Returns the model object (with contextLength etc.) or None."""
|
Returns the model object (with contextLength etc.) or None."""
|
||||||
try:
|
try:
|
||||||
from modules.aicore.aicoreModelRegistry import modelRegistry
|
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
|
from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum
|
||||||
|
|
||||||
_models = modelRegistry.getAvailableModels()
|
_models = modelRegistry.getAvailableModels()
|
||||||
_opts = AiCallOptions(operationType=OperationTypeEnum.NEUTRALIZATION_TEXT)
|
_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
|
return _failover[0] if _failover else None
|
||||||
except Exception as _e:
|
except Exception as _e:
|
||||||
logger.warning(f"_resolveNeutModel failed: {_e}")
|
logger.warning(f"_resolveNeutModel failed: {_e}")
|
||||||
|
|
@ -219,8 +221,6 @@ class NeutralizationService:
|
||||||
Regex patterns run as a supplementary pass to catch anything the
|
Regex patterns run as a supplementary pass to catch anything the
|
||||||
model missed.
|
model missed.
|
||||||
"""
|
"""
|
||||||
import uuid as _uuid
|
|
||||||
|
|
||||||
aiService = None
|
aiService = None
|
||||||
if self._getService:
|
if self._getService:
|
||||||
try:
|
try:
|
||||||
|
|
@ -262,7 +262,7 @@ class NeutralizationService:
|
||||||
continue
|
continue
|
||||||
if _origText in aiMapping:
|
if _origText in aiMapping:
|
||||||
continue
|
continue
|
||||||
_uid = str(_uuid.uuid4())
|
_uid = str(uuid.uuid4())
|
||||||
_placeholder = f"[{_patType}.{_uid}]"
|
_placeholder = f"[{_patType}.{_uid}]"
|
||||||
aiMapping[_origText] = _placeholder
|
aiMapping[_origText] = _placeholder
|
||||||
|
|
||||||
|
|
@ -430,7 +430,6 @@ class NeutralizationService:
|
||||||
Uses NEUTRALIZATION_IMAGE operation type → only internal Private-LLM models.
|
Uses NEUTRALIZATION_IMAGE operation type → only internal Private-LLM models.
|
||||||
If no internal model available → returns 'blocked'.
|
If no internal model available → returns 'blocked'.
|
||||||
"""
|
"""
|
||||||
import base64
|
|
||||||
try:
|
try:
|
||||||
aiService = None
|
aiService = None
|
||||||
if self._getService:
|
if self._getService:
|
||||||
|
|
@ -494,7 +493,6 @@ class NeutralizationService:
|
||||||
|
|
||||||
def processImage(self, imageBytes: bytes, fileName: str, mimeType: str = "image/png") -> Dict[str, Any]:
|
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."""
|
"""Sync wrapper for processImageAsync. Uses asyncio.run when no event loop is running."""
|
||||||
import asyncio
|
|
||||||
try:
|
try:
|
||||||
return asyncio.run(self.processImageAsync(imageBytes, fileName, mimeType))
|
return asyncio.run(self.processImageAsync(imageBytes, fileName, mimeType))
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
|
|
@ -554,7 +552,6 @@ class NeutralizationService:
|
||||||
"""Persist mapping to DB for resolve to work. mapping: originalText -> placeholder e.g. '[email.uuid]'"""
|
"""Persist mapping to DB for resolve to work. mapping: originalText -> placeholder e.g. '[email.uuid]'"""
|
||||||
if not self.interfaceNeutralizer or not mapping:
|
if not self.interfaceNeutralizer or not mapping:
|
||||||
return
|
return
|
||||||
import re
|
|
||||||
placeholder_re = re.compile(r'^\[([a-z]+)\.([a-f0-9-]{36})\]$')
|
placeholder_re = re.compile(r'^\[([a-z]+)\.([a-f0-9-]{36})\]$')
|
||||||
for original_text, placeholder in mapping.items():
|
for original_text, placeholder in mapping.items():
|
||||||
m = placeholder_re.match(placeholder)
|
m = placeholder_re.match(placeholder)
|
||||||
|
|
@ -615,9 +612,8 @@ class NeutralizationService:
|
||||||
neutralized_parts.append(part)
|
neutralized_parts.append(part)
|
||||||
continue
|
continue
|
||||||
if type_group == 'image':
|
if type_group == 'image':
|
||||||
import base64 as _b64img
|
|
||||||
try:
|
try:
|
||||||
_imgBytes = _b64img.b64decode(str(data))
|
_imgBytes = base64.b64decode(str(data))
|
||||||
_imgResult = await self.processImageAsync(_imgBytes, fileName)
|
_imgResult = await self.processImageAsync(_imgBytes, fileName)
|
||||||
if _imgResult.get("status") == "ok":
|
if _imgResult.get("status") == "ok":
|
||||||
neutralized_parts.append(part)
|
neutralized_parts.append(part)
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ Handles structured data with headers (CSV, JSON, XML)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import uuid
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from typing import Dict, List, Any, Union
|
from typing import Dict, List, Any, Union
|
||||||
|
|
@ -58,7 +59,6 @@ class ListProcessor:
|
||||||
original = str(row[i])
|
original = str(row[i])
|
||||||
if original not in self.string_parser.mapping:
|
if original not in self.string_parser.mapping:
|
||||||
# Generate a UUID for the placeholder
|
# Generate a UUID for the placeholder
|
||||||
import uuid
|
|
||||||
placeholderId = str(uuid.uuid4())
|
placeholderId = str(uuid.uuid4())
|
||||||
self.string_parser.mapping[original] = pattern.replacement_template.format(len(self.string_parser.mapping) + 1)
|
self.string_parser.mapping[original] = pattern.replacement_template.format(len(self.string_parser.mapping) + 1)
|
||||||
row[i] = self.string_parser.mapping[original]
|
row[i] = self.string_parser.mapping[original]
|
||||||
|
|
@ -143,7 +143,6 @@ class ListProcessor:
|
||||||
if pattern:
|
if pattern:
|
||||||
if attrValue not in self.string_parser.mapping:
|
if attrValue not in self.string_parser.mapping:
|
||||||
# Generate a UUID for the placeholder
|
# Generate a UUID for the placeholder
|
||||||
import uuid
|
|
||||||
placeholderId = str(uuid.uuid4())
|
placeholderId = str(uuid.uuid4())
|
||||||
# Create placeholder in format [type.uuid]
|
# Create placeholder in format [type.uuid]
|
||||||
typeMapping = {
|
typeMapping = {
|
||||||
|
|
@ -166,7 +165,6 @@ class ListProcessor:
|
||||||
if pattern:
|
if pattern:
|
||||||
if attrValue not in self.string_parser.mapping:
|
if attrValue not in self.string_parser.mapping:
|
||||||
# Generate a UUID for the placeholder
|
# Generate a UUID for the placeholder
|
||||||
import uuid
|
|
||||||
placeholderId = str(uuid.uuid4())
|
placeholderId = str(uuid.uuid4())
|
||||||
# Create placeholder in format [type.uuid]
|
# Create placeholder in format [type.uuid]
|
||||||
typeMapping = {
|
typeMapping = {
|
||||||
|
|
@ -202,7 +200,6 @@ class ListProcessor:
|
||||||
if pattern:
|
if pattern:
|
||||||
if text not in self.string_parser.mapping:
|
if text not in self.string_parser.mapping:
|
||||||
# Generate a UUID for the placeholder
|
# Generate a UUID for the placeholder
|
||||||
import uuid
|
|
||||||
placeholder_id = str(uuid.uuid4())
|
placeholder_id = str(uuid.uuid4())
|
||||||
# Create placeholder in format [type.uuid]
|
# Create placeholder in format [type.uuid]
|
||||||
type_mapping = {
|
type_mapping = {
|
||||||
|
|
@ -223,7 +220,6 @@ class ListProcessor:
|
||||||
if text.lower().strip() == name.lower().strip():
|
if text.lower().strip() == name.lower().strip():
|
||||||
if text not in self.string_parser.mapping:
|
if text not in self.string_parser.mapping:
|
||||||
# Generate a UUID for the placeholder
|
# Generate a UUID for the placeholder
|
||||||
import uuid
|
|
||||||
placeholder_id = str(uuid.uuid4())
|
placeholder_id = str(uuid.uuid4())
|
||||||
self.string_parser.mapping[text] = f"[name.{placeholder_id}]"
|
self.string_parser.mapping[text] = f"[name.{placeholder_id}]"
|
||||||
text = self.string_parser.mapping[text]
|
text = self.string_parser.mapping[text]
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ Queries Dokument table and retrieves PDF content from ComponentObjects.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
from typing import List, Dict, Any, Optional
|
from typing import List, Dict, Any, Optional
|
||||||
from .datamodelFeatureRealEstate import Dokument, DokumentTyp, Gemeinde
|
from .datamodelFeatureRealEstate import Dokument, DokumentTyp, Gemeinde
|
||||||
from .interfaceFeatureRealEstate import RealEstateObjects
|
from .interfaceFeatureRealEstate import RealEstateObjects
|
||||||
|
|
@ -182,8 +183,6 @@ class BZODocumentRetriever:
|
||||||
Returns:
|
Returns:
|
||||||
Year as integer if found, None otherwise
|
Year as integer if found, None otherwise
|
||||||
"""
|
"""
|
||||||
import re
|
|
||||||
|
|
||||||
# Try to extract year from label
|
# Try to extract year from label
|
||||||
if dokument.label:
|
if dokument.label:
|
||||||
year_match = re.search(r'\b(19|20)\d{2}\b', dokument.label)
|
year_match = re.search(r'\b(19|20)\d{2}\b', dokument.label)
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ directly (no external workflow-orchestration framework).
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
import uuid
|
||||||
from typing import TypedDict, List, Dict, Any, Optional
|
from typing import TypedDict, List, Dict, Any, Optional
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
@ -1310,8 +1311,6 @@ def run_extraction(pdf_bytes: bytes, pdf_id: str = None, dokument_id: str = None
|
||||||
"warnings": [...]
|
"warnings": [...]
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
import uuid
|
|
||||||
|
|
||||||
if not pdf_id:
|
if not pdf_id:
|
||||||
pdf_id = f"pdf_{uuid.uuid4().hex[:8]}"
|
pdf_id = f"pdf_{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ Handles CRUD operations on Real Estate entities (Projekt, Parzelle, etc.).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
import time
|
||||||
from typing import Dict, Any, List, Optional, Union
|
from typing import Dict, Any, List, Optional, Union
|
||||||
from .datamodelFeatureRealEstate import (
|
from .datamodelFeatureRealEstate import (
|
||||||
Projekt,
|
Projekt,
|
||||||
|
|
@ -21,7 +23,7 @@ from .datamodelFeatureRealEstate import (
|
||||||
from modules.datamodels.datamodelUam import User
|
from modules.datamodels.datamodelUam import User
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
from modules.shared.configuration import APP_CONFIG
|
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.security.rbac import RbacClass
|
||||||
from modules.datamodels.datamodelRbac import AccessRuleContext
|
from modules.datamodels.datamodelRbac import AccessRuleContext
|
||||||
from modules.datamodels.datamodelUam import AccessLevel
|
from modules.datamodels.datamodelUam import AccessLevel
|
||||||
|
|
@ -322,7 +324,6 @@ class RealEstateObjects:
|
||||||
|
|
||||||
def _isUUID(self, value: str) -> bool:
|
def _isUUID(self, value: str) -> bool:
|
||||||
"""Check if a string looks like a UUID."""
|
"""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)
|
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))
|
return bool(uuid_pattern.match(value))
|
||||||
|
|
||||||
|
|
@ -832,8 +833,6 @@ class RealEstateObjects:
|
||||||
Dictionary with 'rows' (list of dicts), 'columns' (list of column names),
|
Dictionary with 'rows' (list of dicts), 'columns' (list of column names),
|
||||||
'rowCount' (int), and 'executionTime' (float)
|
'rowCount' (int), and 'executionTime' (float)
|
||||||
"""
|
"""
|
||||||
import time
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ This module also handles feature initialization and RBAC catalog registration.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import t
|
from modules.shared.i18nRegistry import t
|
||||||
|
|
||||||
|
|
@ -1351,7 +1352,6 @@ async def executeIntentBasedOperation(
|
||||||
location_id = None
|
location_id = None
|
||||||
try:
|
try:
|
||||||
# Check if it's already a UUID
|
# 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)
|
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):
|
if not uuid_pattern.match(location_filter):
|
||||||
# Try to resolve name to ID
|
# 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 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}."
|
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}")
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -228,20 +228,21 @@ def get_projects(
|
||||||
recordFilter = {"featureInstanceId": instanceId}
|
recordFilter = {"featureInstanceId": instanceId}
|
||||||
|
|
||||||
if mode in ("filterValues", "ids"):
|
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)
|
items = interface.getProjekte(recordFilter=recordFilter)
|
||||||
itemDicts = [i.model_dump() if hasattr(i, 'model_dump') else i for i in items]
|
itemDicts = [i.model_dump() if hasattr(i, 'model_dump') else i for i in items]
|
||||||
if mode == "filterValues":
|
if mode == "filterValues":
|
||||||
if not column:
|
if not column:
|
||||||
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
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 handleFilterValuesInMemory(itemDicts, column, pagination)
|
||||||
return handleIdsInMemory(itemDicts, pagination)
|
return handleIdsInMemory(itemDicts, pagination)
|
||||||
|
|
||||||
items = interface.getProjekte(recordFilter=recordFilter)
|
items = interface.getProjekte(recordFilter=recordFilter)
|
||||||
paginationParams = _parsePagination(pagination)
|
paginationParams = _parsePagination(pagination)
|
||||||
if paginationParams:
|
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]
|
itemDicts = [i.model_dump() if hasattr(i, 'model_dump') else i for i in items]
|
||||||
filtered = applyFiltersAndSort(itemDicts, paginationParams)
|
filtered = applyFiltersAndSort(itemDicts, paginationParams)
|
||||||
total_items = len(filtered)
|
total_items = len(filtered)
|
||||||
|
|
@ -369,20 +370,21 @@ def get_parcels(
|
||||||
recordFilter = {"featureInstanceId": instanceId}
|
recordFilter = {"featureInstanceId": instanceId}
|
||||||
|
|
||||||
if mode in ("filterValues", "ids"):
|
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)
|
items = interface.getParzellen(recordFilter=recordFilter)
|
||||||
itemDicts = [i.model_dump() if hasattr(i, 'model_dump') else i for i in items]
|
itemDicts = [i.model_dump() if hasattr(i, 'model_dump') else i for i in items]
|
||||||
if mode == "filterValues":
|
if mode == "filterValues":
|
||||||
if not column:
|
if not column:
|
||||||
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
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 handleFilterValuesInMemory(itemDicts, column, pagination)
|
||||||
return handleIdsInMemory(itemDicts, pagination)
|
return handleIdsInMemory(itemDicts, pagination)
|
||||||
|
|
||||||
items = interface.getParzellen(recordFilter=recordFilter)
|
items = interface.getParzellen(recordFilter=recordFilter)
|
||||||
paginationParams = _parsePagination(pagination)
|
paginationParams = _parsePagination(pagination)
|
||||||
if paginationParams:
|
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]
|
itemDicts = [i.model_dump() if hasattr(i, 'model_dump') else i for i in items]
|
||||||
filtered = applyFiltersAndSort(itemDicts, paginationParams)
|
filtered = applyFiltersAndSort(itemDicts, paginationParams)
|
||||||
total_items = len(filtered)
|
total_items = len(filtered)
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ from modules.features.redmine.datamodelRedmine import (
|
||||||
)
|
)
|
||||||
from modules.security.rbac import RbacClass
|
from modules.security.rbac import RbacClass
|
||||||
from modules.shared.configuration import APP_CONFIG, decryptValue, encryptValue
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -333,3 +333,51 @@ def _ensureAccessRulesForRole(
|
||||||
rootInterface.db.recordCreate(AccessRule, newRule.model_dump())
|
rootInterface.db.recordCreate(AccessRule, newRule.model_dump())
|
||||||
createdCount += 1
|
createdCount += 1
|
||||||
return createdCount
|
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}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ def _audit(
|
||||||
errorMessage: Optional[str] = None,
|
errorMessage: Optional[str] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
try:
|
try:
|
||||||
from modules.shared.auditLogger import audit_logger
|
from modules.dbHelpers.auditLogger import audit_logger
|
||||||
audit_logger.logEvent(
|
audit_logger.logEvent(
|
||||||
userId=str(context.user.id),
|
userId=str(context.user.id),
|
||||||
mandateId=str(context.mandateId) if context.mandateId else None,
|
mandateId=str(context.mandateId) if context.mandateId else None,
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from modules.connectors.connectorTicketsRedmine import (
|
from modules.connectors.connectorTicketsRedmine import (
|
||||||
|
|
@ -253,7 +254,6 @@ def _isoToEpoch(value: Optional[str]) -> Optional[float]:
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
from datetime import datetime
|
|
||||||
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
|
return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ The whole result is cached in :mod:`serviceRedmineStatsCache` keyed by
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import bisect
|
import bisect
|
||||||
import datetime as _dt
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
from collections import Counter, defaultdict
|
from collections import Counter, defaultdict
|
||||||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||||
|
|
@ -170,8 +170,8 @@ def _aggregate(
|
||||||
def _kpis(
|
def _kpis(
|
||||||
tickets: List[RedmineTicketDto],
|
tickets: List[RedmineTicketDto],
|
||||||
rootTrackerId: Optional[int],
|
rootTrackerId: Optional[int],
|
||||||
periodFrom: Optional[_dt.datetime],
|
periodFrom: Optional[datetime.datetime],
|
||||||
periodTo: Optional[_dt.datetime],
|
periodTo: Optional[datetime.datetime],
|
||||||
) -> RedmineStatsKpis:
|
) -> RedmineStatsKpis:
|
||||||
total = len(tickets)
|
total = len(tickets)
|
||||||
open_count = sum(1 for t in tickets if not t.isClosed)
|
open_count = sum(1 for t in tickets if not t.isClosed)
|
||||||
|
|
@ -260,8 +260,8 @@ def _statusByTracker(
|
||||||
|
|
||||||
def _throughput(
|
def _throughput(
|
||||||
tickets: List[RedmineTicketDto],
|
tickets: List[RedmineTicketDto],
|
||||||
periodFrom: Optional[_dt.datetime],
|
periodFrom: Optional[datetime.datetime],
|
||||||
periodTo: Optional[_dt.datetime],
|
periodTo: Optional[datetime.datetime],
|
||||||
bucket: str,
|
bucket: str,
|
||||||
) -> List[RedmineThroughputBucket]:
|
) -> List[RedmineThroughputBucket]:
|
||||||
"""Build per-bucket snapshots: how many tickets exist at the END of
|
"""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 no period is set, span the lifetime of the data.
|
||||||
if periodFrom is None or periodTo is None:
|
if periodFrom is None or periodTo is None:
|
||||||
all_dates: List[_dt.datetime] = []
|
all_dates: List[datetime.datetime] = []
|
||||||
for t in tickets:
|
for t in tickets:
|
||||||
for s in (t.createdOn, t.updatedOn):
|
for s in (t.createdOn, t.updatedOn):
|
||||||
d = _parseIsoDate(s)
|
d = _parseIsoDate(s)
|
||||||
|
|
@ -309,8 +309,8 @@ def _throughput(
|
||||||
# open = total - #closed with closedTs <= bucket end. We compute
|
# open = total - #closed with closedTs <= bucket end. We compute
|
||||||
# against ALL tickets (not just the period-windowed counters) so
|
# against ALL tickets (not just the period-windowed counters) so
|
||||||
# pre-period tickets are correctly counted in the snapshot.
|
# pre-period tickets are correctly counted in the snapshot.
|
||||||
created_dates: List[_dt.datetime] = []
|
created_dates: List[datetime.datetime] = []
|
||||||
closed_dates: List[_dt.datetime] = []
|
closed_dates: List[datetime.datetime] = []
|
||||||
for t in tickets:
|
for t in tickets:
|
||||||
c = _parseIsoDate(t.createdOn)
|
c = _parseIsoDate(t.createdOn)
|
||||||
if c:
|
if c:
|
||||||
|
|
@ -341,13 +341,13 @@ def _throughput(
|
||||||
return out
|
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``."""
|
"""Binary search: how many entries in ``sortedDates`` are <= ``edge``."""
|
||||||
return bisect.bisect_right(sortedDates, edge)
|
return bisect.bisect_right(sortedDates, edge)
|
||||||
|
|
||||||
|
|
||||||
def _bucketKeysBetween(
|
def _bucketKeysBetween(
|
||||||
fromD: _dt.datetime, toD: _dt.datetime, bucket: str
|
fromD: datetime.datetime, toD: datetime.datetime, bucket: str
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
"""Inclusive list of bucket keys covering ``[fromD, toD]``."""
|
"""Inclusive list of bucket keys covering ``[fromD, toD]``."""
|
||||||
if toD < fromD:
|
if toD < fromD:
|
||||||
|
|
@ -357,9 +357,9 @@ def _bucketKeysBetween(
|
||||||
cursor = fromD
|
cursor = fromD
|
||||||
safety = 0
|
safety = 0
|
||||||
step = (
|
step = (
|
||||||
_dt.timedelta(days=1) if bucket == "day"
|
datetime.timedelta(days=1) if bucket == "day"
|
||||||
else _dt.timedelta(days=7) if bucket == "week"
|
else datetime.timedelta(days=7) if bucket == "week"
|
||||||
else _dt.timedelta(days=27) # month: walk in <31d steps so we never skip
|
else datetime.timedelta(days=27) # month: walk in <31d steps so we never skip
|
||||||
)
|
)
|
||||||
while cursor <= toD and safety < 5000:
|
while cursor <= toD and safety < 5000:
|
||||||
k = _bucketKey(cursor, bucket)
|
k = _bucketKey(cursor, bucket)
|
||||||
|
|
@ -377,27 +377,27 @@ def _bucketKeysBetween(
|
||||||
return keys
|
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."""
|
"""Last-instant timestamp covered by the given bucket key."""
|
||||||
if bucket == "day":
|
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)
|
return d.replace(hour=23, minute=59, second=59)
|
||||||
if bucket == "month":
|
if bucket == "month":
|
||||||
d = _dt.datetime.strptime(key, "%Y-%m")
|
d = datetime.datetime.strptime(key, "%Y-%m")
|
||||||
# First of next month minus one second.
|
# First of next month minus one second.
|
||||||
if d.month == 12:
|
if d.month == 12:
|
||||||
nxt = d.replace(year=d.year + 1, month=1)
|
nxt = d.replace(year=d.year + 1, month=1)
|
||||||
else:
|
else:
|
||||||
nxt = d.replace(month=d.month + 1)
|
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.
|
# week: ISO format ``YYYY-Www``. End = Sunday 23:59:59 of that week.
|
||||||
try:
|
try:
|
||||||
year_str, week_str = key.split("-W")
|
year_str, week_str = key.split("-W")
|
||||||
year = int(year_str)
|
year = int(year_str)
|
||||||
week = int(week_str)
|
week = int(week_str)
|
||||||
# ``%G-%V-%u`` parses ISO year/week/day; %u=1 is Monday.
|
# ``%G-%V-%u`` parses ISO year/week/day; %u=1 is Monday.
|
||||||
monday = _dt.datetime.strptime(f"{year}-{week:02d}-1", "%G-%V-%u")
|
monday = datetime.datetime.strptime(f"{year}-{week:02d}-1", "%G-%V-%u")
|
||||||
return monday + _dt.timedelta(days=6, hours=23, minutes=59, seconds=59)
|
return monday + datetime.timedelta(days=6, hours=23, minutes=59, seconds=59)
|
||||||
except Exception:
|
except Exception:
|
||||||
return _utcNow()
|
return _utcNow()
|
||||||
|
|
||||||
|
|
@ -436,7 +436,7 @@ def _relationDistribution(
|
||||||
|
|
||||||
|
|
||||||
def _backlogAging(
|
def _backlogAging(
|
||||||
tickets: List[RedmineTicketDto], *, now: Optional[_dt.datetime] = None
|
tickets: List[RedmineTicketDto], *, now: Optional[datetime.datetime] = None
|
||||||
) -> List[RedmineAgingBucket]:
|
) -> List[RedmineAgingBucket]:
|
||||||
if now is None:
|
if now is None:
|
||||||
now = _utcNow()
|
now = _utcNow()
|
||||||
|
|
@ -467,40 +467,40 @@ def _backlogAging(
|
||||||
# Date helpers (no external deps)
|
# Date helpers (no external deps)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _utcNow() -> _dt.datetime:
|
def _utcNow() -> datetime.datetime:
|
||||||
"""Naive UTC ``datetime`` -- the rest of the helpers compare naive
|
"""Naive UTC ``datetime`` -- the rest of the helpers compare naive
|
||||||
objects, so we strip tz info on purpose."""
|
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:
|
if not value:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
s = value.replace("Z", "+00:00") if isinstance(value, str) else value
|
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:
|
if isinstance(s, str) and "T" not in s and len(s) == 10:
|
||||||
return _dt.datetime.strptime(s, "%Y-%m-%d")
|
return datetime.datetime.strptime(s, "%Y-%m-%d")
|
||||||
return _dt.datetime.fromisoformat(s).replace(tzinfo=None)
|
return datetime.datetime.fromisoformat(s).replace(tzinfo=None)
|
||||||
except Exception:
|
except Exception:
|
||||||
try:
|
try:
|
||||||
return _dt.datetime.strptime(str(value)[:10], "%Y-%m-%d")
|
return datetime.datetime.strptime(str(value)[:10], "%Y-%m-%d")
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _inPeriod(
|
def _inPeriod(
|
||||||
when: _dt.datetime,
|
when: datetime.datetime,
|
||||||
fromDate: Optional[_dt.datetime],
|
fromDate: Optional[datetime.datetime],
|
||||||
toDate: Optional[_dt.datetime],
|
toDate: Optional[datetime.datetime],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
if fromDate and when < fromDate:
|
if fromDate and when < fromDate:
|
||||||
return False
|
return False
|
||||||
if toDate and when > toDate + _dt.timedelta(days=1):
|
if toDate and when > toDate + datetime.timedelta(days=1):
|
||||||
return False
|
return False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _bucketKey(when: _dt.datetime, bucket: str) -> str:
|
def _bucketKey(when: datetime.datetime, bucket: str) -> str:
|
||||||
if bucket == "day":
|
if bucket == "day":
|
||||||
return when.strftime("%Y-%m-%d")
|
return when.strftime("%Y-%m-%d")
|
||||||
if bucket == "month":
|
if bucket == "month":
|
||||||
|
|
@ -514,7 +514,7 @@ def _bucketLabel(key: str, bucket: str) -> str:
|
||||||
return key
|
return key
|
||||||
if bucket == "month":
|
if bucket == "month":
|
||||||
try:
|
try:
|
||||||
d = _dt.datetime.strptime(key, "%Y-%m")
|
d = datetime.datetime.strptime(key, "%Y-%m")
|
||||||
return d.strftime("%b %Y")
|
return d.strftime("%b %Y")
|
||||||
except Exception:
|
except Exception:
|
||||||
return key
|
return key
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ from __future__ import annotations
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from datetime import datetime
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from modules.connectors.connectorTicketsRedmine import RedmineApiError
|
from modules.connectors.connectorTicketsRedmine import RedmineApiError
|
||||||
|
|
@ -354,7 +355,6 @@ def _parseRedmineDateToEpoch(value: Optional[str]) -> Optional[float]:
|
||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
from datetime import datetime
|
|
||||||
s = value.replace("Z", "+00:00")
|
s = value.replace("Z", "+00:00")
|
||||||
return datetime.fromisoformat(s).timestamp()
|
return datetime.fromisoformat(s).timestamp()
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ from typing import Dict, Any, List, Optional
|
||||||
from modules.datamodels.datamodelUam import User
|
from modules.datamodels.datamodelUam import User
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
from modules.shared.dbRegistry import registerDatabase
|
from modules.dbHelpers.dbRegistry import registerDatabase
|
||||||
|
|
||||||
from .datamodelTeamsbot import (
|
from .datamodelTeamsbot import (
|
||||||
TeamsbotSession,
|
TeamsbotSession,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ Handles feature initialization and RBAC catalog registration.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
from typing import Dict, List, Any
|
from typing import Dict, List, Any
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import t
|
from modules.shared.i18nRegistry import t
|
||||||
|
|
@ -261,7 +263,6 @@ def _runMigrations():
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
import psycopg2
|
import psycopg2
|
||||||
from psycopg2.extras import RealDictCursor
|
from psycopg2.extras import RealDictCursor
|
||||||
import uuid
|
|
||||||
|
|
||||||
conn = psycopg2.connect(
|
conn = psycopg2.connect(
|
||||||
host=APP_CONFIG.get("DB_HOST", "localhost"),
|
host=APP_CONFIG.get("DB_HOST", "localhost"),
|
||||||
|
|
@ -320,8 +321,7 @@ def _runMigrations():
|
||||||
continue
|
continue
|
||||||
|
|
||||||
adhocId = str(uuid.uuid4())
|
adhocId = str(uuid.uuid4())
|
||||||
import time as _time
|
now = time.time()
|
||||||
now = _time.time()
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO "TeamsbotMeetingModule" (id, "instanceId", "mandateId", "ownerUserId", title, "seriesType", status, "sysCreatedAt")
|
INSERT INTO "TeamsbotMeetingModule" (id, "instanceId", "mandateId", "ownerUserId", title, "seriesType", status, "sysCreatedAt")
|
||||||
VALUES (%s, %s, %s, 'system', 'Adhoc', 'adhoc', 'active', %s)
|
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}")
|
logger.debug(f"Created {createdCount} AccessRules for role {roleId}")
|
||||||
|
|
||||||
return createdCount
|
return createdCount
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Feature Lifecycle Hooks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def onMandateDelete(mandateId: str, instances: list) -> None:
|
||||||
|
"""Cascade-delete all 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}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ Teamsbot routes for the backend API.
|
||||||
Implements Teams Bot session management, live streaming, and configuration endpoints.
|
Implements Teams Bot session management, live streaming, and configuration endpoints.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
|
@ -1170,7 +1171,6 @@ async def testVoice(
|
||||||
)
|
)
|
||||||
|
|
||||||
if result and isinstance(result, dict):
|
if result and isinstance(result, dict):
|
||||||
import base64
|
|
||||||
audioContent = result.get("audioContent")
|
audioContent = result.get("audioContent")
|
||||||
if audioContent:
|
if audioContent:
|
||||||
audioB64 = base64.b64encode(
|
audioB64 = base64.b64encode(
|
||||||
|
|
|
||||||
|
|
@ -795,7 +795,6 @@ class TeamsbotService:
|
||||||
|
|
||||||
def _loadAvatarFileData(self, fileId, _teamsbotInterface):
|
def _loadAvatarFileData(self, fileId, _teamsbotInterface):
|
||||||
"""Load avatar file as base64 data + mime type. Returns (data, mimeType) or (None, None)."""
|
"""Load avatar file as base64 data + mime type. Returns (data, mimeType) or (None, None)."""
|
||||||
import base64
|
|
||||||
from modules.interfaces import interfaceDbManagement
|
from modules.interfaces import interfaceDbManagement
|
||||||
try:
|
try:
|
||||||
mgmt = interfaceDbManagement.getInterface(self.currentUser, self.mandateId, featureInstanceId=self.instanceId)
|
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
|
voiced chunks. Here we apply a minimum-duration safety net: very short
|
||||||
chunks (<1s) are buffered until they reach 1s; everything else goes
|
chunks (<1s) are buffered until they reach 1s; everything else goes
|
||||||
straight to STT. A wall-clock timeout flushes stale buffers."""
|
straight to STT. A wall-clock timeout flushes stale buffers."""
|
||||||
import base64
|
|
||||||
_MIN_CHUNK_SEC = 1.0
|
_MIN_CHUNK_SEC = 1.0
|
||||||
_STALE_TIMEOUT_SEC = 3.0
|
_STALE_TIMEOUT_SEC = 3.0
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@ Encapsulates: config loading -> connector resolution -> duplicate check -> push
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
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 typing import List, Dict, Any, Optional
|
||||||
|
|
||||||
from .accountingConnectorBase import (
|
from .accountingConnectorBase import (
|
||||||
|
|
@ -18,6 +19,7 @@ from .accountingConnectorBase import (
|
||||||
SyncResult,
|
SyncResult,
|
||||||
)
|
)
|
||||||
from .accountingRegistry import getAccountingRegistry
|
from .accountingRegistry import getAccountingRegistry
|
||||||
|
from modules.features.trustee.datamodelFeatureTrustee import TrusteeDocument
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -44,7 +46,6 @@ class AccountingBridge:
|
||||||
def _decryptConfig(self, encryptedConfig: str) -> Dict[str, Any]:
|
def _decryptConfig(self, encryptedConfig: str) -> Dict[str, Any]:
|
||||||
"""Decrypt the stored connector config JSON."""
|
"""Decrypt the stored connector config JSON."""
|
||||||
from modules.shared.configuration import decryptValue
|
from modules.shared.configuration import decryptValue
|
||||||
import json
|
|
||||||
try:
|
try:
|
||||||
if not encryptedConfig:
|
if not encryptedConfig:
|
||||||
logger.error("Accounting config encryptedConfig is empty")
|
logger.error("Accounting config encryptedConfig is empty")
|
||||||
|
|
@ -105,7 +106,7 @@ class AccountingBridge:
|
||||||
))
|
))
|
||||||
|
|
||||||
valutaTs = position.get("valuta")
|
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(
|
return AccountingBooking(
|
||||||
reference=position.get("bookingReference") or position.get("id", ""),
|
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)
|
# 1) Pre-booking document upload (RMA-style: upload first, link via belegId)
|
||||||
if documentIds and not postBookingAttach:
|
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))
|
logger.info("Accounting sync: positionId=%s, uploading %s document(s) pre-booking ...", positionId, len(documentIds))
|
||||||
belegIds = []
|
belegIds = []
|
||||||
belegLabels = []
|
belegLabels = []
|
||||||
|
|
@ -197,7 +197,7 @@ class AccountingBridge:
|
||||||
return SyncResult(success=False, errorMessage=f"Dokument-Upload fehlgeschlagen: {uploadResult.errorMessage}")
|
return SyncResult(success=False, errorMessage=f"Dokument-Upload fehlgeschlagen: {uploadResult.errorMessage}")
|
||||||
belegId = uploadResult.externalId
|
belegId = uploadResult.externalId
|
||||||
if belegId:
|
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)
|
logger.info("Accounting sync: document uploaded & belegId=%s stored on document %s", belegId, documentId)
|
||||||
belegIds.append(belegId)
|
belegIds.append(belegId)
|
||||||
belegLabels.append(fileName)
|
belegLabels.append(fileName)
|
||||||
|
|
@ -208,7 +208,6 @@ class AccountingBridge:
|
||||||
|
|
||||||
# 1b) Post-booking flow: collect raw doc data now, attach after pushBooking
|
# 1b) Post-booking flow: collect raw doc data now, attach after pushBooking
|
||||||
if documentIds and postBookingAttach:
|
if documentIds and postBookingAttach:
|
||||||
from modules.features.trustee.datamodelFeatureTrustee import TrusteeDocument as TrusteeDocumentModel
|
|
||||||
for documentId in documentIds:
|
for documentId in documentIds:
|
||||||
doc = self._trusteeInterface.getDocument(documentId)
|
doc = self._trusteeInterface.getDocument(documentId)
|
||||||
if not doc:
|
if not doc:
|
||||||
|
|
@ -263,7 +262,6 @@ class AccountingBridge:
|
||||||
|
|
||||||
# 3) Post-booking document attach (Abacus-style: entry must exist before attaching docs)
|
# 3) Post-booking document attach (Abacus-style: entry must exist before attaching docs)
|
||||||
if result.success and pendingDocs and result.externalId:
|
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)
|
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:
|
for documentId, fileName, docData, mimeType in pendingDocs:
|
||||||
attachResult = await connector.attachDocumentToEntry(
|
attachResult = await connector.attachDocumentToEntry(
|
||||||
|
|
@ -280,11 +278,10 @@ class AccountingBridge:
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
if attachResult.externalId:
|
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)
|
logger.info("Accounting sync: document attached, externalId=%s stored on document %s", attachResult.externalId, documentId)
|
||||||
|
|
||||||
# Save sync record
|
# Save sync record
|
||||||
import uuid
|
|
||||||
syncRecord = {
|
syncRecord = {
|
||||||
"id": str(uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
"positionId": positionId,
|
"positionId": positionId,
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,13 @@ froze every other request (chat, health-check, etc.) for minutes. See
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json as _json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
|
import uuid
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from datetime import datetime as _dt, timezone as _tz
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Dict, Any, List, Optional, Type
|
from typing import Callable, Dict, Any, List, Optional, Type
|
||||||
|
|
||||||
|
|
@ -46,7 +47,7 @@ def _isoDateToTimestamp(raw: Any) -> Optional[float]:
|
||||||
if not s:
|
if not s:
|
||||||
return None
|
return None
|
||||||
try:
|
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:
|
except ValueError:
|
||||||
raise ValueError(f"Cannot parse bookingDate '{raw}' as YYYY-MM-DD")
|
raise ValueError(f"Cannot parse bookingDate '{raw}' as YYYY-MM-DD")
|
||||||
|
|
||||||
|
|
@ -174,7 +175,7 @@ def _dumpSyncData(tag: str, rows: list) -> None:
|
||||||
else:
|
else:
|
||||||
serializable.append(str(r))
|
serializable.append(str(r))
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
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)")
|
logger.info(f"Debug sync dump: {path.name} ({len(serializable)} rows)")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to write debug sync dump for {tag}: {e}")
|
logger.warning(f"Failed to write debug sync dump for {tag}: {e}")
|
||||||
|
|
@ -253,7 +254,7 @@ class AccountingDataSync:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
plainJson = decryptValue(encryptedConfig)
|
plainJson = decryptValue(encryptedConfig)
|
||||||
connConfig = _json.loads(plainJson) if plainJson else {}
|
connConfig = json.loads(plainJson) if plainJson else {}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
summary["errors"].append(f"Failed to decrypt config: {e}")
|
summary["errors"].append(f"Failed to decrypt config: {e}")
|
||||||
return summary
|
return summary
|
||||||
|
|
@ -444,7 +445,6 @@ class AccountingDataSync:
|
||||||
Returns ``(entriesCount, linesCount, oldestBookingDate, newestBookingDate)``
|
Returns ``(entriesCount, linesCount, oldestBookingDate, newestBookingDate)``
|
||||||
where the date strings are ISO ``YYYY-MM-DD`` (or ``None`` if no entries).
|
where the date strings are ISO ``YYYY-MM-DD`` (or ``None`` if no entries).
|
||||||
"""
|
"""
|
||||||
import uuid as _uuid
|
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
self._bulkClear(modelEntry, featureInstanceId)
|
self._bulkClear(modelEntry, featureInstanceId)
|
||||||
self._bulkClear(modelLine, featureInstanceId)
|
self._bulkClear(modelLine, featureInstanceId)
|
||||||
|
|
@ -454,7 +454,7 @@ class AccountingDataSync:
|
||||||
oldestDate: Optional[str] = None
|
oldestDate: Optional[str] = None
|
||||||
newestDate: Optional[str] = None
|
newestDate: Optional[str] = None
|
||||||
for raw in rawEntries:
|
for raw in rawEntries:
|
||||||
entryId = str(_uuid.uuid4())
|
entryId = str(uuid.uuid4())
|
||||||
rawDate = raw.get("bookingDate")
|
rawDate = raw.get("bookingDate")
|
||||||
bookingTs = _isoDateToTimestamp(rawDate)
|
bookingTs = _isoDateToTimestamp(rawDate)
|
||||||
if rawDate:
|
if rawDate:
|
||||||
|
|
@ -603,7 +603,7 @@ class AccountingDataSync:
|
||||||
if not accNo or not bdate:
|
if not accNo or not bdate:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
dt = _dt.fromtimestamp(float(bdate), tz=_tz.utc)
|
dt = datetime.fromtimestamp(float(bdate), tz=timezone.utc)
|
||||||
year = dt.year
|
year = dt.year
|
||||||
month = dt.month
|
month = dt.month
|
||||||
except (ValueError, TypeError, OSError):
|
except (ValueError, TypeError, OSError):
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ Manages trustee organisations, roles, access, contracts, documents, and position
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Dict, Any, List, Optional, Union
|
from typing import Dict, Any, List, Optional, Union
|
||||||
|
|
@ -14,7 +15,7 @@ from pydantic import ValidationError
|
||||||
|
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
from modules.shared.dbRegistry import registerDatabase
|
from modules.dbHelpers.dbRegistry import registerDatabase
|
||||||
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC, getRecordsetPaginatedWithRBAC, getDistinctColumnValuesWithRBAC
|
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC, getRecordsetPaginatedWithRBAC, getDistinctColumnValuesWithRBAC
|
||||||
from modules.security.rbac import RbacClass
|
from modules.security.rbac import RbacClass
|
||||||
from modules.datamodels.datamodelUam import User, AccessLevel
|
from modules.datamodels.datamodelUam import User, AccessLevel
|
||||||
|
|
@ -562,7 +563,6 @@ class TrusteeObjects:
|
||||||
logger.error(f"Invalid organisation ID length: {len(orgId)}")
|
logger.error(f"Invalid organisation ID length: {len(orgId)}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
import re
|
|
||||||
if not re.match(r'^[a-zA-Z0-9_-]+$', orgId):
|
if not re.match(r'^[a-zA-Z0-9_-]+$', orgId):
|
||||||
logger.error(f"Invalid organisation ID format: {orgId}")
|
logger.error(f"Invalid organisation ID format: {orgId}")
|
||||||
return None
|
return None
|
||||||
|
|
@ -739,7 +739,6 @@ class TrusteeObjects:
|
||||||
if "featureInstanceId" not in data:
|
if "featureInstanceId" not in data:
|
||||||
data["featureInstanceId"] = self.featureInstanceId
|
data["featureInstanceId"] = self.featureInstanceId
|
||||||
|
|
||||||
import uuid
|
|
||||||
accessId = data.get("id") or str(uuid.uuid4())
|
accessId = data.get("id") or str(uuid.uuid4())
|
||||||
data["id"] = accessId
|
data["id"] = accessId
|
||||||
|
|
||||||
|
|
@ -936,7 +935,6 @@ class TrusteeObjects:
|
||||||
if "featureInstanceId" not in data:
|
if "featureInstanceId" not in data:
|
||||||
data["featureInstanceId"] = self.featureInstanceId
|
data["featureInstanceId"] = self.featureInstanceId
|
||||||
|
|
||||||
import uuid
|
|
||||||
contractId = data.get("id") or str(uuid.uuid4())
|
contractId = data.get("id") or str(uuid.uuid4())
|
||||||
data["id"] = contractId
|
data["id"] = contractId
|
||||||
|
|
||||||
|
|
@ -1047,7 +1045,6 @@ class TrusteeObjects:
|
||||||
data["mandateId"] = self.mandateId
|
data["mandateId"] = self.mandateId
|
||||||
data["featureInstanceId"] = self.featureInstanceId
|
data["featureInstanceId"] = self.featureInstanceId
|
||||||
|
|
||||||
import uuid
|
|
||||||
documentId = data.get("id") or str(uuid.uuid4())
|
documentId = data.get("id") or str(uuid.uuid4())
|
||||||
data["id"] = documentId
|
data["id"] = documentId
|
||||||
|
|
||||||
|
|
@ -1263,7 +1260,6 @@ class TrusteeObjects:
|
||||||
vatPercentage = data.get("vatPercentage", 0)
|
vatPercentage = data.get("vatPercentage", 0)
|
||||||
data["vatAmount"] = bookingAmount * vatPercentage / 100
|
data["vatAmount"] = bookingAmount * vatPercentage / 100
|
||||||
|
|
||||||
import uuid
|
|
||||||
positionId = data.get("id") or str(uuid.uuid4())
|
positionId = data.get("id") or str(uuid.uuid4())
|
||||||
data["id"] = positionId
|
data["id"] = positionId
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1041,3 +1041,59 @@ def _ensureAccessRulesForRole(rootInterface, roleId: str, ruleTemplates: List[Di
|
||||||
logger.debug(f"Created {createdCount} AccessRules for role {roleId}")
|
logger.debug(f"Created {createdCount} AccessRules for role {roleId}")
|
||||||
|
|
||||||
return createdCount
|
return createdCount
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Feature Lifecycle Hooks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def onMandateDelete(mandateId: str, instances: list) -> None:
|
||||||
|
"""Cascade-delete all 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}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,9 @@ import logging
|
||||||
import json
|
import json
|
||||||
import io
|
import io
|
||||||
import base64
|
import base64
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from modules.auth import limiter, getRequestContext, RequestContext
|
from modules.auth import limiter, getRequestContext, RequestContext
|
||||||
from .interfaceFeatureTrustee import getInterface
|
from .interfaceFeatureTrustee import getInterface
|
||||||
|
|
@ -395,10 +398,9 @@ def get_position_options(
|
||||||
items = result.items if hasattr(result, 'items') else result
|
items = result.items if hasattr(result, 'items') else result
|
||||||
|
|
||||||
def _makePositionLabel(p: TrusteePosition) -> str:
|
def _makePositionLabel(p: TrusteePosition) -> str:
|
||||||
from datetime import datetime as _dt, timezone as _tz
|
|
||||||
parts = []
|
parts = []
|
||||||
if p.valuta:
|
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:
|
if p.company:
|
||||||
parts.append(p.company[:30])
|
parts.append(p.company[:30])
|
||||||
if p.desc:
|
if p.desc:
|
||||||
|
|
@ -424,7 +426,7 @@ def get_organisations(
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
):
|
):
|
||||||
"""Get all organisations for a feature instance with optional pagination."""
|
"""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)
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
|
|
||||||
paginationParams = _parsePagination(pagination)
|
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]
|
return [r.model_dump() if hasattr(r, "model_dump") else r for r in items]
|
||||||
|
|
||||||
if paginationParams and hasattr(result, 'items'):
|
if paginationParams and hasattr(result, 'items'):
|
||||||
enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeOrganisation)
|
enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeOrganisation, db=interface.db)
|
||||||
return {
|
return {
|
||||||
"items": enriched,
|
"items": enriched,
|
||||||
"pagination": PaginationMetadata(
|
"pagination": PaginationMetadata(
|
||||||
|
|
@ -448,7 +450,7 @@ def get_organisations(
|
||||||
).model_dump(),
|
).model_dump(),
|
||||||
}
|
}
|
||||||
items = result if isinstance(result, list) else result.items
|
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}
|
return {"items": enriched, "pagination": None}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -544,7 +546,7 @@ def get_roles(
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
):
|
):
|
||||||
"""Get all roles with optional pagination."""
|
"""Get all roles with optional pagination."""
|
||||||
from modules.routes.routeHelpers import enrichRowsWithFkLabels
|
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
|
|
||||||
paginationParams = _parsePagination(pagination)
|
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]
|
return [r.model_dump() if hasattr(r, "model_dump") else r for r in items]
|
||||||
|
|
||||||
if paginationParams and hasattr(result, 'items'):
|
if paginationParams and hasattr(result, 'items'):
|
||||||
enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeRole)
|
enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeRole, db=interface.db)
|
||||||
return {
|
return {
|
||||||
"items": enriched,
|
"items": enriched,
|
||||||
"pagination": PaginationMetadata(
|
"pagination": PaginationMetadata(
|
||||||
|
|
@ -568,7 +570,7 @@ def get_roles(
|
||||||
).model_dump(),
|
).model_dump(),
|
||||||
}
|
}
|
||||||
items = result if isinstance(result, list) else result.items
|
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}
|
return {"items": enriched, "pagination": None}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -664,7 +666,7 @@ def get_all_access(
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
):
|
):
|
||||||
"""Get all access records with optional pagination."""
|
"""Get all access records with optional pagination."""
|
||||||
from modules.routes.routeHelpers import enrichRowsWithFkLabels
|
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
|
|
||||||
paginationParams = _parsePagination(pagination)
|
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]
|
return [r.model_dump() if hasattr(r, "model_dump") else r for r in items]
|
||||||
|
|
||||||
if paginationParams and hasattr(result, 'items'):
|
if paginationParams and hasattr(result, 'items'):
|
||||||
enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeAccess)
|
enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeAccess, db=interface.db)
|
||||||
return {
|
return {
|
||||||
"items": enriched,
|
"items": enriched,
|
||||||
"pagination": PaginationMetadata(
|
"pagination": PaginationMetadata(
|
||||||
|
|
@ -688,7 +690,7 @@ def get_all_access(
|
||||||
).model_dump(),
|
).model_dump(),
|
||||||
}
|
}
|
||||||
items = result if isinstance(result, list) else result.items
|
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}
|
return {"items": enriched, "pagination": None}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -814,7 +816,7 @@ def get_contracts(
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
):
|
):
|
||||||
"""Get all contracts with optional pagination."""
|
"""Get all contracts with optional pagination."""
|
||||||
from modules.routes.routeHelpers import enrichRowsWithFkLabels
|
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
|
|
||||||
paginationParams = _parsePagination(pagination)
|
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]
|
return [r.model_dump() if hasattr(r, "model_dump") else r for r in items]
|
||||||
|
|
||||||
if paginationParams and hasattr(result, 'items'):
|
if paginationParams and hasattr(result, 'items'):
|
||||||
enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeContract)
|
enriched = enrichRowsWithFkLabels(_toDicts(result.items), TrusteeContract, db=interface.db)
|
||||||
return {
|
return {
|
||||||
"items": enriched,
|
"items": enriched,
|
||||||
"pagination": PaginationMetadata(
|
"pagination": PaginationMetadata(
|
||||||
|
|
@ -838,7 +840,7 @@ def get_contracts(
|
||||||
).model_dump(),
|
).model_dump(),
|
||||||
}
|
}
|
||||||
items = result if isinstance(result, list) else result.items
|
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}
|
return {"items": enriched, "pagination": None}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -981,14 +983,15 @@ def get_documents(
|
||||||
|
|
||||||
def _handleDocumentMode(instanceId, mandateId, mode, column, pagination, context):
|
def _handleDocumentMode(instanceId, mandateId, mode, column, pagination, context):
|
||||||
"""Handle mode=filterValues and mode=ids for trustee documents."""
|
"""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)
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||||
if mode == "filterValues":
|
if mode == "filterValues":
|
||||||
if not column:
|
if not column:
|
||||||
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
raise HTTPException(status_code=400, detail="column parameter required for mode=filterValues")
|
||||||
result = interface.getAllDocuments(None)
|
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)]
|
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)
|
return handleFilterValuesInMemory(items, column, pagination)
|
||||||
if mode == "ids":
|
if mode == "ids":
|
||||||
result = interface.getAllDocuments(None)
|
result = interface.getAllDocuments(None)
|
||||||
|
|
@ -1260,7 +1263,8 @@ def get_positions(
|
||||||
|
|
||||||
def _handlePositionMode(instanceId, mandateId, mode, column, pagination, context):
|
def _handlePositionMode(instanceId, mandateId, mode, column, pagination, context):
|
||||||
"""Handle mode=filterValues and mode=ids for trustee positions."""
|
"""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
|
from .datamodelFeatureTrustee import TrusteePositionView
|
||||||
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||||
if mode == "filterValues":
|
if mode == "filterValues":
|
||||||
|
|
@ -1269,8 +1273,7 @@ def _handlePositionMode(instanceId, mandateId, mode, column, pagination, context
|
||||||
result = interface.getAllPositions(None)
|
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)]
|
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)
|
_enrichPositionsWithSyncStatus(items, interface, instanceId)
|
||||||
# Use the view model so FK labels for the synthetic columns also resolve.
|
enrichRowsWithFkLabels(items, TrusteePositionView, db=interface.db)
|
||||||
enrichRowsWithFkLabels(items, TrusteePositionView)
|
|
||||||
return handleFilterValuesInMemory(items, column, pagination)
|
return handleFilterValuesInMemory(items, column, pagination)
|
||||||
if mode == "ids":
|
if mode == "ids":
|
||||||
result = interface.getAllPositions(None)
|
result = interface.getAllPositions(None)
|
||||||
|
|
@ -1442,7 +1445,6 @@ def get_accounting_config(
|
||||||
record["configured"] = True
|
record["configured"] = True
|
||||||
if encryptedConfig:
|
if encryptedConfig:
|
||||||
try:
|
try:
|
||||||
import json
|
|
||||||
plain = json.loads(decryptValue(encryptedConfig, keyName="accountingConfig"))
|
plain = json.loads(decryptValue(encryptedConfig, keyName="accountingConfig"))
|
||||||
record["configMasked"] = _getConfigMasked(record.get("connectorType", ""), plain)
|
record["configMasked"] = _getConfigMasked(record.get("connectorType", ""), plain)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -1477,7 +1479,6 @@ async def save_accounting_config(
|
||||||
|
|
||||||
from .datamodelFeatureTrustee import TrusteeAccountingConfig
|
from .datamodelFeatureTrustee import TrusteeAccountingConfig
|
||||||
from modules.shared.configuration import encryptValue
|
from modules.shared.configuration import encryptValue
|
||||||
import uuid as _uuid
|
|
||||||
|
|
||||||
plainConfig = body.config if isinstance(body.config, dict) else {}
|
plainConfig = body.config if isinstance(body.config, dict) else {}
|
||||||
# When updating, empty config is normal (frontend never receives credentials from GET).
|
# 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")
|
encryptedConfig = encryptValue(json.dumps(plainConfig), keyName="accountingConfig")
|
||||||
|
|
||||||
configRecord = {
|
configRecord = {
|
||||||
"id": str(_uuid.uuid4()),
|
"id": str(uuid.uuid4()),
|
||||||
"featureInstanceId": instanceId,
|
"featureInstanceId": instanceId,
|
||||||
"connectorType": body.connectorType or "",
|
"connectorType": body.connectorType or "",
|
||||||
"displayLabel": body.displayLabel or "",
|
"displayLabel": body.displayLabel or "",
|
||||||
|
|
@ -2027,8 +2028,6 @@ def export_accounting_data(
|
||||||
TrusteeDataAccountBalance,
|
TrusteeDataAccountBalance,
|
||||||
TrusteeAccountingConfig,
|
TrusteeAccountingConfig,
|
||||||
)
|
)
|
||||||
import time as _time
|
|
||||||
|
|
||||||
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||||
_filter = {"featureInstanceId": instanceId}
|
_filter = {"featureInstanceId": instanceId}
|
||||||
|
|
||||||
|
|
@ -2057,7 +2056,7 @@ def export_accounting_data(
|
||||||
}
|
}
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"exportedAt": _time.time(),
|
"exportedAt": time.time(),
|
||||||
"featureInstanceId": instanceId,
|
"featureInstanceId": instanceId,
|
||||||
"mandateId": mandateId,
|
"mandateId": mandateId,
|
||||||
"syncInfo": syncInfo,
|
"syncInfo": syncInfo,
|
||||||
|
|
@ -2404,7 +2403,6 @@ def _buildFeatureInternalResolvers(modelClass, db) -> Dict[str, Any]:
|
||||||
val = row.get(col)
|
val = row.get(col)
|
||||||
if val is not None and val != "":
|
if val is not None and val != "":
|
||||||
if col == "bookingDate" and isinstance(val, (int, float)):
|
if col == "bookingDate" and isinstance(val, (int, float)):
|
||||||
from datetime import datetime, timezone
|
|
||||||
try:
|
try:
|
||||||
parts.append(datetime.fromtimestamp(val, tz=timezone.utc).strftime("%Y-%m-%d"))
|
parts.append(datetime.fromtimestamp(val, tz=timezone.utc).strftime("%Y-%m-%d"))
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -2440,11 +2438,8 @@ def _paginatedReadEndpoint(
|
||||||
from modules.interfaces.interfaceRbac import (
|
from modules.interfaces.interfaceRbac import (
|
||||||
getRecordsetPaginatedWithRBAC,
|
getRecordsetPaginatedWithRBAC,
|
||||||
)
|
)
|
||||||
from modules.routes.routeHelpers import (
|
from modules.dbHelpers.paginationHelpers import handleIdsInMemory, handleFilterValuesInMemory
|
||||||
handleIdsInMemory,
|
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
|
||||||
handleFilterValuesInMemory,
|
|
||||||
enrichRowsWithFkLabels,
|
|
||||||
)
|
|
||||||
|
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId)
|
||||||
|
|
@ -2465,7 +2460,7 @@ def _paginatedReadEndpoint(
|
||||||
rawItems = result.items if hasattr(result, "items") else result
|
rawItems = result.items if hasattr(result, "items") else result
|
||||||
items = [r.model_dump() if hasattr(r, "model_dump") else r for r in rawItems]
|
items = [r.model_dump() if hasattr(r, "model_dump") else r for r in rawItems]
|
||||||
featureResolvers = _buildFeatureInternalResolvers(modelClass, interface.db)
|
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)
|
return handleFilterValuesInMemory(items, column, pagination)
|
||||||
|
|
||||||
if mode == "ids":
|
if mode == "ids":
|
||||||
|
|
@ -2503,7 +2498,7 @@ def _paginatedReadEndpoint(
|
||||||
if paginationParams and hasattr(result, "items"):
|
if paginationParams and hasattr(result, "items"):
|
||||||
enriched = enrichRowsWithFkLabels(
|
enriched = enrichRowsWithFkLabels(
|
||||||
_itemsToDicts(result.items), modelClass,
|
_itemsToDicts(result.items), modelClass,
|
||||||
extraResolvers=featureResolvers or None,
|
db=interface.db, extraResolvers=featureResolvers or None,
|
||||||
)
|
)
|
||||||
return {
|
return {
|
||||||
"items": enriched,
|
"items": enriched,
|
||||||
|
|
@ -2519,7 +2514,7 @@ def _paginatedReadEndpoint(
|
||||||
items = result.items if hasattr(result, "items") else result
|
items = result.items if hasattr(result, "items") else result
|
||||||
enriched = enrichRowsWithFkLabels(
|
enriched = enrichRowsWithFkLabels(
|
||||||
_itemsToDicts(items), modelClass,
|
_itemsToDicts(items), modelClass,
|
||||||
extraResolvers=featureResolvers or None,
|
db=interface.db, extraResolvers=featureResolvers or None,
|
||||||
)
|
)
|
||||||
return {"items": enriched, "pagination": None}
|
return {"items": enriched, "pagination": None}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import logging
|
||||||
from typing import Dict, Any, Optional
|
from typing import Dict, Any, Optional
|
||||||
|
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
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.datamodels.datamodelUam import User
|
||||||
from modules.features.workspace.datamodelFeatureWorkspace import WorkspaceUserSettings
|
from modules.features.workspace.datamodelFeatureWorkspace import WorkspaceUserSettings
|
||||||
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
|
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
|
||||||
|
|
|
||||||
|
|
@ -311,3 +311,51 @@ def _ensureAccessRulesForRole(rootInterface, roleId: str, ruleTemplates: List[Di
|
||||||
logger.debug(f"Created {createdCount} AccessRules for role {roleId}")
|
logger.debug(f"Created {createdCount} AccessRules for role {roleId}")
|
||||||
|
|
||||||
return createdCount
|
return createdCount
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Feature Lifecycle Hooks
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def onMandateDelete(mandateId: str, instances: list) -> None:
|
||||||
|
"""Cascade-delete all 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}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ SSE-based endpoints for the agent-driven AI Workspace.
|
||||||
import logging
|
import logging
|
||||||
import json
|
import json
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any, Dict, Optional, List
|
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):
|
def _getDbManagement(context: RequestContext, featureInstanceId: str = None):
|
||||||
return interfaceDbManagement.getInterface(
|
return interfaceDbManagement.getInterface(
|
||||||
|
|
@ -236,7 +218,7 @@ def buildDataSourceContext(chatService, dataSourceIds: List[str]) -> str:
|
||||||
|
|
||||||
def buildFeatureDataSourceContext(featureDataSourceIds: List[str]) -> str:
|
def buildFeatureDataSourceContext(featureDataSourceIds: List[str]) -> str:
|
||||||
"""Build a description of attached feature data sources for the agent prompt."""
|
"""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.security.rbacCatalog import getCatalogService
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
|
||||||
|
|
@ -343,7 +325,7 @@ def _buildWorkspaceAttachmentLabel(chatService: Any, dataSourceIds: List[str], f
|
||||||
fdsLabels: List[str] = []
|
fdsLabels: List[str] = []
|
||||||
for fdsId in featureDataSourceIds or []:
|
for fdsId in featureDataSourceIds or []:
|
||||||
try:
|
try:
|
||||||
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
|
from modules.datamodels.datamodelFeatures import FeatureDataSource
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
rootIf = getRootInterface()
|
rootIf = getRootInterface()
|
||||||
records = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"id": fdsId})
|
records = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"id": fdsId})
|
||||||
|
|
@ -1170,10 +1152,10 @@ async def getWorkspaceMessages(
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
rootIf = getRootInterface()
|
rootIf = getRootInterface()
|
||||||
if attachedDsIds:
|
if attachedDsIds:
|
||||||
from modules.datamodels.datamodelDataSource import DataSource as _DS
|
from modules.datamodels.datamodelDataSource import DataSource
|
||||||
for dsId in attachedDsIds:
|
for dsId in attachedDsIds:
|
||||||
try:
|
try:
|
||||||
records = rootIf.db.getRecordset(_DS, recordFilter={"id": dsId})
|
records = rootIf.db.getRecordset(DataSource, recordFilter={"id": dsId})
|
||||||
if records:
|
if records:
|
||||||
lbl = records[0].get("label") or records[0].get("path") or ""
|
lbl = records[0].get("label") or records[0].get("path") or ""
|
||||||
if lbl:
|
if lbl:
|
||||||
|
|
@ -1181,10 +1163,10 @@ async def getWorkspaceMessages(
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if attachedFdsIds:
|
if attachedFdsIds:
|
||||||
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource as _FDS
|
from modules.datamodels.datamodelFeatures import FeatureDataSource
|
||||||
for fdsId in attachedFdsIds:
|
for fdsId in attachedFdsIds:
|
||||||
try:
|
try:
|
||||||
records = rootIf.db.getRecordset(_FDS, recordFilter={"id": fdsId})
|
records = rootIf.db.getRecordset(FeatureDataSource, recordFilter={"id": fdsId})
|
||||||
if records:
|
if records:
|
||||||
tbl = records[0].get("tableName") or ""
|
tbl = records[0].get("tableName") or ""
|
||||||
lbl = records[0].get("label") or tbl
|
lbl = records[0].get("label") or tbl
|
||||||
|
|
@ -1298,7 +1280,6 @@ async def getFileContent(
|
||||||
filePath = fileData.get("filePath")
|
filePath = fileData.get("filePath")
|
||||||
if not filePath:
|
if not filePath:
|
||||||
raise HTTPException(status_code=404, detail=routeApiMsg("File has no stored path"))
|
raise HTTPException(status_code=404, detail=routeApiMsg("File has no stored path"))
|
||||||
import os
|
|
||||||
if not os.path.isfile(filePath):
|
if not os.path.isfile(filePath):
|
||||||
raise HTTPException(status_code=404, detail=routeApiMsg("File not found on disk"))
|
raise HTTPException(status_code=404, detail=routeApiMsg("File not found on disk"))
|
||||||
mimeType = fileData.get("mimeType", "application/octet-stream")
|
mimeType = fileData.get("mimeType", "application/octet-stream")
|
||||||
|
|
@ -1438,7 +1419,7 @@ async def createFeatureDataSource(
|
||||||
"""
|
"""
|
||||||
_validateInstanceAccess(instanceId, context)
|
_validateInstanceAccess(instanceId, context)
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
|
from modules.datamodels.datamodelFeatures import FeatureDataSource
|
||||||
|
|
||||||
rootIf = getRootInterface()
|
rootIf = getRootInterface()
|
||||||
if not rootIf.getFeatureAccess(str(context.user.id), body.featureInstanceId):
|
if not rootIf.getFeatureAccess(str(context.user.id), body.featureInstanceId):
|
||||||
|
|
@ -1482,7 +1463,7 @@ async def listFeatureDataSources(
|
||||||
the mandate."""
|
the mandate."""
|
||||||
wsMandateId, _ = _validateInstanceAccess(instanceId, context)
|
wsMandateId, _ = _validateInstanceAccess(instanceId, context)
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
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
|
from modules.serviceCenter.services.serviceKnowledge._inheritFlags import buildEffectiveByWorkspaceFds
|
||||||
|
|
||||||
rootIf = getRootInterface()
|
rootIf = getRootInterface()
|
||||||
|
|
@ -1514,7 +1495,7 @@ async def deleteFeatureDataSource(
|
||||||
"""Delete a FeatureDataSource."""
|
"""Delete a FeatureDataSource."""
|
||||||
_mandateId, _ = _validateInstanceAccess(instanceId, context)
|
_mandateId, _ = _validateInstanceAccess(instanceId, context)
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
|
from modules.datamodels.datamodelFeatures import FeatureDataSource
|
||||||
|
|
||||||
rootIf = getRootInterface()
|
rootIf = getRootInterface()
|
||||||
rootIf.db.recordDelete(FeatureDataSource, featureDataSourceId)
|
rootIf.db.recordDelete(FeatureDataSource, featureDataSourceId)
|
||||||
|
|
|
||||||
|
|
@ -158,7 +158,7 @@ def _backfillTargetFeatureInstanceId() -> None:
|
||||||
"""
|
"""
|
||||||
def _do() -> None:
|
def _do() -> None:
|
||||||
from modules.shared.configuration import APP_CONFIG
|
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")
|
dbHost = APP_CONFIG.get("DB_HOST", "localhost")
|
||||||
dbUser = APP_CONFIG.get("DB_USER")
|
dbUser = APP_CONFIG.get("DB_USER")
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import logging
|
||||||
import asyncio
|
import asyncio
|
||||||
import uuid
|
import uuid
|
||||||
import base64
|
import base64
|
||||||
|
import json
|
||||||
from typing import Dict, Any, List, Union, Tuple, Optional, Callable, AsyncGenerator
|
from typing import Dict, Any, List, Union, Tuple, Optional, Callable, AsyncGenerator
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
import time
|
import time
|
||||||
|
|
@ -316,8 +317,6 @@ class AiObjects:
|
||||||
tools: List[Dict[str, Any]] = None,
|
tools: List[Dict[str, Any]] = None,
|
||||||
toolChoice: Any = None) -> AiCallResponse:
|
toolChoice: Any = None) -> AiCallResponse:
|
||||||
"""Call a model with pre-built messages (agent mode). Supports tools for native function calling."""
|
"""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)
|
inputBytes = sum(len(str(m.get("content", "")).encode("utf-8")) for m in messages)
|
||||||
startTime = time.time()
|
startTime = time.time()
|
||||||
|
|
||||||
|
|
@ -536,7 +535,7 @@ class AiObjects:
|
||||||
Returns:
|
Returns:
|
||||||
AiCallResponse with metadata["embeddings"] containing the vectors.
|
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:
|
if options is None:
|
||||||
options = AiCallOptions(operationType=OperationTypeEnum.EMBEDDING)
|
options = AiCallOptions(operationType=OperationTypeEnum.EMBEDDING)
|
||||||
|
|
@ -622,7 +621,7 @@ class AiObjects:
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
except _CtxExc as e:
|
except ContextLengthExceededException as e:
|
||||||
logger.error(f"ContextLengthExceeded for {model.name} despite batching – aborting failover: {e}")
|
logger.error(f"ContextLengthExceeded for {model.name} despite batching – aborting failover: {e}")
|
||||||
return AiCallResponse(
|
return AiCallResponse(
|
||||||
content=str(e), modelName=model.name, priceCHF=0.0,
|
content=str(e), modelName=model.name, priceCHF=0.0,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ Multi-Tenant Design:
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import uuid
|
||||||
from typing import Optional, Dict
|
from typing import Optional, Dict
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
|
|
@ -101,9 +102,6 @@ def initBootstrap(db: DatabaseConnector) -> None:
|
||||||
if mandateId:
|
if mandateId:
|
||||||
_initRootMandateSubscription(mandateId)
|
_initRootMandateSubscription(mandateId)
|
||||||
|
|
||||||
# Auto-provision Stripe Products/Prices for paid plans (idempotent)
|
|
||||||
_bootstrapStripePrices()
|
|
||||||
|
|
||||||
# Purge soft-deleted mandates past 30-day retention
|
# Purge soft-deleted mandates past 30-day retention
|
||||||
try:
|
try:
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
|
@ -112,12 +110,15 @@ def initBootstrap(db: DatabaseConnector) -> None:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Mandate retention purge failed: {e}")
|
logger.warning(f"Mandate retention purge failed: {e}")
|
||||||
|
|
||||||
# Bootstrap system workflow templates for graphical editor
|
# Let features run their own bootstrap logic via lifecycle hooks
|
||||||
_bootstrapSystemTemplates(db)
|
from modules.system.registry import loadFeatureMainModules
|
||||||
|
for _fCode, _fMod in loadFeatureMainModules().items():
|
||||||
# Sync feature template workflows (update graph of existing instance workflows
|
_bootHook = getattr(_fMod, "onBootstrap", None)
|
||||||
# whose templateSourceId matches a current code-defined template)
|
if _bootHook:
|
||||||
_syncFeatureTemplateWorkflows()
|
try:
|
||||||
|
_bootHook()
|
||||||
|
except Exception as _bootErr:
|
||||||
|
logger.warning(f"onBootstrap hook for '{_fCode}' failed: {_bootErr}")
|
||||||
|
|
||||||
# Ensure billing settings and accounts exist for all mandates
|
# Ensure billing settings and accounts exist for all mandates
|
||||||
_bootstrapBilling()
|
_bootstrapBilling()
|
||||||
|
|
@ -154,219 +155,10 @@ def _bootstrapBilling() -> None:
|
||||||
logger.warning(f"Billing bootstrap failed (non-critical): {e}")
|
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:
|
def initRootMandateFeatures(db: DatabaseConnector, mandateId: str) -> None:
|
||||||
|
|
@ -749,8 +541,6 @@ def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int:
|
||||||
Returns:
|
Returns:
|
||||||
Number of roles copied
|
Number of roles copied
|
||||||
"""
|
"""
|
||||||
import uuid as _uuid
|
|
||||||
|
|
||||||
# Find system template roles (global: mandateId=NULL, isSystemRole=True)
|
# Find system template roles (global: mandateId=NULL, isSystemRole=True)
|
||||||
templateRoles = db.getRecordset(
|
templateRoles = db.getRecordset(
|
||||||
Role,
|
Role,
|
||||||
|
|
@ -785,7 +575,7 @@ def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int:
|
||||||
logger.debug(f"Mandate {mandateId} already has role '{roleLabel}', skipping")
|
logger.debug(f"Mandate {mandateId} already has role '{roleLabel}', skipping")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
newRoleId = str(_uuid.uuid4())
|
newRoleId = str(uuid.uuid4())
|
||||||
|
|
||||||
# Create mandate-instance role
|
# Create mandate-instance role
|
||||||
newRole = Role(
|
newRole = Role(
|
||||||
|
|
@ -803,7 +593,7 @@ def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int:
|
||||||
templateRules = rulesByRoleId.get(templateRole.get("id"), [])
|
templateRules = rulesByRoleId.get(templateRole.get("id"), [])
|
||||||
for rule in templateRules:
|
for rule in templateRules:
|
||||||
newRule = AccessRule(
|
newRule = AccessRule(
|
||||||
id=str(_uuid.uuid4()),
|
id=str(uuid.uuid4()),
|
||||||
roleId=newRoleId,
|
roleId=newRoleId,
|
||||||
context=rule.get("context"),
|
context=rule.get("context"),
|
||||||
item=rule.get("item"),
|
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}")
|
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(
|
def assignInitialUserMemberships(
|
||||||
db: DatabaseConnector,
|
db: DatabaseConnector,
|
||||||
mandateId: str,
|
mandateId: str,
|
||||||
|
|
@ -2034,7 +1814,7 @@ def _applyDatabaseOptimizations(db: DatabaseConnector) -> None:
|
||||||
db: Database connector instance
|
db: Database connector instance
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from modules.shared.dbMultiTenantOptimizations import applyMultiTenantOptimizations
|
from modules.dbHelpers.dbMultiTenantOptimizations import applyMultiTenantOptimizations
|
||||||
|
|
||||||
result = applyMultiTenantOptimizations(db)
|
result = applyMultiTenantOptimizations(db)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,13 +11,15 @@ Multi-Tenant Design:
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
from typing import Dict, Any, List, Optional, Union
|
from typing import Dict, Any, List, Optional, Union
|
||||||
from passlib.context import CryptContext
|
from passlib.context import CryptContext
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector, getCachedConnector
|
from modules.connectors.connectorDbPostgre import DatabaseConnector, getCachedConnector
|
||||||
from modules.shared.configuration import APP_CONFIG
|
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.timeUtils import getUtcTimestamp, parseTimestamp
|
||||||
from modules.shared.i18nRegistry import resolveText
|
from modules.shared.i18nRegistry import resolveText
|
||||||
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
|
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
|
||||||
|
|
@ -498,6 +500,9 @@ class AppObjects:
|
||||||
recordFilter={"id": userIds}
|
recordFilter={"id": userIds}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
|
||||||
|
enrichRowsWithFkLabels(result.get("items", []), UserInDB, db=self.db)
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
for record in result["items"]:
|
for record in result["items"]:
|
||||||
cleanedUser = dict(record)
|
cleanedUser = dict(record)
|
||||||
|
|
@ -1611,7 +1616,6 @@ class AppObjects:
|
||||||
|
|
||||||
from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot
|
from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot
|
||||||
from modules.interfaces.interfaceDbBilling import getRootInterface as _getBillingRoot
|
from modules.interfaces.interfaceDbBilling import getRootInterface as _getBillingRoot
|
||||||
from datetime import datetime, timezone, timedelta
|
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
nowTs = now.timestamp()
|
nowTs = now.timestamp()
|
||||||
|
|
@ -1710,7 +1714,6 @@ class AppObjects:
|
||||||
SubscriptionStatusEnum, BUILTIN_PLANS,
|
SubscriptionStatusEnum, BUILTIN_PLANS,
|
||||||
)
|
)
|
||||||
from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot
|
from modules.interfaces.interfaceDbSubscription import getRootInterface as _getSubRoot
|
||||||
from datetime import datetime, timezone, timedelta
|
|
||||||
|
|
||||||
activated = 0
|
activated = 0
|
||||||
subInterface = _getSubRoot()
|
subInterface = _getSubRoot()
|
||||||
|
|
@ -1861,14 +1864,21 @@ class AppObjects:
|
||||||
from modules.datamodels.datamodelFiles import FileItem
|
from modules.datamodels.datamodelFiles import FileItem
|
||||||
from modules.datamodels.datamodelDataSource import DataSource
|
from modules.datamodels.datamodelDataSource import DataSource
|
||||||
from modules.datamodels.datamodelKnowledge import FileContentIndex, ContentChunk
|
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.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})
|
instances = self.db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
|
||||||
|
|
||||||
# 0-pre. Delete AutoWorkflow data in Greenfield DB (poweron_graphicaleditor)
|
# 0-pre. Let features cascade-delete their own data via lifecycle hooks
|
||||||
self._cascadeDeleteGraphicalEditorData(mandateId, instances)
|
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
|
# 0. Delete instance-scoped data for each FeatureInstance
|
||||||
for inst in instances:
|
for inst in instances:
|
||||||
|
|
@ -2011,67 +2021,6 @@ class AppObjects:
|
||||||
logger.error(f"Error deleting mandate: {str(e)}")
|
logger.error(f"Error deleting mandate: {str(e)}")
|
||||||
raise ValueError(f"Failed to delete 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:
|
def restoreMandate(self, mandateId: str) -> bool:
|
||||||
"""Restore a soft-deleted mandate (undo soft-delete within the 30-day retention window)."""
|
"""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:
|
def purgeExpiredMandates(self, retentionDays: int = 30) -> int:
|
||||||
"""Hard-delete all mandates whose soft-delete timestamp exceeds the retention period."""
|
"""Hard-delete all mandates whose soft-delete timestamp exceeds the retention period."""
|
||||||
import time
|
|
||||||
cutoff = time.time() - (retentionDays * 86400)
|
cutoff = time.time() - (retentionDays * 86400)
|
||||||
allMandates = self.db.getRecordset(Mandate)
|
allMandates = self.db.getRecordset(Mandate)
|
||||||
purged = 0
|
purged = 0
|
||||||
|
|
@ -2914,6 +2862,9 @@ class AppObjects:
|
||||||
try:
|
try:
|
||||||
result = self.db.getRecordsetPaginated(UserInDB, pagination=pagination)
|
result = self.db.getRecordsetPaginated(UserInDB, pagination=pagination)
|
||||||
|
|
||||||
|
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
|
||||||
|
enrichRowsWithFkLabels(result.get("items", []), UserInDB, db=self.db)
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
for record in result["items"]:
|
for record in result["items"]:
|
||||||
user = User.model_validate(record)
|
user = User.model_validate(record)
|
||||||
|
|
@ -3919,6 +3870,9 @@ class AppObjects:
|
||||||
try:
|
try:
|
||||||
result = self.db.getRecordsetPaginated(Role, pagination=pagination)
|
result = self.db.getRecordsetPaginated(Role, pagination=pagination)
|
||||||
|
|
||||||
|
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
|
||||||
|
enrichRowsWithFkLabels(result.get("items", []), Role, db=self.db)
|
||||||
|
|
||||||
items = []
|
items = []
|
||||||
for record in result["items"]:
|
for record in result["items"]:
|
||||||
cleanedRole = dict(record)
|
cleanedRole = dict(record)
|
||||||
|
|
|
||||||
|
|
@ -8,13 +8,15 @@ All billing data is stored in the poweron_billing database.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import copy
|
||||||
|
import math
|
||||||
from typing import Dict, Any, List, Optional, Union
|
from typing import Dict, Any, List, Optional, Union
|
||||||
from datetime import date, datetime, timedelta, timezone
|
from datetime import date, datetime, timedelta, timezone
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
from modules.shared.dbRegistry import registerDatabase
|
from modules.dbHelpers.dbRegistry import registerDatabase
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
from modules.shared.timeUtils import getUtcTimestamp
|
||||||
from modules.datamodels.datamodelUam import User, Mandate
|
from modules.datamodels.datamodelUam import User, Mandate
|
||||||
from modules.datamodels.datamodelMembership import UserMandate
|
from modules.datamodels.datamodelMembership import UserMandate
|
||||||
|
|
@ -632,6 +634,8 @@ class BillingObjects:
|
||||||
pagination=pagination,
|
pagination=pagination,
|
||||||
recordFilter=recordFilter
|
recordFilter=recordFilter
|
||||||
)
|
)
|
||||||
|
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
|
||||||
|
enrichRowsWithFkLabels(result.get("items", []), BillingTransaction, db=self.db)
|
||||||
_logBillingTransactionsMissingSysCreatedAt(
|
_logBillingTransactionsMissingSysCreatedAt(
|
||||||
result["items"],
|
result["items"],
|
||||||
"getTransactions(accountId) paginated",
|
"getTransactions(accountId) paginated",
|
||||||
|
|
@ -702,6 +706,8 @@ class BillingObjects:
|
||||||
pagination=pagination,
|
pagination=pagination,
|
||||||
recordFilter={"accountId": accountIds}
|
recordFilter={"accountId": accountIds}
|
||||||
)
|
)
|
||||||
|
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
|
||||||
|
enrichRowsWithFkLabels(result.get("items", []), BillingTransaction, db=self.db)
|
||||||
return PaginatedResult(
|
return PaginatedResult(
|
||||||
items=result["items"],
|
items=result["items"],
|
||||||
totalItems=result["totalItems"],
|
totalItems=result["totalItems"],
|
||||||
|
|
@ -1499,7 +1505,6 @@ class BillingObjects:
|
||||||
"""Remap frontend column names to DB column names in filters and sort."""
|
"""Remap frontend column names to DB column names in filters and sort."""
|
||||||
_COL_MAP: dict = {}
|
_COL_MAP: dict = {}
|
||||||
_ENRICHED_COLS = {"mandateName", "userName", "mandateId", "userId"}
|
_ENRICHED_COLS = {"mandateName", "userName", "mandateId", "userId"}
|
||||||
import copy
|
|
||||||
p = copy.deepcopy(pagination)
|
p = copy.deepcopy(pagination)
|
||||||
if p.filters:
|
if p.filters:
|
||||||
mapped = {}
|
mapped = {}
|
||||||
|
|
@ -1578,6 +1583,12 @@ class BillingObjects:
|
||||||
pagination=mappedPagination,
|
pagination=mappedPagination,
|
||||||
recordFilter=recordFilter,
|
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
|
pageItems = result.get("items", []) if isinstance(result, dict) else result.items
|
||||||
totalItems = result.get("totalItems", 0) if isinstance(result, dict) else result.totalItems
|
totalItems = result.get("totalItems", 0) if isinstance(result, dict) else result.totalItems
|
||||||
totalPages = result.get("totalPages", 0) if isinstance(result, dict) else result.totalPages
|
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
|
`amount` column. Resolves matching mandate/user IDs via the app DB
|
||||||
first, then builds a single SQL query with OR-combined conditions.
|
first, then builds a single SQL query with OR-combined conditions.
|
||||||
"""
|
"""
|
||||||
import math
|
|
||||||
from modules.connectors.connectorDbPostgre import getModelFields, parseRecordFields
|
from modules.connectors.connectorDbPostgre import getModelFields, parseRecordFields
|
||||||
from modules.datamodels.datamodelUam import UserInDB
|
from modules.datamodels.datamodelUam import UserInDB
|
||||||
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
||||||
|
|
@ -1701,7 +1711,6 @@ class BillingObjects:
|
||||||
|
|
||||||
# Apply non-search filters from pagination (reuse existing builder for
|
# Apply non-search filters from pagination (reuse existing builder for
|
||||||
# everything except the `search` key which we handle explicitly).
|
# everything except the `search` key which we handle explicitly).
|
||||||
import copy
|
|
||||||
paginationWithoutSearch = copy.deepcopy(pagination) if pagination else None
|
paginationWithoutSearch = copy.deepcopy(pagination) if pagination else None
|
||||||
if paginationWithoutSearch and paginationWithoutSearch.filters:
|
if paginationWithoutSearch and paginationWithoutSearch.filters:
|
||||||
paginationWithoutSearch.filters = {
|
paginationWithoutSearch.filters = {
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,10 @@ Uses the JSON connector for data access with added language support.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
import uuid
|
import uuid
|
||||||
import math
|
import math
|
||||||
|
from datetime import datetime, UTC
|
||||||
from typing import Dict, Any, List, Optional, Union
|
from typing import Dict, Any, List, Optional, Union
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
@ -29,7 +31,7 @@ from modules.datamodels.datamodelUam import User
|
||||||
|
|
||||||
# DYNAMIC PART: Connectors to the Interface
|
# DYNAMIC PART: Connectors to the Interface
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
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.shared.timeUtils import getUtcTimestamp, parseTimestamp
|
||||||
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult
|
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult
|
||||||
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
|
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
|
||||||
|
|
@ -60,8 +62,6 @@ def storeDebugMessageAndDocuments(message, currentUser, mandateId=None, featureI
|
||||||
featureInstanceId: Feature instance ID for RBAC context
|
featureInstanceId: Feature instance ID for RBAC context
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import os
|
|
||||||
from datetime import datetime, UTC
|
|
||||||
from modules.shared.debugLogger import getBaseDebugDir, ensureDir
|
from modules.shared.debugLogger import getBaseDebugDir, ensureDir
|
||||||
from modules.interfaces.interfaceDbManagement import getInterface
|
from modules.interfaces.interfaceDbManagement import getInterface
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from datetime import datetime, timezone, timedelta
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
|
|
||||||
from modules.connectors.connectorDbPostgre import getCachedConnector
|
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.datamodelKnowledge import FileContentIndex, ContentChunk, RoundMemory, WorkflowMemory
|
||||||
from modules.datamodels.datamodelUam import User
|
from modules.datamodels.datamodelUam import User
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
|
|
@ -125,9 +125,9 @@ class KnowledgeObjects:
|
||||||
|
|
||||||
for mid in mandateIds:
|
for mid in mandateIds:
|
||||||
try:
|
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:
|
except Exception as ex:
|
||||||
logger.warning("reconcileMandateStorageBilling after connection purge failed: %s", ex)
|
logger.warning("reconcileMandateStorageBilling after connection purge failed: %s", ex)
|
||||||
|
|
||||||
|
|
@ -168,8 +168,8 @@ class KnowledgeObjects:
|
||||||
|
|
||||||
for mid in mandateIds:
|
for mid in mandateIds:
|
||||||
try:
|
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:
|
except Exception as ex:
|
||||||
logger.warning("reconcileMandateStorageBilling after datasource purge failed: %s", ex)
|
logger.warning("reconcileMandateStorageBilling after datasource purge failed: %s", ex)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,10 +11,11 @@ import base64
|
||||||
import hashlib
|
import hashlib
|
||||||
import math
|
import math
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import re
|
||||||
from typing import Dict, Any, List, Optional, Union
|
from typing import Dict, Any, List, Optional, Union
|
||||||
|
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector, getCachedConnector
|
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.interfaces.interfaceRbac import getRecordsetWithRBAC, getRecordsetPaginatedWithRBAC
|
||||||
from modules.security.rbac import RbacClass
|
from modules.security.rbac import RbacClass
|
||||||
from modules.datamodels.datamodelRbac import AccessRuleContext
|
from modules.datamodels.datamodelRbac import AccessRuleContext
|
||||||
|
|
@ -194,7 +195,6 @@ class ComponentObjects:
|
||||||
try:
|
try:
|
||||||
# Initialize standard prompts
|
# Initialize standard prompts
|
||||||
self._initializeStandardPrompts()
|
self._initializeStandardPrompts()
|
||||||
self._seedUiLanguageSetsIfEmpty()
|
|
||||||
|
|
||||||
# Add other record initializations here
|
# Add other record initializations here
|
||||||
|
|
||||||
|
|
@ -204,47 +204,6 @@ class ComponentObjects:
|
||||||
# Don't raise the error, just log it
|
# Don't raise the error, just log it
|
||||||
# This allows the interface to be created even if initialization fails
|
# 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):
|
def _initializeStandardPrompts(self):
|
||||||
"""Initializes standard prompts if they don't exist yet."""
|
"""Initializes standard prompts if they don't exist yet."""
|
||||||
|
|
@ -606,7 +565,6 @@ class ComponentObjects:
|
||||||
size_str = size_str.replace(",", "").replace(" ", "")
|
size_str = size_str.replace(",", "").replace(" ", "")
|
||||||
|
|
||||||
# Extract number and unit - handle both "MB" and "M" formats
|
# 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: number (with optional decimal) followed by optional unit (K/M/G/T with optional B)
|
||||||
match = re.match(r"^([\d.]+)([KMGT]?B?)$", size_str)
|
match = re.match(r"^([\d.]+)([KMGT]?B?)$", size_str)
|
||||||
if not match:
|
if not match:
|
||||||
|
|
@ -900,36 +858,21 @@ class ComponentObjects:
|
||||||
_extensionToMime: Optional[Dict[str, str]] = None
|
_extensionToMime: Optional[Dict[str, str]] = None
|
||||||
_textMimeTypes: Optional[set] = 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
|
@classmethod
|
||||||
def _ensureMimeMaps(cls):
|
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:
|
if cls._extensionToMime is not None:
|
||||||
return
|
return
|
||||||
try:
|
# Fallback: maps not yet injected from bootstrap
|
||||||
from modules.serviceCenter.services.serviceExtraction.subRegistry import ExtractorRegistry
|
cls._extensionToMime = {}
|
||||||
registry = ExtractorRegistry()
|
cls._textMimeTypes = set()
|
||||||
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()
|
|
||||||
|
|
||||||
def getMimeType(self, fileName: str) -> str:
|
def getMimeType(self, fileName: str) -> str:
|
||||||
"""Determines the MIME type based on the file extension.
|
"""Determines the MIME type based on the file extension.
|
||||||
|
|
@ -2314,4 +2257,24 @@ def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] =
|
||||||
else:
|
else:
|
||||||
logger.info("Returning interface without user context")
|
logger.info("Returning interface without user context")
|
||||||
|
|
||||||
return interface
|
return interface
|
||||||
|
|
||||||
|
|
||||||
|
def buildResolverDbInterface(chatService):
|
||||||
|
"""Build a DB adapter that ConnectorResolver can use to load UserConnections.
|
||||||
|
|
||||||
|
ConnectorResolver calls db.getUserConnection(connectionId).
|
||||||
|
interfaceDbApp provides getUserConnectionById(connectionId).
|
||||||
|
This adapter bridges the method name difference.
|
||||||
|
"""
|
||||||
|
class _ResolverDbAdapter:
|
||||||
|
def __init__(self, appInterface):
|
||||||
|
self._app = appInterface
|
||||||
|
def getUserConnection(self, connectionId: str):
|
||||||
|
if hasattr(self._app, "getUserConnectionById"):
|
||||||
|
return self._app.getUserConnectionById(connectionId)
|
||||||
|
return None
|
||||||
|
appIf = getattr(chatService, "interfaceDbApp", None)
|
||||||
|
if appIf:
|
||||||
|
return _ResolverDbAdapter(appIf)
|
||||||
|
return getattr(chatService, "interfaceDbComponent", None)
|
||||||
|
|
@ -13,7 +13,7 @@ from datetime import datetime, timezone
|
||||||
|
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
from modules.shared.dbRegistry import registerDatabase
|
from modules.dbHelpers.dbRegistry import registerDatabase
|
||||||
from modules.datamodels.datamodelUam import User
|
from modules.datamodels.datamodelUam import User
|
||||||
from modules.datamodels.datamodelMembership import UserMandate
|
from modules.datamodels.datamodelMembership import UserMandate
|
||||||
from modules.datamodels.datamodelSubscription import (
|
from modules.datamodels.datamodelSubscription import (
|
||||||
|
|
@ -30,6 +30,8 @@ from modules.datamodels.datamodelSubscription import (
|
||||||
getEffectiveLimits,
|
getEffectiveLimits,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from modules.shared.serviceExceptions import SubscriptionCapacityException
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SUBSCRIPTION_DATABASE = "poweron_billing"
|
SUBSCRIPTION_DATABASE = "poweron_billing"
|
||||||
|
|
@ -270,7 +272,6 @@ class SubscriptionObjects:
|
||||||
def assertCapacity(self, mandateId: str, resourceType: str, delta: int = 1) -> bool:
|
def assertCapacity(self, mandateId: str, resourceType: str, delta: int = 1) -> bool:
|
||||||
sub = self.getOperativeForMandate(mandateId)
|
sub = self.getOperativeForMandate(mandateId)
|
||||||
if not sub:
|
if not sub:
|
||||||
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
|
|
||||||
raise SubscriptionCapacityException(
|
raise SubscriptionCapacityException(
|
||||||
resourceType=resourceType, currentCount=0, maxAllowed=0,
|
resourceType=resourceType, currentCount=0, maxAllowed=0,
|
||||||
message="No active subscription for this mandate.",
|
message="No active subscription for this mandate.",
|
||||||
|
|
@ -286,7 +287,6 @@ class SubscriptionObjects:
|
||||||
return True
|
return True
|
||||||
current = self.countActiveUsers(mandateId)
|
current = self.countActiveUsers(mandateId)
|
||||||
if current + delta > cap:
|
if current + delta > cap:
|
||||||
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
|
|
||||||
raise SubscriptionCapacityException(
|
raise SubscriptionCapacityException(
|
||||||
resourceType=resourceType, currentCount=current, maxAllowed=cap,
|
resourceType=resourceType, currentCount=current, maxAllowed=cap,
|
||||||
isEnterprise=isEnterprise,
|
isEnterprise=isEnterprise,
|
||||||
|
|
@ -297,7 +297,6 @@ class SubscriptionObjects:
|
||||||
return True
|
return True
|
||||||
current = self.countActiveFeatureInstances(mandateId)
|
current = self.countActiveFeatureInstances(mandateId)
|
||||||
if current + delta > cap:
|
if current + delta > cap:
|
||||||
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
|
|
||||||
raise SubscriptionCapacityException(
|
raise SubscriptionCapacityException(
|
||||||
resourceType=resourceType, currentCount=current, maxAllowed=cap,
|
resourceType=resourceType, currentCount=current, maxAllowed=cap,
|
||||||
isEnterprise=isEnterprise,
|
isEnterprise=isEnterprise,
|
||||||
|
|
@ -308,7 +307,6 @@ class SubscriptionObjects:
|
||||||
return True
|
return True
|
||||||
currentMB = self.getMandateDataVolumeMB(mandateId)
|
currentMB = self.getMandateDataVolumeMB(mandateId)
|
||||||
if currentMB + delta > cap:
|
if currentMB + delta > cap:
|
||||||
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
|
|
||||||
raise SubscriptionCapacityException(
|
raise SubscriptionCapacityException(
|
||||||
resourceType=resourceType, currentCount=int(currentMB), maxAllowed=cap,
|
resourceType=resourceType, currentCount=int(currentMB), maxAllowed=cap,
|
||||||
isEnterprise=isEnterprise,
|
isEnterprise=isEnterprise,
|
||||||
|
|
|
||||||
|
|
@ -287,8 +287,6 @@ class FeatureInterface:
|
||||||
RuntimeError: If templates exist but cannot be copied.
|
RuntimeError: If templates exist but cannot be copied.
|
||||||
Caller decides whether to swallow or re-raise.
|
Caller decides whether to swallow or re-raise.
|
||||||
"""
|
"""
|
||||||
import json
|
|
||||||
|
|
||||||
from modules.system.registry import loadFeatureMainModules
|
from modules.system.registry import loadFeatureMainModules
|
||||||
mainModules = loadFeatureMainModules()
|
mainModules = loadFeatureMainModules()
|
||||||
featureModule = mainModules.get(featureCode)
|
featureModule = mainModules.get(featureCode)
|
||||||
|
|
@ -323,49 +321,26 @@ class FeatureInterface:
|
||||||
f"for feature '{featureCode}' to instance {instanceId} (mandate={mandateId})"
|
f"for feature '{featureCode}' to instance {instanceId} (mandate={mandateId})"
|
||||||
)
|
)
|
||||||
|
|
||||||
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface
|
geMod = mainModules.get("graphicalEditor")
|
||||||
from modules.security.rootAccess import getRootUser
|
onInstanceCreateHook = getattr(geMod, "onInstanceCreate", None) if geMod else None
|
||||||
rootUser = getRootUser()
|
if not onInstanceCreateHook:
|
||||||
geInterface = getGraphicalEditorInterface(rootUser, mandateId, instanceId)
|
logger.warning("_copyTemplateWorkflows: graphicalEditor.onInstanceCreate hook not available")
|
||||||
|
return 0
|
||||||
|
|
||||||
copied = 0
|
try:
|
||||||
failed = 0
|
copied = onInstanceCreateHook(mandateId, instanceId, featureCode, templateWorkflows)
|
||||||
for template in templateWorkflows:
|
except Exception as e:
|
||||||
templateId = template.get("id", "<no-id>")
|
logger.error(
|
||||||
try:
|
f"_copyTemplateWorkflows: onInstanceCreate hook failed for '{featureCode}': {e}",
|
||||||
graphJson = json.dumps(template.get("graph", {}))
|
exc_info=True,
|
||||||
graphJson = graphJson.replace("{{featureInstanceId}}", instanceId)
|
)
|
||||||
graph = json.loads(graphJson)
|
raise RuntimeError(
|
||||||
|
f"_copyTemplateWorkflows: onInstanceCreate failed for feature '{featureCode}': {e}"
|
||||||
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,
|
|
||||||
)
|
|
||||||
|
|
||||||
if copied:
|
if copied:
|
||||||
logger.info(
|
logger.info(
|
||||||
f"_copyTemplateWorkflows: copied {copied}/{len(templateWorkflows)} workflow(s) "
|
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}"
|
f"for feature '{featureCode}' instance {instanceId}"
|
||||||
)
|
)
|
||||||
return copied
|
return copied
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,8 @@ import logging
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
import re
|
import re
|
||||||
|
import copy
|
||||||
|
from datetime import datetime, timezone
|
||||||
from typing import List, Dict, Any, Optional, Type, Union
|
from typing import List, Dict, Any, Optional, Type, Union
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from modules.datamodels.datamodelRbac import AccessRuleContext
|
from modules.datamodels.datamodelRbac import AccessRuleContext
|
||||||
|
|
@ -107,21 +109,20 @@ def _rbacAppendPaginationDictFilter(
|
||||||
toVal and _ISO_DATE_RE.match(str(toVal))
|
toVal and _ISO_DATE_RE.match(str(toVal))
|
||||||
)
|
)
|
||||||
if isNumericCol and isDateVal:
|
if isNumericCol and isDateVal:
|
||||||
from datetime import datetime as _dt, timezone as _tz
|
|
||||||
if fromVal and toVal:
|
if fromVal and toVal:
|
||||||
fromTs = _dt.strptime(str(fromVal), "%Y-%m-%d").replace(tzinfo=_tz.utc).timestamp()
|
fromTs = datetime.strptime(str(fromVal), "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp()
|
||||||
toTs = _dt.strptime(str(toVal), "%Y-%m-%d").replace(
|
toTs = datetime.strptime(str(toVal), "%Y-%m-%d").replace(
|
||||||
hour=23, minute=59, second=59, tzinfo=_tz.utc
|
hour=23, minute=59, second=59, tzinfo=timezone.utc
|
||||||
).timestamp()
|
).timestamp()
|
||||||
whereConditions.append(f'"{key}" >= %s AND "{key}" <= %s')
|
whereConditions.append(f'"{key}" >= %s AND "{key}" <= %s')
|
||||||
whereValues.extend([fromTs, toTs])
|
whereValues.extend([fromTs, toTs])
|
||||||
elif fromVal:
|
elif fromVal:
|
||||||
fromTs = _dt.strptime(str(fromVal), "%Y-%m-%d").replace(tzinfo=_tz.utc).timestamp()
|
fromTs = datetime.strptime(str(fromVal), "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp()
|
||||||
whereConditions.append(f'"{key}" >= %s')
|
whereConditions.append(f'"{key}" >= %s')
|
||||||
whereValues.append(fromTs)
|
whereValues.append(fromTs)
|
||||||
else:
|
else:
|
||||||
toTs = _dt.strptime(str(toVal), "%Y-%m-%d").replace(
|
toTs = datetime.strptime(str(toVal), "%Y-%m-%d").replace(
|
||||||
hour=23, minute=59, second=59, tzinfo=_tz.utc
|
hour=23, minute=59, second=59, tzinfo=timezone.utc
|
||||||
).timestamp()
|
).timestamp()
|
||||||
whereConditions.append(f'"{key}" <= %s')
|
whereConditions.append(f'"{key}" <= %s')
|
||||||
whereValues.append(toTs)
|
whereValues.append(toTs)
|
||||||
|
|
@ -585,8 +586,8 @@ def getRecordsetPaginatedWithRBAC(
|
||||||
if enrichPermissions:
|
if enrichPermissions:
|
||||||
records = _enrichRecordsWithPermissions(records, permissions, currentUser)
|
records = _enrichRecordsWithPermissions(records, permissions, currentUser)
|
||||||
|
|
||||||
from modules.routes.routeHelpers import enrichRowsWithFkLabels
|
from modules.dbHelpers.fkLabelResolver import enrichRowsWithFkLabels
|
||||||
enrichRowsWithFkLabels(records, modelClass)
|
enrichRowsWithFkLabels(records, modelClass, db=connector)
|
||||||
|
|
||||||
if pagination:
|
if pagination:
|
||||||
pageSize = pagination.pageSize
|
pageSize = pagination.pageSize
|
||||||
|
|
@ -614,7 +615,6 @@ def getDistinctColumnValuesWithRBAC(
|
||||||
Get sorted distinct values for a column with RBAC filtering at SQL level.
|
Get sorted distinct values for a column with RBAC filtering at SQL level.
|
||||||
Cross-filtering: removes the requested column from active filters.
|
Cross-filtering: removes the requested column from active filters.
|
||||||
"""
|
"""
|
||||||
import copy
|
|
||||||
table = modelClass.__name__
|
table = modelClass.__name__
|
||||||
objectKey = buildDataObjectKey(table, featureCode)
|
objectKey = buildDataObjectKey(table, featureCode)
|
||||||
|
|
||||||
|
|
|
||||||
330
modules/interfaces/interfaceTableHelpers.py
Normal file
330
modules/interfaces/interfaceTableHelpers.py
Normal file
|
|
@ -0,0 +1,330 @@
|
||||||
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
|
# All rights reserved.
|
||||||
|
"""
|
||||||
|
Table/list presentation helpers: view resolution, grouping, Strategy B.
|
||||||
|
|
||||||
|
These helpers orchestrate how paginated table data is grouped, filtered
|
||||||
|
and sorted according to saved TableListView configurations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from collections import defaultdict
|
||||||
|
from functools import cmp_to_key
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from modules.datamodels.datamodelPagination import PaginationParams
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# View resolution
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def resolveView(interface, contextKey: str, viewKey: Optional[str]):
|
||||||
|
"""
|
||||||
|
Load a TableListView for the current user and contextKey.
|
||||||
|
|
||||||
|
Returns (config_dict, display_name):
|
||||||
|
- (None, None) when viewKey is None / empty
|
||||||
|
- (config, str | None) otherwise — config may be {}; display_name from the row
|
||||||
|
|
||||||
|
Raises HTTPException(404) when viewKey is explicitly set but the view
|
||||||
|
does not exist (prevents silent fallback to ungrouped behaviour).
|
||||||
|
"""
|
||||||
|
from fastapi import HTTPException
|
||||||
|
if not viewKey:
|
||||||
|
return None, None
|
||||||
|
try:
|
||||||
|
view = interface.getTableListView(contextKey=contextKey, viewKey=viewKey)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"resolveView: store lookup failed for key={viewKey!r} context={contextKey!r}: {e}")
|
||||||
|
view = None
|
||||||
|
if view is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"View '{viewKey}' not found for context '{contextKey}'")
|
||||||
|
cfg = view.config or {}
|
||||||
|
dname = getattr(view, "displayName", None) or None
|
||||||
|
return cfg, dname
|
||||||
|
|
||||||
|
|
||||||
|
def effective_group_by_levels(
|
||||||
|
pagination_params: Optional["PaginationParams"],
|
||||||
|
view_config: Optional[dict],
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Choose grouping levels for this request.
|
||||||
|
|
||||||
|
If the client sends ``groupByLevels`` (including ``[]``), it wins over the
|
||||||
|
saved view. If the key is omitted (``None``), use the view's levels.
|
||||||
|
"""
|
||||||
|
if pagination_params is not None:
|
||||||
|
req = getattr(pagination_params, "groupByLevels", None)
|
||||||
|
if req is not None:
|
||||||
|
out: List[Dict[str, Any]] = []
|
||||||
|
for lvl in req:
|
||||||
|
if hasattr(lvl, "model_dump"):
|
||||||
|
out.append(lvl.model_dump())
|
||||||
|
elif isinstance(lvl, dict):
|
||||||
|
out.append(dict(lvl))
|
||||||
|
else:
|
||||||
|
out.append(dict(lvl)) # type: ignore[arg-type]
|
||||||
|
return out
|
||||||
|
vc = (view_config or {}).get("groupByLevels") if view_config else None
|
||||||
|
return list(vc or [])
|
||||||
|
|
||||||
|
|
||||||
|
def applyViewToParams(params: Optional["PaginationParams"], viewConfig: Optional[dict]) -> Optional["PaginationParams"]:
|
||||||
|
"""
|
||||||
|
Merge a view's saved configuration into PaginationParams.
|
||||||
|
|
||||||
|
Priority: explicit request fields win over view defaults.
|
||||||
|
- sort: use request sort if non-empty, otherwise view sort
|
||||||
|
- filters: deep-merge (request filters win per-key)
|
||||||
|
- pageSize: use request value (already set by normalize_pagination_dict)
|
||||||
|
|
||||||
|
Returns the (mutated) params, or a new minimal PaginationParams when
|
||||||
|
params is None (so callers always get a valid object).
|
||||||
|
"""
|
||||||
|
from modules.datamodels.datamodelPagination import SortField
|
||||||
|
if not viewConfig:
|
||||||
|
return params
|
||||||
|
|
||||||
|
if params is None:
|
||||||
|
params = PaginationParams(page=1, pageSize=25)
|
||||||
|
|
||||||
|
if not params.sort and viewConfig.get("sort"):
|
||||||
|
try:
|
||||||
|
params.sort = [
|
||||||
|
SortField(**s) if isinstance(s, dict) else s
|
||||||
|
for s in viewConfig["sort"]
|
||||||
|
]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"applyViewToParams: could not parse view sort: {e}")
|
||||||
|
|
||||||
|
viewFilters = viewConfig.get("filters") or {}
|
||||||
|
if viewFilters:
|
||||||
|
merged = dict(viewFilters)
|
||||||
|
if params.filters:
|
||||||
|
merged.update(params.filters)
|
||||||
|
params.filters = merged
|
||||||
|
|
||||||
|
return params
|
||||||
|
|
||||||
|
|
||||||
|
def apply_strategy_b_filters_and_sort(
|
||||||
|
items: List[Dict[str, Any]],
|
||||||
|
pagination_params: Optional[PaginationParams],
|
||||||
|
current_user: Any,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Shared in-memory filter + sort pass for Strategy B (files/prompts/connections lists).
|
||||||
|
"""
|
||||||
|
if not pagination_params:
|
||||||
|
return list(items)
|
||||||
|
from modules.interfaces.interfaceDbManagement import ComponentObjects
|
||||||
|
|
||||||
|
comp = ComponentObjects()
|
||||||
|
comp.setUserContext(current_user)
|
||||||
|
out = list(items)
|
||||||
|
if pagination_params.filters:
|
||||||
|
out = comp._applyFilters(out, pagination_params.filters)
|
||||||
|
if pagination_params.sort:
|
||||||
|
out = comp._applySorting(out, pagination_params.sort)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_group_summary_groups(
|
||||||
|
items: List[Dict[str, Any]],
|
||||||
|
field: str,
|
||||||
|
null_label: str = "\u2014",
|
||||||
|
groupByLevels: List[Dict[str, Any]] | None = None,
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Build {"value", "label", "totalCount"} summaries for mode=groupSummary.
|
||||||
|
|
||||||
|
When *groupByLevels* contains more than one level the function produces one
|
||||||
|
entry per unique combination of all level values (flat permutations).
|
||||||
|
``value`` becomes a ``///``-joined composite key and ``label`` the ``/``-joined
|
||||||
|
human-readable label so the frontend can split them back.
|
||||||
|
"""
|
||||||
|
|
||||||
|
fields: list[dict] = []
|
||||||
|
if groupByLevels and len(groupByLevels) > 1:
|
||||||
|
for lvl in groupByLevels:
|
||||||
|
f = lvl.get("field", "")
|
||||||
|
nl = str(lvl.get("nullLabel") or null_label)
|
||||||
|
if f:
|
||||||
|
fields.append({"field": f, "nullLabel": nl})
|
||||||
|
if not fields:
|
||||||
|
fields = [{"field": field, "nullLabel": null_label}]
|
||||||
|
|
||||||
|
nullKey = "\x00NULL"
|
||||||
|
|
||||||
|
if len(fields) == 1:
|
||||||
|
f = fields[0]["field"]
|
||||||
|
nl = fields[0]["nullLabel"]
|
||||||
|
counts: Dict[str, int] = defaultdict(int)
|
||||||
|
displayByKey: Dict[str, str] = {}
|
||||||
|
labelAttr = f"{f}Label"
|
||||||
|
for item in items:
|
||||||
|
raw = item.get(f)
|
||||||
|
if raw is None or raw == "":
|
||||||
|
nk = nullKey
|
||||||
|
display = nl
|
||||||
|
else:
|
||||||
|
nk = str(raw)
|
||||||
|
display = None
|
||||||
|
lbl = item.get(labelAttr)
|
||||||
|
if lbl is not None and lbl != "":
|
||||||
|
display = str(lbl)
|
||||||
|
if display is None:
|
||||||
|
display = nk
|
||||||
|
counts[nk] += 1
|
||||||
|
if nk not in displayByKey:
|
||||||
|
displayByKey[nk] = display
|
||||||
|
orderedKeys = sorted(
|
||||||
|
counts.keys(),
|
||||||
|
key=lambda x: (x == nullKey, str(displayByKey.get(x, x)).lower()),
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"value": None if nk == nullKey else nk,
|
||||||
|
"label": displayByKey.get(nk, nk),
|
||||||
|
"totalCount": counts[nk],
|
||||||
|
}
|
||||||
|
for nk in orderedKeys
|
||||||
|
]
|
||||||
|
|
||||||
|
counts = defaultdict(int)
|
||||||
|
displayByComposite: Dict[str, list] = {}
|
||||||
|
filtersByComposite: Dict[str, dict] = {}
|
||||||
|
for item in items:
|
||||||
|
parts: list[str] = []
|
||||||
|
labels: list[str] = []
|
||||||
|
filterMap: dict = {}
|
||||||
|
for fd in fields:
|
||||||
|
f = fd["field"]
|
||||||
|
nl = fd["nullLabel"]
|
||||||
|
labelAttr = f"{f}Label"
|
||||||
|
raw = item.get(f)
|
||||||
|
if raw is None or raw == "":
|
||||||
|
parts.append(nullKey)
|
||||||
|
labels.append(nl)
|
||||||
|
filterMap[f] = None
|
||||||
|
else:
|
||||||
|
parts.append(str(raw))
|
||||||
|
lbl = item.get(labelAttr)
|
||||||
|
labels.append(str(lbl) if lbl not in (None, "") else str(raw))
|
||||||
|
filterMap[f] = str(raw)
|
||||||
|
compositeKey = "///".join(parts)
|
||||||
|
counts[compositeKey] += 1
|
||||||
|
if compositeKey not in displayByComposite:
|
||||||
|
displayByComposite[compositeKey] = labels
|
||||||
|
filtersByComposite[compositeKey] = filterMap
|
||||||
|
|
||||||
|
orderedKeys = sorted(
|
||||||
|
counts.keys(),
|
||||||
|
key=lambda x: tuple(
|
||||||
|
(seg == nullKey, seg.lower()) for seg in x.split("///")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"value": ck.replace(nullKey, "__null__") if nullKey in ck else ck,
|
||||||
|
"label": " / ".join(displayByComposite[ck]),
|
||||||
|
"totalCount": counts[ck],
|
||||||
|
"filters": filtersByComposite[ck],
|
||||||
|
}
|
||||||
|
for ck in orderedKeys
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def buildGroupLayout(
|
||||||
|
all_items: List[Dict[str, Any]],
|
||||||
|
groupByLevels: List[Dict[str, Any]],
|
||||||
|
page: int,
|
||||||
|
pageSize: int,
|
||||||
|
) -> tuple:
|
||||||
|
"""
|
||||||
|
Apply multi-level grouping to all_items, slice to the requested page,
|
||||||
|
and return (page_items, GroupLayout | None).
|
||||||
|
|
||||||
|
Strategy B: grouping operates on the full filtered+sorted candidate list.
|
||||||
|
Items are stably re-sorted by the group path so that members of the same
|
||||||
|
group are always contiguous (preserving the existing per-group sort order
|
||||||
|
from the caller).
|
||||||
|
|
||||||
|
Parameters
|
||||||
|
----------
|
||||||
|
all_items: fully filtered and user-sorted list of row dicts.
|
||||||
|
groupByLevels: list of {"field": str, "nullLabel": str, "direction": "asc"|"desc"} dicts.
|
||||||
|
page, pageSize: 1-based page index and page size.
|
||||||
|
|
||||||
|
Returns
|
||||||
|
-------
|
||||||
|
(page_items, GroupLayout | None)
|
||||||
|
"""
|
||||||
|
from modules.datamodels.datamodelPagination import GroupBand, GroupLayout
|
||||||
|
|
||||||
|
if not groupByLevels:
|
||||||
|
offset = (page - 1) * pageSize
|
||||||
|
return all_items[offset:offset + pageSize], None
|
||||||
|
|
||||||
|
levels = [lvl.get("field", "") for lvl in groupByLevels if lvl.get("field")]
|
||||||
|
if not levels:
|
||||||
|
offset = (page - 1) * pageSize
|
||||||
|
return all_items[offset:offset + pageSize], None
|
||||||
|
|
||||||
|
nullLabels = {lvl.get("field", ""): lvl.get("nullLabel", "\u2014") for lvl in groupByLevels}
|
||||||
|
|
||||||
|
def _path_key(item: dict) -> tuple:
|
||||||
|
return tuple(
|
||||||
|
str(item.get(f) or "") if item.get(f) is not None else nullLabels.get(f, "\u2014")
|
||||||
|
for f in levels
|
||||||
|
)
|
||||||
|
|
||||||
|
def _item_cmp(a: dict, b: dict) -> int:
|
||||||
|
pa, pb = _path_key(a), _path_key(b)
|
||||||
|
for i in range(len(levels)):
|
||||||
|
if pa[i] != pb[i]:
|
||||||
|
asc = (groupByLevels[i].get("direction") or "asc").lower() != "desc"
|
||||||
|
if pa[i] < pb[i]:
|
||||||
|
return -1 if asc else 1
|
||||||
|
return 1 if asc else -1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
all_items.sort(key=cmp_to_key(_item_cmp))
|
||||||
|
|
||||||
|
bands_global: List[dict] = []
|
||||||
|
current_path: Optional[tuple] = None
|
||||||
|
current_start = 0
|
||||||
|
for i, item in enumerate(all_items):
|
||||||
|
path = _path_key(item)
|
||||||
|
if path != current_path:
|
||||||
|
if current_path is not None:
|
||||||
|
bands_global.append({"path": list(current_path), "startIdx": current_start, "endIdx": i})
|
||||||
|
current_path = path
|
||||||
|
current_start = i
|
||||||
|
if current_path is not None:
|
||||||
|
bands_global.append({"path": list(current_path), "startIdx": current_start, "endIdx": len(all_items)})
|
||||||
|
|
||||||
|
page_start = (page - 1) * pageSize
|
||||||
|
page_end = page_start + pageSize
|
||||||
|
page_items = all_items[page_start:page_end]
|
||||||
|
|
||||||
|
bands_on_page: List[GroupBand] = []
|
||||||
|
for band in bands_global:
|
||||||
|
inter_start = max(band["startIdx"], page_start)
|
||||||
|
inter_end = min(band["endIdx"], page_end)
|
||||||
|
if inter_start >= inter_end:
|
||||||
|
continue
|
||||||
|
path_list = band["path"]
|
||||||
|
bands_on_page.append(GroupBand(
|
||||||
|
path=path_list,
|
||||||
|
label=path_list[-1] if path_list else "\u2014",
|
||||||
|
startRowIndex=inter_start - page_start,
|
||||||
|
rowCount=inter_end - inter_start,
|
||||||
|
))
|
||||||
|
|
||||||
|
group_layout = GroupLayout(levels=levels, bands=bands_on_page) if bands_on_page else GroupLayout(levels=levels, bands=[])
|
||||||
|
return page_items, group_layout
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
# Migration modules
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,11 +0,0 @@
|
||||||
# Archived one-off migrations
|
|
||||||
|
|
||||||
`migrate_folders_to_groups.py` copies `FileFolder` + `FileItem.folderId` into `TableGrouping` (`files/list`). It was used during an experimental UI path; **product choice** is to keep physical folders (`FileFolder`, `folderId`) and recover `FormGeneratorTree` (see `wiki/c-work/1-plan/2026-05-formgenerator-tree-and-folder-recovery.md`).
|
|
||||||
|
|
||||||
Run only if you need a historical data rescue:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd gateway
|
|
||||||
python -m modules.migrations._archive.migrate_folders_to_groups --verbose
|
|
||||||
python -m modules.migrations._archive.migrate_folders_to_groups --execute --verbose
|
|
||||||
```
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue