Compare commits
No commits in common. "1f40c59afc35e63b38d07c5ac892a4b5340f7bd3" and "cb5f2d60c4773bb83406a7d95481d2c53168de19" have entirely different histories.
1f40c59afc
...
cb5f2d60c4
162 changed files with 1403 additions and 8331 deletions
15
app.py
15
app.py
|
|
@ -360,18 +360,6 @@ async def lifespan(app: FastAPI):
|
||||||
eventManager.set_event_loop(main_loop)
|
eventManager.set_event_loop(main_loop)
|
||||||
from modules.workflows.scheduler.mainScheduler import setMainLoop as setSchedulerMainLoop
|
from modules.workflows.scheduler.mainScheduler import setMainLoop as setSchedulerMainLoop
|
||||||
setSchedulerMainLoop(main_loop)
|
setSchedulerMainLoop(main_loop)
|
||||||
|
|
||||||
# Suppress noisy ConnectionResetError from ProactorEventLoop on Windows
|
|
||||||
# when clients (browsers) close connections abruptly. This is a known
|
|
||||||
# asyncio issue on Windows: https://bugs.python.org/issue39010
|
|
||||||
def _suppressClientDisconnect(loop, ctx):
|
|
||||||
exc = ctx.get("exception")
|
|
||||||
if isinstance(exc, ConnectionResetError):
|
|
||||||
return
|
|
||||||
if isinstance(exc, ConnectionAbortedError):
|
|
||||||
return
|
|
||||||
loop.default_exception_handler(ctx)
|
|
||||||
main_loop.set_exception_handler(_suppressClientDisconnect)
|
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
pass
|
pass
|
||||||
eventManager.start()
|
eventManager.start()
|
||||||
|
|
@ -615,9 +603,6 @@ app.include_router(userAccessOverviewRouter)
|
||||||
from modules.routes.routeAdminDemoConfig import router as demoConfigRouter
|
from modules.routes.routeAdminDemoConfig import router as demoConfigRouter
|
||||||
app.include_router(demoConfigRouter)
|
app.include_router(demoConfigRouter)
|
||||||
|
|
||||||
from modules.routes.routeAdminDatabaseHealth import router as adminDatabaseHealthRouter
|
|
||||||
app.include_router(adminDatabaseHealthRouter)
|
|
||||||
|
|
||||||
from modules.routes.routeGdpr import router as gdprRouter
|
from modules.routes.routeGdpr import router as gdprRouter
|
||||||
app.include_router(gdprRouter)
|
app.include_router(gdprRouter)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,7 @@ High-level security functionality that depends on FastAPI and interfaces.
|
||||||
Multi-Tenant Design:
|
Multi-Tenant Design:
|
||||||
- RequestContext: Per-request context with user, mandate, feature instance, roles
|
- RequestContext: Per-request context with user, mandate, feature instance, roles
|
||||||
- getRequestContext: FastAPI dependency to extract context from X-Mandate-Id header
|
- getRequestContext: FastAPI dependency to extract context from X-Mandate-Id header
|
||||||
- requireSysAdmin: FastAPI dependency for INFRASTRUCTURE-level operations
|
- requireSysAdmin: FastAPI dependency for system-level admin operations
|
||||||
(logs, tokens, DB-health, i18n-master). Includes RBAC bypass.
|
|
||||||
- requirePlatformAdmin: FastAPI dependency for CROSS-MANDATE GOVERNANCE
|
|
||||||
(user-/mandate-/RBAC-/feature-registry mgmt). No bypass.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .authentication import (
|
from .authentication import (
|
||||||
|
|
@ -22,7 +19,7 @@ from .authentication import (
|
||||||
RequestContext,
|
RequestContext,
|
||||||
getRequestContext,
|
getRequestContext,
|
||||||
requireSysAdmin,
|
requireSysAdmin,
|
||||||
requirePlatformAdmin,
|
requireSysAdminRole,
|
||||||
)
|
)
|
||||||
from .jwtService import (
|
from .jwtService import (
|
||||||
createAccessToken,
|
createAccessToken,
|
||||||
|
|
@ -48,7 +45,7 @@ __all__ = [
|
||||||
"RequestContext",
|
"RequestContext",
|
||||||
"getRequestContext",
|
"getRequestContext",
|
||||||
"requireSysAdmin",
|
"requireSysAdmin",
|
||||||
"requirePlatformAdmin",
|
"requireSysAdminRole",
|
||||||
# JWT Service
|
# JWT Service
|
||||||
"createAccessToken",
|
"createAccessToken",
|
||||||
"createRefreshToken",
|
"createRefreshToken",
|
||||||
|
|
|
||||||
|
|
@ -272,6 +272,7 @@ class RequestContext:
|
||||||
|
|
||||||
# Request-scoped cache: rules loaded only once per request
|
# Request-scoped cache: rules loaded only once per request
|
||||||
self._cachedRules: Optional[List[tuple]] = None
|
self._cachedRules: Optional[List[tuple]] = None
|
||||||
|
self._cachedHasSysAdminRole: Optional[bool] = None
|
||||||
|
|
||||||
def getRules(self) -> List[tuple]:
|
def getRules(self) -> List[tuple]:
|
||||||
"""
|
"""
|
||||||
|
|
@ -298,17 +299,18 @@ class RequestContext:
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def isSysAdmin(self) -> bool:
|
def isSysAdmin(self) -> bool:
|
||||||
"""Convenience property: Infrastructure/System Operator flag.
|
"""Convenience property to check if user has the isSysAdmin FLAG.
|
||||||
For Category A (Logs, Tokens, DB-Health, i18n-Master, Registry).
|
Category A only: true system operations (tokens, logs, databases)."""
|
||||||
Wirkt auch als RBAC-Engine-Bypass (siehe rbac.py:getUserPermissions)."""
|
|
||||||
return getattr(self.user, 'isSysAdmin', False)
|
return getattr(self.user, 'isSysAdmin', False)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def isPlatformAdmin(self) -> bool:
|
def hasSysAdminRole(self) -> bool:
|
||||||
"""Convenience property: Cross-Mandate-Governance flag.
|
"""Check if user has sysadmin ROLE in root mandate (cached per request).
|
||||||
For Categories B–E (User-/Mandate-/RBAC-/Feature-Registry über alle Mandanten).
|
Use for admin operations (Categories B/C/D/E) instead of isSysAdmin flag."""
|
||||||
KEIN RBAC-Bypass — Daten-Zugriff geht weiterhin über Mandanten-Mitgliedschaft."""
|
if self._cachedHasSysAdminRole is None:
|
||||||
return getattr(self.user, 'isPlatformAdmin', False)
|
self._cachedHasSysAdminRole = _hasSysAdminRole(str(self.user.id))
|
||||||
|
return self._cachedHasSysAdminRole
|
||||||
|
|
||||||
|
|
||||||
def getRequestContext(
|
def getRequestContext(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|
@ -321,37 +323,33 @@ def getRequestContext(
|
||||||
Checks authorization and loads role IDs.
|
Checks authorization and loads role IDs.
|
||||||
|
|
||||||
Security Model:
|
Security Model:
|
||||||
- Regular users: Must be explicit members of mandates/feature instances.
|
- Regular users: Must be explicit members of mandates/feature instances
|
||||||
- isSysAdmin users: RBAC-Engine-Bypass; können jeden Mandant für
|
- SysAdmin users: Can access ANY mandate for administrative operations.
|
||||||
Infrastruktur-Operationen betreten ohne Mitgliedschaft. ``ctx.roleIds``
|
Root mandate roles (incl. sysadmin role) are loaded for RBAC-based authorization.
|
||||||
bleibt leer (Bypass läuft direkt in ``rbac.py:getUserPermissions``).
|
Routes use ctx.hasSysAdminRole for admin checks (not ctx.isSysAdmin flag).
|
||||||
- isPlatformAdmin users: Cross-Mandate-Governance; können jeden Mandant
|
|
||||||
betreten, aber Routen prüfen die Berechtigung explizit via
|
|
||||||
``requirePlatformAdmin``. ``ctx.roleIds`` bleibt leer.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request: FastAPI Request object
|
request: FastAPI Request object
|
||||||
mandateId: Mandate ID from X-Mandate-Id header
|
mandateId: Mandate ID from X-Mandate-Id header
|
||||||
featureInstanceId: Feature instance ID from X-Instance-Id header
|
featureInstanceId: Feature instance ID from X-Instance-Id header
|
||||||
currentUser: Current authenticated user
|
currentUser: Current authenticated user
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
RequestContext with user, mandate, roles
|
RequestContext with user, mandate, roles
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException 403: If user is not member of mandate (and not Sys/Platform admin)
|
HTTPException 403: If non-SysAdmin user is not member of mandate or has no feature access
|
||||||
"""
|
"""
|
||||||
ctx = RequestContext(user=currentUser)
|
ctx = RequestContext(user=currentUser)
|
||||||
isSysAdmin = getattr(currentUser, 'isSysAdmin', False)
|
isSysAdmin = getattr(currentUser, 'isSysAdmin', False)
|
||||||
isPlatformAdmin = getattr(currentUser, 'isPlatformAdmin', False)
|
|
||||||
|
|
||||||
# Get root interface for membership checks
|
# Get root interface for membership checks
|
||||||
rootInterface = getRootInterface()
|
rootInterface = getRootInterface()
|
||||||
|
|
||||||
if mandateId:
|
if mandateId:
|
||||||
# Check mandate membership
|
# Check mandate membership
|
||||||
membership = rootInterface.getUserMandate(currentUser.id, mandateId)
|
membership = rootInterface.getUserMandate(currentUser.id, mandateId)
|
||||||
|
|
||||||
if membership:
|
if membership:
|
||||||
# User is a member - load their roles
|
# User is a member - load their roles
|
||||||
if not membership.enabled:
|
if not membership.enabled:
|
||||||
|
|
@ -361,16 +359,12 @@ def getRequestContext(
|
||||||
)
|
)
|
||||||
ctx.mandateId = mandateId
|
ctx.mandateId = mandateId
|
||||||
ctx.roleIds = rootInterface.getRoleIdsForUserMandate(membership.id)
|
ctx.roleIds = rootInterface.getRoleIdsForUserMandate(membership.id)
|
||||||
elif isSysAdmin or isPlatformAdmin:
|
elif isSysAdmin:
|
||||||
# Platform-level authority can enter any mandate without membership.
|
# SysAdmin can access any mandate for admin operations
|
||||||
# No fake role loading: isSysAdmin bypasses RBAC engine; platform-admin
|
# Load root mandate roles for RBAC-based authorization (includes sysadmin role)
|
||||||
# routes verify authority explicitly via requirePlatformAdmin.
|
|
||||||
ctx.mandateId = mandateId
|
ctx.mandateId = mandateId
|
||||||
ctx.roleIds = []
|
ctx.roleIds = _getRootMandateRoleIds(rootInterface, str(currentUser.id))
|
||||||
logger.debug(
|
logger.debug(f"SysAdmin {currentUser.id} accessing mandate {mandateId} with root mandate roles")
|
||||||
f"Platform-level user {currentUser.id} accessing mandate {mandateId} "
|
|
||||||
f"(isSysAdmin={isSysAdmin}, isPlatformAdmin={isPlatformAdmin})"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
# Regular user without membership - denied
|
# Regular user without membership - denied
|
||||||
logger.warning(f"User {currentUser.id} is not member of mandate {mandateId}")
|
logger.warning(f"User {currentUser.id} is not member of mandate {mandateId}")
|
||||||
|
|
@ -378,11 +372,11 @@ def getRequestContext(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Not member of mandate"
|
detail="Not member of mandate"
|
||||||
)
|
)
|
||||||
|
|
||||||
if featureInstanceId:
|
if featureInstanceId:
|
||||||
# Check feature access
|
# Check feature access
|
||||||
access = rootInterface.getFeatureAccess(currentUser.id, featureInstanceId)
|
access = rootInterface.getFeatureAccess(currentUser.id, featureInstanceId)
|
||||||
|
|
||||||
if access:
|
if access:
|
||||||
# User has access - load their instance roles
|
# User has access - load their instance roles
|
||||||
if not access.enabled:
|
if not access.enabled:
|
||||||
|
|
@ -393,15 +387,13 @@ def getRequestContext(
|
||||||
ctx.featureInstanceId = featureInstanceId
|
ctx.featureInstanceId = featureInstanceId
|
||||||
instanceRoleIds = rootInterface.getRoleIdsForFeatureAccess(access.id)
|
instanceRoleIds = rootInterface.getRoleIdsForFeatureAccess(access.id)
|
||||||
ctx.roleIds.extend(instanceRoleIds)
|
ctx.roleIds.extend(instanceRoleIds)
|
||||||
elif isSysAdmin or isPlatformAdmin:
|
elif isSysAdmin:
|
||||||
# Platform-level authority can enter any feature instance without
|
# SysAdmin can access any feature instance for admin operations
|
||||||
# explicit access record.
|
|
||||||
ctx.featureInstanceId = featureInstanceId
|
ctx.featureInstanceId = featureInstanceId
|
||||||
logger.debug(
|
# If no roles loaded yet, load root mandate roles
|
||||||
f"Platform-level user {currentUser.id} accessing feature instance "
|
if not ctx.roleIds:
|
||||||
f"{featureInstanceId} (isSysAdmin={isSysAdmin}, "
|
ctx.roleIds = _getRootMandateRoleIds(rootInterface, str(currentUser.id))
|
||||||
f"isPlatformAdmin={isPlatformAdmin})"
|
logger.debug(f"SysAdmin {currentUser.id} accessing feature instance {featureInstanceId} with root mandate roles")
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
# Regular user without access - denied
|
# Regular user without access - denied
|
||||||
logger.warning(f"User {currentUser.id} has no access to feature instance {featureInstanceId}")
|
logger.warning(f"User {currentUser.id} has no access to feature instance {featureInstanceId}")
|
||||||
|
|
@ -409,7 +401,7 @@ def getRequestContext(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="No access to feature instance"
|
detail="No access to feature instance"
|
||||||
)
|
)
|
||||||
|
|
||||||
return ctx
|
return ctx
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -452,46 +444,95 @@ def requireSysAdmin(currentUser: User = Depends(getCurrentUser)) -> User:
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# PLATFORM ADMIN: Flag-based cross-mandate governance (replaces sysadmin role)
|
# SYSADMIN ROLE: RBAC-based admin checks (hybrid model)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
def requirePlatformAdmin(currentUser: User = Depends(getCurrentUser)) -> User:
|
def _getRootMandateRoleIds(rootInterface, userId: str) -> List[str]:
|
||||||
"""
|
"""
|
||||||
Require Platform-Admin flag for cross-mandate governance operations.
|
Load the user's role IDs from the root mandate.
|
||||||
|
Used by auth middleware to provide RBAC roles for SysAdmin cross-mandate access.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
rootInterface: Root database interface
|
||||||
|
userId: User ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of role IDs from root mandate membership, empty list if no membership
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
rootMandateId = rootInterface._getRootMandateId()
|
||||||
|
if not rootMandateId:
|
||||||
|
return []
|
||||||
|
membership = rootInterface.getUserMandate(userId, rootMandateId)
|
||||||
|
if not membership:
|
||||||
|
return []
|
||||||
|
return rootInterface.getRoleIdsForUserMandate(membership.id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading root mandate roles: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
Verwendung für alle Operationen, die mandanten-übergreifend wirken:
|
|
||||||
User-Mgmt, Mandate-Mgmt, RBAC-Catalog, Feature-Registry, User-Access-Overview,
|
|
||||||
Cross-Mandate-Audit, Cross-Mandate-Billing-Übersicht, Subscription-Mgmt.
|
|
||||||
|
|
||||||
KEIN RBAC-Bypass: Daten-Zugriff auf einen einzelnen Mandanten erfordert
|
def _hasSysAdminRole(userId: str) -> bool:
|
||||||
weiterhin Mitgliedschaft (oder zusätzlich isSysAdmin für Infrastruktur-Bypass).
|
"""
|
||||||
|
Check if a user has the sysadmin role in the root mandate.
|
||||||
|
|
||||||
|
Standalone check that queries the database directly, independent of
|
||||||
|
request context. Used for authorization checks where the sysadmin
|
||||||
|
ROLE (not just the isSysAdmin flag) is required.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
userId: User ID to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if user has sysadmin role in root mandate
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
rootInterface = getRootInterface()
|
||||||
|
roleIds = _getRootMandateRoleIds(rootInterface, str(userId))
|
||||||
|
for roleId in roleIds:
|
||||||
|
role = rootInterface.getRole(roleId)
|
||||||
|
if role and role.roleLabel == "sysadmin":
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking sysadmin role: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def requireSysAdminRole(currentUser: User = Depends(getCurrentUser)) -> User:
|
||||||
|
"""
|
||||||
|
Require sysadmin ROLE for admin operations.
|
||||||
|
|
||||||
|
Unlike requireSysAdmin (which checks the isSysAdmin FLAG for system-level ops),
|
||||||
|
this dependency checks the sysadmin ROLE in the root mandate.
|
||||||
|
Use for admin operations that should be RBAC-controlled (Category E).
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
currentUser: Current authenticated user
|
currentUser: Current authenticated user
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
User if they have isPlatformAdmin=True
|
User if they have the sysadmin role
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
HTTPException 403: If user is not a Platform Admin
|
HTTPException 403: If user doesn't have sysadmin role
|
||||||
"""
|
"""
|
||||||
if not getattr(currentUser, 'isPlatformAdmin', False):
|
if not _hasSysAdminRole(str(currentUser.id)):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail="Platform admin privileges required"
|
detail="SysAdmin role required"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Audit for all Platform-Admin actions
|
# Audit
|
||||||
try:
|
try:
|
||||||
from modules.shared.auditLogger import audit_logger
|
from modules.shared.auditLogger import audit_logger
|
||||||
audit_logger.logSecurityEvent(
|
audit_logger.logSecurityEvent(
|
||||||
userId=str(currentUser.id),
|
userId=str(currentUser.id),
|
||||||
mandateId="system",
|
mandateId="system",
|
||||||
action="platform_admin_action",
|
action="sysadmin_role_action",
|
||||||
details="Cross-mandate governance operation"
|
details="Admin operation via sysadmin role"
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return currentUser
|
return currentUser
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ 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
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -563,34 +562,16 @@ class ConnectorGoogleSpeech:
|
||||||
"""Google TTS WaveNet cost: ~$0.000004/char."""
|
"""Google TTS WaveNet cost: ~$0.000004/char."""
|
||||||
return round(characterCount * 0.000004, 8)
|
return round(characterCount * 0.000004, 8)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _normalizeLanguageCode(code: Optional[str]) -> Optional[str]:
|
|
||||||
"""Normalize a user/LLM-supplied language hint to an ISO-639-1 code or None.
|
|
||||||
|
|
||||||
Google Cloud Translation v2 only accepts ISO codes (e.g. 'de', 'en') or
|
|
||||||
an omitted source for auto-detection. Strings like 'auto', '' or full
|
|
||||||
BCP-47 tags ('de-DE') would otherwise reach the API and trigger
|
|
||||||
'400 Invalid Value'. Centralising the mapping here keeps every caller
|
|
||||||
(tools, interface, internal pipelines) safe.
|
|
||||||
"""
|
|
||||||
if not code:
|
|
||||||
return None
|
|
||||||
normalized = code.strip().lower()
|
|
||||||
if not normalized or normalized in ("auto", "detect", "any", "*"):
|
|
||||||
return None
|
|
||||||
return normalized.split("-")[0]
|
|
||||||
|
|
||||||
async def translateText(self, text: str, targetLanguage: str = "en",
|
async def translateText(self, text: str, targetLanguage: str = "en",
|
||||||
sourceLanguage: Optional[str] = None) -> Dict:
|
sourceLanguage: str = "de") -> Dict:
|
||||||
"""
|
"""
|
||||||
Translate text using Google Cloud Translation API.
|
Translate text using Google Cloud Translation API.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: Text to translate
|
text: Text to translate
|
||||||
targetLanguage: Target language code (e.g., 'en', 'de')
|
target_language: Target language code (e.g., 'en', 'de')
|
||||||
sourceLanguage: Source language code (e.g., 'de', 'en'); pass None
|
source_language: Source language code (e.g., 'de', 'en')
|
||||||
or 'auto' for Google's auto-detection.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict containing translated text and metadata
|
Dict containing translated text and metadata
|
||||||
"""
|
"""
|
||||||
|
|
@ -602,18 +583,14 @@ class ConnectorGoogleSpeech:
|
||||||
"translated_text": "",
|
"translated_text": "",
|
||||||
"error": "Empty text provided"
|
"error": "Empty text provided"
|
||||||
}
|
}
|
||||||
|
|
||||||
normalizedSource = self._normalizeLanguageCode(sourceLanguage)
|
logger.info(f"🌐 Translating: '{text}' ({sourceLanguage} -> {targetLanguage})")
|
||||||
normalizedTarget = self._normalizeLanguageCode(targetLanguage) or "en"
|
|
||||||
logger.info(
|
# Perform translation
|
||||||
f"🌐 Translating: '{text}' "
|
|
||||||
f"({normalizedSource or 'auto'} -> {normalizedTarget})"
|
|
||||||
)
|
|
||||||
|
|
||||||
result = self.translate_client.translate(
|
result = self.translate_client.translate(
|
||||||
text,
|
text,
|
||||||
source_language=normalizedSource,
|
source_language=sourceLanguage,
|
||||||
target_language=normalizedTarget,
|
target_language=targetLanguage
|
||||||
)
|
)
|
||||||
|
|
||||||
translatedText = result['translatedText']
|
translatedText = result['translatedText']
|
||||||
|
|
@ -731,8 +708,8 @@ class ConnectorGoogleSpeech:
|
||||||
# Step 2: Translation
|
# Step 2: Translation
|
||||||
translationResult = await self.translateText(
|
translationResult = await self.translateText(
|
||||||
text=originalText,
|
text=originalText,
|
||||||
sourceLanguage=fromLanguage,
|
sourceLanguage=fromLanguage.split('-')[0], # Convert 'de-DE' to 'de'
|
||||||
targetLanguage=toLanguage,
|
targetLanguage=toLanguage.split('-')[0] # Convert 'en-US' to 'en'
|
||||||
)
|
)
|
||||||
|
|
||||||
if not translationResult["success"]:
|
if not translationResult["success"]:
|
||||||
|
|
@ -941,26 +918,33 @@ class ConnectorGoogleSpeech:
|
||||||
stripped = voiceName.strip()
|
stripped = voiceName.strip()
|
||||||
return bool(stripped) and "-" not in stripped
|
return bool(stripped) and "-" not in stripped
|
||||||
|
|
||||||
async def textToSpeech(self, text: str, languageCode: str = "de-DE", voiceName: Optional[str] = None) -> Dict[str, Any]:
|
async def textToSpeech(self, text: str, languageCode: str = "de-DE", voiceName: str = None) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Convert text to speech using Google Cloud Text-to-Speech.
|
Convert text to speech using Google Cloud Text-to-Speech.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: Text to convert to speech
|
text: Text to convert to speech
|
||||||
languageCode: BCP-47 language code (e.g., 'de-DE', 'en-US', 'ru-RU')
|
language_code: Language code (e.g., 'de-DE', 'en-US')
|
||||||
voiceName: Specific voice name (optional). If omitted, a curated
|
voice_name: Specific voice name (optional)
|
||||||
default is used; if no curated default exists for the language,
|
|
||||||
Google selects a default voice automatically based on
|
|
||||||
languageCode + ssml_gender (no hard failure).
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with success status and audio data
|
Dict with success status and audio data
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logger.info(f"Converting text to speech: '{text[:50]}...' in {languageCode}")
|
logger.info(f"Converting text to speech: '{text[:50]}...' in {languageCode}")
|
||||||
|
|
||||||
|
# Build the voice request
|
||||||
selectedVoice = voiceName or self._getDefaultVoice(languageCode)
|
selectedVoice = voiceName or self._getDefaultVoice(languageCode)
|
||||||
isGeminiVoice = self._isGeminiTtsSpeakerVoiceName(selectedVoice) if selectedVoice else False
|
|
||||||
|
if not selectedVoice:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": f"No voice specified for language {languageCode}. Please select a voice."
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(f"Using TTS voice: {selectedVoice} for language: {languageCode}")
|
||||||
|
|
||||||
|
isGeminiVoice = self._isGeminiTtsSpeakerVoiceName(selectedVoice)
|
||||||
|
|
||||||
if isGeminiVoice:
|
if isGeminiVoice:
|
||||||
synthesisInput = texttospeech.SynthesisInput(
|
synthesisInput = texttospeech.SynthesisInput(
|
||||||
|
|
@ -975,23 +959,11 @@ class ConnectorGoogleSpeech:
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
synthesisInput = texttospeech.SynthesisInput(text=text)
|
synthesisInput = texttospeech.SynthesisInput(text=text)
|
||||||
voiceKwargs: Dict[str, Any] = {
|
voice = texttospeech.VoiceSelectionParams(
|
||||||
"language_code": languageCode,
|
language_code=languageCode,
|
||||||
"ssml_gender": texttospeech.SsmlVoiceGender.NEUTRAL,
|
name=selectedVoice,
|
||||||
}
|
ssml_gender=texttospeech.SsmlVoiceGender.NEUTRAL,
|
||||||
if selectedVoice:
|
)
|
||||||
voiceKwargs["name"] = selectedVoice
|
|
||||||
else:
|
|
||||||
logger.info(
|
|
||||||
f"TTS: no curated voice for '{languageCode}', "
|
|
||||||
f"letting Google auto-select by language + gender"
|
|
||||||
)
|
|
||||||
voice = texttospeech.VoiceSelectionParams(**voiceKwargs)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
f"Using TTS voice: {selectedVoice or '<google-auto>'} "
|
|
||||||
f"for language: {languageCode}"
|
|
||||||
)
|
|
||||||
|
|
||||||
audioConfig = texttospeech.AudioConfig(
|
audioConfig = texttospeech.AudioConfig(
|
||||||
audio_encoding=texttospeech.AudioEncoding.MP3
|
audio_encoding=texttospeech.AudioEncoding.MP3
|
||||||
|
|
@ -1000,15 +972,16 @@ class ConnectorGoogleSpeech:
|
||||||
response = self.tts_client.synthesize_speech(
|
response = self.tts_client.synthesize_speech(
|
||||||
input=synthesisInput,
|
input=synthesisInput,
|
||||||
voice=voice,
|
voice=voice,
|
||||||
audio_config=audioConfig,
|
audio_config=audioConfig
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Return the audio content
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
"audio_content": response.audio_content,
|
"audio_content": response.audio_content,
|
||||||
"audio_format": "mp3",
|
"audio_format": "mp3",
|
||||||
"language_code": languageCode,
|
"language_code": languageCode,
|
||||||
"voice_name": selectedVoice or "<google-auto>",
|
"voice_name": voice.name
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -1023,15 +996,59 @@ class ConnectorGoogleSpeech:
|
||||||
"error": f"Text-to-Speech failed: {detail}{extra}",
|
"error": f"Text-to-Speech failed: {detail}{extra}",
|
||||||
}
|
}
|
||||||
|
|
||||||
def _getDefaultVoice(self, languageCode: str) -> Optional[str]:
|
def _getDefaultVoice(self, languageCode: str) -> str:
|
||||||
"""Return the curated default Google TTS voice for `languageCode`.
|
|
||||||
|
|
||||||
Delegates to the central voice catalog; returns None when no curated
|
|
||||||
voice exists, in which case the caller omits `name` and Google
|
|
||||||
auto-selects based on languageCode + ssml_gender.
|
|
||||||
"""
|
"""
|
||||||
return _catalogDefaultVoice(languageCode)
|
Get default voice name for a language code.
|
||||||
|
Falls back to a Wavenet voice for common languages.
|
||||||
|
"""
|
||||||
|
_defaults = {
|
||||||
|
"de-DE": "de-DE-Wavenet-A",
|
||||||
|
"de-CH": "de-DE-Wavenet-A",
|
||||||
|
"en-US": "en-US-Wavenet-C",
|
||||||
|
"en-GB": "en-GB-Wavenet-A",
|
||||||
|
"fr-FR": "fr-FR-Wavenet-A",
|
||||||
|
"it-IT": "it-IT-Wavenet-A",
|
||||||
|
}
|
||||||
|
return _defaults.get(languageCode)
|
||||||
|
|
||||||
|
async def getAvailableLanguages(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get available languages from Google Cloud Text-to-Speech.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict containing success status and list of available languages
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info("🌐 Getting available languages from Google Cloud TTS")
|
||||||
|
|
||||||
|
# List voices from Google Cloud TTS
|
||||||
|
response = self.tts_client.list_voices()
|
||||||
|
|
||||||
|
# Extract unique language codes
|
||||||
|
# Note: Google TTS API doesn't provide language descriptions, only codes
|
||||||
|
language_codes = set()
|
||||||
|
for voice in response.voices:
|
||||||
|
if voice.language_codes:
|
||||||
|
language_codes.update(voice.language_codes)
|
||||||
|
|
||||||
|
# Convert to sorted list of language codes
|
||||||
|
available_languages = sorted(list(language_codes))
|
||||||
|
|
||||||
|
logger.info(f"✅ Found {len(available_languages)} available languages")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"languages": available_languages
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Failed to get available languages: {e}")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"languages": []
|
||||||
|
}
|
||||||
|
|
||||||
async def getAvailableVoices(self, languageCode: Optional[str] = None) -> Dict[str, Any]:
|
async def getAvailableVoices(self, languageCode: Optional[str] = None) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get available voices from Google Cloud Text-to-Speech.
|
Get available voices from Google Cloud Text-to-Speech.
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ class AiAuditLogEntry(BaseModel):
|
||||||
|
|
||||||
userId: str = Field(
|
userId: str = Field(
|
||||||
description="ID of the user who triggered the AI call",
|
description="ID of the user who triggered the AI call",
|
||||||
json_schema_extra={"label": "Benutzer-ID", "fk_target": {"db": "poweron_app", "table": "User"}},
|
json_schema_extra={"label": "Benutzer-ID"},
|
||||||
)
|
)
|
||||||
username: Optional[str] = Field(
|
username: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
|
|
@ -43,17 +43,17 @@ class AiAuditLogEntry(BaseModel):
|
||||||
)
|
)
|
||||||
mandateId: str = Field(
|
mandateId: str = Field(
|
||||||
description="Mandate context of the call",
|
description="Mandate context of the call",
|
||||||
json_schema_extra={"label": "Mandanten-ID", "fk_target": {"db": "poweron_app", "table": "Mandate"}},
|
json_schema_extra={"label": "Mandanten-ID"},
|
||||||
)
|
)
|
||||||
featureInstanceId: Optional[str] = Field(
|
featureInstanceId: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Feature instance context",
|
description="Feature instance context",
|
||||||
json_schema_extra={"label": "Feature-Instanz-ID", "fk_target": {"db": "poweron_app", "table": "FeatureInstance"}},
|
json_schema_extra={"label": "Feature-Instanz-ID"},
|
||||||
)
|
)
|
||||||
featureCode: Optional[str] = Field(
|
featureCode: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Feature code (e.g. workspace, trustee)",
|
description="Feature code (e.g. workspace, trustee)",
|
||||||
json_schema_extra={"label": "Feature", "fk_target": {"db": "poweron_app", "table": "Feature", "column": "code"}},
|
json_schema_extra={"label": "Feature"},
|
||||||
)
|
)
|
||||||
instanceLabel: Optional[str] = Field(
|
instanceLabel: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
|
|
|
||||||
|
|
@ -106,13 +106,7 @@ class AuditLogEntry(BaseModel):
|
||||||
# Actor identification
|
# Actor identification
|
||||||
userId: str = Field(
|
userId: str = Field(
|
||||||
description="ID of the user who performed the action (or 'system' for system events)",
|
description="ID of the user who performed the action (or 'system' for system events)",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Benutzer-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
|
||||||
"label": "Benutzer-ID",
|
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": True,
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "User"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
username: Optional[str] = Field(
|
username: Optional[str] = Field(
|
||||||
|
|
@ -125,25 +119,13 @@ class AuditLogEntry(BaseModel):
|
||||||
mandateId: Optional[str] = Field(
|
mandateId: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Mandate context (if applicable)",
|
description="Mandate context (if applicable)",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Mandanten-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||||
"label": "Mandanten-ID",
|
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": False,
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "Mandate"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
featureInstanceId: Optional[str] = Field(
|
featureInstanceId: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Feature instance context (if applicable)",
|
description="Feature instance context (if applicable)",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Feature-Instanz-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||||
"label": "Feature-Instanz-ID",
|
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": False,
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "FeatureInstance"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Event classification
|
# Event classification
|
||||||
|
|
|
||||||
|
|
@ -2,28 +2,16 @@
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""Base Pydantic model with system-managed fields (DB + API + UI metadata)."""
|
"""Base Pydantic model with system-managed fields (DB + API + UI metadata)."""
|
||||||
|
|
||||||
from typing import Dict, Optional, Type
|
from typing import Optional
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import i18nModel
|
from modules.shared.i18nRegistry import i18nModel
|
||||||
|
|
||||||
_MODEL_REGISTRY: Dict[str, Type["PowerOnModel"]] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def _getModelByTableName(tableName: str) -> Optional[Type["PowerOnModel"]]:
|
|
||||||
"""Look up a PowerOnModel subclass by its table name (= class name)."""
|
|
||||||
return _MODEL_REGISTRY.get(tableName)
|
|
||||||
|
|
||||||
|
|
||||||
@i18nModel("Basisdatensatz")
|
@i18nModel("Basisdatensatz")
|
||||||
class PowerOnModel(BaseModel):
|
class PowerOnModel(BaseModel):
|
||||||
"""Basis-Datenmodell mit System-Audit-Feldern fuer alle DB-Tabellen."""
|
"""Basis-Datenmodell mit System-Audit-Feldern fuer alle DB-Tabellen."""
|
||||||
|
|
||||||
def __init_subclass__(cls, **kwargs):
|
|
||||||
super().__init_subclass__(**kwargs)
|
|
||||||
_MODEL_REGISTRY[cls.__name__] = cls
|
|
||||||
|
|
||||||
sysCreatedAt: Optional[float] = Field(
|
sysCreatedAt: Optional[float] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Record creation timestamp (UTC, set by system)",
|
description="Record creation timestamp (UTC, set by system)",
|
||||||
|
|
|
||||||
|
|
@ -46,15 +46,11 @@ class BillingAccount(PowerOnModel):
|
||||||
description="Primary key",
|
description="Primary key",
|
||||||
json_schema_extra={"label": "ID"},
|
json_schema_extra={"label": "ID"},
|
||||||
)
|
)
|
||||||
mandateId: str = Field(
|
mandateId: str = Field(..., description="Foreign key to Mandate", json_schema_extra={"label": "Mandanten-ID"})
|
||||||
...,
|
|
||||||
description="Foreign key to Mandate",
|
|
||||||
json_schema_extra={"label": "Mandanten-ID", "fk_target": {"db": "poweron_app", "table": "Mandate"}},
|
|
||||||
)
|
|
||||||
userId: Optional[str] = Field(
|
userId: Optional[str] = Field(
|
||||||
None,
|
None,
|
||||||
description="Foreign key to User (None = mandate pool account, set = user audit account)",
|
description="Foreign key to User (None = mandate pool account, set = user audit account)",
|
||||||
json_schema_extra={"label": "Benutzer-ID", "fk_target": {"db": "poweron_app", "table": "User"}},
|
json_schema_extra={"label": "Benutzer-ID"},
|
||||||
)
|
)
|
||||||
balance: float = Field(default=0.0, description="Current balance in CHF", json_schema_extra={"label": "Guthaben (CHF)"})
|
balance: float = Field(default=0.0, description="Current balance in CHF", json_schema_extra={"label": "Guthaben (CHF)"})
|
||||||
warningThreshold: float = Field(
|
warningThreshold: float = Field(
|
||||||
|
|
@ -78,11 +74,7 @@ class BillingTransaction(PowerOnModel):
|
||||||
description="Primary key",
|
description="Primary key",
|
||||||
json_schema_extra={"label": "ID"},
|
json_schema_extra={"label": "ID"},
|
||||||
)
|
)
|
||||||
accountId: str = Field(
|
accountId: str = Field(..., description="Foreign key to BillingAccount", json_schema_extra={"label": "Konto-ID"})
|
||||||
...,
|
|
||||||
description="Foreign key to BillingAccount",
|
|
||||||
json_schema_extra={"label": "Konto-ID", "fk_target": {"db": "poweron_billing", "table": "BillingAccount"}},
|
|
||||||
)
|
|
||||||
transactionType: TransactionTypeEnum = Field(..., description="Transaction type", json_schema_extra={"label": "Typ"})
|
transactionType: TransactionTypeEnum = Field(..., description="Transaction type", json_schema_extra={"label": "Typ"})
|
||||||
amount: float = Field(..., description="Amount in CHF (always positive)", json_schema_extra={"label": "Betrag (CHF)"})
|
amount: float = Field(..., description="Amount in CHF (always positive)", json_schema_extra={"label": "Betrag (CHF)"})
|
||||||
description: str = Field(..., description="Transaction description", json_schema_extra={"label": "Beschreibung"})
|
description: str = Field(..., description="Transaction description", json_schema_extra={"label": "Beschreibung"})
|
||||||
|
|
@ -92,28 +84,12 @@ class BillingTransaction(PowerOnModel):
|
||||||
referenceId: Optional[str] = Field(None, description="Reference ID", json_schema_extra={"label": "Referenz-ID"})
|
referenceId: Optional[str] = Field(None, description="Reference ID", json_schema_extra={"label": "Referenz-ID"})
|
||||||
|
|
||||||
# Context for workflow transactions
|
# Context for workflow transactions
|
||||||
workflowId: Optional[str] = Field(
|
workflowId: Optional[str] = Field(None, description="Workflow ID (for WORKFLOW transactions)", json_schema_extra={"label": "Workflow-ID"})
|
||||||
None,
|
featureInstanceId: Optional[str] = Field(None, description="Feature instance ID", json_schema_extra={"label": "Feature-Instanz-ID"})
|
||||||
description="Workflow ID (for WORKFLOW transactions; may be Chat or Graphical Editor)",
|
featureCode: Optional[str] = Field(None, description="Feature code (e.g., automation)", json_schema_extra={"label": "Feature-Code"})
|
||||||
json_schema_extra={"label": "Workflow-ID"},
|
|
||||||
)
|
|
||||||
featureInstanceId: Optional[str] = Field(
|
|
||||||
None,
|
|
||||||
description="Feature instance ID",
|
|
||||||
json_schema_extra={"label": "Feature-Instanz-ID", "fk_target": {"db": "poweron_app", "table": "FeatureInstance"}},
|
|
||||||
)
|
|
||||||
featureCode: Optional[str] = Field(
|
|
||||||
None,
|
|
||||||
description="Feature code (e.g., automation)",
|
|
||||||
json_schema_extra={"label": "Feature-Code", "fk_target": {"db": "poweron_app", "table": "Feature", "column": "code"}},
|
|
||||||
)
|
|
||||||
aicoreProvider: Optional[str] = Field(None, description="AICore provider (anthropic, openai, etc.)", json_schema_extra={"label": "AI-Anbieter"})
|
aicoreProvider: Optional[str] = Field(None, description="AICore provider (anthropic, openai, etc.)", json_schema_extra={"label": "AI-Anbieter"})
|
||||||
aicoreModel: Optional[str] = Field(None, description="AICore model name (e.g., claude-4-sonnet, gpt-4o)", json_schema_extra={"label": "AI-Modell"})
|
aicoreModel: Optional[str] = Field(None, description="AICore model name (e.g., claude-4-sonnet, gpt-4o)", json_schema_extra={"label": "AI-Modell"})
|
||||||
createdByUserId: Optional[str] = Field(
|
createdByUserId: Optional[str] = Field(None, description="User who created/caused this transaction", json_schema_extra={"label": "Erstellt von Benutzer"})
|
||||||
None,
|
|
||||||
description="User who created/caused this transaction",
|
|
||||||
json_schema_extra={"label": "Erstellt von Benutzer", "fk_target": {"db": "poweron_app", "table": "User"}},
|
|
||||||
)
|
|
||||||
|
|
||||||
# AI call metadata (for per-call analytics)
|
# AI call metadata (for per-call analytics)
|
||||||
processingTime: Optional[float] = Field(None, description="Processing time in seconds", json_schema_extra={"label": "Verarbeitungszeit (s)"})
|
processingTime: Optional[float] = Field(None, description="Processing time in seconds", json_schema_extra={"label": "Verarbeitungszeit (s)"})
|
||||||
|
|
@ -130,11 +106,7 @@ class BillingSettings(BaseModel):
|
||||||
description="Primary key",
|
description="Primary key",
|
||||||
json_schema_extra={"label": "ID"},
|
json_schema_extra={"label": "ID"},
|
||||||
)
|
)
|
||||||
mandateId: str = Field(
|
mandateId: str = Field(..., description="Foreign key to Mandate (UNIQUE)", json_schema_extra={"label": "Mandanten-ID"})
|
||||||
...,
|
|
||||||
description="Foreign key to Mandate (UNIQUE)",
|
|
||||||
json_schema_extra={"label": "Mandanten-ID", "fk_target": {"db": "poweron_app", "table": "Mandate"}},
|
|
||||||
)
|
|
||||||
|
|
||||||
warningThresholdPercent: float = Field(
|
warningThresholdPercent: float = Field(
|
||||||
default=10.0,
|
default=10.0,
|
||||||
|
|
@ -207,11 +179,7 @@ class UsageStatistics(BaseModel):
|
||||||
description="Primary key",
|
description="Primary key",
|
||||||
json_schema_extra={"label": "ID"},
|
json_schema_extra={"label": "ID"},
|
||||||
)
|
)
|
||||||
accountId: str = Field(
|
accountId: str = Field(..., description="Foreign key to BillingAccount", json_schema_extra={"label": "Konto-ID"})
|
||||||
...,
|
|
||||||
description="Foreign key to BillingAccount",
|
|
||||||
json_schema_extra={"label": "Konto-ID", "fk_target": {"db": "poweron_billing", "table": "BillingAccount"}},
|
|
||||||
)
|
|
||||||
periodType: PeriodTypeEnum = Field(..., description="Period type", json_schema_extra={"label": "Periodentyp"})
|
periodType: PeriodTypeEnum = Field(..., description="Period type", json_schema_extra={"label": "Periodentyp"})
|
||||||
periodStart: date = Field(..., description="Period start date", json_schema_extra={"label": "Periodenbeginn"})
|
periodStart: date = Field(..., description="Period start date", json_schema_extra={"label": "Periodenbeginn"})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,7 @@ import uuid
|
||||||
class ChatLog(PowerOnModel):
|
class ChatLog(PowerOnModel):
|
||||||
"""Log entries for chat workflows. User-owned, no mandate context."""
|
"""Log entries for chat workflows. User-owned, no mandate context."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"label": "ID"})
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"label": "ID"})
|
||||||
workflowId: str = Field(
|
workflowId: str = Field(description="Foreign key to workflow", json_schema_extra={"label": "Workflow-ID"})
|
||||||
description="Foreign key to workflow",
|
|
||||||
json_schema_extra={"label": "Workflow-ID", "fk_target": {"db": "poweron_chat", "table": "ChatWorkflow"}},
|
|
||||||
)
|
|
||||||
message: str = Field(description="Log message", json_schema_extra={"label": "Nachricht"})
|
message: str = Field(description="Log message", json_schema_extra={"label": "Nachricht"})
|
||||||
type: str = Field(description="Log type (info, warning, error, etc.)", json_schema_extra={"label": "Typ"})
|
type: str = Field(description="Log type (info, warning, error, etc.)", json_schema_extra={"label": "Typ"})
|
||||||
timestamp: float = Field(default_factory=getUtcTimestamp,
|
timestamp: float = Field(default_factory=getUtcTimestamp,
|
||||||
|
|
@ -35,14 +32,8 @@ class ChatLog(PowerOnModel):
|
||||||
class ChatDocument(PowerOnModel):
|
class ChatDocument(PowerOnModel):
|
||||||
"""Documents attached to chat messages. User-owned, no mandate context."""
|
"""Documents attached to chat messages. User-owned, no mandate context."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"label": "ID"})
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"label": "ID"})
|
||||||
messageId: str = Field(
|
messageId: str = Field(description="Foreign key to message", json_schema_extra={"label": "Nachrichten-ID"})
|
||||||
description="Foreign key to message",
|
fileId: str = Field(description="Foreign key to file", json_schema_extra={"label": "Datei-ID"})
|
||||||
json_schema_extra={"label": "Nachrichten-ID", "fk_target": {"db": "poweron_chat", "table": "ChatMessage"}},
|
|
||||||
)
|
|
||||||
fileId: str = Field(
|
|
||||||
description="Foreign key to file",
|
|
||||||
json_schema_extra={"label": "Datei-ID", "fk_target": {"db": "poweron_management", "table": "FileItem"}},
|
|
||||||
)
|
|
||||||
fileName: str = Field(description="Name of the file", json_schema_extra={"label": "Dateiname"})
|
fileName: str = Field(description="Name of the file", json_schema_extra={"label": "Dateiname"})
|
||||||
fileSize: int = Field(description="Size of the file", json_schema_extra={"label": "Dateigröße"})
|
fileSize: int = Field(description="Size of the file", json_schema_extra={"label": "Dateigröße"})
|
||||||
mimeType: str = Field(description="MIME type of the file", json_schema_extra={"label": "MIME-Typ"})
|
mimeType: str = Field(description="MIME type of the file", json_schema_extra={"label": "MIME-Typ"})
|
||||||
|
|
@ -79,15 +70,8 @@ class ChatContentExtracted(BaseModel):
|
||||||
class ChatMessage(PowerOnModel):
|
class ChatMessage(PowerOnModel):
|
||||||
"""Messages in chat workflows. User-owned, no mandate context."""
|
"""Messages in chat workflows. User-owned, no mandate context."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"label": "ID"})
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"label": "ID"})
|
||||||
workflowId: str = Field(
|
workflowId: str = Field(description="Foreign key to workflow", json_schema_extra={"label": "Workflow-ID"})
|
||||||
description="Foreign key to workflow",
|
parentMessageId: Optional[str] = Field(None, description="Parent message ID for threading", json_schema_extra={"label": "Übergeordnete Nachrichten-ID"})
|
||||||
json_schema_extra={"label": "Workflow-ID", "fk_target": {"db": "poweron_chat", "table": "ChatWorkflow"}},
|
|
||||||
)
|
|
||||||
parentMessageId: Optional[str] = Field(
|
|
||||||
None,
|
|
||||||
description="Parent message ID for threading",
|
|
||||||
json_schema_extra={"label": "Übergeordnete Nachrichten-ID", "fk_target": {"db": "poweron_chat", "table": "ChatMessage"}},
|
|
||||||
)
|
|
||||||
documents: List[ChatDocument] = Field(default_factory=list, description="Associated documents", json_schema_extra={"label": "Dokumente"})
|
documents: List[ChatDocument] = Field(default_factory=list, description="Associated documents", json_schema_extra={"label": "Dokumente"})
|
||||||
documentsLabel: Optional[str] = Field(None, description="Label for the set of documents", json_schema_extra={"label": "Dokumenten-Label"})
|
documentsLabel: Optional[str] = Field(None, description="Label for the set of documents", json_schema_extra={"label": "Dokumenten-Label"})
|
||||||
message: Optional[str] = Field(None, description="Message content", json_schema_extra={"label": "Nachricht"})
|
message: Optional[str] = Field(None, description="Message content", json_schema_extra={"label": "Nachricht"})
|
||||||
|
|
@ -117,32 +101,7 @@ class WorkflowModeEnum(str, Enum):
|
||||||
class ChatWorkflow(PowerOnModel):
|
class ChatWorkflow(PowerOnModel):
|
||||||
"""Chat workflow container. User-owned, no mandate context."""
|
"""Chat workflow container. User-owned, no mandate context."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
featureInstanceId: Optional[str] = Field(
|
featureInstanceId: Optional[str] = Field(None, description="Feature instance ID for multi-tenancy isolation", json_schema_extra={"label": "Feature-Instanz-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
|
||||||
None,
|
|
||||||
description="Feature instance ID for multi-tenancy isolation",
|
|
||||||
json_schema_extra={
|
|
||||||
"label": "Feature-Instanz-ID",
|
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": False,
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "FeatureInstance"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
linkedWorkflowId: Optional[str] = Field(
|
|
||||||
None,
|
|
||||||
description=(
|
|
||||||
"Optional foreign key linking this chat to an entity outside the "
|
|
||||||
"ChatWorkflow table (e.g. an Automation2Workflow in the GraphicalEditor "
|
|
||||||
"AI editor chat). NULL for the default workspace chats. Combined with "
|
|
||||||
"featureInstanceId this gives a 1:1 relation entity ↔ chat per feature."
|
|
||||||
),
|
|
||||||
json_schema_extra={
|
|
||||||
"label": "Verknüpfter Workflow",
|
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": False,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
status: str = Field(default="running", description="Current status of the workflow", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
|
status: str = Field(default="running", description="Current status of the workflow", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [
|
||||||
{"value": "running", "label": "Running"},
|
{"value": "running", "label": "Running"},
|
||||||
{"value": "completed", "label": "Completed"},
|
{"value": "completed", "label": "Completed"},
|
||||||
|
|
@ -210,11 +169,7 @@ class UserInputRequest(BaseModel):
|
||||||
prompt: str = Field(description="Prompt for the user", json_schema_extra={"label": "Eingabeaufforderung"})
|
prompt: str = Field(description="Prompt for the user", json_schema_extra={"label": "Eingabeaufforderung"})
|
||||||
listFileId: List[str] = Field(default_factory=list, description="List of file IDs", json_schema_extra={"label": "Datei-IDs"})
|
listFileId: List[str] = Field(default_factory=list, description="List of file IDs", json_schema_extra={"label": "Datei-IDs"})
|
||||||
userLanguage: str = Field(default="en", description="User's preferred language", json_schema_extra={"label": "Benutzersprache"})
|
userLanguage: str = Field(default="en", description="User's preferred language", json_schema_extra={"label": "Benutzersprache"})
|
||||||
workflowId: Optional[str] = Field(
|
workflowId: Optional[str] = Field(None, description="Optional ID of the workflow to continue", json_schema_extra={"label": "Workflow-ID"})
|
||||||
None,
|
|
||||||
description="Optional ID of the workflow to continue",
|
|
||||||
json_schema_extra={"label": "Workflow-ID", "fk_target": {"db": "poweron_chat", "table": "ChatWorkflow"}},
|
|
||||||
)
|
|
||||||
allowedProviders: Optional[List[str]] = Field(None, description="List of allowed AI providers (multiselect)", json_schema_extra={"label": "Erlaubte Anbieter"})
|
allowedProviders: Optional[List[str]] = Field(None, description="List of allowed AI providers (multiselect)", json_schema_extra={"label": "Erlaubte Anbieter"})
|
||||||
|
|
||||||
@i18nModel("Aktions-Dokument")
|
@i18nModel("Aktions-Dokument")
|
||||||
|
|
@ -352,11 +307,7 @@ class ChatTaskResult(BaseModel):
|
||||||
@i18nModel("Aufgabe")
|
@i18nModel("Aufgabe")
|
||||||
class TaskItem(BaseModel):
|
class TaskItem(BaseModel):
|
||||||
id: str = Field(..., description="Task ID", json_schema_extra={"label": "Aufgaben-ID"})
|
id: str = Field(..., description="Task ID", json_schema_extra={"label": "Aufgaben-ID"})
|
||||||
workflowId: str = Field(
|
workflowId: str = Field(..., description="Workflow ID", json_schema_extra={"label": "Workflow-ID"})
|
||||||
...,
|
|
||||||
description="Workflow ID",
|
|
||||||
json_schema_extra={"label": "Workflow-ID", "fk_target": {"db": "poweron_chat", "table": "ChatWorkflow"}},
|
|
||||||
)
|
|
||||||
userInput: str = Field(..., description="User input that triggered the task", json_schema_extra={"label": "Benutzereingabe"})
|
userInput: str = Field(..., description="User input that triggered the task", json_schema_extra={"label": "Benutzereingabe"})
|
||||||
status: TaskStatus = Field(default=TaskStatus.PENDING, description="Task status", json_schema_extra={"label": "Status"})
|
status: TaskStatus = Field(default=TaskStatus.PENDING, description="Task status", json_schema_extra={"label": "Status"})
|
||||||
error: Optional[str] = Field(None, description="Error message if task failed", json_schema_extra={"label": "Fehler"})
|
error: Optional[str] = Field(None, description="Error message if task failed", json_schema_extra={"label": "Fehler"})
|
||||||
|
|
|
||||||
|
|
@ -32,10 +32,7 @@ class ContentContextRef(BaseModel):
|
||||||
class ContentObject(BaseModel):
|
class ContentObject(BaseModel):
|
||||||
"""Scalar content object extracted from a file. No AI involved."""
|
"""Scalar content object extracted from a file. No AI involved."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||||
fileId: str = Field(
|
fileId: str = Field(description="FK to the physical file")
|
||||||
description="FK to the physical file",
|
|
||||||
json_schema_extra={"fk_target": {"db": "poweron_management", "table": "FileItem"}},
|
|
||||||
)
|
|
||||||
contentType: str = Field(description="text, image, videostream, audiostream, other")
|
contentType: str = Field(description="text, image, videostream, audiostream, other")
|
||||||
data: str = Field(default="", description="Content data (text, base64, URL)")
|
data: str = Field(default="", description="Content data (text, base64, URL)")
|
||||||
contextRef: ContentContextRef = Field(default_factory=ContentContextRef)
|
contextRef: ContentContextRef = Field(default_factory=ContentContextRef)
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ class DataSource(PowerOnModel):
|
||||||
)
|
)
|
||||||
connectionId: str = Field(
|
connectionId: str = Field(
|
||||||
description="FK to UserConnection",
|
description="FK to UserConnection",
|
||||||
json_schema_extra={"label": "Verbindungs-ID", "fk_target": {"db": "poweron_app", "table": "UserConnection"}},
|
json_schema_extra={"label": "Verbindungs-ID"},
|
||||||
)
|
)
|
||||||
sourceType: str = Field(
|
sourceType: str = Field(
|
||||||
description="sharepointFolder, googleDriveFolder, outlookFolder, ftpFolder, clickupList (path under /team/...)",
|
description="sharepointFolder, googleDriveFolder, outlookFolder, ftpFolder, clickupList (path under /team/...)",
|
||||||
|
|
@ -45,17 +45,17 @@ class DataSource(PowerOnModel):
|
||||||
featureInstanceId: Optional[str] = Field(
|
featureInstanceId: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Scoped to feature instance",
|
description="Scoped to feature instance",
|
||||||
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance"}},
|
json_schema_extra={"label": "Feature-Instanz"},
|
||||||
)
|
)
|
||||||
mandateId: Optional[str] = Field(
|
mandateId: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Mandate scope",
|
description="Mandate scope",
|
||||||
json_schema_extra={"label": "Mandanten-ID", "fk_target": {"db": "poweron_app", "table": "Mandate"}},
|
json_schema_extra={"label": "Mandanten-ID"},
|
||||||
)
|
)
|
||||||
userId: str = Field(
|
userId: str = Field(
|
||||||
default="",
|
default="",
|
||||||
description="Owner user ID",
|
description="Owner user ID",
|
||||||
json_schema_extra={"label": "Benutzer-ID", "fk_target": {"db": "poweron_app", "table": "User"}},
|
json_schema_extra={"label": "Benutzer-ID"},
|
||||||
)
|
)
|
||||||
autoSync: bool = Field(
|
autoSync: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ class ContentExtracted(BaseModel):
|
||||||
id: str = Field(description="Extraction id or source document id")
|
id: str = Field(description="Extraction id or source document id")
|
||||||
parts: List[ContentPart] = Field(default_factory=list, description="List of extracted parts")
|
parts: List[ContentPart] = Field(default_factory=list, description="List of extracted parts")
|
||||||
summary: Optional[Dict[str, Any]] = Field(default=None, description="Optional extraction summary")
|
summary: Optional[Dict[str, Any]] = Field(default=None, description="Optional extraction summary")
|
||||||
udm: Optional[Any] = Field(default=None, description="Optional UdmDocument (when outputFormat is udm or both)")
|
|
||||||
|
|
||||||
|
|
||||||
class ChunkResult(BaseModel):
|
class ChunkResult(BaseModel):
|
||||||
|
|
@ -76,19 +75,6 @@ class ExtractionOptions(BaseModel):
|
||||||
# Core extraction parameters
|
# Core extraction parameters
|
||||||
prompt: str = Field(default="", description="Extraction prompt for AI processing")
|
prompt: str = Field(default="", description="Extraction prompt for AI processing")
|
||||||
processDocumentsIndividually: bool = Field(default=True, description="Process each document separately")
|
processDocumentsIndividually: bool = Field(default=True, description="Process each document separately")
|
||||||
|
|
||||||
outputFormat: Literal["parts", "udm", "both"] = Field(
|
|
||||||
default="parts",
|
|
||||||
description="Return flat parts only, UDM tree only, or both (parts always populated; udm when udm or both)",
|
|
||||||
)
|
|
||||||
outputDetail: Literal["full", "structure", "references"] = Field(
|
|
||||||
default="full",
|
|
||||||
description="Extraction detail: full inline data, skeleton without raw payloads, or file references only",
|
|
||||||
)
|
|
||||||
lazyContainer: bool = Field(
|
|
||||||
default=False,
|
|
||||||
description="For archives: emit file entries with metadata only (no nested extraction)",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Image processing parameters
|
# Image processing parameters
|
||||||
imageMaxPixels: int = Field(default=1024 * 1024, ge=1, description="Maximum pixels for image processing")
|
imageMaxPixels: int = Field(default=1024 * 1024, ge=1, description="Maximum pixels for image processing")
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ A FeatureDataSource links a FeatureInstance table (DATA_OBJECT) to a workspace
|
||||||
so the agent can query structured feature data (e.g. TrusteePosition rows).
|
so the agent can query structured feature data (e.g. TrusteePosition rows).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, Optional
|
||||||
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
|
||||||
|
|
@ -23,11 +23,11 @@ class FeatureDataSource(PowerOnModel):
|
||||||
)
|
)
|
||||||
featureInstanceId: str = Field(
|
featureInstanceId: str = Field(
|
||||||
description="FK to FeatureInstance",
|
description="FK to FeatureInstance",
|
||||||
json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance"}},
|
json_schema_extra={"label": "Feature-Instanz"},
|
||||||
)
|
)
|
||||||
featureCode: str = Field(
|
featureCode: str = Field(
|
||||||
description="Feature code (e.g. trustee, commcoach)",
|
description="Feature code (e.g. trustee, commcoach)",
|
||||||
json_schema_extra={"label": "Feature", "fk_target": {"db": "poweron_app", "table": "Feature", "column": "code"}},
|
json_schema_extra={"label": "Feature"},
|
||||||
)
|
)
|
||||||
tableName: str = Field(
|
tableName: str = Field(
|
||||||
description="Table name from DATA_OBJECTS meta (e.g. TrusteePosition)",
|
description="Table name from DATA_OBJECTS meta (e.g. TrusteePosition)",
|
||||||
|
|
@ -44,16 +44,16 @@ class FeatureDataSource(PowerOnModel):
|
||||||
mandateId: str = Field(
|
mandateId: str = Field(
|
||||||
default="",
|
default="",
|
||||||
description="Mandate scope",
|
description="Mandate scope",
|
||||||
json_schema_extra={"label": "Mandant", "fk_target": {"db": "poweron_app", "table": "Mandate"}},
|
json_schema_extra={"label": "Mandant"},
|
||||||
)
|
)
|
||||||
userId: str = Field(
|
userId: str = Field(
|
||||||
default="",
|
default="",
|
||||||
description="Owner user ID",
|
description="Owner user ID",
|
||||||
json_schema_extra={"label": "Benutzer", "fk_target": {"db": "poweron_app", "table": "User"}},
|
json_schema_extra={"label": "Benutzer"},
|
||||||
)
|
)
|
||||||
workspaceInstanceId: str = Field(
|
workspaceInstanceId: str = Field(
|
||||||
description="Workspace feature instance where this source is used",
|
description="Workspace instance where this source is used",
|
||||||
json_schema_extra={"label": "Workspace", "fk_target": {"db": "poweron_app", "table": "FeatureInstance"}},
|
json_schema_extra={"label": "Workspace"},
|
||||||
)
|
)
|
||||||
scope: str = Field(
|
scope: str = Field(
|
||||||
default="personal",
|
default="personal",
|
||||||
|
|
@ -70,11 +70,6 @@ class FeatureDataSource(PowerOnModel):
|
||||||
description="Whether this data source should be neutralized before AI processing",
|
description="Whether this data source should be neutralized before AI processing",
|
||||||
json_schema_extra={"label": "Neutralisieren", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False},
|
json_schema_extra={"label": "Neutralisieren", "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(
|
recordFilter: Optional[Dict[str, str]] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Record-level filter applied when querying this table, e.g. {'sessionId': 'abc-123'}",
|
description="Record-level filter applied when querying this table, e.g. {'sessionId': 'abc-123'}",
|
||||||
|
|
|
||||||
|
|
@ -38,23 +38,11 @@ class FeatureInstance(PowerOnModel):
|
||||||
)
|
)
|
||||||
featureCode: str = Field(
|
featureCode: str = Field(
|
||||||
description="FK -> Feature.code",
|
description="FK -> Feature.code",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Feature", "frontend_type": "select", "frontend_readonly": True, "frontend_required": True}
|
||||||
"label": "Feature",
|
|
||||||
"frontend_type": "select",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": True,
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "Feature", "column": "code"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
mandateId: str = Field(
|
mandateId: str = Field(
|
||||||
description="FK -> Mandate.id (CASCADE DELETE)",
|
description="FK -> Mandate.id (CASCADE DELETE)",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Mandant", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
|
||||||
"label": "Mandant",
|
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": True,
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "Mandate"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
label: str = Field(
|
label: str = Field(
|
||||||
default="",
|
default="",
|
||||||
|
|
|
||||||
|
|
@ -24,59 +24,15 @@ class FileFolder(PowerOnModel):
|
||||||
parentId: Optional[str] = Field(
|
parentId: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Parent folder ID (null = root)",
|
description="Parent folder ID (null = root)",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Uebergeordneter Ordner", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False},
|
||||||
"label": "Uebergeordneter Ordner",
|
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": False,
|
|
||||||
"frontend_required": False,
|
|
||||||
"fk_target": {"db": "poweron_management", "table": "FileFolder"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
mandateId: Optional[str] = Field(
|
mandateId: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Mandate context",
|
description="Mandate context",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Mandanten-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
|
||||||
"label": "Mandanten-ID",
|
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": False,
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "Mandate"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
featureInstanceId: Optional[str] = Field(
|
featureInstanceId: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Feature instance context",
|
description="Feature instance context",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Feature-Instanz-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
|
||||||
"label": "Feature-Instanz-ID",
|
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": False,
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "FeatureInstance"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
scope: str = Field(
|
|
||||||
default="personal",
|
|
||||||
description="Data visibility scope: personal, featureInstance, mandate, global. Inherited by files in this folder.",
|
|
||||||
json_schema_extra={
|
|
||||||
"label": "Sichtbarkeit",
|
|
||||||
"frontend_type": "select",
|
|
||||||
"frontend_readonly": False,
|
|
||||||
"frontend_required": False,
|
|
||||||
"frontend_options": [
|
|
||||||
{"value": "personal", "label": "Persönlich"},
|
|
||||||
{"value": "featureInstance", "label": "Feature-Instanz"},
|
|
||||||
{"value": "mandate", "label": "Mandant"},
|
|
||||||
{"value": "global", "label": "Global"},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
neutralize: bool = Field(
|
|
||||||
default=False,
|
|
||||||
description="Whether files in this folder should be neutralized before AI processing. Inherited by new/moved files.",
|
|
||||||
json_schema_extra={
|
|
||||||
"label": "Neutralisieren",
|
|
||||||
"frontend_type": "checkbox",
|
|
||||||
"frontend_readonly": False,
|
|
||||||
"frontend_required": False,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,6 @@ class FileItem(PowerOnModel):
|
||||||
"frontend_fk_source": "/api/mandates/",
|
"frontend_fk_source": "/api/mandates/",
|
||||||
"frontend_fk_display_field": "label",
|
"frontend_fk_display_field": "label",
|
||||||
"fk_model": "Mandate",
|
"fk_model": "Mandate",
|
||||||
"fk_target": {"db": "poweron_app", "table": "Mandate"},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
featureInstanceId: Optional[str] = Field(
|
featureInstanceId: Optional[str] = Field(
|
||||||
|
|
@ -47,7 +46,6 @@ class FileItem(PowerOnModel):
|
||||||
"frontend_fk_source": "/api/features/instances",
|
"frontend_fk_source": "/api/features/instances",
|
||||||
"frontend_fk_display_field": "label",
|
"frontend_fk_display_field": "label",
|
||||||
"fk_model": "FeatureInstance",
|
"fk_model": "FeatureInstance",
|
||||||
"fk_target": {"db": "poweron_app", "table": "FeatureInstance"},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
mimeType: str = Field(
|
mimeType: str = Field(
|
||||||
|
|
@ -70,13 +68,7 @@ class FileItem(PowerOnModel):
|
||||||
folderId: Optional[str] = Field(
|
folderId: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="ID of the parent folder",
|
description="ID of the parent folder",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Ordner-ID", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False},
|
||||||
"label": "Ordner-ID",
|
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": False,
|
|
||||||
"frontend_required": False,
|
|
||||||
"fk_target": {"db": "poweron_management", "table": "FileFolder"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
description: Optional[str] = Field(
|
description: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
|
|
|
||||||
|
|
@ -32,24 +32,12 @@ class Invitation(PowerOnModel):
|
||||||
|
|
||||||
mandateId: str = Field(
|
mandateId: str = Field(
|
||||||
description="FK → Mandate.id - Target mandate for the invitation",
|
description="FK → Mandate.id - Target mandate for the invitation",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Mandant", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
|
||||||
"label": "Mandant",
|
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": True,
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "Mandate"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
featureInstanceId: Optional[str] = Field(
|
featureInstanceId: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Optional FK → FeatureInstance.id - Direct access to specific feature",
|
description="Optional FK → FeatureInstance.id - Direct access to specific feature",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Feature-Instanz", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||||
"label": "Feature-Instanz",
|
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": False,
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "FeatureInstance"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
roleIds: List[str] = Field(
|
roleIds: List[str] = Field(
|
||||||
default_factory=list,
|
default_factory=list,
|
||||||
|
|
@ -75,13 +63,7 @@ class Invitation(PowerOnModel):
|
||||||
usedBy: Optional[str] = Field(
|
usedBy: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="User ID of the person who used the invitation",
|
description="User ID of the person who used the invitation",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Verwendet von", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}
|
||||||
"label": "Verwendet von",
|
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": False,
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "User"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
usedAt: Optional[float] = Field(
|
usedAt: Optional[float] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
|
|
|
||||||
|
|
@ -30,17 +30,17 @@ class FileContentIndex(PowerOnModel):
|
||||||
)
|
)
|
||||||
userId: str = Field(
|
userId: str = Field(
|
||||||
description="Owner user ID",
|
description="Owner user ID",
|
||||||
json_schema_extra={"label": "Benutzer-ID", "fk_target": {"db": "poweron_app", "table": "User"}},
|
json_schema_extra={"label": "Benutzer-ID"},
|
||||||
)
|
)
|
||||||
featureInstanceId: str = Field(
|
featureInstanceId: str = Field(
|
||||||
default="",
|
default="",
|
||||||
description="Feature instance scope",
|
description="Feature instance scope",
|
||||||
json_schema_extra={"label": "Feature-Instanz-ID", "fk_target": {"db": "poweron_app", "table": "FeatureInstance"}},
|
json_schema_extra={"label": "Feature-Instanz-ID"},
|
||||||
)
|
)
|
||||||
mandateId: str = Field(
|
mandateId: str = Field(
|
||||||
default="",
|
default="",
|
||||||
description="Mandate scope",
|
description="Mandate scope",
|
||||||
json_schema_extra={"label": "Mandanten-ID", "fk_target": {"db": "poweron_app", "table": "Mandate"}},
|
json_schema_extra={"label": "Mandanten-ID"},
|
||||||
)
|
)
|
||||||
fileName: str = Field(
|
fileName: str = Field(
|
||||||
description="Original file name",
|
description="Original file name",
|
||||||
|
|
@ -116,16 +116,16 @@ class ContentChunk(PowerOnModel):
|
||||||
)
|
)
|
||||||
fileId: str = Field(
|
fileId: str = Field(
|
||||||
description="FK to the source file",
|
description="FK to the source file",
|
||||||
json_schema_extra={"label": "Datei-ID", "fk_target": {"db": "poweron_management", "table": "FileItem"}},
|
json_schema_extra={"label": "Datei-ID"},
|
||||||
)
|
)
|
||||||
userId: str = Field(
|
userId: str = Field(
|
||||||
description="Owner user ID",
|
description="Owner user ID",
|
||||||
json_schema_extra={"label": "Benutzer-ID", "fk_target": {"db": "poweron_app", "table": "User"}},
|
json_schema_extra={"label": "Benutzer-ID"},
|
||||||
)
|
)
|
||||||
featureInstanceId: str = Field(
|
featureInstanceId: str = Field(
|
||||||
default="",
|
default="",
|
||||||
description="Feature instance scope",
|
description="Feature instance scope",
|
||||||
json_schema_extra={"label": "Feature-Instanz-ID", "fk_target": {"db": "poweron_app", "table": "FeatureInstance"}},
|
json_schema_extra={"label": "Feature-Instanz-ID"},
|
||||||
)
|
)
|
||||||
contentType: str = Field(
|
contentType: str = Field(
|
||||||
description="Content type: text, image, videostream, audiostream, other",
|
description="Content type: text, image, videostream, audiostream, other",
|
||||||
|
|
@ -214,16 +214,16 @@ class WorkflowMemory(PowerOnModel):
|
||||||
)
|
)
|
||||||
workflowId: str = Field(
|
workflowId: str = Field(
|
||||||
description="FK to the workflow",
|
description="FK to the workflow",
|
||||||
json_schema_extra={"label": "Workflow-ID", "fk_target": {"db": "poweron_chat", "table": "ChatWorkflow"}},
|
json_schema_extra={"label": "Workflow-ID"},
|
||||||
)
|
)
|
||||||
userId: str = Field(
|
userId: str = Field(
|
||||||
description="Owner user ID",
|
description="Owner user ID",
|
||||||
json_schema_extra={"label": "Benutzer-ID", "fk_target": {"db": "poweron_app", "table": "User"}},
|
json_schema_extra={"label": "Benutzer-ID"},
|
||||||
)
|
)
|
||||||
featureInstanceId: str = Field(
|
featureInstanceId: str = Field(
|
||||||
default="",
|
default="",
|
||||||
description="Feature instance scope",
|
description="Feature instance scope",
|
||||||
json_schema_extra={"label": "Feature-Instanz-ID", "fk_target": {"db": "poweron_app", "table": "FeatureInstance"}},
|
json_schema_extra={"label": "Feature-Instanz-ID"},
|
||||||
)
|
)
|
||||||
key: str = Field(
|
key: str = Field(
|
||||||
description="Key identifier (e.g. 'entity:companyName')",
|
description="Key identifier (e.g. 'entity:companyName')",
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ class UserMandate(PowerOnModel):
|
||||||
"frontend_fk_source": "/api/users/",
|
"frontend_fk_source": "/api/users/",
|
||||||
"frontend_fk_display_field": "username",
|
"frontend_fk_display_field": "username",
|
||||||
"fk_model": "User",
|
"fk_model": "User",
|
||||||
"fk_target": {"db": "poweron_app", "table": "User"},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
mandateId: str = Field(
|
mandateId: str = Field(
|
||||||
|
|
@ -47,7 +46,6 @@ class UserMandate(PowerOnModel):
|
||||||
"frontend_fk_source": "/api/mandates/",
|
"frontend_fk_source": "/api/mandates/",
|
||||||
"frontend_fk_display_field": "label",
|
"frontend_fk_display_field": "label",
|
||||||
"fk_model": "Mandate",
|
"fk_model": "Mandate",
|
||||||
"fk_target": {"db": "poweron_app", "table": "Mandate"},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
enabled: bool = Field(
|
enabled: bool = Field(
|
||||||
|
|
@ -70,27 +68,11 @@ class FeatureAccess(PowerOnModel):
|
||||||
)
|
)
|
||||||
userId: str = Field(
|
userId: str = Field(
|
||||||
description="FK → User.id (CASCADE DELETE)",
|
description="FK → User.id (CASCADE DELETE)",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Benutzer", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/users/", "frontend_fk_display_field": "username"}
|
||||||
"label": "Benutzer",
|
|
||||||
"frontend_type": "select",
|
|
||||||
"frontend_readonly": False,
|
|
||||||
"frontend_required": True,
|
|
||||||
"frontend_fk_source": "/api/users/",
|
|
||||||
"frontend_fk_display_field": "username",
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "User"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
featureInstanceId: str = Field(
|
featureInstanceId: str = Field(
|
||||||
description="FK → FeatureInstance.id (CASCADE DELETE)",
|
description="FK → FeatureInstance.id (CASCADE DELETE)",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Feature-Instanz", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/features/instances", "frontend_fk_display_field": "label"}
|
||||||
"label": "Feature-Instanz",
|
|
||||||
"frontend_type": "select",
|
|
||||||
"frontend_readonly": False,
|
|
||||||
"frontend_required": True,
|
|
||||||
"frontend_fk_source": "/api/features/instances",
|
|
||||||
"frontend_fk_display_field": "label",
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "FeatureInstance"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
enabled: bool = Field(
|
enabled: bool = Field(
|
||||||
default=True,
|
default=True,
|
||||||
|
|
@ -112,25 +94,11 @@ class UserMandateRole(PowerOnModel):
|
||||||
)
|
)
|
||||||
userMandateId: str = Field(
|
userMandateId: str = Field(
|
||||||
description="FK → UserMandate.id (CASCADE DELETE)",
|
description="FK → UserMandate.id (CASCADE DELETE)",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Benutzer-Mandant", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
|
||||||
"label": "Benutzer-Mandant",
|
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": True,
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "UserMandate"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
roleId: str = Field(
|
roleId: str = Field(
|
||||||
description="FK → Role.id (CASCADE DELETE)",
|
description="FK → Role.id (CASCADE DELETE)",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Rolle", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"}
|
||||||
"label": "Rolle",
|
|
||||||
"frontend_type": "select",
|
|
||||||
"frontend_readonly": False,
|
|
||||||
"frontend_required": True,
|
|
||||||
"frontend_fk_source": "/api/rbac/roles",
|
|
||||||
"frontend_fk_display_field": "roleLabel",
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "Role"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -147,23 +115,9 @@ class FeatureAccessRole(PowerOnModel):
|
||||||
)
|
)
|
||||||
featureAccessId: str = Field(
|
featureAccessId: str = Field(
|
||||||
description="FK → FeatureAccess.id (CASCADE DELETE)",
|
description="FK → FeatureAccess.id (CASCADE DELETE)",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Feature-Zugang", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
|
||||||
"label": "Feature-Zugang",
|
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": True,
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "FeatureAccess"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
roleId: str = Field(
|
roleId: str = Field(
|
||||||
description="FK → Role.id (CASCADE DELETE)",
|
description="FK → Role.id (CASCADE DELETE)",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Rolle", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"}
|
||||||
"label": "Rolle",
|
|
||||||
"frontend_type": "select",
|
|
||||||
"frontend_readonly": False,
|
|
||||||
"frontend_required": True,
|
|
||||||
"frontend_fk_source": "/api/rbac/roles",
|
|
||||||
"frontend_fk_display_field": "roleLabel",
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "Role"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,6 @@ class MessagingSubscription(PowerOnModel):
|
||||||
"frontend_readonly": True,
|
"frontend_readonly": True,
|
||||||
"frontend_required": False,
|
"frontend_required": False,
|
||||||
"label": "Mandanten-ID",
|
"label": "Mandanten-ID",
|
||||||
"fk_target": {"db": "poweron_app", "table": "Mandate"},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
featureInstanceId: str = Field(
|
featureInstanceId: str = Field(
|
||||||
|
|
@ -74,7 +73,6 @@ class MessagingSubscription(PowerOnModel):
|
||||||
"frontend_readonly": True,
|
"frontend_readonly": True,
|
||||||
"frontend_required": False,
|
"frontend_required": False,
|
||||||
"label": "Feature-Instanz-ID",
|
"label": "Feature-Instanz-ID",
|
||||||
"fk_target": {"db": "poweron_app", "table": "FeatureInstance"},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
description: Optional[str] = Field(
|
description: Optional[str] = Field(
|
||||||
|
|
@ -131,7 +129,6 @@ class MessagingSubscriptionRegistration(BaseModel):
|
||||||
"frontend_readonly": True,
|
"frontend_readonly": True,
|
||||||
"frontend_required": False,
|
"frontend_required": False,
|
||||||
"label": "Mandanten-ID",
|
"label": "Mandanten-ID",
|
||||||
"fk_target": {"db": "poweron_app", "table": "Mandate"},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
featureInstanceId: str = Field(
|
featureInstanceId: str = Field(
|
||||||
|
|
@ -141,7 +138,6 @@ class MessagingSubscriptionRegistration(BaseModel):
|
||||||
"frontend_readonly": True,
|
"frontend_readonly": True,
|
||||||
"frontend_required": False,
|
"frontend_required": False,
|
||||||
"label": "Feature-Instanz-ID",
|
"label": "Feature-Instanz-ID",
|
||||||
"fk_target": {"db": "poweron_app", "table": "FeatureInstance"},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
subscriptionId: str = Field(
|
subscriptionId: str = Field(
|
||||||
|
|
@ -160,7 +156,6 @@ class MessagingSubscriptionRegistration(BaseModel):
|
||||||
"frontend_readonly": True,
|
"frontend_readonly": True,
|
||||||
"frontend_required": False,
|
"frontend_required": False,
|
||||||
"label": "Benutzer-ID",
|
"label": "Benutzer-ID",
|
||||||
"fk_target": {"db": "poweron_app", "table": "User"},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
channel: MessagingChannel = Field(
|
channel: MessagingChannel = Field(
|
||||||
|
|
@ -249,7 +244,6 @@ class MessagingDelivery(BaseModel):
|
||||||
"frontend_readonly": True,
|
"frontend_readonly": True,
|
||||||
"frontend_required": False,
|
"frontend_required": False,
|
||||||
"label": "Benutzer-ID",
|
"label": "Benutzer-ID",
|
||||||
"fk_target": {"db": "poweron_app", "table": "User"},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
channel: MessagingChannel = Field(
|
channel: MessagingChannel = Field(
|
||||||
|
|
|
||||||
|
|
@ -60,13 +60,7 @@ class UserNotification(PowerOnModel):
|
||||||
)
|
)
|
||||||
userId: str = Field(
|
userId: str = Field(
|
||||||
description="Target user ID for this notification",
|
description="Target user ID for this notification",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Benutzer", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}
|
||||||
"label": "Benutzer",
|
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": True,
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "User"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type: NotificationType = Field(
|
type: NotificationType = Field(
|
||||||
|
|
|
||||||
|
|
@ -57,30 +57,12 @@ class Role(PowerOnModel):
|
||||||
mandateId: Optional[str] = Field(
|
mandateId: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="FK → Mandate.id (CASCADE DELETE). Null = Global/Template role.",
|
description="FK → Mandate.id (CASCADE DELETE). Null = Global/Template role.",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Mandant", "frontend_type": "select", "frontend_readonly": True, "frontend_visible": True, "frontend_required": False, "frontend_fk_source": "/api/mandates/", "frontend_fk_display_field": "label"}
|
||||||
"label": "Mandant",
|
|
||||||
"frontend_type": "select",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_visible": True,
|
|
||||||
"frontend_required": False,
|
|
||||||
"frontend_fk_source": "/api/mandates/",
|
|
||||||
"frontend_fk_display_field": "label",
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "Mandate"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
featureInstanceId: Optional[str] = Field(
|
featureInstanceId: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="FK → FeatureInstance.id (CASCADE DELETE). Null = Mandate-level or Global role.",
|
description="FK → FeatureInstance.id (CASCADE DELETE). Null = Mandate-level or Global role.",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Feature-Instanz", "frontend_type": "select", "frontend_readonly": True, "frontend_visible": True, "frontend_required": False, "frontend_fk_source": "/api/features/instances", "frontend_fk_display_field": "label"}
|
||||||
"label": "Feature-Instanz",
|
|
||||||
"frontend_type": "select",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_visible": True,
|
|
||||||
"frontend_required": False,
|
|
||||||
"frontend_fk_source": "/api/features/instances",
|
|
||||||
"frontend_fk_display_field": "label",
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "FeatureInstance"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
featureCode: Optional[str] = Field(
|
featureCode: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
|
|
@ -110,15 +92,7 @@ class AccessRule(PowerOnModel):
|
||||||
)
|
)
|
||||||
roleId: str = Field(
|
roleId: str = Field(
|
||||||
description="FK → Role.id (CASCADE DELETE!)",
|
description="FK → Role.id (CASCADE DELETE!)",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Rolle", "frontend_type": "select", "frontend_readonly": True, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"}
|
||||||
"label": "Rolle",
|
|
||||||
"frontend_type": "select",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": True,
|
|
||||||
"frontend_fk_source": "/api/rbac/roles",
|
|
||||||
"frontend_fk_display_field": "roleLabel",
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "Role"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
context: AccessRuleContext = Field(
|
context: AccessRuleContext = Field(
|
||||||
description="Context type: DATA (database), UI (interface), RESOURCE (system resources). IMMUTABLE!",
|
description="Context type: DATA (database), UI (interface), RESOURCE (system resources). IMMUTABLE!",
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,7 @@ class Token(PowerOnModel):
|
||||||
)
|
)
|
||||||
userId: str = Field(
|
userId: str = Field(
|
||||||
...,
|
...,
|
||||||
json_schema_extra={"label": "Benutzer-ID", "fk_target": {"db": "poweron_app", "table": "User"}},
|
json_schema_extra={"label": "Benutzer-ID"},
|
||||||
)
|
)
|
||||||
authority: AuthAuthority = Field(
|
authority: AuthAuthority = Field(
|
||||||
...,
|
...,
|
||||||
|
|
@ -56,7 +56,7 @@ class Token(PowerOnModel):
|
||||||
connectionId: Optional[str] = Field(
|
connectionId: Optional[str] = Field(
|
||||||
None,
|
None,
|
||||||
description="ID of the connection this token belongs to",
|
description="ID of the connection this token belongs to",
|
||||||
json_schema_extra={"label": "Verbindungs-ID", "fk_target": {"db": "poweron_app", "table": "UserConnection"}},
|
json_schema_extra={"label": "Verbindungs-ID"},
|
||||||
)
|
)
|
||||||
tokenPurpose: Optional[TokenPurpose] = Field(
|
tokenPurpose: Optional[TokenPurpose] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
|
|
@ -92,7 +92,7 @@ class Token(PowerOnModel):
|
||||||
revokedBy: Optional[str] = Field(
|
revokedBy: Optional[str] = Field(
|
||||||
None,
|
None,
|
||||||
description="User ID who revoked the token (admin/self)",
|
description="User ID who revoked the token (admin/self)",
|
||||||
json_schema_extra={"label": "Widerrufen von", "fk_target": {"db": "poweron_app", "table": "User"}},
|
json_schema_extra={"label": "Widerrufen von"},
|
||||||
)
|
)
|
||||||
reason: Optional[str] = Field(
|
reason: Optional[str] = Field(
|
||||||
None,
|
None,
|
||||||
|
|
@ -134,13 +134,7 @@ class AuthEvent(PowerOnModel):
|
||||||
)
|
)
|
||||||
userId: str = Field(
|
userId: str = Field(
|
||||||
description="ID of the user this event belongs to",
|
description="ID of the user this event belongs to",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Benutzer-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
|
||||||
"label": "Benutzer-ID",
|
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": True,
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "User"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
eventType: str = Field(
|
eventType: str = Field(
|
||||||
description="Type of authentication event (e.g., 'login', 'logout', 'token_refresh')",
|
description="Type of authentication event (e.g., 'login', 'logout', 'token_refresh')",
|
||||||
|
|
|
||||||
|
|
@ -207,7 +207,7 @@ class MandateSubscription(PowerOnModel):
|
||||||
mandateId: str = Field(
|
mandateId: str = Field(
|
||||||
...,
|
...,
|
||||||
description="Foreign key to Mandate",
|
description="Foreign key to Mandate",
|
||||||
json_schema_extra={"label": "Mandanten-ID", "fk_target": {"db": "poweron_app", "table": "Mandate"}},
|
json_schema_extra={"label": "Mandanten-ID"},
|
||||||
)
|
)
|
||||||
planKey: str = Field(
|
planKey: str = Field(
|
||||||
...,
|
...,
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,7 @@ UAM models: User, Mandate, UserConnection.
|
||||||
Multi-Tenant Design:
|
Multi-Tenant Design:
|
||||||
- User gehört NICHT direkt zu einem Mandanten
|
- User gehört NICHT direkt zu einem Mandanten
|
||||||
- Zugehörigkeit wird über UserMandate gesteuert (siehe datamodelMembership.py)
|
- Zugehörigkeit wird über UserMandate gesteuert (siehe datamodelMembership.py)
|
||||||
- Zwei orthogonale Plattform-Autoritäts-Flags:
|
- isSysAdmin ist globales Admin-Flag für System-Zugriff (KEIN Daten-Zugriff!)
|
||||||
* isSysAdmin → Infrastruktur-Operator (Logs, Tokens, DB-Health,
|
|
||||||
i18n-Master, Registry). RBAC-Engine-Bypass.
|
|
||||||
KEIN Cross-Mandate-Governance.
|
|
||||||
* isPlatformAdmin → Cross-Mandate-Governance (User-/Mandate-/RBAC-/
|
|
||||||
Feature-Verwaltung über alle Mandanten).
|
|
||||||
KEIN RBAC-Bypass.
|
|
||||||
Beide einzeln vergebbar, einzeln auditierbar.
|
|
||||||
Siehe wiki/c-work/4-done/2026-04-sysadmin-authority-split.md
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
@ -23,7 +15,6 @@ from enum import Enum
|
||||||
from pydantic import BaseModel, Field, EmailStr, field_validator, computed_field
|
from pydantic import BaseModel, Field, EmailStr, field_validator, computed_field
|
||||||
from modules.datamodels.datamodelBase import PowerOnModel
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
from modules.shared.i18nRegistry import i18nModel, normalizePrimaryLanguageTag
|
from modules.shared.i18nRegistry import i18nModel, normalizePrimaryLanguageTag
|
||||||
from modules.shared.mandateNameUtils import MANDATE_NAME_MAX_LEN, MANDATE_NAME_MIN_LEN
|
|
||||||
from modules.shared.timeUtils import getUtcTimestamp
|
from modules.shared.timeUtils import getUtcTimestamp
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -75,11 +66,6 @@ class Mandate(PowerOnModel):
|
||||||
"""
|
"""
|
||||||
Mandate (Mandant/Tenant) model.
|
Mandate (Mandant/Tenant) model.
|
||||||
Ein Mandant ist ein isolierter Bereich für Daten und Berechtigungen.
|
Ein Mandant ist ein isolierter Bereich für Daten und Berechtigungen.
|
||||||
|
|
||||||
Semantik:
|
|
||||||
- ``name`` (Kurzzeichen): plattformweit eindeutiger, stabiler technischer Code (Slug),
|
|
||||||
Audit-/Referenz-Identifier. Nur Kleinbuchstaben, Ziffern und ``-`` (Länge 2–32).
|
|
||||||
- ``label`` (Voller Name): Anzeigename im UI, frei änderbar unabhängig vom Slug.
|
|
||||||
"""
|
"""
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()),
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
|
@ -87,26 +73,13 @@ class Mandate(PowerOnModel):
|
||||||
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False, "label": "ID"},
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False, "label": "ID"},
|
||||||
)
|
)
|
||||||
name: str = Field(
|
name: str = Field(
|
||||||
description="Unique stable mandate code (slug); lowercase, digits, hyphen segments only.",
|
description="Name of the mandate",
|
||||||
min_length=MANDATE_NAME_MIN_LEN,
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True, "label": "Name"},
|
||||||
max_length=MANDATE_NAME_MAX_LEN,
|
|
||||||
pattern=r"^[a-z0-9]+(-[a-z0-9]+)*$",
|
|
||||||
json_schema_extra={
|
|
||||||
"frontend_type": "slug",
|
|
||||||
"frontend_readonly": False,
|
|
||||||
"frontend_required": True,
|
|
||||||
"label": "Kurzzeichen",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
label: str = Field(
|
label: Optional[str] = Field(
|
||||||
description="Human-readable mandate name shown in the UI (Voller Name).",
|
default=None,
|
||||||
min_length=1,
|
description="Display label of the mandate",
|
||||||
json_schema_extra={
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False, "label": "Label"},
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": False,
|
|
||||||
"frontend_required": True,
|
|
||||||
"label": "Voller Name",
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
enabled: bool = Field(
|
enabled: bool = Field(
|
||||||
default=True,
|
default=True,
|
||||||
|
|
@ -132,30 +105,6 @@ class Mandate(PowerOnModel):
|
||||||
return False
|
return False
|
||||||
return v
|
return v
|
||||||
|
|
||||||
@field_validator("name", mode="before")
|
|
||||||
@classmethod
|
|
||||||
def _stripName(cls, v):
|
|
||||||
if v is None:
|
|
||||||
return ""
|
|
||||||
if isinstance(v, str):
|
|
||||||
return v.strip()
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("label", mode="before")
|
|
||||||
@classmethod
|
|
||||||
def _coerceLabel(cls, v):
|
|
||||||
if v is None:
|
|
||||||
return ""
|
|
||||||
return v
|
|
||||||
|
|
||||||
@field_validator("label")
|
|
||||||
@classmethod
|
|
||||||
def _validateMandateLabel(cls, v: str) -> str:
|
|
||||||
s = v.strip()
|
|
||||||
if len(s) < 1:
|
|
||||||
raise ValueError("Mandate Voller Name (label) must not be empty.")
|
|
||||||
return s
|
|
||||||
|
|
||||||
@i18nModel("Benutzerverbindung")
|
@i18nModel("Benutzerverbindung")
|
||||||
class UserConnection(PowerOnModel):
|
class UserConnection(PowerOnModel):
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
|
|
@ -165,13 +114,7 @@ class UserConnection(PowerOnModel):
|
||||||
)
|
)
|
||||||
userId: str = Field(
|
userId: str = Field(
|
||||||
description="ID of the user this connection belongs to",
|
description="ID of the user this connection belongs to",
|
||||||
json_schema_extra={
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Benutzer-ID"},
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": False,
|
|
||||||
"label": "Benutzer-ID",
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "User"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
authority: AuthAuthority = Field(
|
authority: AuthAuthority = Field(
|
||||||
description="Authentication authority",
|
description="Authentication authority",
|
||||||
|
|
@ -248,6 +191,7 @@ class UserConnection(PowerOnModel):
|
||||||
json_schema_extra={"frontend_type": "list", "frontend_readonly": True, "frontend_required": False, "label": "Gewährte Berechtigungen"},
|
json_schema_extra={"frontend_type": "list", "frontend_readonly": True, "frontend_required": False, "label": "Gewährte Berechtigungen"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@computed_field
|
||||||
@computed_field
|
@computed_field
|
||||||
@property
|
@property
|
||||||
def connectionReference(self) -> str:
|
def connectionReference(self) -> str:
|
||||||
|
|
@ -275,11 +219,8 @@ class User(PowerOnModel):
|
||||||
Multi-Tenant Design:
|
Multi-Tenant Design:
|
||||||
- User gehört NICHT direkt zu einem Mandanten
|
- User gehört NICHT direkt zu einem Mandanten
|
||||||
- Zugehörigkeit wird über UserMandate gesteuert (siehe datamodelMembership.py)
|
- Zugehörigkeit wird über UserMandate gesteuert (siehe datamodelMembership.py)
|
||||||
- Rollen werden über UserMandateRole gesteuert (mandanten-scoped)
|
- Rollen werden über UserMandateRole gesteuert
|
||||||
- Plattform-Autorität via zwei orthogonalen Flags:
|
- isSysAdmin = System-Zugriff, KEIN Daten-Zugriff
|
||||||
* isSysAdmin → Infrastruktur (Bypass der RBAC-Engine, KEIN
|
|
||||||
Cross-Mandate-Governance)
|
|
||||||
* isPlatformAdmin → Cross-Mandate-Governance (KEIN RBAC-Bypass)
|
|
||||||
"""
|
"""
|
||||||
id: str = Field(
|
id: str = Field(
|
||||||
default_factory=lambda: str(uuid.uuid4()),
|
default_factory=lambda: str(uuid.uuid4()),
|
||||||
|
|
@ -337,15 +278,10 @@ class User(PowerOnModel):
|
||||||
|
|
||||||
isSysAdmin: bool = Field(
|
isSysAdmin: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
description=(
|
description="Global SysAdmin flag. SysAdmin = System-Zugriff, KEIN Daten-Zugriff!",
|
||||||
"Infrastructure/System Operator flag. Erlaubt RBAC-Engine-Bypass "
|
|
||||||
"und Zugriff auf Infrastruktur-Operationen (Logs, Tokens, DB-Health, "
|
|
||||||
"i18n-Master, Registry). Gibt KEIN Cross-Mandate-Governance-Recht "
|
|
||||||
"(dafür ist isPlatformAdmin zuständig)."
|
|
||||||
),
|
|
||||||
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False, "label": "System-Admin"},
|
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False, "label": "System-Admin"},
|
||||||
)
|
)
|
||||||
|
|
||||||
@field_validator('isSysAdmin', mode='before')
|
@field_validator('isSysAdmin', mode='before')
|
||||||
@classmethod
|
@classmethod
|
||||||
def _coerceIsSysAdmin(cls, v):
|
def _coerceIsSysAdmin(cls, v):
|
||||||
|
|
@ -353,25 +289,6 @@ class User(PowerOnModel):
|
||||||
if v is None:
|
if v is None:
|
||||||
return False
|
return False
|
||||||
return v
|
return v
|
||||||
|
|
||||||
isPlatformAdmin: bool = Field(
|
|
||||||
default=False,
|
|
||||||
description=(
|
|
||||||
"Platform/Cross-Mandate Governance flag. Erlaubt mandanten-übergreifende "
|
|
||||||
"Verwaltungsoperationen (User-/Mandate-/RBAC-/Feature-Registry). "
|
|
||||||
"KEIN RBAC-Engine-Bypass und KEIN impliziter Zugriff auf Mandanten-Daten."
|
|
||||||
),
|
|
||||||
json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False, "label": "Plattform-Admin"},
|
|
||||||
)
|
|
||||||
|
|
||||||
@field_validator('isPlatformAdmin', mode='before')
|
|
||||||
@classmethod
|
|
||||||
def _coerceIsPlatformAdmin(cls, v):
|
|
||||||
"""Konvertiert None zu False (für bestehende DB-Einträge ohne isPlatformAdmin Feld)."""
|
|
||||||
if v is None:
|
|
||||||
return False
|
|
||||||
return v
|
|
||||||
|
|
||||||
|
|
||||||
authenticationAuthority: AuthAuthority = Field(
|
authenticationAuthority: AuthAuthority = Field(
|
||||||
default=AuthAuthority.LOCAL,
|
default=AuthAuthority.LOCAL,
|
||||||
|
|
@ -452,14 +369,11 @@ class UserVoicePreferences(PowerOnModel):
|
||||||
description="Primary key",
|
description="Primary key",
|
||||||
json_schema_extra={"label": "ID"},
|
json_schema_extra={"label": "ID"},
|
||||||
)
|
)
|
||||||
userId: str = Field(
|
userId: str = Field(description="User ID", json_schema_extra={"label": "Benutzer-ID"})
|
||||||
description="User ID",
|
|
||||||
json_schema_extra={"label": "Benutzer-ID", "fk_target": {"db": "poweron_app", "table": "User"}},
|
|
||||||
)
|
|
||||||
mandateId: Optional[str] = Field(
|
mandateId: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="Mandate scope (None = global for user)",
|
description="Mandate scope (None = global for user)",
|
||||||
json_schema_extra={"label": "Mandanten-ID", "fk_target": {"db": "poweron_app", "table": "Mandate"}},
|
json_schema_extra={"label": "Mandanten-ID"},
|
||||||
)
|
)
|
||||||
sttLanguage: str = Field(
|
sttLanguage: str = Field(
|
||||||
default="de-DE",
|
default="de-DE",
|
||||||
|
|
|
||||||
|
|
@ -1,316 +0,0 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
|
||||||
# All rights reserved.
|
|
||||||
"""Unified Document Model (UDM) — hierarchical document tree and ContentPart bridge."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from typing import Any, Dict, List, Literal, Optional, Tuple, Union
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
from modules.datamodels.datamodelExtraction import ContentExtracted, ContentPart
|
|
||||||
|
|
||||||
|
|
||||||
class UdmMetadata(BaseModel):
|
|
||||||
title: Optional[str] = None
|
|
||||||
author: Optional[str] = None
|
|
||||||
createdAt: Optional[str] = None
|
|
||||||
modifiedAt: Optional[str] = None
|
|
||||||
sourcePath: str = ""
|
|
||||||
tags: List[str] = Field(default_factory=list)
|
|
||||||
custom: Dict[str, Any] = Field(default_factory=dict)
|
|
||||||
|
|
||||||
|
|
||||||
class UdmBoundingBox(BaseModel):
|
|
||||||
x: float = 0.0
|
|
||||||
y: float = 0.0
|
|
||||||
width: float = 0.0
|
|
||||||
height: float = 0.0
|
|
||||||
unit: Literal["px", "pt", "mm"] = "pt"
|
|
||||||
|
|
||||||
|
|
||||||
class UdmPosition(BaseModel):
|
|
||||||
index: int = 0
|
|
||||||
page: Optional[int] = None
|
|
||||||
row: Optional[int] = None
|
|
||||||
col: Optional[int] = None
|
|
||||||
bbox: Optional[UdmBoundingBox] = None
|
|
||||||
|
|
||||||
|
|
||||||
class UdmContentBlock(BaseModel):
|
|
||||||
id: str
|
|
||||||
contentType: Literal["text", "image", "table", "code", "media", "link", "formula"]
|
|
||||||
raw: str = ""
|
|
||||||
fileRef: Optional[str] = None
|
|
||||||
mimeType: Optional[str] = None
|
|
||||||
language: Optional[str] = None
|
|
||||||
attributes: Dict[str, Any] = Field(default_factory=dict)
|
|
||||||
position: UdmPosition = Field(default_factory=lambda: UdmPosition(index=0))
|
|
||||||
metadata: UdmMetadata = Field(default_factory=UdmMetadata)
|
|
||||||
|
|
||||||
|
|
||||||
class UdmStructuralNode(BaseModel):
|
|
||||||
id: str
|
|
||||||
role: Literal["page", "section", "slide", "sheet"]
|
|
||||||
index: int
|
|
||||||
label: Optional[str] = None
|
|
||||||
metadata: UdmMetadata = Field(default_factory=UdmMetadata)
|
|
||||||
children: List[UdmContentBlock] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class UdmDocument(BaseModel):
|
|
||||||
id: str
|
|
||||||
role: Literal["document"] = "document"
|
|
||||||
sourceType: Literal["pdf", "docx", "pptx", "xlsx", "html", "binary", "unknown"] = "unknown"
|
|
||||||
sourcePath: str = ""
|
|
||||||
metadata: UdmMetadata = Field(default_factory=UdmMetadata)
|
|
||||||
children: List[UdmStructuralNode] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
class UdmArchive(BaseModel):
|
|
||||||
id: str
|
|
||||||
role: Literal["archive"] = "archive"
|
|
||||||
sourceType: Literal["zip", "tar", "gz", "unknown"] = "unknown"
|
|
||||||
sourcePath: str = ""
|
|
||||||
metadata: UdmMetadata = Field(default_factory=UdmMetadata)
|
|
||||||
children: List[Union[UdmArchive, UdmDocument]] = Field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
def _newId() -> str:
|
|
||||||
return str(uuid.uuid4())
|
|
||||||
|
|
||||||
|
|
||||||
def _mapTypeGroupToContentType(typeGroup: str) -> Literal["text", "image", "table", "code", "media", "link", "formula"]:
|
|
||||||
if typeGroup == "image":
|
|
||||||
return "image"
|
|
||||||
if typeGroup == "table":
|
|
||||||
return "table"
|
|
||||||
if typeGroup in ("code",):
|
|
||||||
return "code"
|
|
||||||
if typeGroup in ("binary", "audiostream", "videostream"):
|
|
||||||
return "media"
|
|
||||||
if typeGroup in ("structure", "text", "container"):
|
|
||||||
return "text"
|
|
||||||
return "text"
|
|
||||||
|
|
||||||
|
|
||||||
def _contentPartToBlock(part: ContentPart, blockIndex: int) -> UdmContentBlock:
|
|
||||||
meta = part.metadata or {}
|
|
||||||
ctx = meta.get("contextRef") or {}
|
|
||||||
if not isinstance(ctx, dict):
|
|
||||||
ctx = {}
|
|
||||||
page = meta.get("pageIndex")
|
|
||||||
if page is None:
|
|
||||||
page = ctx.get("pageIndex")
|
|
||||||
slide = meta.get("slide_number")
|
|
||||||
if slide is None:
|
|
||||||
slide = ctx.get("slideIndex")
|
|
||||||
pos = UdmPosition(
|
|
||||||
index=blockIndex,
|
|
||||||
page=int(page) + 1 if isinstance(page, int) else None,
|
|
||||||
)
|
|
||||||
extraAttr: Dict[str, Any] = {}
|
|
||||||
if isinstance(slide, int):
|
|
||||||
extraAttr["slideIndex"] = slide
|
|
||||||
return UdmContentBlock(
|
|
||||||
id=part.id,
|
|
||||||
contentType=_mapTypeGroupToContentType(part.typeGroup),
|
|
||||||
raw=part.data or "",
|
|
||||||
mimeType=part.mimeType or None,
|
|
||||||
attributes={
|
|
||||||
"typeGroup": part.typeGroup,
|
|
||||||
"label": part.label,
|
|
||||||
"parentId": part.parentId,
|
|
||||||
**({"contextRef": ctx} if ctx else {}),
|
|
||||||
**extraAttr,
|
|
||||||
},
|
|
||||||
position=pos,
|
|
||||||
metadata=UdmMetadata(
|
|
||||||
sourcePath=meta.get("containerPath", "") or "",
|
|
||||||
custom={k: v for k, v in meta.items() if k not in ("contextRef",)},
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _groupKeyForPart(part: ContentPart) -> Tuple[str, int, str]:
|
|
||||||
"""Return (role, structural_index, label) for grouping parts into structural nodes."""
|
|
||||||
meta = part.metadata or {}
|
|
||||||
ctx = meta.get("contextRef") or {}
|
|
||||||
if not isinstance(ctx, dict):
|
|
||||||
ctx = {}
|
|
||||||
|
|
||||||
if "pageIndex" in meta or "pageIndex" in ctx:
|
|
||||||
pi = meta.get("pageIndex", ctx.get("pageIndex", 0))
|
|
||||||
try:
|
|
||||||
idx = int(pi)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
idx = 0
|
|
||||||
return ("page", idx, f"page_{idx + 1}")
|
|
||||||
|
|
||||||
if meta.get("slide_number") is not None:
|
|
||||||
try:
|
|
||||||
idx = int(meta["slide_number"]) - 1
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
idx = 0
|
|
||||||
return ("slide", max(0, idx), f"slide_{idx + 1}")
|
|
||||||
if ctx.get("slideIndex") is not None:
|
|
||||||
try:
|
|
||||||
idx = int(ctx.get("slideIndex", 0))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
idx = 0
|
|
||||||
return ("slide", max(0, idx), f"slide_{idx + 1}")
|
|
||||||
|
|
||||||
if meta.get("sheet") or ctx.get("sheetName"):
|
|
||||||
name = str(meta.get("sheet") or ctx.get("sheetName") or "sheet")
|
|
||||||
return ("sheet", abs(hash(name)) % (10**9), name)
|
|
||||||
|
|
||||||
if ctx.get("sectionId") or meta.get("sectionId"):
|
|
||||||
sid = str(ctx.get("sectionId") or meta.get("sectionId") or "section")
|
|
||||||
return ("section", abs(hash(sid)) % (10**9), sid)
|
|
||||||
|
|
||||||
if part.typeGroup == "container":
|
|
||||||
return ("section", 0, "root")
|
|
||||||
|
|
||||||
return ("section", 0, "body")
|
|
||||||
|
|
||||||
|
|
||||||
_VALID_DOC_SOURCES = frozenset({"pdf", "docx", "pptx", "xlsx", "html", "binary", "unknown"})
|
|
||||||
|
|
||||||
|
|
||||||
def _contentPartsToUdm(extracted: ContentExtracted, sourceType: str, sourcePath: str) -> UdmDocument:
|
|
||||||
"""Convert flat ContentPart list into a UdmDocument using structural heuristics."""
|
|
||||||
parts = list(extracted.parts or [])
|
|
||||||
st: Literal["pdf", "docx", "pptx", "xlsx", "html", "binary", "unknown"] = (
|
|
||||||
sourceType if sourceType in _VALID_DOC_SOURCES else "unknown" # type: ignore[assignment]
|
|
||||||
)
|
|
||||||
doc = UdmDocument(
|
|
||||||
id=extracted.id or _newId(),
|
|
||||||
sourceType=st,
|
|
||||||
sourcePath=sourcePath,
|
|
||||||
metadata=UdmMetadata(sourcePath=sourcePath),
|
|
||||||
)
|
|
||||||
|
|
||||||
if not parts:
|
|
||||||
return doc
|
|
||||||
|
|
||||||
skipIds = set()
|
|
||||||
rootIds = set()
|
|
||||||
for p in parts:
|
|
||||||
if p.typeGroup == "container" and p.parentId is None:
|
|
||||||
rootIds.add(p.id)
|
|
||||||
skipIds.add(p.id)
|
|
||||||
|
|
||||||
contentParts = [p for p in parts if p.id not in skipIds and p.typeGroup != "container"]
|
|
||||||
|
|
||||||
if not contentParts:
|
|
||||||
for p in parts:
|
|
||||||
if p.id not in skipIds:
|
|
||||||
contentParts.append(p)
|
|
||||||
|
|
||||||
if not contentParts:
|
|
||||||
return doc
|
|
||||||
|
|
||||||
groups: Dict[Tuple[str, int, str], List[ContentPart]] = {}
|
|
||||||
for p in contentParts:
|
|
||||||
key = _groupKeyForPart(p)
|
|
||||||
groups.setdefault(key, []).append(p)
|
|
||||||
|
|
||||||
sortedKeys = sorted(groups.keys(), key=lambda k: (k[0], k[1], k[2]))
|
|
||||||
for gi, key in enumerate(sortedKeys):
|
|
||||||
role, structIdx, label = key
|
|
||||||
plist = groups[key]
|
|
||||||
node = UdmStructuralNode(
|
|
||||||
id=_newId(),
|
|
||||||
role=role if role in ("page", "section", "slide", "sheet") else "section",
|
|
||||||
index=gi if role == "section" else structIdx,
|
|
||||||
label=label,
|
|
||||||
metadata=UdmMetadata(sourcePath=sourcePath),
|
|
||||||
)
|
|
||||||
for bi, part in enumerate(plist):
|
|
||||||
node.children.append(_contentPartToBlock(part, bi))
|
|
||||||
doc.children.append(node)
|
|
||||||
|
|
||||||
return doc
|
|
||||||
|
|
||||||
|
|
||||||
def _udmToContentParts(document: UdmDocument) -> ContentExtracted:
|
|
||||||
"""Flatten UdmDocument back to ContentExtracted for backward compatibility."""
|
|
||||||
rootId = _newId()
|
|
||||||
parts: List[ContentPart] = [
|
|
||||||
ContentPart(
|
|
||||||
id=rootId,
|
|
||||||
parentId=None,
|
|
||||||
label=document.sourceType or "document",
|
|
||||||
typeGroup="container",
|
|
||||||
mimeType="application/octet-stream",
|
|
||||||
data="",
|
|
||||||
metadata={"udmRoot": True, "sourcePath": document.sourcePath},
|
|
||||||
)
|
|
||||||
]
|
|
||||||
for sn in document.children:
|
|
||||||
for block in sn.children:
|
|
||||||
meta = dict(block.metadata.custom) if block.metadata else {}
|
|
||||||
meta.setdefault("structuralRole", sn.role)
|
|
||||||
meta.setdefault("structuralIndex", sn.index)
|
|
||||||
parts.append(
|
|
||||||
ContentPart(
|
|
||||||
id=block.id,
|
|
||||||
parentId=rootId,
|
|
||||||
label=block.attributes.get("label", sn.label or ""),
|
|
||||||
typeGroup=str(block.attributes.get("typeGroup", "text")),
|
|
||||||
mimeType=block.mimeType or "text/plain",
|
|
||||||
data=block.raw,
|
|
||||||
metadata=meta,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return ContentExtracted(id=document.id, parts=parts)
|
|
||||||
|
|
||||||
|
|
||||||
def _stripUdmRaw(udm: UdmDocument) -> UdmDocument:
|
|
||||||
"""Return a deep copy with all content block `raw` cleared (structure-only preview)."""
|
|
||||||
clone = udm.model_copy(deep=True)
|
|
||||||
for sn in clone.children:
|
|
||||||
for block in sn.children:
|
|
||||||
block.raw = ""
|
|
||||||
return clone
|
|
||||||
|
|
||||||
|
|
||||||
def _stripUdmForReferences(udm: UdmDocument) -> UdmDocument:
|
|
||||||
"""Clear inline payloads; keep `fileRef` when already set in attributes/metadata."""
|
|
||||||
clone = udm.model_copy(deep=True)
|
|
||||||
for sn in clone.children:
|
|
||||||
for block in sn.children:
|
|
||||||
block.raw = ""
|
|
||||||
if not block.fileRef:
|
|
||||||
ref = block.attributes.get("fileRef")
|
|
||||||
if block.metadata and block.metadata.custom:
|
|
||||||
ref = ref or block.metadata.custom.get("fileRef")
|
|
||||||
if isinstance(ref, str) and ref:
|
|
||||||
block.fileRef = ref
|
|
||||||
return clone
|
|
||||||
|
|
||||||
|
|
||||||
def _applyUdmOutputDetail(udm: UdmDocument, detail: str) -> UdmDocument:
|
|
||||||
if detail == "structure":
|
|
||||||
return _stripUdmRaw(udm)
|
|
||||||
if detail == "references":
|
|
||||||
return _stripUdmForReferences(udm)
|
|
||||||
return udm
|
|
||||||
|
|
||||||
|
|
||||||
def _mimeToUdmSourceType(mimeType: str, fileName: str) -> Literal["pdf", "docx", "pptx", "xlsx", "html", "binary", "unknown"]:
|
|
||||||
m = (mimeType or "").lower()
|
|
||||||
fn = (fileName or "").lower()
|
|
||||||
if m == "application/pdf" or fn.endswith(".pdf"):
|
|
||||||
return "pdf"
|
|
||||||
if "wordprocessingml" in m or fn.endswith(".docx"):
|
|
||||||
return "docx"
|
|
||||||
if "presentationml" in m or fn.endswith((".pptx", ".ppt")):
|
|
||||||
return "pptx"
|
|
||||||
if "spreadsheetml" in m or fn.endswith((".xlsx", ".xlsm")):
|
|
||||||
return "xlsx"
|
|
||||||
if m == "text/html" or fn.endswith((".html", ".htm")):
|
|
||||||
return "html"
|
|
||||||
if m == "application/octet-stream" or not m:
|
|
||||||
return "binary"
|
|
||||||
return "unknown"
|
|
||||||
|
|
@ -22,13 +22,7 @@ class Prompt(PowerOnModel):
|
||||||
mandateId: str = Field(
|
mandateId: str = Field(
|
||||||
default="",
|
default="",
|
||||||
description="ID of the mandate this prompt belongs to",
|
description="ID of the mandate this prompt belongs to",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Mandanten-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
|
||||||
"label": "Mandanten-ID",
|
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": False,
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "Mandate"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
isSystem: bool = Field(
|
isSystem: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
|
|
|
||||||
|
|
@ -77,7 +77,7 @@ class InvestorDemo2026(_BaseDemoConfig):
|
||||||
mandateIdAlpina = self._ensureMandate(db, _MANDATE_ALPINA, summary)
|
mandateIdAlpina = self._ensureMandate(db, _MANDATE_ALPINA, summary)
|
||||||
|
|
||||||
userId = self._ensureUser(db, summary)
|
userId = self._ensureUser(db, summary)
|
||||||
self._ensurePlatformAdminFlag(db, userId, summary)
|
self._ensureRootMandateSysAdminRole(db, userId, summary)
|
||||||
|
|
||||||
if mandateIdHappy:
|
if mandateIdHappy:
|
||||||
self._ensureMembership(db, userId, mandateIdHappy, _MANDATE_HAPPYLIFE["label"], summary)
|
self._ensureMembership(db, userId, mandateIdHappy, _MANDATE_HAPPYLIFE["label"], summary)
|
||||||
|
|
@ -195,24 +195,47 @@ class InvestorDemo2026(_BaseDemoConfig):
|
||||||
summary["created"].append(f"User {_USER['fullName']}")
|
summary["created"].append(f"User {_USER['fullName']}")
|
||||||
return uid
|
return uid
|
||||||
|
|
||||||
def _ensurePlatformAdminFlag(self, db, userId: str, summary: Dict):
|
def _ensureRootMandateSysAdminRole(self, db, userId: str, summary: Dict):
|
||||||
"""Ensure the demo user has isPlatformAdmin=True for cross-mandate governance.
|
"""Ensure the demo user is member of the root mandate with the sysadmin role.
|
||||||
Without this, the admin UI menus would be hidden."""
|
Without this, hasSysAdminRole returns False and admin menus are hidden."""
|
||||||
from modules.datamodels.datamodelUam import UserInDB
|
from modules.datamodels.datamodelUam import Mandate
|
||||||
|
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
|
||||||
|
from modules.datamodels.datamodelRbac import Role
|
||||||
|
|
||||||
existing = db.getRecord(UserInDB, userId)
|
rootMandates = db.getRecordset(Mandate, recordFilter={"name": "root", "isSystem": True})
|
||||||
if not existing:
|
if not rootMandates:
|
||||||
summary["errors"].append(f"Demo user {userId} not found — cannot set isPlatformAdmin")
|
summary["errors"].append("Root mandate not found — cannot assign sysadmin role")
|
||||||
return
|
return
|
||||||
|
|
||||||
currentFlag = bool(existing.get("isPlatformAdmin", False)) if isinstance(existing, dict) else bool(getattr(existing, "isPlatformAdmin", False))
|
rootMandateId = rootMandates[0].get("id")
|
||||||
if currentFlag:
|
|
||||||
summary["skipped"].append("isPlatformAdmin already set")
|
existing = db.getRecordset(UserMandate, recordFilter={"userId": userId, "mandateId": rootMandateId})
|
||||||
|
if existing:
|
||||||
|
userMandateId = existing[0].get("id")
|
||||||
|
else:
|
||||||
|
um = UserMandate(userId=userId, mandateId=rootMandateId, enabled=True)
|
||||||
|
created = db.recordCreate(UserMandate, um)
|
||||||
|
userMandateId = created.get("id")
|
||||||
|
summary["created"].append("Membership -> root mandate")
|
||||||
|
logger.info(f"Created root mandate membership for {_USER['username']}")
|
||||||
|
|
||||||
|
sysadminRoles = db.getRecordset(Role, recordFilter={"mandateId": rootMandateId, "roleLabel": "sysadmin"})
|
||||||
|
if not sysadminRoles:
|
||||||
|
summary["errors"].append("sysadmin role not found in root mandate")
|
||||||
return
|
return
|
||||||
|
|
||||||
db.recordModify(UserInDB, userId, {"isPlatformAdmin": True})
|
sysadminRoleId = sysadminRoles[0].get("id")
|
||||||
summary["created"].append("isPlatformAdmin flag")
|
existingRole = db.getRecordset(UserMandateRole, recordFilter={
|
||||||
logger.info(f"Set isPlatformAdmin=True for {_USER['username']}")
|
"userMandateId": userMandateId,
|
||||||
|
"roleId": sysadminRoleId,
|
||||||
|
})
|
||||||
|
if not existingRole:
|
||||||
|
umr = UserMandateRole(userMandateId=userMandateId, roleId=sysadminRoleId)
|
||||||
|
db.recordCreate(UserMandateRole, umr)
|
||||||
|
summary["created"].append("SysAdmin role in root mandate")
|
||||||
|
logger.info(f"Assigned sysadmin role in root mandate for {_USER['username']}")
|
||||||
|
else:
|
||||||
|
summary["skipped"].append("SysAdmin role in root mandate exists")
|
||||||
|
|
||||||
def _ensureMembership(self, db, userId: str, mandateId: str, mandateLabel: str, summary: Dict):
|
def _ensureMembership(self, db, userId: str, mandateId: str, mandateLabel: str, summary: Dict):
|
||||||
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
|
from modules.datamodels.datamodelMembership import UserMandate, UserMandateRole
|
||||||
|
|
|
||||||
|
|
@ -21,12 +21,8 @@ from modules.datamodels.datamodelUam import AccessLevel
|
||||||
|
|
||||||
from modules.datamodels.datamodelChat import UserInputRequest
|
from modules.datamodels.datamodelChat import UserInputRequest
|
||||||
from modules.datamodels.datamodelBase import PowerOnModel
|
from modules.datamodels.datamodelBase import PowerOnModel
|
||||||
from modules.shared.dbRegistry import registerDatabase
|
|
||||||
from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp
|
from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp
|
||||||
|
|
||||||
chatbotDatabase = "poweron_chatbot"
|
|
||||||
registerDatabase(chatbotDatabase)
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Chatbot-specific Pydantic models for poweron_chatbot (per-instance isolation)
|
# Chatbot-specific Pydantic models for poweron_chatbot (per-instance isolation)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -396,7 +392,7 @@ class ChatObjects:
|
||||||
try:
|
try:
|
||||||
# Get configuration values with defaults
|
# Get configuration values with defaults
|
||||||
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
||||||
dbDatabase = chatbotDatabase
|
dbDatabase = "poweron_chatbot"
|
||||||
dbUser = APP_CONFIG.get("DB_USER")
|
dbUser = APP_CONFIG.get("DB_USER")
|
||||||
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
||||||
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
||||||
|
|
|
||||||
|
|
@ -116,18 +116,11 @@ TEMPLATE_ROLES = [
|
||||||
|
|
||||||
|
|
||||||
def getFeatureDefinition() -> Dict[str, Any]:
|
def getFeatureDefinition() -> Dict[str, Any]:
|
||||||
"""Return the feature definition for registration.
|
"""Return the feature definition for registration."""
|
||||||
|
|
||||||
The chatbot feature is currently soft-disabled via ``enabled=False``: its
|
|
||||||
catalog objects, template roles and routes stay loaded so already-running
|
|
||||||
instances keep working, but it is filtered out of the Store and the
|
|
||||||
Admin Feature-Instances "Neue Instanz" selection list.
|
|
||||||
"""
|
|
||||||
return {
|
return {
|
||||||
"code": FEATURE_CODE,
|
"code": FEATURE_CODE,
|
||||||
"label": FEATURE_LABEL,
|
"label": FEATURE_LABEL,
|
||||||
"icon": FEATURE_ICON,
|
"icon": FEATURE_ICON,
|
||||||
"enabled": False,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify user has access to this instance
|
# Verify user has access to this instance
|
||||||
if not context.isPlatformAdmin:
|
if not context.hasSysAdminRole:
|
||||||
# Check if user has FeatureAccess for this instance
|
# Check if user has FeatureAccess for this instance
|
||||||
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
|
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
|
||||||
hasAccess = any(
|
hasAccess = any(
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ 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.shared.timeUtils import getIsoTimestamp
|
from modules.shared.timeUtils import getIsoTimestamp
|
||||||
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
|
||||||
|
|
@ -27,9 +26,6 @@ from .datamodelCommcoach import (
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
commcoachDatabase = "poweron_commcoach"
|
|
||||||
registerDatabase(commcoachDatabase)
|
|
||||||
|
|
||||||
_interfaces = {}
|
_interfaces = {}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -55,7 +51,7 @@ class CommcoachObjects:
|
||||||
self.userId = str(currentUser.id) if currentUser else "system"
|
self.userId = str(currentUser.id) if currentUser else "system"
|
||||||
|
|
||||||
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
||||||
dbDatabase = commcoachDatabase
|
dbDatabase = "poweron_commcoach"
|
||||||
dbUser = APP_CONFIG.get("DB_USER")
|
dbUser = APP_CONFIG.get("DB_USER")
|
||||||
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
||||||
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
||||||
|
|
|
||||||
|
|
@ -150,7 +150,7 @@ async def checkAndAwardBadges(interface, userId: str, mandateId: str, instanceId
|
||||||
except Exception:
|
except Exception:
|
||||||
allContexts = []
|
allContexts = []
|
||||||
|
|
||||||
completedTasks = interface.getCompletedTaskCount(userId, instanceId) if hasattr(interface, 'getCompletedTaskCount') else 0
|
completedTasks = interface.getCompletedTaskCount(userId) if hasattr(interface, 'getCompletedTaskCount') else 0
|
||||||
if completedTasks >= 10:
|
if completedTasks >= 10:
|
||||||
badgesToCheck.append(("task_completer", True))
|
badgesToCheck.append(("task_completer", True))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -101,51 +101,6 @@ BUILTIN_PERSONAS: List[Dict[str, Any]] = [
|
||||||
"gender": "m",
|
"gender": "m",
|
||||||
"category": "builtin",
|
"category": "builtin",
|
||||||
},
|
},
|
||||||
# --- Immobilien / Liegenschaftsverwaltung (PWG-Kontext) ---
|
|
||||||
{
|
|
||||||
"key": "tenant_payment_arrears_m",
|
|
||||||
"label": "Mieter mit Zahlungsrückstand",
|
|
||||||
"description": "René Bachmann, Mieter einer 3.5-Zimmer-Wohnung. Seit drei Monaten im Mietrückstand, hat zwei Mahnungen "
|
|
||||||
"erhalten und ist genervt vom Druck. Fühlt sich ungerecht behandelt, verweist auf persönliche Schwierigkeiten "
|
|
||||||
"(Jobverlust, Scheidung). Reagiert defensiv und gereizt auf Forderungen. Braucht empathisches Gegenüber, "
|
|
||||||
"das gleichzeitig klar die Zahlungspflicht kommuniziert. Kann sich auf eine Ratenzahlung einlassen, "
|
|
||||||
"wenn er sich respektiert fühlt und einen konkreten Plan sieht.",
|
|
||||||
"gender": "m",
|
|
||||||
"category": "builtin",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "tenant_utility_costs_f",
|
|
||||||
"label": "Mieterin mit Nebenkostenfragen",
|
|
||||||
"description": "Fatima El-Amin, Mieterin seit vier Jahren. Hat die jährliche Nebenkostenabrechnung erhalten und versteht "
|
|
||||||
"mehrere Positionen nicht (Hauswartung, Allgemeinstrom, Verwaltungskosten). Emotional aufgebracht, weil die "
|
|
||||||
"Nachzahlung unerwartet hoch ist. Vermutet Fehler oder unfaire Verteilung. Spricht schnell und unterbricht. "
|
|
||||||
"Braucht geduldige, verständliche Erklärungen ohne Fachjargon. Beruhigt sich, wenn man Positionen einzeln "
|
|
||||||
"durchgeht und auf die Rechtsgrundlage (Mietvertrag, Nebenkosten-Verordnung) verweist.",
|
|
||||||
"gender": "f",
|
|
||||||
"category": "builtin",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "new_tenant_move_in_m",
|
|
||||||
"label": "Neuer Mieter (Einzug)",
|
|
||||||
"description": "Luca Steiner, zieht nächste Woche in seine erste eigene Wohnung ein. Aufgeregt aber unsicher — hat viele "
|
|
||||||
"Fragen zu Wohnungsübergabe, Schlüsselabholung, Hausordnung, Kautionseinzahlung und Anmeldung bei Werken "
|
|
||||||
"(Strom, Internet). Höflich und kooperativ, braucht aber klare, schrittweise Informationen. Fragt mehrfach "
|
|
||||||
"nach, wenn etwas unklar ist. Reagiert sehr positiv auf eine willkommene, strukturierte Begleitung.",
|
|
||||||
"gender": "m",
|
|
||||||
"category": "builtin",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"key": "difficult_neighbor_noise_m",
|
|
||||||
"label": "Nachbar mit Lärmbeschwerde",
|
|
||||||
"description": "Kurt Zürcher, langjähriger Mieter im Erdgeschoss. Beschwert sich massiv über Lärm aus der Wohnung darüber "
|
|
||||||
"(Musik abends, Kindergetrampel, Waschmaschine nach 22 Uhr). Hat bereits ein Lärmprotokoll geführt und "
|
|
||||||
"droht mit Mietminderung und Anwalt. Spricht laut, ist aufgebracht und fühlt sich von der Verwaltung "
|
|
||||||
"nicht ernst genommen. Erwartet sofortige Massnahmen. Kann deeskaliert werden, wenn man sein Anliegen "
|
|
||||||
"ernst nimmt, konkrete nächste Schritte aufzeigt (Gespräch mit Nachbar, schriftliche Verwarnung) und "
|
|
||||||
"auf die Hausordnung sowie seine Rechte und Pflichten verweist.",
|
|
||||||
"gender": "m",
|
|
||||||
"category": "builtin",
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,6 @@ class AutoWorkflow(PowerOnModel):
|
||||||
"frontend_fk_source": "/api/mandates/",
|
"frontend_fk_source": "/api/mandates/",
|
||||||
"frontend_fk_display_field": "label",
|
"frontend_fk_display_field": "label",
|
||||||
"fk_model": "Mandate",
|
"fk_model": "Mandate",
|
||||||
"fk_target": {"db": "poweron_app", "table": "Mandate"},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
featureInstanceId: str = Field(
|
featureInstanceId: str = Field(
|
||||||
|
|
@ -84,7 +83,6 @@ class AutoWorkflow(PowerOnModel):
|
||||||
"frontend_fk_source": "/api/features/instances",
|
"frontend_fk_source": "/api/features/instances",
|
||||||
"frontend_fk_display_field": "label",
|
"frontend_fk_display_field": "label",
|
||||||
"fk_model": "FeatureInstance",
|
"fk_model": "FeatureInstance",
|
||||||
"fk_target": {"db": "poweron_app", "table": "FeatureInstance"},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
label: str = Field(
|
label: str = Field(
|
||||||
|
|
@ -109,13 +107,7 @@ class AutoWorkflow(PowerOnModel):
|
||||||
templateSourceId: Optional[str] = Field(
|
templateSourceId: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="ID of the template this workflow was created from",
|
description="ID of the template this workflow was created from",
|
||||||
json_schema_extra={
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Vorlagen-Quelle"},
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": False,
|
|
||||||
"label": "Vorlagen-Quelle",
|
|
||||||
"fk_target": {"db": "poweron_graphicaleditor", "table": "AutoWorkflow"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
templateScope: Optional[str] = Field(
|
templateScope: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
|
|
@ -130,13 +122,7 @@ class AutoWorkflow(PowerOnModel):
|
||||||
currentVersionId: Optional[str] = Field(
|
currentVersionId: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="ID of the currently published AutoVersion",
|
description="ID of the currently published AutoVersion",
|
||||||
json_schema_extra={
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Aktuelle Version"},
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": False,
|
|
||||||
"label": "Aktuelle Version",
|
|
||||||
"fk_target": {"db": "poweron_graphicaleditor", "table": "AutoVersion"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
active: bool = Field(
|
active: bool = Field(
|
||||||
default=True,
|
default=True,
|
||||||
|
|
@ -179,13 +165,7 @@ class AutoVersion(PowerOnModel):
|
||||||
)
|
)
|
||||||
workflowId: str = Field(
|
workflowId: str = Field(
|
||||||
description="FK -> AutoWorkflow",
|
description="FK -> AutoWorkflow",
|
||||||
json_schema_extra={
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Workflow-ID"},
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": True,
|
|
||||||
"label": "Workflow-ID",
|
|
||||||
"fk_target": {"db": "poweron_graphicaleditor", "table": "AutoWorkflow"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
versionNumber: int = Field(
|
versionNumber: int = Field(
|
||||||
default=1,
|
default=1,
|
||||||
|
|
@ -215,13 +195,7 @@ class AutoVersion(PowerOnModel):
|
||||||
publishedBy: Optional[str] = Field(
|
publishedBy: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="User ID who published this version",
|
description="User ID who published this version",
|
||||||
json_schema_extra={
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Veröffentlicht von"},
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": False,
|
|
||||||
"label": "Veröffentlicht von",
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "User"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -238,13 +212,7 @@ class AutoRun(PowerOnModel):
|
||||||
)
|
)
|
||||||
workflowId: str = Field(
|
workflowId: str = Field(
|
||||||
description="Workflow ID",
|
description="Workflow ID",
|
||||||
json_schema_extra={
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Workflow-ID"},
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": True,
|
|
||||||
"label": "Workflow-ID",
|
|
||||||
"fk_target": {"db": "poweron_graphicaleditor", "table": "AutoWorkflow"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
label: Optional[str] = Field(
|
label: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
|
|
@ -262,30 +230,17 @@ class AutoRun(PowerOnModel):
|
||||||
"frontend_fk_source": "/api/mandates/",
|
"frontend_fk_source": "/api/mandates/",
|
||||||
"frontend_fk_display_field": "label",
|
"frontend_fk_display_field": "label",
|
||||||
"fk_model": "Mandate",
|
"fk_model": "Mandate",
|
||||||
"fk_target": {"db": "poweron_app", "table": "Mandate"},
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
ownerId: Optional[str] = Field(
|
ownerId: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="User ID who triggered this run",
|
description="User ID who triggered this run",
|
||||||
json_schema_extra={
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Auslöser"},
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": False,
|
|
||||||
"label": "Auslöser",
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "User"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
versionId: Optional[str] = Field(
|
versionId: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="AutoVersion ID used for this run",
|
description="AutoVersion ID used for this run",
|
||||||
json_schema_extra={
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Versions-ID"},
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": False,
|
|
||||||
"label": "Versions-ID",
|
|
||||||
"fk_target": {"db": "poweron_graphicaleditor", "table": "AutoVersion"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
status: str = Field(
|
status: str = Field(
|
||||||
default=AutoRunStatus.RUNNING.value,
|
default=AutoRunStatus.RUNNING.value,
|
||||||
|
|
@ -352,13 +307,7 @@ class AutoStepLog(PowerOnModel):
|
||||||
)
|
)
|
||||||
runId: str = Field(
|
runId: str = Field(
|
||||||
description="FK -> AutoRun",
|
description="FK -> AutoRun",
|
||||||
json_schema_extra={
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Lauf-ID"},
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": True,
|
|
||||||
"label": "Lauf-ID",
|
|
||||||
"fk_target": {"db": "poweron_graphicaleditor", "table": "AutoRun"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
nodeId: str = Field(
|
nodeId: str = Field(
|
||||||
description="Node ID in the graph",
|
description="Node ID in the graph",
|
||||||
|
|
@ -428,23 +377,11 @@ class AutoTask(PowerOnModel):
|
||||||
)
|
)
|
||||||
runId: str = Field(
|
runId: str = Field(
|
||||||
description="FK -> AutoRun",
|
description="FK -> AutoRun",
|
||||||
json_schema_extra={
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Lauf-ID"},
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": True,
|
|
||||||
"label": "Lauf-ID",
|
|
||||||
"fk_target": {"db": "poweron_graphicaleditor", "table": "AutoRun"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
workflowId: str = Field(
|
workflowId: str = Field(
|
||||||
description="Workflow ID",
|
description="Workflow ID",
|
||||||
json_schema_extra={
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Workflow-ID"},
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": True,
|
|
||||||
"label": "Workflow-ID",
|
|
||||||
"fk_target": {"db": "poweron_graphicaleditor", "table": "AutoWorkflow"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
nodeId: str = Field(
|
nodeId: str = Field(
|
||||||
description="Node ID in the graph",
|
description="Node ID in the graph",
|
||||||
|
|
@ -462,13 +399,7 @@ class AutoTask(PowerOnModel):
|
||||||
assigneeId: Optional[str] = Field(
|
assigneeId: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="User ID assigned to complete the task",
|
description="User ID assigned to complete the task",
|
||||||
json_schema_extra={
|
json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False, "label": "Zugewiesen an"},
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": False,
|
|
||||||
"frontend_required": False,
|
|
||||||
"label": "Zugewiesen an",
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "User"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
status: str = Field(
|
status: str = Field(
|
||||||
default=AutoTaskStatus.PENDING.value,
|
default=AutoTaskStatus.PENDING.value,
|
||||||
|
|
|
||||||
|
|
@ -38,12 +38,10 @@ from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import (
|
||||||
from modules.features.graphicalEditor.entryPoints import normalize_invocations_list
|
from modules.features.graphicalEditor.entryPoints import normalize_invocations_list
|
||||||
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
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
graphicalEditorDatabase = "poweron_graphicaleditor"
|
_GREENFIELD_DB = "poweron_graphicaleditor"
|
||||||
registerDatabase(graphicalEditorDatabase)
|
|
||||||
_CALLBACK_WORKFLOW_CHANGED = "graphicalEditor.workflow.changed"
|
_CALLBACK_WORKFLOW_CHANGED = "graphicalEditor.workflow.changed"
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -70,7 +68,7 @@ def getAllWorkflowsForScheduling() -> List[Dict[str, Any]]:
|
||||||
Used by the scheduler to register cron jobs. Does not filter by mandate/instance.
|
Used by the scheduler to register cron jobs. Does not filter by mandate/instance.
|
||||||
"""
|
"""
|
||||||
dbHost = APP_CONFIG.get("DB_HOST", "localhost")
|
dbHost = APP_CONFIG.get("DB_HOST", "localhost")
|
||||||
dbDatabase = graphicalEditorDatabase
|
dbDatabase = _GREENFIELD_DB
|
||||||
dbUser = APP_CONFIG.get("DB_USER")
|
dbUser = APP_CONFIG.get("DB_USER")
|
||||||
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD")
|
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD")
|
||||||
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
||||||
|
|
@ -157,7 +155,7 @@ class GraphicalEditorObjects:
|
||||||
def _init_db(self):
|
def _init_db(self):
|
||||||
"""Initialize database connection to poweron_graphicaleditor (Greenfield)."""
|
"""Initialize database connection to poweron_graphicaleditor (Greenfield)."""
|
||||||
dbHost = APP_CONFIG.get("DB_HOST", "localhost")
|
dbHost = APP_CONFIG.get("DB_HOST", "localhost")
|
||||||
dbDatabase = graphicalEditorDatabase
|
dbDatabase = _GREENFIELD_DB
|
||||||
dbUser = APP_CONFIG.get("DB_USER")
|
dbUser = APP_CONFIG.get("DB_USER")
|
||||||
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD")
|
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD")
|
||||||
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
||||||
|
|
@ -176,11 +174,12 @@ class GraphicalEditorObjects:
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
def getWorkflows(self, active: Optional[bool] = None) -> List[Dict[str, Any]]:
|
def getWorkflows(self, active: Optional[bool] = None) -> List[Dict[str, Any]]:
|
||||||
"""Get all workflows for this mandate (cross-instance)."""
|
"""Get all workflows for this mandate and feature instance."""
|
||||||
if not self.db._ensureTableExists(Automation2Workflow):
|
if not self.db._ensureTableExists(Automation2Workflow):
|
||||||
return []
|
return []
|
||||||
rf: Dict[str, Any] = {
|
rf: Dict[str, Any] = {
|
||||||
"mandateId": self.mandateId,
|
"mandateId": self.mandateId,
|
||||||
|
"featureInstanceId": self.featureInstanceId,
|
||||||
}
|
}
|
||||||
if active is not None:
|
if active is not None:
|
||||||
rf["active"] = active
|
rf["active"] = active
|
||||||
|
|
@ -194,7 +193,7 @@ class GraphicalEditorObjects:
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
def getWorkflow(self, workflowId: str) -> Optional[Dict[str, Any]]:
|
def getWorkflow(self, workflowId: str) -> Optional[Dict[str, Any]]:
|
||||||
"""Get a single workflow by ID (mandate-scoped, cross-instance)."""
|
"""Get a single workflow by ID."""
|
||||||
if not self.db._ensureTableExists(Automation2Workflow):
|
if not self.db._ensureTableExists(Automation2Workflow):
|
||||||
return None
|
return None
|
||||||
records = self.db.getRecordset(
|
records = self.db.getRecordset(
|
||||||
|
|
@ -202,6 +201,7 @@ class GraphicalEditorObjects:
|
||||||
recordFilter={
|
recordFilter={
|
||||||
"id": workflowId,
|
"id": workflowId,
|
||||||
"mandateId": self.mandateId,
|
"mandateId": self.mandateId,
|
||||||
|
"featureInstanceId": self.featureInstanceId,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
if not records:
|
if not records:
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ from .clickup import CLICKUP_NODES
|
||||||
from .file import FILE_NODES
|
from .file import FILE_NODES
|
||||||
from .trustee import TRUSTEE_NODES
|
from .trustee import TRUSTEE_NODES
|
||||||
from .data import DATA_NODES
|
from .data import DATA_NODES
|
||||||
from .context import CONTEXT_NODES
|
|
||||||
|
|
||||||
STATIC_NODE_TYPES = (
|
STATIC_NODE_TYPES = (
|
||||||
TRIGGER_NODES
|
TRIGGER_NODES
|
||||||
|
|
@ -24,5 +23,4 @@ STATIC_NODE_TYPES = (
|
||||||
+ FILE_NODES
|
+ FILE_NODES
|
||||||
+ TRUSTEE_NODES
|
+ TRUSTEE_NODES
|
||||||
+ DATA_NODES
|
+ DATA_NODES
|
||||||
+ CONTEXT_NODES
|
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ AI_NODES = [
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["DocumentList", "AiResult", "TextResult", "Transit"]}},
|
"inputPorts": {0: {"accepts": ["DocumentList", "AiResult", "TextResult", "Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "AiResult"}},
|
"outputPorts": {0: {"schema": "AiResult"}},
|
||||||
"meta": {"icon": "mdi-robot", "color": "#9C27B0", "usesAi": True},
|
"meta": {"icon": "mdi-robot", "color": "#9C27B0"},
|
||||||
"_method": "ai",
|
"_method": "ai",
|
||||||
"_action": "process",
|
"_action": "process",
|
||||||
},
|
},
|
||||||
|
|
@ -43,7 +43,7 @@ AI_NODES = [
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "AiResult"}},
|
"outputPorts": {0: {"schema": "AiResult"}},
|
||||||
"meta": {"icon": "mdi-magnify", "color": "#9C27B0", "usesAi": True},
|
"meta": {"icon": "mdi-magnify", "color": "#9C27B0"},
|
||||||
"_method": "ai",
|
"_method": "ai",
|
||||||
"_action": "webResearch",
|
"_action": "webResearch",
|
||||||
},
|
},
|
||||||
|
|
@ -61,7 +61,7 @@ AI_NODES = [
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
|
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "AiResult"}},
|
"outputPorts": {0: {"schema": "AiResult"}},
|
||||||
"meta": {"icon": "mdi-file-document-outline", "color": "#9C27B0", "usesAi": True},
|
"meta": {"icon": "mdi-file-document-outline", "color": "#9C27B0"},
|
||||||
"_method": "ai",
|
"_method": "ai",
|
||||||
"_action": "summarizeDocument",
|
"_action": "summarizeDocument",
|
||||||
},
|
},
|
||||||
|
|
@ -79,7 +79,7 @@ AI_NODES = [
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
|
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "AiResult"}},
|
"outputPorts": {0: {"schema": "AiResult"}},
|
||||||
"meta": {"icon": "mdi-translate", "color": "#9C27B0", "usesAi": True},
|
"meta": {"icon": "mdi-translate", "color": "#9C27B0"},
|
||||||
"_method": "ai",
|
"_method": "ai",
|
||||||
"_action": "translateDocument",
|
"_action": "translateDocument",
|
||||||
},
|
},
|
||||||
|
|
@ -97,7 +97,7 @@ AI_NODES = [
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
|
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "DocumentList"}},
|
"outputPorts": {0: {"schema": "DocumentList"}},
|
||||||
"meta": {"icon": "mdi-file-convert", "color": "#9C27B0", "usesAi": True},
|
"meta": {"icon": "mdi-file-convert", "color": "#9C27B0"},
|
||||||
"_method": "ai",
|
"_method": "ai",
|
||||||
"_action": "convertDocument",
|
"_action": "convertDocument",
|
||||||
},
|
},
|
||||||
|
|
@ -114,7 +114,7 @@ AI_NODES = [
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "DocumentList"}},
|
"outputPorts": {0: {"schema": "DocumentList"}},
|
||||||
"meta": {"icon": "mdi-file-plus", "color": "#9C27B0", "usesAi": True},
|
"meta": {"icon": "mdi-file-plus", "color": "#9C27B0"},
|
||||||
"_method": "ai",
|
"_method": "ai",
|
||||||
"_action": "generateDocument",
|
"_action": "generateDocument",
|
||||||
},
|
},
|
||||||
|
|
@ -134,28 +134,8 @@ AI_NODES = [
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "AiResult"}},
|
"outputPorts": {0: {"schema": "AiResult"}},
|
||||||
"meta": {"icon": "mdi-code-tags", "color": "#9C27B0", "usesAi": True},
|
"meta": {"icon": "mdi-code-tags", "color": "#9C27B0"},
|
||||||
"_method": "ai",
|
"_method": "ai",
|
||||||
"_action": "generateCode",
|
"_action": "generateCode",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "ai.consolidate",
|
|
||||||
"category": "ai",
|
|
||||||
"label": t("KI-Konsolidierung"),
|
|
||||||
"description": t("Gesammelte Ergebnisse mit KI zusammenfassen, klassifizieren oder semantisch zusammenführen"),
|
|
||||||
"parameters": [
|
|
||||||
{"name": "mode", "type": "string", "required": False, "frontendType": "select",
|
|
||||||
"frontendOptions": {"options": ["summarize", "classify", "semanticMerge"]},
|
|
||||||
"description": t("Konsolidierungsmodus"), "default": "summarize"},
|
|
||||||
{"name": "prompt", "type": "string", "required": False, "frontendType": "textarea",
|
|
||||||
"description": t("Optionaler Prompt für die Konsolidierung"), "default": ""},
|
|
||||||
],
|
|
||||||
"inputs": 1,
|
|
||||||
"outputs": 1,
|
|
||||||
"inputPorts": {0: {"accepts": ["AggregateResult", "Transit"]}},
|
|
||||||
"outputPorts": {0: {"schema": "ConsolidateResult"}},
|
|
||||||
"meta": {"icon": "mdi-table-merge-cells", "color": "#9C27B0", "usesAi": True},
|
|
||||||
"_method": "ai",
|
|
||||||
"_action": "consolidate",
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ CLICKUP_NODES = [
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "TaskList"}},
|
"outputPorts": {0: {"schema": "TaskList"}},
|
||||||
"meta": {"icon": "mdi-magnify", "color": "#7B68EE", "usesAi": False},
|
"meta": {"icon": "mdi-magnify", "color": "#7B68EE"},
|
||||||
"_method": "clickup",
|
"_method": "clickup",
|
||||||
"_action": "searchTasks",
|
"_action": "searchTasks",
|
||||||
},
|
},
|
||||||
|
|
@ -57,7 +57,7 @@ CLICKUP_NODES = [
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "TaskList"}},
|
"outputPorts": {0: {"schema": "TaskList"}},
|
||||||
"meta": {"icon": "mdi-format-list-bulleted", "color": "#7B68EE", "usesAi": False},
|
"meta": {"icon": "mdi-format-list-bulleted", "color": "#7B68EE"},
|
||||||
"_method": "clickup",
|
"_method": "clickup",
|
||||||
"_action": "listTasks",
|
"_action": "listTasks",
|
||||||
},
|
},
|
||||||
|
|
@ -78,7 +78,7 @@ CLICKUP_NODES = [
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "TaskResult"}},
|
"outputPorts": {0: {"schema": "TaskResult"}},
|
||||||
"meta": {"icon": "mdi-file-document-outline", "color": "#7B68EE", "usesAi": False},
|
"meta": {"icon": "mdi-file-document-outline", "color": "#7B68EE"},
|
||||||
"_method": "clickup",
|
"_method": "clickup",
|
||||||
"_action": "getTask",
|
"_action": "getTask",
|
||||||
},
|
},
|
||||||
|
|
@ -123,7 +123,7 @@ CLICKUP_NODES = [
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "TaskResult"}},
|
"outputPorts": {0: {"schema": "TaskResult"}},
|
||||||
"meta": {"icon": "mdi-plus-circle-outline", "color": "#7B68EE", "usesAi": False},
|
"meta": {"icon": "mdi-plus-circle-outline", "color": "#7B68EE"},
|
||||||
"_method": "clickup",
|
"_method": "clickup",
|
||||||
"_action": "createTask",
|
"_action": "createTask",
|
||||||
},
|
},
|
||||||
|
|
@ -148,7 +148,7 @@ CLICKUP_NODES = [
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["TaskResult", "Transit"]}},
|
"inputPorts": {0: {"accepts": ["TaskResult", "Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "TaskResult"}},
|
"outputPorts": {0: {"schema": "TaskResult"}},
|
||||||
"meta": {"icon": "mdi-pencil-outline", "color": "#7B68EE", "usesAi": False},
|
"meta": {"icon": "mdi-pencil-outline", "color": "#7B68EE"},
|
||||||
"_method": "clickup",
|
"_method": "clickup",
|
||||||
"_action": "updateTask",
|
"_action": "updateTask",
|
||||||
},
|
},
|
||||||
|
|
@ -171,7 +171,7 @@ CLICKUP_NODES = [
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
|
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "ActionResult"}},
|
"outputPorts": {0: {"schema": "ActionResult"}},
|
||||||
"meta": {"icon": "mdi-attachment", "color": "#7B68EE", "usesAi": False},
|
"meta": {"icon": "mdi-attachment", "color": "#7B68EE"},
|
||||||
"_method": "clickup",
|
"_method": "clickup",
|
||||||
"_action": "uploadAttachment",
|
"_action": "uploadAttachment",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
|
||||||
# Context node definitions — structural extraction without AI.
|
|
||||||
|
|
||||||
from modules.shared.i18nRegistry import t
|
|
||||||
|
|
||||||
CONTEXT_NODES = [
|
|
||||||
{
|
|
||||||
"id": "context.extractContent",
|
|
||||||
"category": "context",
|
|
||||||
"label": t("Inhalt extrahieren"),
|
|
||||||
"description": t("Dokumentstruktur extrahieren ohne KI (Seiten, Abschnitte, Bilder, Tabellen)"),
|
|
||||||
"parameters": [
|
|
||||||
{"name": "outputDetail", "type": "string", "required": False, "frontendType": "select",
|
|
||||||
"frontendOptions": {"options": ["full", "structure", "references"]},
|
|
||||||
"description": t("Detailgrad: full = alles, structure = Skelett, references = Dateireferenzen"),
|
|
||||||
"default": "full"},
|
|
||||||
{"name": "includeImages", "type": "boolean", "required": False, "frontendType": "checkbox",
|
|
||||||
"description": t("Bilder extrahieren"), "default": True},
|
|
||||||
{"name": "includeTables", "type": "boolean", "required": False, "frontendType": "checkbox",
|
|
||||||
"description": t("Tabellen extrahieren"), "default": True},
|
|
||||||
],
|
|
||||||
"inputs": 1,
|
|
||||||
"outputs": 1,
|
|
||||||
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
|
|
||||||
"outputPorts": {0: {"schema": "UdmDocument"}},
|
|
||||||
"meta": {"icon": "mdi-file-tree-outline", "color": "#00897B", "usesAi": False},
|
|
||||||
"_method": "context",
|
|
||||||
"_action": "extractContent",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
@ -19,7 +19,7 @@ DATA_NODES = [
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "AggregateResult"}},
|
"outputPorts": {0: {"schema": "AggregateResult"}},
|
||||||
"executor": "data",
|
"executor": "data",
|
||||||
"meta": {"icon": "mdi-playlist-plus", "color": "#607D8B", "usesAi": False},
|
"meta": {"icon": "mdi-playlist-plus", "color": "#607D8B"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "data.transform",
|
"id": "data.transform",
|
||||||
|
|
@ -35,7 +35,7 @@ DATA_NODES = [
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "ActionResult", "dynamic": True, "deriveFrom": "mappings"}},
|
"outputPorts": {0: {"schema": "ActionResult", "dynamic": True, "deriveFrom": "mappings"}},
|
||||||
"executor": "data",
|
"executor": "data",
|
||||||
"meta": {"icon": "mdi-swap-horizontal-bold", "color": "#607D8B", "usesAi": False},
|
"meta": {"icon": "mdi-swap-horizontal-bold", "color": "#607D8B"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "data.filter",
|
"id": "data.filter",
|
||||||
|
|
@ -45,34 +45,12 @@ DATA_NODES = [
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{"name": "condition", "type": "string", "required": True, "frontendType": "filterExpression",
|
{"name": "condition", "type": "string", "required": True, "frontendType": "filterExpression",
|
||||||
"description": t("Filterbedingung")},
|
"description": t("Filterbedingung")},
|
||||||
{"name": "udmContentType", "type": "string", "required": False, "frontendType": "select",
|
|
||||||
"frontendOptions": {"options": ["", "text", "image", "table", "code", "media", "link", "formula"]},
|
|
||||||
"description": t("UDM-ContentType-Filter (optional, leer = kein UDM-Filter)"), "default": ""},
|
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["AggregateResult", "FileList", "TaskList", "EmailList", "DocumentList", "UdmDocument", "UdmNodeList"]}},
|
"inputPorts": {0: {"accepts": ["AggregateResult", "FileList", "TaskList", "EmailList", "DocumentList"]}},
|
||||||
"outputPorts": {0: {"schema": "Transit"}},
|
"outputPorts": {0: {"schema": "Transit"}},
|
||||||
"executor": "data",
|
"executor": "data",
|
||||||
"meta": {"icon": "mdi-filter-outline", "color": "#607D8B", "usesAi": False},
|
"meta": {"icon": "mdi-filter-outline", "color": "#607D8B"},
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "data.consolidate",
|
|
||||||
"category": "data",
|
|
||||||
"label": t("Konsolidieren"),
|
|
||||||
"description": t("Gesammelte Ergebnisse deterministisch zusammenführen (Tabelle, CSV, Merge)"),
|
|
||||||
"parameters": [
|
|
||||||
{"name": "mode", "type": "string", "required": False, "frontendType": "select",
|
|
||||||
"frontendOptions": {"options": ["table", "concat", "merge", "csvJoin"]},
|
|
||||||
"description": t("Konsolidierungsmodus"), "default": "table"},
|
|
||||||
{"name": "separator", "type": "string", "required": False, "frontendType": "text",
|
|
||||||
"description": t("Trennzeichen (für concat/csvJoin)"), "default": "\n"},
|
|
||||||
],
|
|
||||||
"inputs": 1,
|
|
||||||
"outputs": 1,
|
|
||||||
"inputPorts": {0: {"accepts": ["AggregateResult", "Transit"]}},
|
|
||||||
"outputPorts": {0: {"schema": "ConsolidateResult"}},
|
|
||||||
"executor": "data",
|
|
||||||
"meta": {"icon": "mdi-table-merge-cells", "color": "#607D8B", "usesAi": False},
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ EMAIL_NODES = [
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "EmailList"}},
|
"outputPorts": {0: {"schema": "EmailList"}},
|
||||||
"meta": {"icon": "mdi-email-check", "color": "#1976D2", "usesAi": False},
|
"meta": {"icon": "mdi-email-check", "color": "#1976D2"},
|
||||||
"_method": "outlook",
|
"_method": "outlook",
|
||||||
"_action": "readEmails",
|
"_action": "readEmails",
|
||||||
},
|
},
|
||||||
|
|
@ -64,7 +64,7 @@ EMAIL_NODES = [
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "EmailList"}},
|
"outputPorts": {0: {"schema": "EmailList"}},
|
||||||
"meta": {"icon": "mdi-email-search", "color": "#1976D2", "usesAi": False},
|
"meta": {"icon": "mdi-email-search", "color": "#1976D2"},
|
||||||
"_method": "outlook",
|
"_method": "outlook",
|
||||||
"_action": "searchEmails",
|
"_action": "searchEmails",
|
||||||
},
|
},
|
||||||
|
|
@ -87,7 +87,7 @@ EMAIL_NODES = [
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["EmailDraft", "AiResult", "Transit"]}},
|
"inputPorts": {0: {"accepts": ["EmailDraft", "AiResult", "Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "ActionResult"}},
|
"outputPorts": {0: {"schema": "ActionResult"}},
|
||||||
"meta": {"icon": "mdi-email-edit", "color": "#1976D2", "usesAi": False},
|
"meta": {"icon": "mdi-email-edit", "color": "#1976D2"},
|
||||||
"_method": "outlook",
|
"_method": "outlook",
|
||||||
"_action": "composeAndDraftEmailWithContext",
|
"_action": "composeAndDraftEmailWithContext",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ FILE_NODES = [
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["AiResult", "TextResult", "Transit"]}},
|
"inputPorts": {0: {"accepts": ["AiResult", "TextResult", "Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "DocumentList"}},
|
"outputPorts": {0: {"schema": "DocumentList"}},
|
||||||
"meta": {"icon": "mdi-file-plus-outline", "color": "#2196F3", "usesAi": False},
|
"meta": {"icon": "mdi-file-plus-outline", "color": "#2196F3"},
|
||||||
"_method": "file",
|
"_method": "file",
|
||||||
"_action": "create",
|
"_action": "create",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ FLOW_NODES = [
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "Transit"}, 1: {"schema": "Transit"}},
|
"outputPorts": {0: {"schema": "Transit"}, 1: {"schema": "Transit"}},
|
||||||
"executor": "flow",
|
"executor": "flow",
|
||||||
"meta": {"icon": "mdi-source-branch", "color": "#FF9800", "usesAi": False},
|
"meta": {"icon": "mdi-source-branch", "color": "#FF9800"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "flow.switch",
|
"id": "flow.switch",
|
||||||
|
|
@ -52,13 +52,13 @@ FLOW_NODES = [
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "Transit"}},
|
"outputPorts": {0: {"schema": "Transit"}},
|
||||||
"executor": "flow",
|
"executor": "flow",
|
||||||
"meta": {"icon": "mdi-swap-horizontal", "color": "#FF9800", "usesAi": False},
|
"meta": {"icon": "mdi-swap-horizontal", "color": "#FF9800"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "flow.loop",
|
"id": "flow.loop",
|
||||||
"category": "flow",
|
"category": "flow",
|
||||||
"label": t("Schleife / Für Jedes"),
|
"label": t("Schleife / Für Jedes"),
|
||||||
"description": t("Über Array-Elemente oder UDM-Strukturebenen iterieren"),
|
"description": t("Über Array-Elemente iterieren"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "items",
|
"name": "items",
|
||||||
|
|
@ -67,37 +67,19 @@ FLOW_NODES = [
|
||||||
"frontendType": "text",
|
"frontendType": "text",
|
||||||
"description": t("Pfad zum Array"),
|
"description": t("Pfad zum Array"),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "level",
|
|
||||||
"type": "string",
|
|
||||||
"required": False,
|
|
||||||
"frontendType": "select",
|
|
||||||
"frontendOptions": {"options": ["auto", "documents", "structuralNodes", "contentBlocks"]},
|
|
||||||
"description": t("UDM-Iterationsebene"),
|
|
||||||
"default": "auto",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "concurrency",
|
|
||||||
"type": "number",
|
|
||||||
"required": False,
|
|
||||||
"frontendType": "number",
|
|
||||||
"frontendOptions": {"min": 1, "max": 20},
|
|
||||||
"description": t("Parallele Iterationen (1 = sequentiell)"),
|
|
||||||
"default": 1,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
"inputs": 1,
|
"inputs": 1,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit", "UdmDocument"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "LoopItem"}},
|
"outputPorts": {0: {"schema": "LoopItem"}},
|
||||||
"executor": "flow",
|
"executor": "flow",
|
||||||
"meta": {"icon": "mdi-repeat", "color": "#FF9800", "usesAi": False},
|
"meta": {"icon": "mdi-repeat", "color": "#FF9800"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "flow.merge",
|
"id": "flow.merge",
|
||||||
"category": "flow",
|
"category": "flow",
|
||||||
"label": t("Zusammenführen"),
|
"label": t("Zusammenführen"),
|
||||||
"description": t("Mehrere Zweige zusammenführen (2-5 Eingänge)"),
|
"description": t("Mehrere Zweige zusammenführen"),
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"name": "mode",
|
"name": "mode",
|
||||||
|
|
@ -108,21 +90,12 @@ FLOW_NODES = [
|
||||||
"description": t("Zusammenführungsmodus"),
|
"description": t("Zusammenführungsmodus"),
|
||||||
"default": "first",
|
"default": "first",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "inputCount",
|
|
||||||
"type": "number",
|
|
||||||
"required": False,
|
|
||||||
"frontendType": "number",
|
|
||||||
"frontendOptions": {"min": 2, "max": 5},
|
|
||||||
"description": t("Anzahl Eingänge"),
|
|
||||||
"default": 2,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
"inputs": 2,
|
"inputs": 2,
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}, 1: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}, 1: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "MergeResult"}},
|
"outputPorts": {0: {"schema": "MergeResult"}},
|
||||||
"executor": "flow",
|
"executor": "flow",
|
||||||
"meta": {"icon": "mdi-call-merge", "color": "#FF9800", "usesAi": False},
|
"meta": {"icon": "mdi-call-merge", "color": "#FF9800"},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ INPUT_NODES = [
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "FormPayload", "dynamic": True, "deriveFrom": "fields"}},
|
"outputPorts": {0: {"schema": "FormPayload", "dynamic": True, "deriveFrom": "fields"}},
|
||||||
"executor": "input",
|
"executor": "input",
|
||||||
"meta": {"icon": "mdi-form-textbox", "color": "#9C27B0", "usesAi": False},
|
"meta": {"icon": "mdi-form-textbox", "color": "#9C27B0"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "input.approval",
|
"id": "input.approval",
|
||||||
|
|
@ -45,7 +45,7 @@ INPUT_NODES = [
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "BoolResult"}},
|
"outputPorts": {0: {"schema": "BoolResult"}},
|
||||||
"executor": "input",
|
"executor": "input",
|
||||||
"meta": {"icon": "mdi-check-decagram", "color": "#4CAF50", "usesAi": False},
|
"meta": {"icon": "mdi-check-decagram", "color": "#4CAF50"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "input.upload",
|
"id": "input.upload",
|
||||||
|
|
@ -68,7 +68,7 @@ INPUT_NODES = [
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "DocumentList"}},
|
"outputPorts": {0: {"schema": "DocumentList"}},
|
||||||
"executor": "input",
|
"executor": "input",
|
||||||
"meta": {"icon": "mdi-upload", "color": "#2196F3", "usesAi": False},
|
"meta": {"icon": "mdi-upload", "color": "#2196F3"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "input.comment",
|
"id": "input.comment",
|
||||||
|
|
@ -86,7 +86,7 @@ INPUT_NODES = [
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "TextResult"}},
|
"outputPorts": {0: {"schema": "TextResult"}},
|
||||||
"executor": "input",
|
"executor": "input",
|
||||||
"meta": {"icon": "mdi-comment-text", "color": "#FF9800", "usesAi": False},
|
"meta": {"icon": "mdi-comment-text", "color": "#FF9800"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "input.review",
|
"id": "input.review",
|
||||||
|
|
@ -105,7 +105,7 @@ INPUT_NODES = [
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "BoolResult"}},
|
"outputPorts": {0: {"schema": "BoolResult"}},
|
||||||
"executor": "input",
|
"executor": "input",
|
||||||
"meta": {"icon": "mdi-magnify-scan", "color": "#673AB7", "usesAi": False},
|
"meta": {"icon": "mdi-magnify-scan", "color": "#673AB7"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "input.selection",
|
"id": "input.selection",
|
||||||
|
|
@ -123,7 +123,7 @@ INPUT_NODES = [
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "TextResult"}},
|
"outputPorts": {0: {"schema": "TextResult"}},
|
||||||
"executor": "input",
|
"executor": "input",
|
||||||
"meta": {"icon": "mdi-format-list-checks", "color": "#009688", "usesAi": False},
|
"meta": {"icon": "mdi-format-list-checks", "color": "#009688"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "input.confirmation",
|
"id": "input.confirmation",
|
||||||
|
|
@ -143,6 +143,6 @@ INPUT_NODES = [
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "BoolResult"}},
|
"outputPorts": {0: {"schema": "BoolResult"}},
|
||||||
"executor": "input",
|
"executor": "input",
|
||||||
"meta": {"icon": "mdi-checkbox-marked-circle", "color": "#8BC34A", "usesAi": False},
|
"meta": {"icon": "mdi-checkbox-marked-circle", "color": "#8BC34A"},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ SHAREPOINT_NODES = [
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "FileList"}},
|
"outputPorts": {0: {"schema": "FileList"}},
|
||||||
"meta": {"icon": "mdi-file-search", "color": "#0078D4", "usesAi": False},
|
"meta": {"icon": "mdi-file-search", "color": "#0078D4"},
|
||||||
"_method": "sharepoint",
|
"_method": "sharepoint",
|
||||||
"_action": "findDocumentPath",
|
"_action": "findDocumentPath",
|
||||||
},
|
},
|
||||||
|
|
@ -43,7 +43,7 @@ SHAREPOINT_NODES = [
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["FileList", "Transit"]}},
|
"inputPorts": {0: {"accepts": ["FileList", "Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "DocumentList"}},
|
"outputPorts": {0: {"schema": "DocumentList"}},
|
||||||
"meta": {"icon": "mdi-file-document", "color": "#0078D4", "usesAi": False},
|
"meta": {"icon": "mdi-file-document", "color": "#0078D4"},
|
||||||
"_method": "sharepoint",
|
"_method": "sharepoint",
|
||||||
"_action": "readDocuments",
|
"_action": "readDocuments",
|
||||||
},
|
},
|
||||||
|
|
@ -63,7 +63,7 @@ SHAREPOINT_NODES = [
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
|
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "ActionResult"}},
|
"outputPorts": {0: {"schema": "ActionResult"}},
|
||||||
"meta": {"icon": "mdi-upload", "color": "#0078D4", "usesAi": False},
|
"meta": {"icon": "mdi-upload", "color": "#0078D4"},
|
||||||
"_method": "sharepoint",
|
"_method": "sharepoint",
|
||||||
"_action": "uploadFile",
|
"_action": "uploadFile",
|
||||||
},
|
},
|
||||||
|
|
@ -83,7 +83,7 @@ SHAREPOINT_NODES = [
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "FileList"}},
|
"outputPorts": {0: {"schema": "FileList"}},
|
||||||
"meta": {"icon": "mdi-folder-open", "color": "#0078D4", "usesAi": False},
|
"meta": {"icon": "mdi-folder-open", "color": "#0078D4"},
|
||||||
"_method": "sharepoint",
|
"_method": "sharepoint",
|
||||||
"_action": "listDocuments",
|
"_action": "listDocuments",
|
||||||
},
|
},
|
||||||
|
|
@ -103,7 +103,7 @@ SHAREPOINT_NODES = [
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["FileList", "Transit"]}},
|
"inputPorts": {0: {"accepts": ["FileList", "Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "DocumentList"}},
|
"outputPorts": {0: {"schema": "DocumentList"}},
|
||||||
"meta": {"icon": "mdi-download", "color": "#0078D4", "usesAi": False},
|
"meta": {"icon": "mdi-download", "color": "#0078D4"},
|
||||||
"_method": "sharepoint",
|
"_method": "sharepoint",
|
||||||
"_action": "downloadFileByPath",
|
"_action": "downloadFileByPath",
|
||||||
},
|
},
|
||||||
|
|
@ -126,7 +126,7 @@ SHAREPOINT_NODES = [
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "ActionResult"}},
|
"outputPorts": {0: {"schema": "ActionResult"}},
|
||||||
"meta": {"icon": "mdi-content-copy", "color": "#0078D4", "usesAi": False},
|
"meta": {"icon": "mdi-content-copy", "color": "#0078D4"},
|
||||||
"_method": "sharepoint",
|
"_method": "sharepoint",
|
||||||
"_action": "copyFile",
|
"_action": "copyFile",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ TRIGGER_NODES = [
|
||||||
"inputPorts": {},
|
"inputPorts": {},
|
||||||
"outputPorts": {0: {"schema": "ActionResult"}},
|
"outputPorts": {0: {"schema": "ActionResult"}},
|
||||||
"executor": "trigger",
|
"executor": "trigger",
|
||||||
"meta": {"icon": "mdi-play", "color": "#4CAF50", "usesAi": False},
|
"meta": {"icon": "mdi-play", "color": "#4CAF50"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "trigger.form",
|
"id": "trigger.form",
|
||||||
|
|
@ -36,7 +36,7 @@ TRIGGER_NODES = [
|
||||||
"inputPorts": {},
|
"inputPorts": {},
|
||||||
"outputPorts": {0: {"schema": "FormPayload", "dynamic": True, "deriveFrom": "formFields"}},
|
"outputPorts": {0: {"schema": "FormPayload", "dynamic": True, "deriveFrom": "formFields"}},
|
||||||
"executor": "trigger",
|
"executor": "trigger",
|
||||||
"meta": {"icon": "mdi-form-select", "color": "#9C27B0", "usesAi": False},
|
"meta": {"icon": "mdi-form-select", "color": "#9C27B0"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "trigger.schedule",
|
"id": "trigger.schedule",
|
||||||
|
|
@ -57,6 +57,6 @@ TRIGGER_NODES = [
|
||||||
"inputPorts": {},
|
"inputPorts": {},
|
||||||
"outputPorts": {0: {"schema": "ActionResult"}},
|
"outputPorts": {0: {"schema": "ActionResult"}},
|
||||||
"executor": "trigger",
|
"executor": "trigger",
|
||||||
"meta": {"icon": "mdi-clock", "color": "#2196F3", "usesAi": False},
|
"meta": {"icon": "mdi-clock", "color": "#2196F3"},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ TRUSTEE_NODES = [
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "ActionResult"}},
|
"outputPorts": {0: {"schema": "ActionResult"}},
|
||||||
"meta": {"icon": "mdi-database-refresh", "color": "#4CAF50", "usesAi": False},
|
"meta": {"icon": "mdi-database-refresh", "color": "#4CAF50"},
|
||||||
"_method": "trustee",
|
"_method": "trustee",
|
||||||
"_action": "refreshAccountingData",
|
"_action": "refreshAccountingData",
|
||||||
},
|
},
|
||||||
|
|
@ -47,7 +47,7 @@ TRUSTEE_NODES = [
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
|
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "DocumentList"}},
|
"outputPorts": {0: {"schema": "DocumentList"}},
|
||||||
"meta": {"icon": "mdi-file-document-scan", "color": "#4CAF50", "usesAi": True},
|
"meta": {"icon": "mdi-file-document-scan", "color": "#4CAF50"},
|
||||||
"_method": "trustee",
|
"_method": "trustee",
|
||||||
"_action": "extractFromFiles",
|
"_action": "extractFromFiles",
|
||||||
},
|
},
|
||||||
|
|
@ -66,7 +66,7 @@ TRUSTEE_NODES = [
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
|
"inputPorts": {0: {"accepts": ["DocumentList", "Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "ActionResult"}},
|
"outputPorts": {0: {"schema": "ActionResult"}},
|
||||||
"meta": {"icon": "mdi-file-document-check", "color": "#4CAF50", "usesAi": False},
|
"meta": {"icon": "mdi-file-document-check", "color": "#4CAF50"},
|
||||||
"_method": "trustee",
|
"_method": "trustee",
|
||||||
"_action": "processDocuments",
|
"_action": "processDocuments",
|
||||||
},
|
},
|
||||||
|
|
@ -85,7 +85,7 @@ TRUSTEE_NODES = [
|
||||||
"outputs": 1,
|
"outputs": 1,
|
||||||
"inputPorts": {0: {"accepts": ["Transit"]}},
|
"inputPorts": {0: {"accepts": ["Transit"]}},
|
||||||
"outputPorts": {0: {"schema": "ActionResult"}},
|
"outputPorts": {0: {"schema": "ActionResult"}},
|
||||||
"meta": {"icon": "mdi-calculator", "color": "#4CAF50", "usesAi": False},
|
"meta": {"icon": "mdi-calculator", "color": "#4CAF50"},
|
||||||
"_method": "trustee",
|
"_method": "trustee",
|
||||||
"_action": "syncToAccounting",
|
"_action": "syncToAccounting",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,6 @@ def getNodeTypesForApi(
|
||||||
{"id": "input", "label": "Eingabe/Mensch"},
|
{"id": "input", "label": "Eingabe/Mensch"},
|
||||||
{"id": "flow", "label": "Ablauf"},
|
{"id": "flow", "label": "Ablauf"},
|
||||||
{"id": "data", "label": "Daten"},
|
{"id": "data", "label": "Daten"},
|
||||||
{"id": "context", "label": "Kontext"},
|
|
||||||
{"id": "ai", "label": "KI"},
|
{"id": "ai", "label": "KI"},
|
||||||
{"id": "file", "label": "Datei"},
|
{"id": "file", "label": "Datei"},
|
||||||
{"id": "email", "label": "E-Mail"},
|
{"id": "email", "label": "E-Mail"},
|
||||||
|
|
|
||||||
|
|
@ -152,21 +152,6 @@ PORT_TYPE_CATALOG: Dict[str, PortSchema] = {
|
||||||
description="Ergebnisdaten"),
|
description="Ergebnisdaten"),
|
||||||
]),
|
]),
|
||||||
"Transit": PortSchema(name="Transit", fields=[]),
|
"Transit": PortSchema(name="Transit", fields=[]),
|
||||||
"UdmDocument": PortSchema(name="UdmDocument", 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"),
|
|
||||||
]),
|
|
||||||
"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"),
|
|
||||||
]),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -427,36 +412,6 @@ def _extractMergeResult(upstream: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _extractUdmDocument(upstream: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
"""Extract UdmDocument fields from upstream output."""
|
|
||||||
if upstream.get("children") is not None and upstream.get("sourceType"):
|
|
||||||
return upstream
|
|
||||||
udm = upstream.get("udm")
|
|
||||||
if isinstance(udm, dict) and udm.get("children") is not None:
|
|
||||||
return udm
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _extractUdmNodeList(upstream: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
"""Extract UdmNodeList fields from upstream output."""
|
|
||||||
nodes = upstream.get("nodes")
|
|
||||||
if isinstance(nodes, list):
|
|
||||||
return {"nodes": nodes, "count": len(nodes)}
|
|
||||||
children = upstream.get("children")
|
|
||||||
if isinstance(children, list):
|
|
||||||
return {"nodes": children, "count": len(children)}
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _extractConsolidateResult(upstream: Dict[str, Any]) -> Dict[str, Any]:
|
|
||||||
"""Extract ConsolidateResult fields from upstream output."""
|
|
||||||
result = {}
|
|
||||||
for key in ("result", "mode", "count"):
|
|
||||||
if key in upstream:
|
|
||||||
result[key] = upstream[key]
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
INPUT_EXTRACTORS: Dict[str, Callable] = {
|
INPUT_EXTRACTORS: Dict[str, Callable] = {
|
||||||
"EmailDraft": _extractEmailDraft,
|
"EmailDraft": _extractEmailDraft,
|
||||||
"DocumentList": _extractDocuments,
|
"DocumentList": _extractDocuments,
|
||||||
|
|
@ -470,9 +425,6 @@ INPUT_EXTRACTORS: Dict[str, Callable] = {
|
||||||
"TaskResult": _extractTaskResult,
|
"TaskResult": _extractTaskResult,
|
||||||
"AggregateResult": _extractAggregateResult,
|
"AggregateResult": _extractAggregateResult,
|
||||||
"MergeResult": _extractMergeResult,
|
"MergeResult": _extractMergeResult,
|
||||||
"UdmDocument": _extractUdmDocument,
|
|
||||||
"UdmNodeList": _extractUdmNodeList,
|
|
||||||
"ConsolidateResult": _extractConsolidateResult,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -470,63 +470,16 @@ def share_template(
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _editorChatQueueId(workflowId: str) -> str:
|
|
||||||
"""Deterministic SSE queue id for the editor chat (one active stream per workflow).
|
|
||||||
|
|
||||||
Mirrors the workspace pattern (``workspace-{workflowId}``) so stop/cancel can
|
|
||||||
target the running task by workflowId without needing per-request handles.
|
|
||||||
"""
|
|
||||||
return f"ge-chat-{workflowId}"
|
|
||||||
|
|
||||||
|
|
||||||
def _getEditorChatInterface(context: RequestContext, mandateId: str, instanceId: str):
|
|
||||||
"""Build the ChatObjects interface used to persist editor-chat messages."""
|
|
||||||
from modules.interfaces import interfaceDbChat
|
|
||||||
return interfaceDbChat.getInterface(
|
|
||||||
context.user,
|
|
||||||
mandateId=mandateId,
|
|
||||||
featureInstanceId=instanceId,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _editorConversationHistoryFromPersisted(chatInterface, chatWorkflowId: str) -> List[Dict[str, Any]]:
|
|
||||||
"""Load persisted ChatMessages for the editor chat and shape them as the
|
|
||||||
agent expects (``[{role, message}]``). Skips empty / system messages.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
msgs = chatInterface.getMessages(chatWorkflowId) or []
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Editor chat: could not load persisted history for %s: %s", chatWorkflowId, e)
|
|
||||||
return []
|
|
||||||
history: List[Dict[str, Any]] = []
|
|
||||||
for m in msgs:
|
|
||||||
role = (getattr(m, "role", None) or (m.get("role") if isinstance(m, dict) else None) or "").strip()
|
|
||||||
text = (getattr(m, "message", None) or (m.get("message") if isinstance(m, dict) else None) or "").strip()
|
|
||||||
if not role or not text:
|
|
||||||
continue
|
|
||||||
if role not in ("user", "assistant", "system"):
|
|
||||||
continue
|
|
||||||
history.append({"role": role, "message": text})
|
|
||||||
return history
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{instanceId}/{workflowId}/chat/stream")
|
@router.post("/{instanceId}/{workflowId}/chat/stream")
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
async def post_editor_chat(
|
async def post_editor_chat(
|
||||||
request: Request,
|
request: Request,
|
||||||
instanceId: str = Path(..., description="Feature instance ID"),
|
instanceId: str = Path(..., description="Feature instance ID"),
|
||||||
workflowId: str = Path(..., description="Workflow ID"),
|
workflowId: str = Path(..., description="Workflow ID"),
|
||||||
body: dict = Body(..., description="{ message, userLanguage? }"),
|
body: dict = Body(..., description="{ message, conversationHistory?, userLanguage? }"),
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
"""AI chat endpoint for the editor with SSE streaming. Uses workflow tools to mutate the graph.
|
"""AI chat endpoint for the editor with SSE streaming. Uses workflow tools to mutate the graph."""
|
||||||
|
|
||||||
Persistence: the chat is stored in the standard ``ChatWorkflow`` table linked
|
|
||||||
to this Automation2Workflow via ``ChatWorkflow.linkedWorkflowId``. The user
|
|
||||||
message is persisted before the agent starts; the assistant message after.
|
|
||||||
Conversation history is loaded server-side from this linked ChatWorkflow —
|
|
||||||
the client does not need to maintain it.
|
|
||||||
"""
|
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
message = body.get("message", "")
|
message = body.get("message", "")
|
||||||
if not message:
|
if not message:
|
||||||
|
|
@ -538,35 +491,14 @@ async def post_editor_chat(
|
||||||
raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found"))
|
raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found"))
|
||||||
|
|
||||||
userLanguage = body.get("userLanguage", "de")
|
userLanguage = body.get("userLanguage", "de")
|
||||||
|
conversationHistory = body.get("conversationHistory") or []
|
||||||
fileIds = body.get("fileIds") or []
|
fileIds = body.get("fileIds") or []
|
||||||
dataSourceIds = body.get("dataSourceIds") or []
|
dataSourceIds = body.get("dataSourceIds") or []
|
||||||
featureDataSourceIds = body.get("featureDataSourceIds") or []
|
featureDataSourceIds = body.get("featureDataSourceIds") or []
|
||||||
|
|
||||||
chatInterface = _getEditorChatInterface(context, mandateId, instanceId)
|
|
||||||
wfLabel = wf.get("label") if isinstance(wf, dict) else getattr(wf, "label", None)
|
|
||||||
chatWorkflow = chatInterface.getOrCreateLinkedWorkflow(
|
|
||||||
featureInstanceId=instanceId,
|
|
||||||
linkedWorkflowId=workflowId,
|
|
||||||
name=wfLabel or f"Editor Chat ({workflowId})",
|
|
||||||
)
|
|
||||||
chatWorkflowId = chatWorkflow.id if hasattr(chatWorkflow, "id") else chatWorkflow.get("id")
|
|
||||||
|
|
||||||
conversationHistory = _editorConversationHistoryFromPersisted(chatInterface, chatWorkflowId)
|
|
||||||
|
|
||||||
try:
|
|
||||||
chatInterface.createMessage({
|
|
||||||
"workflowId": chatWorkflowId,
|
|
||||||
"role": "user",
|
|
||||||
"message": message,
|
|
||||||
"status": "first" if not conversationHistory else "step",
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Editor chat: failed to persist user message: %s", e)
|
|
||||||
|
|
||||||
from modules.serviceCenter.core.serviceStreaming import get_event_manager
|
from modules.serviceCenter.core.serviceStreaming import get_event_manager
|
||||||
sseEventManager = get_event_manager()
|
sseEventManager = get_event_manager()
|
||||||
queueId = _editorChatQueueId(workflowId)
|
queueId = f"ge-chat-{workflowId}-{id(request)}"
|
||||||
await sseEventManager.cancel_agent(queueId)
|
|
||||||
sseEventManager.create_queue(queueId)
|
sseEventManager.create_queue(queueId)
|
||||||
|
|
||||||
agentTask = asyncio.ensure_future(
|
agentTask = asyncio.ensure_future(
|
||||||
|
|
@ -583,8 +515,6 @@ async def post_editor_chat(
|
||||||
fileIds=fileIds,
|
fileIds=fileIds,
|
||||||
dataSourceIds=dataSourceIds,
|
dataSourceIds=dataSourceIds,
|
||||||
featureDataSourceIds=featureDataSourceIds,
|
featureDataSourceIds=featureDataSourceIds,
|
||||||
chatInterface=chatInterface,
|
|
||||||
chatWorkflowId=chatWorkflowId,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
sseEventManager.register_agent_task(queueId, agentTask)
|
sseEventManager.register_agent_task(queueId, agentTask)
|
||||||
|
|
@ -619,80 +549,6 @@ async def post_editor_chat(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{instanceId}/{workflowId}/chat/messages")
|
|
||||||
@limiter.limit("120/minute")
|
|
||||||
def get_editor_chat_messages(
|
|
||||||
request: Request,
|
|
||||||
instanceId: str = Path(..., description="Feature instance ID"),
|
|
||||||
workflowId: str = Path(..., description="Workflow ID (Automation2Workflow)"),
|
|
||||||
context: RequestContext = Depends(getRequestContext),
|
|
||||||
):
|
|
||||||
"""Return persisted editor-chat messages for an Automation2Workflow.
|
|
||||||
|
|
||||||
The chat is stored in ``ChatWorkflow`` with ``linkedWorkflowId == workflowId``;
|
|
||||||
if no chat has been started yet for this workflow we return an empty list (we
|
|
||||||
do NOT eagerly create one — the row is created on the first POST /chat/stream).
|
|
||||||
"""
|
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
|
||||||
chatInterface = _getEditorChatInterface(context, mandateId, instanceId)
|
|
||||||
chatWorkflow = chatInterface.getWorkflowByLink(
|
|
||||||
featureInstanceId=instanceId,
|
|
||||||
linkedWorkflowId=workflowId,
|
|
||||||
)
|
|
||||||
if not chatWorkflow:
|
|
||||||
return JSONResponse({
|
|
||||||
"chatWorkflowId": None,
|
|
||||||
"messages": [],
|
|
||||||
})
|
|
||||||
|
|
||||||
chatWorkflowId = chatWorkflow.id if hasattr(chatWorkflow, "id") else chatWorkflow.get("id")
|
|
||||||
rawMessages = chatInterface.getMessages(chatWorkflowId) or []
|
|
||||||
|
|
||||||
items: List[Dict[str, Any]] = []
|
|
||||||
for m in rawMessages:
|
|
||||||
getter = (lambda key, default=None: getattr(m, key, default)) if not isinstance(m, dict) else (lambda key, default=None: m.get(key, default))
|
|
||||||
role = (getter("role") or "").strip()
|
|
||||||
content = (getter("message") or "").strip()
|
|
||||||
if not role or not content:
|
|
||||||
continue
|
|
||||||
items.append({
|
|
||||||
"id": getter("id"),
|
|
||||||
"role": role,
|
|
||||||
"content": content,
|
|
||||||
"timestamp": getter("publishedAt") or 0,
|
|
||||||
"sequenceNr": getter("sequenceNr") or 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
items.sort(key=lambda x: (float(x.get("timestamp") or 0), int(x.get("sequenceNr") or 0)))
|
|
||||||
|
|
||||||
return JSONResponse({
|
|
||||||
"chatWorkflowId": chatWorkflowId,
|
|
||||||
"messages": items,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{instanceId}/{workflowId}/chat/stop")
|
|
||||||
@limiter.limit("120/minute")
|
|
||||||
async def post_editor_chat_stop(
|
|
||||||
request: Request,
|
|
||||||
instanceId: str = Path(..., description="Feature instance ID"),
|
|
||||||
workflowId: str = Path(..., description="Workflow ID"),
|
|
||||||
context: RequestContext = Depends(getRequestContext),
|
|
||||||
):
|
|
||||||
"""Stop a running editor-chat agent for the given workflow."""
|
|
||||||
_validateInstanceAccess(instanceId, context)
|
|
||||||
from modules.serviceCenter.core.serviceStreaming import get_event_manager
|
|
||||||
sseEventManager = get_event_manager()
|
|
||||||
queueId = _editorChatQueueId(workflowId)
|
|
||||||
cancelled = await sseEventManager.cancel_agent(queueId)
|
|
||||||
await sseEventManager.emit_event(queueId, "stopped", {
|
|
||||||
"type": "stopped",
|
|
||||||
"workflowId": workflowId,
|
|
||||||
})
|
|
||||||
logger.info("Editor chat stop requested for workflow %s, cancelled=%s", workflowId, cancelled)
|
|
||||||
return JSONResponse({"status": "stopped", "workflowId": workflowId, "cancelled": cancelled})
|
|
||||||
|
|
||||||
|
|
||||||
async def _runEditorAgent(
|
async def _runEditorAgent(
|
||||||
workflowId: str,
|
workflowId: str,
|
||||||
queueId: str,
|
queueId: str,
|
||||||
|
|
@ -706,41 +562,12 @@ async def _runEditorAgent(
|
||||||
fileIds: List[str] = None,
|
fileIds: List[str] = None,
|
||||||
dataSourceIds: List[str] = None,
|
dataSourceIds: List[str] = None,
|
||||||
featureDataSourceIds: List[str] = None,
|
featureDataSourceIds: List[str] = None,
|
||||||
chatInterface=None,
|
|
||||||
chatWorkflowId: Optional[str] = None,
|
|
||||||
):
|
):
|
||||||
"""Run the serviceAgent loop with workflow toolbox and forward events to the SSE queue.
|
"""Run the serviceAgent loop with workflow toolbox and forward events to the SSE queue."""
|
||||||
|
|
||||||
Persists the assistant response to ``ChatMessage`` (linked via ``chatWorkflowId``)
|
|
||||||
on FINAL/ERROR. On cancellation any partial accumulated text is still saved so
|
|
||||||
the editor chat history reflects what the user actually saw on screen.
|
|
||||||
"""
|
|
||||||
assistantPersisted = False
|
|
||||||
|
|
||||||
def _persistAssistant(text: str) -> None:
|
|
||||||
nonlocal assistantPersisted
|
|
||||||
if assistantPersisted or not chatInterface or not chatWorkflowId:
|
|
||||||
return
|
|
||||||
cleaned = (text or "").strip()
|
|
||||||
if not cleaned:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
chatInterface.createMessage({
|
|
||||||
"workflowId": chatWorkflowId,
|
|
||||||
"role": "assistant",
|
|
||||||
"message": cleaned,
|
|
||||||
"status": "last",
|
|
||||||
})
|
|
||||||
assistantPersisted = True
|
|
||||||
except Exception as msgErr:
|
|
||||||
logger.error("Editor chat: failed to persist assistant message: %s", msgErr)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from modules.serviceCenter import getService
|
from modules.serviceCenter import getService
|
||||||
from modules.serviceCenter.context import ServiceCenterContext
|
from modules.serviceCenter.context import ServiceCenterContext
|
||||||
from modules.serviceCenter.services.serviceAgent.datamodelAgent import (
|
from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentEventTypeEnum
|
||||||
AgentEventTypeEnum, AgentConfig,
|
|
||||||
)
|
|
||||||
|
|
||||||
ctx = ServiceCenterContext(
|
ctx = ServiceCenterContext(
|
||||||
user=user,
|
user=user,
|
||||||
|
|
@ -752,41 +579,11 @@ async def _runEditorAgent(
|
||||||
agentService = getService("agent", ctx)
|
agentService = getService("agent", ctx)
|
||||||
|
|
||||||
systemPrompt = (
|
systemPrompt = (
|
||||||
"You are a workflow EDITOR assistant for the GraphicalEditor. "
|
"You are a workflow editor assistant. The user describes changes to a workflow graph. "
|
||||||
"Your ONLY job is to BUILD or MODIFY the workflow graph (nodes + connections) "
|
"Use the available workflow tools (readWorkflowGraph, addNode, removeNode, connectNodes, "
|
||||||
"for the user — you must NEVER execute the workflow or any of its actions. "
|
"setNodeParameter, listAvailableNodeTypes, validateGraph) to modify the graph. "
|
||||||
"Even when the user says 'create a workflow that sends an email', you build the "
|
"Always read the current graph first before making changes. "
|
||||||
"graph (e.g. add an email node, connect it) — you do NOT actually send an email. "
|
"Respond concisely and confirm what you changed."
|
||||||
"\n\nGraph-mutating tools: readWorkflowGraph, listAvailableNodeTypes, "
|
|
||||||
"describeNodeType, addNode, removeNode, connectNodes, setNodeParameter, "
|
|
||||||
"autoLayoutWorkflow, validateGraph. "
|
|
||||||
"Connection discovery (for parameters of frontendType='userConnection'): listConnections."
|
|
||||||
"\n\nMandatory build sequence:"
|
|
||||||
"\n1. readWorkflowGraph — understand current state."
|
|
||||||
"\n2. listAvailableNodeTypes — find candidate node ids."
|
|
||||||
"\n3. For EACH node type you plan to add: call describeNodeType(nodeType=...) "
|
|
||||||
"to learn its requiredParameters, allowedValues and ports. Never skip this "
|
|
||||||
"step — guessing parameters leaves the user with empty config cards."
|
|
||||||
"\n4. If any required parameter has frontendType='userConnection' (e.g. "
|
|
||||||
"email.checkEmail.connectionReference), call listConnections and pick the "
|
|
||||||
"connectionId that matches the user's intent (or ask the user if none clearly fits)."
|
|
||||||
"\n5. addNode with parameters={...} containing AT LEAST every requiredParameter "
|
|
||||||
"filled with a sensible value (use the user's request, the parameter "
|
|
||||||
"description, sane defaults, or — for required user-connection fields — "
|
|
||||||
"an actual connectionId). Do NOT pass position; the layout step handles it."
|
|
||||||
"\n6. connectNodes — wire the nodes consistent with port schemas from describeNodeType."
|
|
||||||
"\n7. autoLayoutWorkflow — call exactly once as the LAST graph-mutating step so the "
|
|
||||||
"canvas shows a readable top-down layout instead of overlapping boxes."
|
|
||||||
"\n8. validateGraph — sanity check, then answer the user."
|
|
||||||
"\n\nIf a required parameter cannot be filled from the user's request and has "
|
|
||||||
"no safe default, ask the user once for that specific value (e.g. recipient "
|
|
||||||
"address, target language, prompt text) instead of leaving the field blank. "
|
|
||||||
"Respond concisely in the user's language and list what you changed in the graph."
|
|
||||||
)
|
|
||||||
|
|
||||||
editorConfig = AgentConfig(
|
|
||||||
toolSet="core",
|
|
||||||
excludeActionTools=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
enrichedPrompt = prompt
|
enrichedPrompt = prompt
|
||||||
|
|
@ -808,7 +605,6 @@ async def _runEditorAgent(
|
||||||
async for event in agentService.runAgent(
|
async for event in agentService.runAgent(
|
||||||
prompt=enrichedPrompt,
|
prompt=enrichedPrompt,
|
||||||
fileIds=fileIds or [],
|
fileIds=fileIds or [],
|
||||||
config=editorConfig,
|
|
||||||
workflowId=workflowId,
|
workflowId=workflowId,
|
||||||
userLanguage=userLanguage,
|
userLanguage=userLanguage,
|
||||||
conversationHistory=conversationHistory or [],
|
conversationHistory=conversationHistory or [],
|
||||||
|
|
@ -835,13 +631,8 @@ async def _runEditorAgent(
|
||||||
await sseEventManager.emit_event(queueId, sseEvent["type"], sseEvent)
|
await sseEventManager.emit_event(queueId, sseEvent["type"], sseEvent)
|
||||||
|
|
||||||
if event.type in (AgentEventTypeEnum.FINAL, AgentEventTypeEnum.ERROR):
|
if event.type in (AgentEventTypeEnum.FINAL, AgentEventTypeEnum.ERROR):
|
||||||
_persistAssistant(event.content or accumulatedText)
|
|
||||||
break
|
break
|
||||||
|
|
||||||
# Fallback: any streamed content not yet stored (cancellation path, no FINAL).
|
|
||||||
if not assistantPersisted and accumulatedText.strip():
|
|
||||||
_persistAssistant(accumulatedText)
|
|
||||||
|
|
||||||
await sseEventManager.emit_event(queueId, "complete", {
|
await sseEventManager.emit_event(queueId, "complete", {
|
||||||
"type": "complete",
|
"type": "complete",
|
||||||
"workflowId": workflowId,
|
"workflowId": workflowId,
|
||||||
|
|
@ -849,12 +640,6 @@ async def _runEditorAgent(
|
||||||
|
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
logger.info("Editor chat agent task cancelled for workflow %s", workflowId)
|
logger.info("Editor chat agent task cancelled for workflow %s", workflowId)
|
||||||
# Save whatever the user already saw before cancelling so the next reload
|
|
||||||
# shows the same partial answer (matches workspace behaviour).
|
|
||||||
try:
|
|
||||||
_persistAssistant(accumulatedText if "accumulatedText" in locals() else "")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
await sseEventManager.emit_event(queueId, "stopped", {
|
await sseEventManager.emit_event(queueId, "stopped", {
|
||||||
"type": "stopped",
|
"type": "stopped",
|
||||||
"workflowId": workflowId,
|
"workflowId": workflowId,
|
||||||
|
|
|
||||||
|
|
@ -27,33 +27,15 @@ class DataNeutraliserConfig(PowerOnModel):
|
||||||
)
|
)
|
||||||
mandateId: str = Field(
|
mandateId: str = Field(
|
||||||
description="ID of the mandate this configuration belongs to",
|
description="ID of the mandate this configuration belongs to",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Mandanten-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
|
||||||
"label": "Mandanten-ID",
|
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": True,
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "Mandate"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
featureInstanceId: str = Field(
|
featureInstanceId: str = Field(
|
||||||
description="ID of the feature instance this configuration belongs to",
|
description="ID of the feature instance this configuration belongs to",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Feature-Instanz-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
|
||||||
"label": "Feature-Instanz-ID",
|
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": True,
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "FeatureInstance"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
userId: str = Field(
|
userId: str = Field(
|
||||||
description="ID of the user who created this configuration",
|
description="ID of the user who created this configuration",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Benutzer-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
|
||||||
"label": "Benutzer-ID",
|
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": True,
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "User"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
enabled: bool = Field(
|
enabled: bool = Field(
|
||||||
default=True,
|
default=True,
|
||||||
|
|
@ -102,33 +84,15 @@ class DataNeutralizerAttributes(BaseModel):
|
||||||
)
|
)
|
||||||
mandateId: str = Field(
|
mandateId: str = Field(
|
||||||
description="ID of the mandate this attribute belongs to",
|
description="ID of the mandate this attribute belongs to",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Mandanten-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
|
||||||
"label": "Mandanten-ID",
|
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": True,
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "Mandate"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
featureInstanceId: str = Field(
|
featureInstanceId: str = Field(
|
||||||
description="ID of the feature instance this attribute belongs to",
|
description="ID of the feature instance this attribute belongs to",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Feature-Instanz-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
|
||||||
"label": "Feature-Instanz-ID",
|
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": True,
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "FeatureInstance"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
userId: str = Field(
|
userId: str = Field(
|
||||||
description="ID of the user who created this attribute",
|
description="ID of the user who created this attribute",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Benutzer-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
|
||||||
"label": "Benutzer-ID",
|
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": True,
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "User"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
originalText: str = Field(
|
originalText: str = Field(
|
||||||
description="Original text that was neutralized",
|
description="Original text that was neutralized",
|
||||||
|
|
@ -137,13 +101,7 @@ class DataNeutralizerAttributes(BaseModel):
|
||||||
fileId: Optional[str] = Field(
|
fileId: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description="ID of the file this attribute belongs to",
|
description="ID of the file this attribute belongs to",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Datei-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
|
||||||
"label": "Datei-ID",
|
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": False,
|
|
||||||
"fk_target": {"db": "poweron_management", "table": "FileItem"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
patternType: str = Field(
|
patternType: str = Field(
|
||||||
description="Type of pattern that matched (email, phone, name, etc.)",
|
description="Type of pattern that matched (email, phone, name, etc.)",
|
||||||
|
|
@ -160,16 +118,16 @@ class DataNeutralizationSnapshot(BaseModel):
|
||||||
)
|
)
|
||||||
mandateId: str = Field(
|
mandateId: str = Field(
|
||||||
description="Mandate scope",
|
description="Mandate scope",
|
||||||
json_schema_extra={"label": "Mandanten-ID", "fk_target": {"db": "poweron_app", "table": "Mandate"}},
|
json_schema_extra={"label": "Mandanten-ID"},
|
||||||
)
|
)
|
||||||
featureInstanceId: str = Field(
|
featureInstanceId: str = Field(
|
||||||
default="",
|
default="",
|
||||||
description="Feature instance scope",
|
description="Feature instance scope",
|
||||||
json_schema_extra={"label": "Feature-Instanz-ID", "fk_target": {"db": "poweron_app", "table": "FeatureInstance"}},
|
json_schema_extra={"label": "Feature-Instanz-ID"},
|
||||||
)
|
)
|
||||||
userId: str = Field(
|
userId: str = Field(
|
||||||
description="User who triggered neutralization",
|
description="User who triggered neutralization",
|
||||||
json_schema_extra={"label": "Benutzer-ID", "fk_target": {"db": "poweron_app", "table": "User"}},
|
json_schema_extra={"label": "Benutzer-ID"},
|
||||||
)
|
)
|
||||||
sourceLabel: str = Field(
|
sourceLabel: str = Field(
|
||||||
description="Human label, e.g. 'Prompt', 'Kontext', 'Nachricht 3'",
|
description="Human label, e.g. 'Prompt', 'Kontext', 'Nachricht 3'",
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ 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.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
|
||||||
|
|
@ -22,9 +21,6 @@ from modules.datamodels.datamodelUam import User
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
neutralizationDatabase = "poweron_neutralization"
|
|
||||||
registerDatabase(neutralizationDatabase)
|
|
||||||
|
|
||||||
# Singleton cache for interface instances
|
# Singleton cache for interface instances
|
||||||
_neutralizerInterfaces = {}
|
_neutralizerInterfaces = {}
|
||||||
|
|
||||||
|
|
@ -58,7 +54,7 @@ class InterfaceFeatureNeutralizer:
|
||||||
try:
|
try:
|
||||||
# Use same database config pattern as other feature interfaces
|
# Use same database config pattern as other feature interfaces
|
||||||
dbHost = APP_CONFIG.get("DB_HOST", "localhost")
|
dbHost = APP_CONFIG.get("DB_HOST", "localhost")
|
||||||
dbDatabase = neutralizationDatabase
|
dbDatabase = "poweron_neutralization"
|
||||||
dbUser = APP_CONFIG.get("DB_USER", "postgres")
|
dbUser = APP_CONFIG.get("DB_USER", "postgres")
|
||||||
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
||||||
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
||||||
|
|
|
||||||
|
|
@ -284,12 +284,9 @@ class Kanton(PowerOnModel):
|
||||||
id_land: Optional[str] = Field(
|
id_land: Optional[str] = Field(
|
||||||
None,
|
None,
|
||||||
description="Land ID (Foreign Key) - eindeutiger Link zum Land, in welchem Land der Kanton liegt",
|
description="Land ID (Foreign Key) - eindeutiger Link zum Land, in welchem Land der Kanton liegt",
|
||||||
json_schema_extra={
|
frontend_type="text",
|
||||||
"frontend_type": "text",
|
frontend_readonly=False,
|
||||||
"frontend_readonly": False,
|
frontend_required=False,
|
||||||
"frontend_required": False,
|
|
||||||
"fk_target": {"db": "poweron_realestate", "table": "Land"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
abk: Optional[str] = Field(
|
abk: Optional[str] = Field(
|
||||||
None,
|
None,
|
||||||
|
|
@ -344,12 +341,9 @@ class Gemeinde(BaseModel):
|
||||||
id_kanton: Optional[str] = Field(
|
id_kanton: Optional[str] = Field(
|
||||||
None,
|
None,
|
||||||
description="Kanton ID (Foreign Key) - eindeutiger Link zum Kanton, in welchem Kanton die Gemeinde liegt",
|
description="Kanton ID (Foreign Key) - eindeutiger Link zum Kanton, in welchem Kanton die Gemeinde liegt",
|
||||||
json_schema_extra={
|
frontend_type="text",
|
||||||
"frontend_type": "text",
|
frontend_readonly=False,
|
||||||
"frontend_readonly": False,
|
frontend_required=False,
|
||||||
"frontend_required": False,
|
|
||||||
"fk_target": {"db": "poweron_realestate", "table": "Kanton"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
plz: Optional[str] = Field(
|
plz: Optional[str] = Field(
|
||||||
None,
|
None,
|
||||||
|
|
@ -393,23 +387,17 @@ class Parzelle(PowerOnModel):
|
||||||
)
|
)
|
||||||
mandateId: str = Field(
|
mandateId: str = Field(
|
||||||
description="ID of the mandate",
|
description="ID of the mandate",
|
||||||
json_schema_extra={
|
frontend_type="text",
|
||||||
"frontend_type": "text",
|
frontend_readonly=True,
|
||||||
"frontend_readonly": True,
|
frontend_required=False,
|
||||||
"frontend_required": False,
|
label="Mandats-ID",
|
||||||
"label": "Mandats-ID",
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "Mandate"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
featureInstanceId: str = Field(
|
featureInstanceId: str = Field(
|
||||||
description="ID of the feature instance",
|
description="ID of the feature instance",
|
||||||
json_schema_extra={
|
frontend_type="text",
|
||||||
"frontend_type": "text",
|
frontend_readonly=True,
|
||||||
"frontend_readonly": True,
|
frontend_required=False,
|
||||||
"frontend_required": False,
|
label="Feature-Instanz-ID",
|
||||||
"label": "Feature-Instanz-ID",
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "FeatureInstance"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Grunddaten
|
# Grunddaten
|
||||||
|
|
@ -468,12 +456,9 @@ class Parzelle(PowerOnModel):
|
||||||
kontextGemeinde: Optional[str] = Field(
|
kontextGemeinde: Optional[str] = Field(
|
||||||
None,
|
None,
|
||||||
description="Municipality ID (Foreign Key)",
|
description="Municipality ID (Foreign Key)",
|
||||||
json_schema_extra={
|
frontend_type="text",
|
||||||
"frontend_type": "text",
|
frontend_readonly=False,
|
||||||
"frontend_readonly": False,
|
frontend_required=False,
|
||||||
"frontend_required": False,
|
|
||||||
"fk_target": {"db": "poweron_realestate", "table": "Gemeinde"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Bebauungsparameter
|
# Bebauungsparameter
|
||||||
|
|
@ -633,23 +618,17 @@ class Projekt(PowerOnModel):
|
||||||
)
|
)
|
||||||
mandateId: str = Field(
|
mandateId: str = Field(
|
||||||
description="ID of the mandate",
|
description="ID of the mandate",
|
||||||
json_schema_extra={
|
frontend_type="text",
|
||||||
"frontend_type": "text",
|
frontend_readonly=True,
|
||||||
"frontend_readonly": True,
|
frontend_required=False,
|
||||||
"frontend_required": False,
|
label="Mandats-ID",
|
||||||
"label": "Mandats-ID",
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "Mandate"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
featureInstanceId: str = Field(
|
featureInstanceId: str = Field(
|
||||||
description="ID of the feature instance",
|
description="ID of the feature instance",
|
||||||
json_schema_extra={
|
frontend_type="text",
|
||||||
"frontend_type": "text",
|
frontend_readonly=True,
|
||||||
"frontend_readonly": True,
|
frontend_required=False,
|
||||||
"frontend_required": False,
|
label="Feature-Instanz-ID",
|
||||||
"label": "Feature-Instanz-ID",
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "FeatureInstance"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
label: str = Field(
|
label: str = Field(
|
||||||
description="Project designation",
|
description="Project designation",
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ 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.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
|
||||||
|
|
@ -30,9 +29,6 @@ from modules.datamodels.datamodelPagination import PaginationParams, PaginatedRe
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
realEstateDatabase = "poweron_realestate"
|
|
||||||
registerDatabase(realEstateDatabase)
|
|
||||||
|
|
||||||
# Singleton factory for Real Estate interfaces
|
# Singleton factory for Real Estate interfaces
|
||||||
_realEstateInterfaces = {}
|
_realEstateInterfaces = {}
|
||||||
|
|
||||||
|
|
@ -75,7 +71,7 @@ class RealEstateObjects:
|
||||||
try:
|
try:
|
||||||
# Get database configuration from environment
|
# Get database configuration from environment
|
||||||
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
||||||
dbDatabase = realEstateDatabase
|
dbDatabase = "poweron_realestate"
|
||||||
dbUser = APP_CONFIG.get("DB_USER")
|
dbUser = APP_CONFIG.get("DB_USER")
|
||||||
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
||||||
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
||||||
|
|
|
||||||
|
|
@ -116,7 +116,7 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Instance '{instanceId}' is not a realestate instance"
|
detail=f"Instance '{instanceId}' is not a realestate instance"
|
||||||
)
|
)
|
||||||
if not context.isPlatformAdmin:
|
if not context.hasSysAdminRole:
|
||||||
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
|
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
|
||||||
hasAccess = any(
|
hasAccess = any(
|
||||||
str(fa.featureInstanceId) == instanceId and fa.enabled
|
str(fa.featureInstanceId) == instanceId and fa.enabled
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ 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 .datamodelTeamsbot import (
|
from .datamodelTeamsbot import (
|
||||||
TeamsbotSession,
|
TeamsbotSession,
|
||||||
|
|
@ -25,9 +24,6 @@ from .datamodelTeamsbot import (
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
teamsbotDatabase = "poweron_teamsbot"
|
|
||||||
registerDatabase(teamsbotDatabase)
|
|
||||||
|
|
||||||
# Singleton factory
|
# Singleton factory
|
||||||
_interfaces = {}
|
_interfaces = {}
|
||||||
|
|
||||||
|
|
@ -54,7 +50,7 @@ class TeamsbotObjects:
|
||||||
self.userId = str(currentUser.id) if currentUser else "system"
|
self.userId = str(currentUser.id) if currentUser else "system"
|
||||||
|
|
||||||
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
||||||
dbDatabase = teamsbotDatabase
|
dbDatabase = "poweron_teamsbot"
|
||||||
dbUser = APP_CONFIG.get("DB_USER")
|
dbUser = APP_CONFIG.get("DB_USER")
|
||||||
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
||||||
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
||||||
|
|
|
||||||
|
|
@ -138,7 +138,7 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
|
||||||
|
|
||||||
def _validateSessionOwnership(session: dict, context: RequestContext) -> None:
|
def _validateSessionOwnership(session: dict, context: RequestContext) -> None:
|
||||||
"""Raise 404 if the user does not own this session (sysAdmin bypasses)."""
|
"""Raise 404 if the user does not own this session (sysAdmin bypasses)."""
|
||||||
if context.isPlatformAdmin:
|
if context.hasSysAdminRole:
|
||||||
return
|
return
|
||||||
if session.get("startedByUserId") != str(context.user.id):
|
if session.get("startedByUserId") != str(context.user.id):
|
||||||
raise HTTPException(status_code=404, detail=f"Session '{session.get('id')}' not found")
|
raise HTTPException(status_code=404, detail=f"Session '{session.get('id')}' not found")
|
||||||
|
|
@ -319,7 +319,7 @@ async def listSessions(
|
||||||
"""List sessions for a feature instance (filtered to own sessions unless sysAdmin)."""
|
"""List sessions for a feature instance (filtered to own sessions unless sysAdmin)."""
|
||||||
_validateInstanceAccess(instanceId, context)
|
_validateInstanceAccess(instanceId, context)
|
||||||
interface = _getInterface(context, instanceId)
|
interface = _getInterface(context, instanceId)
|
||||||
userId = None if context.isPlatformAdmin else str(context.user.id)
|
userId = None if context.hasSysAdminRole else str(context.user.id)
|
||||||
sessions = interface.getSessions(instanceId, includeEnded=includeEnded, userId=userId)
|
sessions = interface.getSessions(instanceId, includeEnded=includeEnded, userId=userId)
|
||||||
return {"sessions": sessions}
|
return {"sessions": sessions}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,6 @@ class TrusteeOrganisation(PowerOnModel):
|
||||||
description="Mandate ID (system-level organisation)",
|
description="Mandate ID (system-level organisation)",
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"label": "Mandat",
|
"label": "Mandat",
|
||||||
"fk_target": {"db": "poweron_app", "table": "Mandate"},
|
|
||||||
"frontend_type": "text",
|
"frontend_type": "text",
|
||||||
"frontend_readonly": True,
|
"frontend_readonly": True,
|
||||||
"frontend_required": False
|
"frontend_required": False
|
||||||
|
|
@ -57,7 +56,6 @@ class TrusteeOrganisation(PowerOnModel):
|
||||||
description="Feature Instance ID for instance-level isolation",
|
description="Feature Instance ID for instance-level isolation",
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"label": "Feature-Instanz",
|
"label": "Feature-Instanz",
|
||||||
"fk_target": {"db": "poweron_app", "table": "FeatureInstance"},
|
|
||||||
"frontend_type": "text",
|
"frontend_type": "text",
|
||||||
"frontend_readonly": True,
|
"frontend_readonly": True,
|
||||||
"frontend_required": False
|
"frontend_required": False
|
||||||
|
|
@ -92,7 +90,6 @@ class TrusteeRole(PowerOnModel):
|
||||||
description="Mandate ID",
|
description="Mandate ID",
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"label": "Mandat",
|
"label": "Mandat",
|
||||||
"fk_target": {"db": "poweron_app", "table": "Mandate"},
|
|
||||||
"frontend_type": "text",
|
"frontend_type": "text",
|
||||||
"frontend_readonly": True,
|
"frontend_readonly": True,
|
||||||
"frontend_required": False
|
"frontend_required": False
|
||||||
|
|
@ -103,7 +100,6 @@ class TrusteeRole(PowerOnModel):
|
||||||
description="Feature Instance ID for instance-level isolation",
|
description="Feature Instance ID for instance-level isolation",
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"label": "Feature-Instanz",
|
"label": "Feature-Instanz",
|
||||||
"fk_target": {"db": "poweron_app", "table": "FeatureInstance"},
|
|
||||||
"frontend_type": "text",
|
"frontend_type": "text",
|
||||||
"frontend_readonly": True,
|
"frontend_readonly": True,
|
||||||
"frontend_required": False
|
"frontend_required": False
|
||||||
|
|
@ -131,8 +127,7 @@ class TrusteeAccess(PowerOnModel):
|
||||||
"frontend_type": "select",
|
"frontend_type": "select",
|
||||||
"frontend_readonly": False,
|
"frontend_readonly": False,
|
||||||
"frontend_required": True,
|
"frontend_required": True,
|
||||||
"frontend_options": "/api/trustee/{instanceId}/organisations/options",
|
"frontend_options": "/api/trustee/{instanceId}/organisations/options"
|
||||||
"fk_target": {"db": "poweron_trustee", "table": "TrusteeOrganisation"},
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
roleId: str = Field(
|
roleId: str = Field(
|
||||||
|
|
@ -142,8 +137,7 @@ class TrusteeAccess(PowerOnModel):
|
||||||
"frontend_type": "select",
|
"frontend_type": "select",
|
||||||
"frontend_readonly": False,
|
"frontend_readonly": False,
|
||||||
"frontend_required": True,
|
"frontend_required": True,
|
||||||
"frontend_options": "/api/trustee/{instanceId}/roles/options",
|
"frontend_options": "/api/trustee/{instanceId}/roles/options"
|
||||||
"fk_target": {"db": "poweron_trustee", "table": "TrusteeRole"},
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
userId: str = Field(
|
userId: str = Field(
|
||||||
|
|
@ -153,8 +147,7 @@ class TrusteeAccess(PowerOnModel):
|
||||||
"frontend_type": "select",
|
"frontend_type": "select",
|
||||||
"frontend_readonly": False,
|
"frontend_readonly": False,
|
||||||
"frontend_required": True,
|
"frontend_required": True,
|
||||||
"frontend_options": "/api/users/options",
|
"frontend_options": "/api/users/options"
|
||||||
"fk_target": {"db": "poweron_app", "table": "User"},
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
contractId: Optional[str] = Field(
|
contractId: Optional[str] = Field(
|
||||||
|
|
@ -166,8 +159,7 @@ class TrusteeAccess(PowerOnModel):
|
||||||
"frontend_readonly": False,
|
"frontend_readonly": False,
|
||||||
"frontend_required": False,
|
"frontend_required": False,
|
||||||
"frontend_options": "/api/trustee/{instanceId}/contracts/options",
|
"frontend_options": "/api/trustee/{instanceId}/contracts/options",
|
||||||
"frontend_depends_on": "organisationId",
|
"frontend_depends_on": "organisationId"
|
||||||
"fk_target": {"db": "poweron_trustee", "table": "TrusteeContract"},
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
mandateId: Optional[str] = Field(
|
mandateId: Optional[str] = Field(
|
||||||
|
|
@ -175,7 +167,6 @@ class TrusteeAccess(PowerOnModel):
|
||||||
description="Mandate ID",
|
description="Mandate ID",
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"label": "Mandat",
|
"label": "Mandat",
|
||||||
"fk_target": {"db": "poweron_app", "table": "Mandate"},
|
|
||||||
"frontend_type": "text",
|
"frontend_type": "text",
|
||||||
"frontend_readonly": True,
|
"frontend_readonly": True,
|
||||||
"frontend_required": False
|
"frontend_required": False
|
||||||
|
|
@ -186,7 +177,6 @@ class TrusteeAccess(PowerOnModel):
|
||||||
description="Feature Instance ID for instance-level isolation",
|
description="Feature Instance ID for instance-level isolation",
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"label": "Feature-Instanz",
|
"label": "Feature-Instanz",
|
||||||
"fk_target": {"db": "poweron_app", "table": "FeatureInstance"},
|
|
||||||
"frontend_type": "text",
|
"frontend_type": "text",
|
||||||
"frontend_readonly": True,
|
"frontend_readonly": True,
|
||||||
"frontend_required": False
|
"frontend_required": False
|
||||||
|
|
@ -214,8 +204,7 @@ class TrusteeContract(PowerOnModel):
|
||||||
"frontend_type": "select",
|
"frontend_type": "select",
|
||||||
"frontend_readonly": False, # Editable at creation, then readonly
|
"frontend_readonly": False, # Editable at creation, then readonly
|
||||||
"frontend_required": True,
|
"frontend_required": True,
|
||||||
"frontend_options": "/api/trustee/{instanceId}/organisations/options",
|
"frontend_options": "/api/trustee/{instanceId}/organisations/options"
|
||||||
"fk_target": {"db": "poweron_trustee", "table": "TrusteeOrganisation"},
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
label: str = Field(
|
label: str = Field(
|
||||||
|
|
@ -242,7 +231,6 @@ class TrusteeContract(PowerOnModel):
|
||||||
description="Mandate ID",
|
description="Mandate ID",
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"label": "Mandat",
|
"label": "Mandat",
|
||||||
"fk_target": {"db": "poweron_app", "table": "Mandate"},
|
|
||||||
"frontend_type": "text",
|
"frontend_type": "text",
|
||||||
"frontend_readonly": True,
|
"frontend_readonly": True,
|
||||||
"frontend_required": False
|
"frontend_required": False
|
||||||
|
|
@ -253,7 +241,6 @@ class TrusteeContract(PowerOnModel):
|
||||||
description="Feature Instance ID for instance-level isolation",
|
description="Feature Instance ID for instance-level isolation",
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"label": "Feature-Instanz",
|
"label": "Feature-Instanz",
|
||||||
"fk_target": {"db": "poweron_app", "table": "FeatureInstance"},
|
|
||||||
"frontend_type": "text",
|
"frontend_type": "text",
|
||||||
"frontend_readonly": True,
|
"frontend_readonly": True,
|
||||||
"frontend_required": False
|
"frontend_required": False
|
||||||
|
|
@ -310,8 +297,7 @@ class TrusteeDocument(PowerOnModel):
|
||||||
"label": "Datei-Referenz",
|
"label": "Datei-Referenz",
|
||||||
"frontend_type": "file_reference",
|
"frontend_type": "file_reference",
|
||||||
"frontend_readonly": False,
|
"frontend_readonly": False,
|
||||||
"frontend_required": False,
|
"frontend_required": False
|
||||||
"fk_target": {"db": "poweron_management", "table": "FileItem"},
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
documentName: str = Field(
|
documentName: str = Field(
|
||||||
|
|
@ -359,7 +345,6 @@ class TrusteeDocument(PowerOnModel):
|
||||||
description="Mandate ID (auto-set from context)",
|
description="Mandate ID (auto-set from context)",
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"label": "Mandat",
|
"label": "Mandat",
|
||||||
"fk_target": {"db": "poweron_app", "table": "Mandate"},
|
|
||||||
"frontend_type": "text",
|
"frontend_type": "text",
|
||||||
"frontend_readonly": True,
|
"frontend_readonly": True,
|
||||||
"frontend_required": False,
|
"frontend_required": False,
|
||||||
|
|
@ -371,7 +356,6 @@ class TrusteeDocument(PowerOnModel):
|
||||||
description="Feature Instance ID for instance-level isolation (auto-set from context)",
|
description="Feature Instance ID for instance-level isolation (auto-set from context)",
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"label": "Feature-Instanz",
|
"label": "Feature-Instanz",
|
||||||
"fk_target": {"db": "poweron_app", "table": "FeatureInstance"},
|
|
||||||
"frontend_type": "text",
|
"frontend_type": "text",
|
||||||
"frontend_readonly": True,
|
"frontend_readonly": True,
|
||||||
"frontend_required": False,
|
"frontend_required": False,
|
||||||
|
|
@ -438,8 +422,7 @@ class TrusteePosition(PowerOnModel):
|
||||||
"frontend_type": "select",
|
"frontend_type": "select",
|
||||||
"frontend_readonly": False,
|
"frontend_readonly": False,
|
||||||
"frontend_required": False,
|
"frontend_required": False,
|
||||||
"frontend_options": "/api/trustee/{instanceId}/documents/options",
|
"frontend_options": "/api/trustee/{instanceId}/documents/options"
|
||||||
"fk_target": {"db": "poweron_trustee", "table": "TrusteeDocument"},
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
bankDocumentId: Optional[str] = Field(
|
bankDocumentId: Optional[str] = Field(
|
||||||
|
|
@ -450,8 +433,7 @@ class TrusteePosition(PowerOnModel):
|
||||||
"frontend_type": "select",
|
"frontend_type": "select",
|
||||||
"frontend_readonly": False,
|
"frontend_readonly": False,
|
||||||
"frontend_required": False,
|
"frontend_required": False,
|
||||||
"frontend_options": "/api/trustee/{instanceId}/documents/options",
|
"frontend_options": "/api/trustee/{instanceId}/documents/options"
|
||||||
"fk_target": {"db": "poweron_trustee", "table": "TrusteeDocument"},
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
valuta: Optional[str] = Field(
|
valuta: Optional[str] = Field(
|
||||||
|
|
@ -695,7 +677,6 @@ class TrusteePosition(PowerOnModel):
|
||||||
description="Mandate ID (auto-set from context)",
|
description="Mandate ID (auto-set from context)",
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"label": "Mandat",
|
"label": "Mandat",
|
||||||
"fk_target": {"db": "poweron_app", "table": "Mandate"},
|
|
||||||
"frontend_type": "text",
|
"frontend_type": "text",
|
||||||
"frontend_readonly": True,
|
"frontend_readonly": True,
|
||||||
"frontend_required": False,
|
"frontend_required": False,
|
||||||
|
|
@ -707,7 +688,6 @@ class TrusteePosition(PowerOnModel):
|
||||||
description="Feature Instance ID for instance-level isolation (auto-set from context)",
|
description="Feature Instance ID for instance-level isolation (auto-set from context)",
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"label": "Feature-Instanz",
|
"label": "Feature-Instanz",
|
||||||
"fk_target": {"db": "poweron_app", "table": "FeatureInstance"},
|
|
||||||
"frontend_type": "text",
|
"frontend_type": "text",
|
||||||
"frontend_readonly": True,
|
"frontend_readonly": True,
|
||||||
"frontend_required": False,
|
"frontend_required": False,
|
||||||
|
|
@ -738,8 +718,8 @@ class TrusteeDataAccount(PowerOnModel):
|
||||||
accountGroup: Optional[str] = Field(default=None, description="Account group/category", json_schema_extra={"label": "Gruppe"})
|
accountGroup: Optional[str] = Field(default=None, description="Account group/category", json_schema_extra={"label": "Gruppe"})
|
||||||
currency: str = Field(default="CHF", description="Account currency", json_schema_extra={"label": "Währung"})
|
currency: str = Field(default="CHF", description="Account currency", json_schema_extra={"label": "Währung"})
|
||||||
isActive: bool = Field(default=True, json_schema_extra={"label": "Aktiv"})
|
isActive: bool = Field(default=True, json_schema_extra={"label": "Aktiv"})
|
||||||
mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat", "fk_target": {"db": "poweron_app", "table": "Mandate"}})
|
mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat"})
|
||||||
featureInstanceId: Optional[str] = Field(default=None, json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance"}})
|
featureInstanceId: Optional[str] = Field(default=None, json_schema_extra={"label": "Feature-Instanz"})
|
||||||
|
|
||||||
@i18nModel("Buchung (Sync)")
|
@i18nModel("Buchung (Sync)")
|
||||||
class TrusteeDataJournalEntry(PowerOnModel):
|
class TrusteeDataJournalEntry(PowerOnModel):
|
||||||
|
|
@ -751,14 +731,14 @@ class TrusteeDataJournalEntry(PowerOnModel):
|
||||||
description: str = Field(default="", description="Booking text", json_schema_extra={"label": "Beschreibung"})
|
description: str = Field(default="", description="Booking text", json_schema_extra={"label": "Beschreibung"})
|
||||||
currency: str = Field(default="CHF", json_schema_extra={"label": "Währung"})
|
currency: str = Field(default="CHF", json_schema_extra={"label": "Währung"})
|
||||||
totalAmount: float = Field(default=0.0, description="Total amount of entry", json_schema_extra={"label": "Betrag"})
|
totalAmount: float = Field(default=0.0, description="Total amount of entry", json_schema_extra={"label": "Betrag"})
|
||||||
mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat", "fk_target": {"db": "poweron_app", "table": "Mandate"}})
|
mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat"})
|
||||||
featureInstanceId: Optional[str] = Field(default=None, json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance"}})
|
featureInstanceId: Optional[str] = Field(default=None, json_schema_extra={"label": "Feature-Instanz"})
|
||||||
|
|
||||||
@i18nModel("Buchungszeile (Sync)")
|
@i18nModel("Buchungszeile (Sync)")
|
||||||
class TrusteeDataJournalLine(PowerOnModel):
|
class TrusteeDataJournalLine(PowerOnModel):
|
||||||
"""Journal entry line (debit/credit) synced from external accounting system."""
|
"""Journal entry line (debit/credit) synced from external accounting system."""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), json_schema_extra={"label": "ID"})
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), json_schema_extra={"label": "ID"})
|
||||||
journalEntryId: str = Field(description="FK → TrusteeDataJournalEntry.id", json_schema_extra={"label": "Buchung", "fk_target": {"db": "poweron_trustee", "table": "TrusteeDataJournalEntry"}})
|
journalEntryId: str = Field(description="FK → TrusteeDataJournalEntry.id", json_schema_extra={"label": "Buchung"})
|
||||||
accountNumber: str = Field(description="Account number", json_schema_extra={"label": "Konto"})
|
accountNumber: str = Field(description="Account number", json_schema_extra={"label": "Konto"})
|
||||||
debitAmount: float = Field(default=0.0, json_schema_extra={"label": "Soll"})
|
debitAmount: float = Field(default=0.0, json_schema_extra={"label": "Soll"})
|
||||||
creditAmount: float = Field(default=0.0, json_schema_extra={"label": "Haben"})
|
creditAmount: float = Field(default=0.0, json_schema_extra={"label": "Haben"})
|
||||||
|
|
@ -766,8 +746,8 @@ class TrusteeDataJournalLine(PowerOnModel):
|
||||||
taxCode: Optional[str] = Field(default=None, json_schema_extra={"label": "Steuercode"})
|
taxCode: Optional[str] = Field(default=None, json_schema_extra={"label": "Steuercode"})
|
||||||
costCenter: Optional[str] = Field(default=None, json_schema_extra={"label": "Kostenstelle"})
|
costCenter: Optional[str] = Field(default=None, json_schema_extra={"label": "Kostenstelle"})
|
||||||
description: str = Field(default="", json_schema_extra={"label": "Beschreibung"})
|
description: str = Field(default="", json_schema_extra={"label": "Beschreibung"})
|
||||||
mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat", "fk_target": {"db": "poweron_app", "table": "Mandate"}})
|
mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat"})
|
||||||
featureInstanceId: Optional[str] = Field(default=None, json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance"}})
|
featureInstanceId: Optional[str] = Field(default=None, json_schema_extra={"label": "Feature-Instanz"})
|
||||||
|
|
||||||
@i18nModel("Kontakt (Sync)")
|
@i18nModel("Kontakt (Sync)")
|
||||||
class TrusteeDataContact(PowerOnModel):
|
class TrusteeDataContact(PowerOnModel):
|
||||||
|
|
@ -784,8 +764,8 @@ class TrusteeDataContact(PowerOnModel):
|
||||||
email: Optional[str] = Field(default=None, json_schema_extra={"label": "E-Mail"})
|
email: Optional[str] = Field(default=None, json_schema_extra={"label": "E-Mail"})
|
||||||
phone: Optional[str] = Field(default=None, json_schema_extra={"label": "Telefon"})
|
phone: Optional[str] = Field(default=None, json_schema_extra={"label": "Telefon"})
|
||||||
vatNumber: Optional[str] = Field(default=None, json_schema_extra={"label": "MWST-Nr."})
|
vatNumber: Optional[str] = Field(default=None, json_schema_extra={"label": "MWST-Nr."})
|
||||||
mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat", "fk_target": {"db": "poweron_app", "table": "Mandate"}})
|
mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat"})
|
||||||
featureInstanceId: Optional[str] = Field(default=None, json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance"}})
|
featureInstanceId: Optional[str] = Field(default=None, json_schema_extra={"label": "Feature-Instanz"})
|
||||||
|
|
||||||
@i18nModel("Kontosaldo (Sync)")
|
@i18nModel("Kontosaldo (Sync)")
|
||||||
class TrusteeDataAccountBalance(PowerOnModel):
|
class TrusteeDataAccountBalance(PowerOnModel):
|
||||||
|
|
@ -799,8 +779,8 @@ class TrusteeDataAccountBalance(PowerOnModel):
|
||||||
creditTotal: float = Field(default=0.0, json_schema_extra={"label": "Haben-Umsatz"})
|
creditTotal: float = Field(default=0.0, json_schema_extra={"label": "Haben-Umsatz"})
|
||||||
closingBalance: float = Field(default=0.0, json_schema_extra={"label": "Schlusssaldo"})
|
closingBalance: float = Field(default=0.0, json_schema_extra={"label": "Schlusssaldo"})
|
||||||
currency: str = Field(default="CHF", json_schema_extra={"label": "Währung"})
|
currency: str = Field(default="CHF", json_schema_extra={"label": "Währung"})
|
||||||
mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat", "fk_target": {"db": "poweron_app", "table": "Mandate"}})
|
mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat"})
|
||||||
featureInstanceId: Optional[str] = Field(default=None, json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance"}})
|
featureInstanceId: Optional[str] = Field(default=None, json_schema_extra={"label": "Feature-Instanz"})
|
||||||
|
|
||||||
@i18nModel("Buchhaltungs-Konfiguration")
|
@i18nModel("Buchhaltungs-Konfiguration")
|
||||||
class TrusteeAccountingConfig(PowerOnModel):
|
class TrusteeAccountingConfig(PowerOnModel):
|
||||||
|
|
@ -810,7 +790,7 @@ class TrusteeAccountingConfig(PowerOnModel):
|
||||||
Credentials are stored encrypted (decrypted at runtime by the AccountingBridge).
|
Credentials are stored encrypted (decrypted at runtime by the AccountingBridge).
|
||||||
"""
|
"""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), json_schema_extra={"label": "ID"})
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), json_schema_extra={"label": "ID"})
|
||||||
featureInstanceId: str = Field(description="FK -> FeatureInstance.id (1:1)", json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance"}})
|
featureInstanceId: str = Field(description="FK -> FeatureInstance.id (1:1)", json_schema_extra={"label": "Feature-Instanz"})
|
||||||
connectorType: str = Field(description="Connector type key, e.g. 'rma', 'bexio', 'abacus'", json_schema_extra={"label": "System"})
|
connectorType: str = Field(description="Connector type key, e.g. 'rma', 'bexio', 'abacus'", json_schema_extra={"label": "System"})
|
||||||
displayLabel: str = Field(default="", description="User-visible label for this integration", json_schema_extra={"label": "Bezeichnung"})
|
displayLabel: str = Field(default="", description="User-visible label for this integration", json_schema_extra={"label": "Bezeichnung"})
|
||||||
encryptedConfig: str = Field(default="", description="Encrypted JSON blob with connector credentials", json_schema_extra={"label": "Verschlüsselte Konfiguration"})
|
encryptedConfig: str = Field(default="", description="Encrypted JSON blob with connector credentials", json_schema_extra={"label": "Verschlüsselte Konfiguration"})
|
||||||
|
|
@ -820,7 +800,7 @@ class TrusteeAccountingConfig(PowerOnModel):
|
||||||
lastSyncErrorMessage: Optional[str] = Field(default=None, description="Error message when lastSyncStatus is error", json_schema_extra={"label": "Fehlermeldung"})
|
lastSyncErrorMessage: Optional[str] = Field(default=None, description="Error message when lastSyncStatus is error", json_schema_extra={"label": "Fehlermeldung"})
|
||||||
cachedChartOfAccounts: Optional[str] = Field(default=None, description="JSON-serialised chart of accounts cache (list of {accountNumber, label, accountType})", json_schema_extra={"label": "Cached Kontoplan"})
|
cachedChartOfAccounts: Optional[str] = Field(default=None, description="JSON-serialised chart of accounts cache (list of {accountNumber, label, accountType})", json_schema_extra={"label": "Cached Kontoplan"})
|
||||||
chartCachedAt: Optional[float] = Field(default=None, description="Timestamp when cachedChartOfAccounts was last refreshed", json_schema_extra={"label": "Kontoplan-Cache-Zeitpunkt"})
|
chartCachedAt: Optional[float] = Field(default=None, description="Timestamp when cachedChartOfAccounts was last refreshed", json_schema_extra={"label": "Kontoplan-Cache-Zeitpunkt"})
|
||||||
mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat", "fk_target": {"db": "poweron_app", "table": "Mandate"}})
|
mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat"})
|
||||||
|
|
||||||
@i18nModel("Buchhaltungs-Synchronisation")
|
@i18nModel("Buchhaltungs-Synchronisation")
|
||||||
class TrusteeAccountingSync(PowerOnModel):
|
class TrusteeAccountingSync(PowerOnModel):
|
||||||
|
|
@ -829,11 +809,8 @@ class TrusteeAccountingSync(PowerOnModel):
|
||||||
Used for duplicate prevention, audit trail, and retry logic.
|
Used for duplicate prevention, audit trail, and retry logic.
|
||||||
"""
|
"""
|
||||||
id: str = Field(default_factory=lambda: str(uuid.uuid4()), json_schema_extra={"label": "ID"})
|
id: str = Field(default_factory=lambda: str(uuid.uuid4()), json_schema_extra={"label": "ID"})
|
||||||
positionId: str = Field(
|
positionId: str = Field(description="FK -> TrusteePosition.id", json_schema_extra={"label": "Position"})
|
||||||
description="FK -> TrusteePosition.id",
|
featureInstanceId: str = Field(description="FK -> FeatureInstance.id", json_schema_extra={"label": "Feature-Instanz"})
|
||||||
json_schema_extra={"label": "Position", "fk_target": {"db": "poweron_trustee", "table": "TrusteePosition"}},
|
|
||||||
)
|
|
||||||
featureInstanceId: str = Field(description="FK -> FeatureInstance.id", json_schema_extra={"label": "Feature-Instanz", "fk_target": {"db": "poweron_app", "table": "FeatureInstance"}})
|
|
||||||
connectorType: str = Field(description="Connector type at time of sync", json_schema_extra={"label": "System"})
|
connectorType: str = Field(description="Connector type at time of sync", json_schema_extra={"label": "System"})
|
||||||
externalId: Optional[str] = Field(default=None, description="ID assigned by the external system", json_schema_extra={"label": "Externe ID"})
|
externalId: Optional[str] = Field(default=None, description="ID assigned by the external system", json_schema_extra={"label": "Externe ID"})
|
||||||
externalReference: Optional[str] = Field(default=None, description="Reference in the external system", json_schema_extra={"label": "Externe Referenz"})
|
externalReference: Optional[str] = Field(default=None, description="Reference in the external system", json_schema_extra={"label": "Externe Referenz"})
|
||||||
|
|
@ -842,5 +819,5 @@ class TrusteeAccountingSync(PowerOnModel):
|
||||||
syncedAt: Optional[float] = Field(default=None, description="Timestamp of successful sync", json_schema_extra={"label": "Synchronisiert am"})
|
syncedAt: Optional[float] = Field(default=None, description="Timestamp of successful sync", json_schema_extra={"label": "Synchronisiert am"})
|
||||||
errorMessage: Optional[str] = Field(default=None, json_schema_extra={"label": "Fehler"})
|
errorMessage: Optional[str] = Field(default=None, json_schema_extra={"label": "Fehler"})
|
||||||
bookingPayload: Optional[dict] = Field(default=None, description="Payload sent to the external system (audit)", json_schema_extra={"label": "Buchungs-Payload"})
|
bookingPayload: Optional[dict] = Field(default=None, description="Payload sent to the external system (audit)", json_schema_extra={"label": "Buchungs-Payload"})
|
||||||
mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat", "fk_target": {"db": "poweron_app", "table": "Mandate"}})
|
mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat"})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ 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.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
|
||||||
|
|
@ -31,9 +30,6 @@ from modules.datamodels.datamodelPagination import PaginationParams, PaginatedRe
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
trusteeDatabase = "poweron_trustee"
|
|
||||||
registerDatabase(trusteeDatabase)
|
|
||||||
|
|
||||||
# Singleton factory for TrusteeObjects instances per context
|
# Singleton factory for TrusteeObjects instances per context
|
||||||
_trusteeInterfaces = {}
|
_trusteeInterfaces = {}
|
||||||
|
|
||||||
|
|
@ -280,7 +276,7 @@ class TrusteeObjects:
|
||||||
"""Initializes the database connection directly."""
|
"""Initializes the database connection directly."""
|
||||||
try:
|
try:
|
||||||
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
||||||
dbDatabase = trusteeDatabase
|
dbDatabase = "poweron_trustee"
|
||||||
dbUser = APP_CONFIG.get("DB_USER")
|
dbUser = APP_CONFIG.get("DB_USER")
|
||||||
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
||||||
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,7 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify user has access to this instance
|
# Verify user has access to this instance
|
||||||
if not context.isPlatformAdmin:
|
if not context.hasSysAdminRole:
|
||||||
# Check if user has FeatureAccess for this instance
|
# Check if user has FeatureAccess for this instance
|
||||||
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
|
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
|
||||||
hasAccess = any(
|
hasAccess = any(
|
||||||
|
|
@ -138,7 +138,7 @@ def getQuickActions(
|
||||||
from .mainTrustee import QUICK_ACTIONS, QUICK_ACTION_CATEGORIES
|
from .mainTrustee import QUICK_ACTIONS, QUICK_ACTION_CATEGORIES
|
||||||
|
|
||||||
userRoleLabels: set = set()
|
userRoleLabels: set = set()
|
||||||
if context.isPlatformAdmin:
|
if context.hasSysAdminRole:
|
||||||
userRoleLabels.add("trustee-admin")
|
userRoleLabels.add("trustee-admin")
|
||||||
else:
|
else:
|
||||||
rootInterface = getRootInterface()
|
rootInterface = getRootInterface()
|
||||||
|
|
@ -156,9 +156,9 @@ def getQuickActions(
|
||||||
filteredActions = []
|
filteredActions = []
|
||||||
for action in QUICK_ACTIONS:
|
for action in QUICK_ACTIONS:
|
||||||
required = set(action.get("requiredRoles", []))
|
required = set(action.get("requiredRoles", []))
|
||||||
if not userRoleLabels and not context.isPlatformAdmin:
|
if not userRoleLabels and not context.hasSysAdminRole:
|
||||||
continue
|
continue
|
||||||
if context.isPlatformAdmin or required.intersection(userRoleLabels):
|
if context.hasSysAdminRole or required.intersection(userRoleLabels):
|
||||||
resolved = {
|
resolved = {
|
||||||
"id": action["id"],
|
"id": action["id"],
|
||||||
"label": resolveText(action.get("label", {})),
|
"label": resolveText(action.get("label", {})),
|
||||||
|
|
@ -1563,13 +1563,7 @@ async def sync_positions_to_accounting(
|
||||||
raise HTTPException(status_code=400, detail=routeApiMsg("positionIds required"))
|
raise HTTPException(status_code=400, detail=routeApiMsg("positionIds required"))
|
||||||
|
|
||||||
results = await bridge.pushBatchToAccounting(instanceId, positionIds)
|
results = await bridge.pushBatchToAccounting(instanceId, positionIds)
|
||||||
skipped = [r for r in results if not r.success and r.errorMessage and "already synced" in r.errorMessage]
|
failed = [r for r in results if not r.success]
|
||||||
failed = [r for r in results if not r.success and r not in skipped]
|
|
||||||
if skipped:
|
|
||||||
logger.info(
|
|
||||||
"Accounting sync: %s position(s) already synced, skipped",
|
|
||||||
len(skipped),
|
|
||||||
)
|
|
||||||
if failed:
|
if failed:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Accounting sync had %s failure(s): %s",
|
"Accounting sync had %s failure(s): %s",
|
||||||
|
|
@ -1579,8 +1573,7 @@ async def sync_positions_to_accounting(
|
||||||
return {
|
return {
|
||||||
"total": len(results),
|
"total": len(results),
|
||||||
"success": sum(1 for r in results if r.success),
|
"success": sum(1 for r in results if r.success),
|
||||||
"skipped": len(skipped),
|
"errors": sum(1 for r in results if not r.success),
|
||||||
"errors": len(failed),
|
|
||||||
"results": [r.model_dump() for r in results],
|
"results": [r.model_dump() for r in results],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1811,7 +1804,7 @@ def _validateInstanceAdmin(instanceId: str, context: RequestContext) -> str:
|
||||||
mandateId = _validateInstanceAccess(instanceId, context)
|
mandateId = _validateInstanceAccess(instanceId, context)
|
||||||
|
|
||||||
# SysAdmin role always has access
|
# SysAdmin role always has access
|
||||||
if context.isPlatformAdmin:
|
if context.hasSysAdminRole:
|
||||||
return mandateId
|
return mandateId
|
||||||
|
|
||||||
# Check for instance-roles.manage resource permission via AccessRules
|
# Check for instance-roles.manage resource permission via AccessRules
|
||||||
|
|
|
||||||
|
|
@ -19,33 +19,15 @@ class WorkspaceUserSettings(PowerOnModel):
|
||||||
)
|
)
|
||||||
userId: str = Field(
|
userId: str = Field(
|
||||||
description="User ID",
|
description="User ID",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Benutzer-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
|
||||||
"label": "Benutzer-ID",
|
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": True,
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "User"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
mandateId: str = Field(
|
mandateId: str = Field(
|
||||||
description="Mandate ID",
|
description="Mandate ID",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Mandanten-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
|
||||||
"label": "Mandanten-ID",
|
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": True,
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "Mandate"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
featureInstanceId: str = Field(
|
featureInstanceId: str = Field(
|
||||||
description="Feature Instance ID",
|
description="Feature Instance ID",
|
||||||
json_schema_extra={
|
json_schema_extra={"label": "Feature-Instanz-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True},
|
||||||
"label": "Feature-Instanz-ID",
|
|
||||||
"frontend_type": "text",
|
|
||||||
"frontend_readonly": True,
|
|
||||||
"frontend_required": True,
|
|
||||||
"fk_target": {"db": "poweron_app", "table": "FeatureInstance"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
maxAgentRounds: Optional[int] = Field(
|
maxAgentRounds: Optional[int] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ 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.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
|
||||||
|
|
@ -18,9 +17,6 @@ from modules.shared.configuration import APP_CONFIG
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
workspaceDatabase = "poweron_workspace"
|
|
||||||
registerDatabase(workspaceDatabase)
|
|
||||||
|
|
||||||
_workspaceInterfaces: Dict[str, "WorkspaceObjects"] = {}
|
_workspaceInterfaces: Dict[str, "WorkspaceObjects"] = {}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -43,7 +39,7 @@ class WorkspaceObjects:
|
||||||
|
|
||||||
def _initializeDatabase(self):
|
def _initializeDatabase(self):
|
||||||
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
||||||
dbDatabase = workspaceDatabase
|
dbDatabase = "poweron_workspace"
|
||||||
dbUser = APP_CONFIG.get("DB_USER")
|
dbUser = APP_CONFIG.get("DB_USER")
|
||||||
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
||||||
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
||||||
|
|
|
||||||
|
|
@ -1464,18 +1464,18 @@ async def listFeatureConnectionTables(
|
||||||
tables = []
|
tables = []
|
||||||
for obj in accessible:
|
for obj in accessible:
|
||||||
meta = obj.get("meta", {})
|
meta = obj.get("meta", {})
|
||||||
if meta.get("wildcard"):
|
|
||||||
continue
|
|
||||||
node = {
|
node = {
|
||||||
"objectKey": obj.get("objectKey", ""),
|
"objectKey": obj.get("objectKey", ""),
|
||||||
"tableName": meta.get("table", ""),
|
"tableName": meta.get("table", ""),
|
||||||
"label": resolveText(obj.get("label", "")),
|
"label": resolveText(obj.get("label", "")),
|
||||||
"fields": meta.get("fields", []),
|
"fields": meta.get("fields", []),
|
||||||
"isParent": bool(meta.get("isParent", False)),
|
|
||||||
"parentTable": meta.get("parentTable") or None,
|
|
||||||
"parentKey": meta.get("parentKey") or None,
|
|
||||||
"displayFields": meta.get("displayFields", []),
|
|
||||||
}
|
}
|
||||||
|
if meta.get("isParent"):
|
||||||
|
node["isParent"] = True
|
||||||
|
node["displayFields"] = meta.get("displayFields", [])
|
||||||
|
if meta.get("parentTable"):
|
||||||
|
node["parentTable"] = meta["parentTable"]
|
||||||
|
node["parentKey"] = meta.get("parentKey", "")
|
||||||
tables.append(node)
|
tables.append(node)
|
||||||
|
|
||||||
return JSONResponse({"tables": tables})
|
return JSONResponse({"tables": tables})
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ Contains all bootstrap logic including mandate, users, and RBAC rules.
|
||||||
Multi-Tenant Design:
|
Multi-Tenant Design:
|
||||||
- Rollen werden mit Kontext erstellt (mandateId=None für globale Template-Rollen)
|
- Rollen werden mit Kontext erstellt (mandateId=None für globale Template-Rollen)
|
||||||
- AccessRules referenzieren roleId (FK), nicht roleLabel
|
- AccessRules referenzieren roleId (FK), nicht roleLabel
|
||||||
- Admin-User bekommt isSysAdmin=True UND isPlatformAdmin=True (statt einer Rolle)
|
- Admin-User bekommt isSysAdmin=True statt roleLabels
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
@ -61,7 +61,6 @@ def initBootstrap(db: DatabaseConnector) -> None:
|
||||||
|
|
||||||
# Migrate existing mandate records: description -> label
|
# Migrate existing mandate records: description -> label
|
||||||
_migrateMandateDescriptionToLabel(db)
|
_migrateMandateDescriptionToLabel(db)
|
||||||
_migrateMandateNameLabelSlugRules(db)
|
|
||||||
|
|
||||||
# Clean up duplicate roles and fix corrupted templates FIRST
|
# Clean up duplicate roles and fix corrupted templates FIRST
|
||||||
_deduplicateRoles(db)
|
_deduplicateRoles(db)
|
||||||
|
|
@ -76,14 +75,12 @@ def initBootstrap(db: DatabaseConnector) -> None:
|
||||||
# This also serves as migration for existing mandates that don't have instance roles yet
|
# This also serves as migration for existing mandates that don't have instance roles yet
|
||||||
_ensureAllMandatesHaveSystemRoles(db)
|
_ensureAllMandatesHaveSystemRoles(db)
|
||||||
|
|
||||||
# Migration: eliminate the legacy ``sysadmin`` role in root mandate
|
# Initialize sysadmin role in root mandate (NOT a template, mandate-specific)
|
||||||
# (replaced by ``User.isPlatformAdmin`` flag — see
|
# Hybrid model: isSysAdmin flag → system ops, sysadmin role → admin ops via RBAC
|
||||||
# wiki/c-work/4-done/2026-04-sysadmin-authority-split.md).
|
|
||||||
# Idempotent: noop after first successful run.
|
|
||||||
if mandateId:
|
if mandateId:
|
||||||
_migrateAndDropSysAdminRole(db, mandateId)
|
_initSysAdminRole(db, mandateId)
|
||||||
|
|
||||||
# Ensure UI rules for navigation items (admin/user/viewer roles)
|
# Ensure UI rules for sysadmin role (created after initRbacRules, needs second pass)
|
||||||
_ensureUiContextRules(db)
|
_ensureUiContextRules(db)
|
||||||
|
|
||||||
# Initialize admin user
|
# Initialize admin user
|
||||||
|
|
@ -162,12 +159,11 @@ def _bootstrapSystemTemplates(db: DatabaseConnector) -> None:
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow
|
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import AutoWorkflow
|
||||||
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import graphicalEditorDatabase
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
greenfieldDb = DatabaseConnector(
|
greenfieldDb = DatabaseConnector(
|
||||||
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
|
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
|
||||||
dbDatabase=graphicalEditorDatabase,
|
dbDatabase="poweron_graphicaleditor",
|
||||||
dbUser=APP_CONFIG.get("DB_USER"),
|
dbUser=APP_CONFIG.get("DB_USER"),
|
||||||
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
|
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
|
||||||
)
|
)
|
||||||
|
|
@ -423,101 +419,32 @@ def _migrateMandateDescriptionToLabel(db: DatabaseConnector) -> None:
|
||||||
logger.debug("No mandate description->label migration needed")
|
logger.debug("No mandate description->label migration needed")
|
||||||
|
|
||||||
|
|
||||||
def _migrateMandateNameLabelSlugRules(db: DatabaseConnector) -> None:
|
|
||||||
"""
|
|
||||||
Migration: normalize Mandate.name to the slug rules ([a-z0-9-], length 2..32, single
|
|
||||||
hyphen segments) and ensure Mandate.label is non-empty.
|
|
||||||
|
|
||||||
Rules (see wiki/c-work/1-plan/2026-04-mandate-name-label-logic.md):
|
|
||||||
1. If ``label`` is empty/None → set ``label := name`` (or "Mandate" when both empty).
|
|
||||||
2. If ``name`` is not a valid slug, or collides with an earlier mandate in stable id
|
|
||||||
order, allocate a unique slug from the (now non-empty) ``label`` using
|
|
||||||
``slugifyMandateName`` + ``allocateUniqueMandateSlug``.
|
|
||||||
|
|
||||||
Idempotent: a second run is a no-op because all valid names stay valid and stay unique.
|
|
||||||
Each rename and label fill-in is logged for audit.
|
|
||||||
"""
|
|
||||||
from modules.shared.mandateNameUtils import (
|
|
||||||
allocateUniqueMandateSlug,
|
|
||||||
isValidMandateName,
|
|
||||||
slugifyMandateName,
|
|
||||||
)
|
|
||||||
|
|
||||||
allRows = db.getRecordset(Mandate)
|
|
||||||
if not allRows:
|
|
||||||
return
|
|
||||||
sortedRows = sorted(allRows, key=lambda r: str(r.get("id", "")))
|
|
||||||
|
|
||||||
used: set[str] = set()
|
|
||||||
labelFills = 0
|
|
||||||
nameRenames: list[tuple[str, str, str]] = []
|
|
||||||
|
|
||||||
for rec in sortedRows:
|
|
||||||
mid = rec.get("id")
|
|
||||||
if not mid:
|
|
||||||
continue
|
|
||||||
name = (rec.get("name") or "").strip()
|
|
||||||
labelRaw = rec.get("label")
|
|
||||||
label = (labelRaw or "").strip() if labelRaw is not None else ""
|
|
||||||
|
|
||||||
if not label:
|
|
||||||
label = name if name else "Mandate"
|
|
||||||
db.recordModify(Mandate, mid, {"label": label})
|
|
||||||
labelFills += 1
|
|
||||||
logger.info(f"Mandate {mid}: filled empty label with '{label}'")
|
|
||||||
|
|
||||||
nameFits = isValidMandateName(name)
|
|
||||||
nameCollides = name in used
|
|
||||||
if nameFits and not nameCollides:
|
|
||||||
used.add(name)
|
|
||||||
continue
|
|
||||||
|
|
||||||
base = slugifyMandateName(label) or "mn"
|
|
||||||
newName = allocateUniqueMandateSlug(base, used)
|
|
||||||
used.add(newName)
|
|
||||||
if newName != name:
|
|
||||||
db.recordModify(Mandate, mid, {"name": newName})
|
|
||||||
nameRenames.append((str(mid), name, newName))
|
|
||||||
logger.info(f"Mandate {mid}: renamed name '{name}' -> '{newName}'")
|
|
||||||
|
|
||||||
if labelFills or nameRenames:
|
|
||||||
logger.info(
|
|
||||||
"Mandate name/label slug migration: %d label fill-in(s), %d name rename(s)",
|
|
||||||
labelFills, len(nameRenames),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
logger.debug("No mandate name/label slug migration needed")
|
|
||||||
|
|
||||||
|
|
||||||
def initAdminUser(db: DatabaseConnector, mandateId: Optional[str]) -> Optional[str]:
|
def initAdminUser(db: DatabaseConnector, mandateId: Optional[str]) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Creates the Admin user if it doesn't exist.
|
Creates the Admin user if it doesn't exist.
|
||||||
Admin user gets BOTH platform flags:
|
Admin user gets isSysAdmin=True for system-level access.
|
||||||
- isSysAdmin=True (Infrastructure: logs/tokens/DB-health)
|
Role assignment is done via UserMandate + UserMandateRole in assignInitialUserMemberships().
|
||||||
- isPlatformAdmin=True (Cross-Mandate-Governance: user/mandate/RBAC mgmt)
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database connector instance
|
db: Database connector instance
|
||||||
mandateId: Root mandate ID (for membership assignment, not on User)
|
mandateId: Root mandate ID (for membership assignment, not on User)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
User ID if created or found, None otherwise
|
User ID if created or found, None otherwise
|
||||||
"""
|
"""
|
||||||
existingUsers = db.getRecordset(UserInDB, recordFilter={"username": "admin"})
|
existingUsers = db.getRecordset(UserInDB, recordFilter={"username": "admin"})
|
||||||
if existingUsers:
|
if existingUsers:
|
||||||
userId = existingUsers[0].get("id")
|
userId = existingUsers[0].get("id")
|
||||||
updates: Dict[str, bool] = {}
|
existingIsSysAdmin = existingUsers[0].get("isSysAdmin", False)
|
||||||
if not existingUsers[0].get("isSysAdmin", False):
|
|
||||||
updates["isSysAdmin"] = True
|
# Ensure admin user has isSysAdmin=True
|
||||||
if not existingUsers[0].get("isPlatformAdmin", False):
|
if not existingIsSysAdmin:
|
||||||
updates["isPlatformAdmin"] = True
|
logger.info(f"Updating admin user {userId} to set isSysAdmin=True")
|
||||||
if updates:
|
db.recordModify(UserInDB, userId, {"isSysAdmin": True})
|
||||||
logger.info(f"Updating admin user {userId} platform flags: {updates}")
|
|
||||||
db.recordModify(UserInDB, userId, updates)
|
|
||||||
|
|
||||||
logger.info(f"Admin user already exists with ID {userId}")
|
logger.info(f"Admin user already exists with ID {userId}")
|
||||||
return userId
|
return userId
|
||||||
|
|
||||||
logger.info("Creating Admin user")
|
logger.info("Creating Admin user")
|
||||||
adminUser = UserInDB(
|
adminUser = UserInDB(
|
||||||
username="admin",
|
username="admin",
|
||||||
|
|
@ -526,7 +453,6 @@ def initAdminUser(db: DatabaseConnector, mandateId: Optional[str]) -> Optional[s
|
||||||
enabled=True,
|
enabled=True,
|
||||||
language="en",
|
language="en",
|
||||||
isSysAdmin=True,
|
isSysAdmin=True,
|
||||||
isPlatformAdmin=True,
|
|
||||||
authenticationAuthority=AuthAuthority.LOCAL,
|
authenticationAuthority=AuthAuthority.LOCAL,
|
||||||
hashedPassword=_getPasswordHash(APP_CONFIG.get("APP_INIT_PASS_ADMIN_SECRET")),
|
hashedPassword=_getPasswordHash(APP_CONFIG.get("APP_INIT_PASS_ADMIN_SECRET")),
|
||||||
)
|
)
|
||||||
|
|
@ -539,30 +465,22 @@ def initAdminUser(db: DatabaseConnector, mandateId: Optional[str]) -> Optional[s
|
||||||
def initEventUser(db: DatabaseConnector, mandateId: Optional[str]) -> Optional[str]:
|
def initEventUser(db: DatabaseConnector, mandateId: Optional[str]) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Creates the Event user if it doesn't exist.
|
Creates the Event user if it doesn't exist.
|
||||||
Event user gets isSysAdmin=True for infrastructure-level operations
|
Event user gets isSysAdmin=True for system operations.
|
||||||
(system events, internal callbacks). It does NOT need cross-mandate
|
Role assignment is done via UserMandate + UserMandateRole in assignInitialUserMemberships().
|
||||||
governance, so isPlatformAdmin is left False.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database connector instance
|
db: Database connector instance
|
||||||
mandateId: Root mandate ID (for membership assignment, not on User)
|
mandateId: Root mandate ID (for membership assignment, not on User)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
User ID if created or found, None otherwise
|
User ID if created or found, None otherwise
|
||||||
"""
|
"""
|
||||||
existingUsers = db.getRecordset(UserInDB, recordFilter={"username": "event"})
|
existingUsers = db.getRecordset(UserInDB, recordFilter={"username": "event"})
|
||||||
if existingUsers:
|
if existingUsers:
|
||||||
userId = existingUsers[0].get("id")
|
userId = existingUsers[0].get("id")
|
||||||
# Defensive: revoke any historic platform-admin grant on the event user
|
|
||||||
if existingUsers[0].get("isPlatformAdmin", False):
|
|
||||||
logger.warning(
|
|
||||||
f"Event user {userId} had isPlatformAdmin=True; "
|
|
||||||
f"revoking (event user is infrastructure-only)"
|
|
||||||
)
|
|
||||||
db.recordModify(UserInDB, userId, {"isPlatformAdmin": False})
|
|
||||||
logger.info(f"Event user already exists with ID {userId}")
|
logger.info(f"Event user already exists with ID {userId}")
|
||||||
return userId
|
return userId
|
||||||
|
|
||||||
logger.info("Creating Event user")
|
logger.info("Creating Event user")
|
||||||
eventUser = UserInDB(
|
eventUser = UserInDB(
|
||||||
username="event",
|
username="event",
|
||||||
|
|
@ -571,7 +489,6 @@ def initEventUser(db: DatabaseConnector, mandateId: Optional[str]) -> Optional[s
|
||||||
enabled=True,
|
enabled=True,
|
||||||
language="en",
|
language="en",
|
||||||
isSysAdmin=True,
|
isSysAdmin=True,
|
||||||
isPlatformAdmin=False,
|
|
||||||
authenticationAuthority=AuthAuthority.LOCAL,
|
authenticationAuthority=AuthAuthority.LOCAL,
|
||||||
hashedPassword=_getPasswordHash(APP_CONFIG.get("APP_INIT_PASS_EVENT_SECRET")),
|
hashedPassword=_getPasswordHash(APP_CONFIG.get("APP_INIT_PASS_EVENT_SECRET")),
|
||||||
)
|
)
|
||||||
|
|
@ -585,19 +502,20 @@ def initRoles(db: DatabaseConnector) -> None:
|
||||||
"""
|
"""
|
||||||
Initialize standard roles if they don't exist.
|
Initialize standard roles if they don't exist.
|
||||||
Roles are created as GLOBAL (mandateId=None) template roles.
|
Roles are created as GLOBAL (mandateId=None) template roles.
|
||||||
|
|
||||||
NOTE: There is no platform-level "sysadmin" role any more — platform
|
NOTE: The "sysadmin" role is NOT a template - it's created separately in
|
||||||
authority lives on the User record via ``isSysAdmin`` and
|
_initSysAdminRole() as a root-mandate-specific role (isSystemRole=False).
|
||||||
``isPlatformAdmin``. These template roles (admin/user/viewer) are
|
These template roles (admin/user/viewer) are for mandate/feature-level access control.
|
||||||
purely for mandate/feature-level access control.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database connector instance
|
db: Database connector instance
|
||||||
"""
|
"""
|
||||||
logger.info("Initializing roles")
|
logger.info("Initializing roles")
|
||||||
global _roleIdCache
|
global _roleIdCache
|
||||||
_roleIdCache = {}
|
_roleIdCache = {}
|
||||||
|
|
||||||
|
# Standard template roles for mandate/feature-level access
|
||||||
|
# NOTE: "sysadmin" role is created separately in _initSysAdminRole (root mandate only)
|
||||||
standardRoles = [
|
standardRoles = [
|
||||||
Role(
|
Role(
|
||||||
roleLabel="admin",
|
roleLabel="admin",
|
||||||
|
|
@ -815,99 +733,145 @@ def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int:
|
||||||
return copiedCount
|
return copiedCount
|
||||||
|
|
||||||
|
|
||||||
def _migrateAndDropSysAdminRole(db: DatabaseConnector, mandateId: str) -> None:
|
def _initSysAdminRole(db: DatabaseConnector, mandateId: str) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
One-shot migration: eliminate the legacy ``sysadmin`` role in the root mandate.
|
Initialize the sysadmin role in the root mandate.
|
||||||
|
|
||||||
Authority semantics moved to two orthogonal flags on User:
|
The sysadmin role is a mandate-specific role (NOT a system template) that provides
|
||||||
- ``isSysAdmin`` → Infrastructure-Operator (RBAC bypass)
|
full administrative access via RBAC. It only exists in the root mandate and is
|
||||||
- ``isPlatformAdmin`` → Cross-Mandate-Governance (no bypass)
|
NOT copied to other mandates (isSystemRole=False).
|
||||||
|
|
||||||
Migration steps (idempotent):
|
Hybrid model:
|
||||||
1. Find sysadmin role(s) in root mandate. If none exist → done.
|
- User.isSysAdmin flag → true system operations (Category A: tokens, logs, databases)
|
||||||
2. For every UserMandateRole row referencing such a role: set
|
- sysadmin role → admin operations via RBAC (Categories B/C/D/E)
|
||||||
``user.isPlatformAdmin = True`` (preserves cross-mandate authority).
|
|
||||||
3. Delete those UserMandateRole rows.
|
|
||||||
4. Delete AccessRules attached to the sysadmin role.
|
|
||||||
5. Delete the sysadmin Role record.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database connector instance
|
db: Database connector instance
|
||||||
mandateId: Root mandate ID
|
mandateId: Root mandate ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sysadmin role ID or None
|
||||||
"""
|
"""
|
||||||
sysadminRoles = db.getRecordset(
|
# Check if sysadmin role already exists in root mandate
|
||||||
|
existingRoles = db.getRecordset(
|
||||||
Role,
|
Role,
|
||||||
recordFilter={"roleLabel": "sysadmin", "mandateId": mandateId, "featureInstanceId": None},
|
recordFilter={"roleLabel": "sysadmin", "mandateId": mandateId, "featureInstanceId": None}
|
||||||
)
|
)
|
||||||
if not sysadminRoles:
|
|
||||||
logger.debug("Sysadmin role migration: no legacy sysadmin role present, nothing to do")
|
if existingRoles:
|
||||||
|
sysadminRoleId = existingRoles[0].get("id")
|
||||||
|
logger.info(f"Sysadmin role already exists in root mandate with ID {sysadminRoleId}")
|
||||||
|
# Ensure AccessRules exist (migration safety)
|
||||||
|
_ensureSysAdminAccessRules(db, sysadminRoleId)
|
||||||
|
return sysadminRoleId
|
||||||
|
|
||||||
|
# Create sysadmin role in root mandate
|
||||||
|
logger.info("Creating sysadmin role in root mandate")
|
||||||
|
sysadminRole = Role(
|
||||||
|
roleLabel="sysadmin",
|
||||||
|
description=coerce_text_multilingual("System-Administrator - Vollständiger administrativer Zugriff über alle Mandanten"),
|
||||||
|
mandateId=mandateId,
|
||||||
|
featureInstanceId=None,
|
||||||
|
featureCode=None,
|
||||||
|
isSystemRole=False # NOT a template → NOT copied to other mandates
|
||||||
|
)
|
||||||
|
createdRole = db.recordCreate(Role, sysadminRole)
|
||||||
|
sysadminRoleId = createdRole.get("id")
|
||||||
|
logger.info(f"Created sysadmin role with ID {sysadminRoleId}")
|
||||||
|
|
||||||
|
# Create AccessRules for sysadmin role
|
||||||
|
_createSysAdminAccessRules(db, sysadminRoleId)
|
||||||
|
|
||||||
|
return sysadminRoleId
|
||||||
|
|
||||||
|
|
||||||
|
def _createSysAdminAccessRules(db: DatabaseConnector, sysadminRoleId: str) -> None:
|
||||||
|
"""
|
||||||
|
Create AccessRules for the sysadmin role.
|
||||||
|
|
||||||
|
DATA + RESOURCE: generic item=None (full access).
|
||||||
|
UI: NO generic rule here — explicit ui.admin.* rules are created by
|
||||||
|
_ensureUiContextRules() (same logic as admin role).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database connector instance
|
||||||
|
sysadminRoleId: Sysadmin role ID
|
||||||
|
"""
|
||||||
|
rules = [
|
||||||
|
# DATA: Full access to all data tables (generic rule, item=None)
|
||||||
|
AccessRule(
|
||||||
|
roleId=sysadminRoleId,
|
||||||
|
context=AccessRuleContext.DATA,
|
||||||
|
item=None,
|
||||||
|
view=True,
|
||||||
|
read=AccessLevel.ALL,
|
||||||
|
create=AccessLevel.ALL,
|
||||||
|
update=AccessLevel.ALL,
|
||||||
|
delete=AccessLevel.ALL,
|
||||||
|
),
|
||||||
|
# RESOURCE: Access to all system resources (generic rule, item=None)
|
||||||
|
AccessRule(
|
||||||
|
roleId=sysadminRoleId,
|
||||||
|
context=AccessRuleContext.RESOURCE,
|
||||||
|
item=None,
|
||||||
|
view=True,
|
||||||
|
read=None,
|
||||||
|
create=None,
|
||||||
|
update=None,
|
||||||
|
delete=None,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
for rule in rules:
|
||||||
|
db.recordCreate(AccessRule, rule)
|
||||||
|
|
||||||
|
logger.info(f"Created {len(rules)} AccessRules for sysadmin role (UI rules via _ensureUiContextRules)")
|
||||||
|
|
||||||
|
|
||||||
|
def _ensureSysAdminAccessRules(db: DatabaseConnector, sysadminRoleId: str) -> None:
|
||||||
|
"""
|
||||||
|
Ensure AccessRules exist for the sysadmin role (migration safety).
|
||||||
|
Creates missing rules without duplicating existing ones.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database connector instance
|
||||||
|
sysadminRoleId: Sysadmin role ID
|
||||||
|
"""
|
||||||
|
existingRules = db.getRecordset(AccessRule, recordFilter={"roleId": sysadminRoleId})
|
||||||
|
|
||||||
|
if not existingRules:
|
||||||
|
logger.info("No AccessRules found for sysadmin role, creating them")
|
||||||
|
_createSysAdminAccessRules(db, sysadminRoleId)
|
||||||
return
|
return
|
||||||
|
|
||||||
sysadminRoleIds = [str(r.get("id")) for r in sysadminRoles if r.get("id")]
|
# Check for DATA and RESOURCE contexts (UI is handled by _ensureUiContextRules)
|
||||||
logger.warning(
|
existingContexts = {r.get("context") for r in existingRules}
|
||||||
f"Sysadmin role migration: found {len(sysadminRoleIds)} legacy sysadmin role(s) "
|
|
||||||
f"in root mandate, migrating to isPlatformAdmin flag"
|
missingRules = []
|
||||||
)
|
if AccessRuleContext.DATA.value not in existingContexts:
|
||||||
|
missingRules.append(AccessRule(
|
||||||
# 1) Promote every holder to isPlatformAdmin=True
|
roleId=sysadminRoleId,
|
||||||
promoted = 0
|
context=AccessRuleContext.DATA,
|
||||||
for sysadminRoleId in sysadminRoleIds:
|
item=None,
|
||||||
umRoleRows = db.getRecordset(
|
view=True,
|
||||||
UserMandateRole, recordFilter={"roleId": sysadminRoleId}
|
read=AccessLevel.ALL,
|
||||||
)
|
create=AccessLevel.ALL,
|
||||||
userMandateIds = [str(r.get("userMandateId")) for r in umRoleRows if r.get("userMandateId")]
|
update=AccessLevel.ALL,
|
||||||
if not userMandateIds:
|
delete=AccessLevel.ALL,
|
||||||
continue
|
))
|
||||||
|
if AccessRuleContext.RESOURCE.value not in existingContexts:
|
||||||
# Resolve userIds via UserMandate
|
missingRules.append(AccessRule(
|
||||||
userIds = set()
|
roleId=sysadminRoleId,
|
||||||
for umId in userMandateIds:
|
context=AccessRuleContext.RESOURCE,
|
||||||
ums = db.getRecordset(UserMandate, recordFilter={"id": umId})
|
item=None,
|
||||||
for um in ums:
|
view=True,
|
||||||
uid = um.get("userId") if isinstance(um, dict) else getattr(um, "userId", None)
|
read=None, create=None, update=None, delete=None,
|
||||||
if uid:
|
))
|
||||||
userIds.add(str(uid))
|
|
||||||
|
if missingRules:
|
||||||
for userId in userIds:
|
for rule in missingRules:
|
||||||
users = db.getRecordset(UserInDB, recordFilter={"id": userId})
|
db.recordCreate(AccessRule, rule)
|
||||||
if not users:
|
logger.info(f"Created {len(missingRules)} missing AccessRules for sysadmin role")
|
||||||
continue
|
|
||||||
current = users[0].get("isPlatformAdmin", False)
|
|
||||||
if not current:
|
|
||||||
db.recordModify(UserInDB, userId, {"isPlatformAdmin": True})
|
|
||||||
promoted += 1
|
|
||||||
logger.warning(
|
|
||||||
f"Sysadmin role migration: granted isPlatformAdmin=True to user {userId}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# 2) Delete UserMandateRole rows
|
|
||||||
for umRow in umRoleRows:
|
|
||||||
rowId = umRow.get("id") if isinstance(umRow, dict) else getattr(umRow, "id", None)
|
|
||||||
if rowId:
|
|
||||||
try:
|
|
||||||
db.recordDelete(UserMandateRole, str(rowId))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Sysadmin role migration: failed to drop UserMandateRole {rowId}: {e}")
|
|
||||||
|
|
||||||
# 3) Delete AccessRules
|
|
||||||
accessRules = db.getRecordset(AccessRule, recordFilter={"roleId": sysadminRoleId})
|
|
||||||
for ar in accessRules:
|
|
||||||
arId = ar.get("id") if isinstance(ar, dict) else getattr(ar, "id", None)
|
|
||||||
if arId:
|
|
||||||
try:
|
|
||||||
db.recordDelete(AccessRule, str(arId))
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Sysadmin role migration: failed to drop AccessRule {arId}: {e}")
|
|
||||||
|
|
||||||
# 4) Delete the Role
|
|
||||||
try:
|
|
||||||
db.recordDelete(Role, sysadminRoleId)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Sysadmin role migration: failed to drop Role {sysadminRoleId}: {e}")
|
|
||||||
|
|
||||||
logger.warning(
|
|
||||||
f"Sysadmin role migration: completed; promoted {promoted} user(s) to isPlatformAdmin"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _getRoleId(db: DatabaseConnector, roleLabel: str) -> Optional[str]:
|
def _getRoleId(db: DatabaseConnector, roleLabel: str) -> Optional[str]:
|
||||||
|
|
@ -975,9 +939,8 @@ def _createDefaultRoleRules(db: DatabaseConnector) -> None:
|
||||||
Create default role rules for generic access (item = null).
|
Create default role rules for generic access (item = null).
|
||||||
Uses roleId instead of roleLabel.
|
Uses roleId instead of roleLabel.
|
||||||
|
|
||||||
NOTE: There is no sysadmin role any more — platform/infra authority is
|
NOTE: Sysadmin role rules are created separately in _initSysAdminRole().
|
||||||
governed by the ``isSysAdmin`` / ``isPlatformAdmin`` flags on the User
|
These default rules cover admin/user/viewer template roles.
|
||||||
record. These default rules cover admin/user/viewer template roles.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database connector instance
|
db: Database connector instance
|
||||||
|
|
@ -1027,16 +990,15 @@ def _createTableSpecificRules(db: DatabaseConnector) -> None:
|
||||||
These rules override generic rules for specific tables.
|
These rules override generic rules for specific tables.
|
||||||
Uses roleId instead of roleLabel.
|
Uses roleId instead of roleLabel.
|
||||||
|
|
||||||
NOTE: There is no sysadmin role any more — platform/infra authority is
|
NOTE: Sysadmin role rules are created separately in _initSysAdminRole().
|
||||||
governed by the ``isSysAdmin`` / ``isPlatformAdmin`` flags on the User
|
These table-specific rules cover admin/user/viewer template roles.
|
||||||
record. These table-specific rules cover admin/user/viewer template roles.
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database connector instance
|
db: Database connector instance
|
||||||
"""
|
"""
|
||||||
tableRules = []
|
tableRules = []
|
||||||
|
|
||||||
# Get role IDs for template roles (platform authority lives on User flags)
|
# Get role IDs for template roles (sysadmin is a separate mandate-level role)
|
||||||
adminId = _getRoleId(db, "admin")
|
adminId = _getRoleId(db, "admin")
|
||||||
userId = _getRoleId(db, "user")
|
userId = _getRoleId(db, "user")
|
||||||
viewerId = _getRoleId(db, "viewer")
|
viewerId = _getRoleId(db, "viewer")
|
||||||
|
|
@ -1507,7 +1469,8 @@ def _ensureUiContextRules(db: DatabaseConnector) -> None:
|
||||||
mandateAdminRoleIds = []
|
mandateAdminRoleIds = []
|
||||||
mandateUserRoleIds = []
|
mandateUserRoleIds = []
|
||||||
mandateViewerRoleIds = []
|
mandateViewerRoleIds = []
|
||||||
|
sysadminRoleIds = []
|
||||||
|
|
||||||
mandateRoles = db.getRecordset(
|
mandateRoles = db.getRecordset(
|
||||||
Role,
|
Role,
|
||||||
recordFilter={"isSystemRole": False, "featureInstanceId": None}
|
recordFilter={"isSystemRole": False, "featureInstanceId": None}
|
||||||
|
|
@ -1523,12 +1486,12 @@ def _ensureUiContextRules(db: DatabaseConnector) -> None:
|
||||||
mandateUserRoleIds.append(roleId)
|
mandateUserRoleIds.append(roleId)
|
||||||
elif label == "viewer":
|
elif label == "viewer":
|
||||||
mandateViewerRoleIds.append(roleId)
|
mandateViewerRoleIds.append(roleId)
|
||||||
|
elif label == "sysadmin":
|
||||||
# All role IDs per level (template + mandate-instance).
|
sysadminRoleIds.append(roleId)
|
||||||
# Admin-only navigation items are governed by these admin roles plus the
|
|
||||||
# ``isPlatformAdmin`` flag (checked in routes via requirePlatformAdmin),
|
# All role IDs per level (template + mandate-instance)
|
||||||
# NOT by a dedicated platform-level role.
|
# sysadmin gets ALL UI rules (admin-only + public) — same logic, explicit rules
|
||||||
allAdminRoleIds = ([adminId] if adminId else []) + mandateAdminRoleIds
|
allAdminRoleIds = ([adminId] if adminId else []) + mandateAdminRoleIds + sysadminRoleIds
|
||||||
allUserRoleIds = ([userId] if userId else []) + mandateUserRoleIds
|
allUserRoleIds = ([userId] if userId else []) + mandateUserRoleIds
|
||||||
allViewerRoleIds = ([viewerId] if viewerId else []) + mandateViewerRoleIds
|
allViewerRoleIds = ([viewerId] if viewerId else []) + mandateViewerRoleIds
|
||||||
|
|
||||||
|
|
@ -1546,16 +1509,12 @@ def _ensureUiContextRules(db: DatabaseConnector) -> None:
|
||||||
if roleId and item:
|
if roleId and item:
|
||||||
existingCombinations.add((roleId, item))
|
existingCombinations.add((roleId, item))
|
||||||
|
|
||||||
# Check each navigation item and add missing rules (including subgroup items)
|
# Check each navigation item and add missing rules
|
||||||
missingRules = []
|
missingRules = []
|
||||||
for section in NAVIGATION_SECTIONS:
|
for section in NAVIGATION_SECTIONS:
|
||||||
isAdminSection = section.get("adminOnly", False)
|
isAdminSection = section.get("adminOnly", False)
|
||||||
|
|
||||||
allItems = list(section.get("items", []))
|
for item in section.get("items", []):
|
||||||
for subgroup in section.get("subgroups", []):
|
|
||||||
allItems.extend(subgroup.get("items", []))
|
|
||||||
|
|
||||||
for item in allItems:
|
|
||||||
objectKey = item.get("objectKey")
|
objectKey = item.get("objectKey")
|
||||||
if not objectKey:
|
if not objectKey:
|
||||||
continue
|
continue
|
||||||
|
|
@ -1896,7 +1855,7 @@ def _createStoreResourceRules(db: DatabaseConnector) -> None:
|
||||||
Store resources control which roles can activate features via the Store.
|
Store resources control which roles can activate features via the Store.
|
||||||
- admin/user: view=True (can see and activate store features)
|
- admin/user: view=True (can see and activate store features)
|
||||||
- viewer: no store access
|
- viewer: no store access
|
||||||
- isSysAdmin flag bypasses RBAC (rbac.py:getUserPermissions)
|
- sysadmin: covered by generic RESOURCE rule (item=None, view=True)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database connector instance
|
db: Database connector instance
|
||||||
|
|
@ -1905,7 +1864,6 @@ def _createStoreResourceRules(db: DatabaseConnector) -> None:
|
||||||
"resource.store.teamsbot",
|
"resource.store.teamsbot",
|
||||||
"resource.store.workspace",
|
"resource.store.workspace",
|
||||||
"resource.store.commcoach",
|
"resource.store.commcoach",
|
||||||
"resource.store.trustee",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
storeRules = []
|
storeRules = []
|
||||||
|
|
@ -2034,11 +1992,9 @@ def assignInitialUserMemberships(
|
||||||
Assign initial memberships to admin and event users via UserMandate + UserMandateRole.
|
Assign initial memberships to admin and event users via UserMandate + UserMandateRole.
|
||||||
This is the NEW multi-tenant way of assigning roles.
|
This is the NEW multi-tenant way of assigning roles.
|
||||||
|
|
||||||
Initial users get the "admin" role in the root mandate. Platform-level
|
Hybrid model: Initial users get BOTH the isSysAdmin flag (for system ops)
|
||||||
authority (cross-mandate governance + infrastructure ops) is conveyed via
|
AND the "admin" + "sysadmin" roles in the root mandate (for RBAC-based admin ops).
|
||||||
the ``isSysAdmin`` / ``isPlatformAdmin`` flags on the User record itself
|
|
||||||
(see ``initAdminUser`` / ``initEventUser``).
|
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: Database connector instance
|
db: Database connector instance
|
||||||
mandateId: Root mandate ID
|
mandateId: Root mandate ID
|
||||||
|
|
@ -2056,7 +2012,13 @@ def assignInitialUserMemberships(
|
||||||
if not adminRoleId:
|
if not adminRoleId:
|
||||||
logger.warning(f"No mandate-level role found for mandate {mandateId}, skipping membership assignment")
|
logger.warning(f"No mandate-level role found for mandate {mandateId}, skipping membership assignment")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Find sysadmin role in root mandate (created by _initSysAdminRole)
|
||||||
|
sysadminRole = next((r for r in mandateRoles if r.get("roleLabel") == "sysadmin"), None)
|
||||||
|
sysadminRoleId = sysadminRole.get("id") if sysadminRole else None
|
||||||
|
if not sysadminRoleId:
|
||||||
|
logger.warning("Sysadmin role not found in root mandate - run _initSysAdminRole first")
|
||||||
|
|
||||||
for userId, userName in [(adminUserId, "admin"), (eventUserId, "event")]:
|
for userId, userName in [(adminUserId, "admin"), (eventUserId, "event")]:
|
||||||
# Check if UserMandate already exists
|
# Check if UserMandate already exists
|
||||||
existingMemberships = db.getRecordset(
|
existingMemberships = db.getRecordset(
|
||||||
|
|
@ -2091,6 +2053,20 @@ def assignInitialUserMemberships(
|
||||||
)
|
)
|
||||||
db.recordCreate(UserMandateRole, userMandateRole)
|
db.recordCreate(UserMandateRole, userMandateRole)
|
||||||
logger.info(f"Assigned admin role to {userName} user in mandate")
|
logger.info(f"Assigned admin role to {userName} user in mandate")
|
||||||
|
|
||||||
|
# Assign sysadmin role (in addition to admin role)
|
||||||
|
if sysadminRoleId:
|
||||||
|
existingSysadminRoles = db.getRecordset(
|
||||||
|
UserMandateRole,
|
||||||
|
recordFilter={"userMandateId": userMandateId, "roleId": sysadminRoleId}
|
||||||
|
)
|
||||||
|
if not existingSysadminRoles:
|
||||||
|
sysadminMandateRole = UserMandateRole(
|
||||||
|
userMandateId=userMandateId,
|
||||||
|
roleId=sysadminRoleId
|
||||||
|
)
|
||||||
|
db.recordCreate(UserMandateRole, sysadminMandateRole)
|
||||||
|
logger.info(f"Assigned sysadmin role to {userName} user in root mandate")
|
||||||
|
|
||||||
|
|
||||||
def _getPasswordHash(password: Optional[str]) -> Optional[str]:
|
def _getPasswordHash(password: Optional[str]) -> Optional[str]:
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ import uuid
|
||||||
|
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector, _get_cached_connector
|
from modules.connectors.connectorDbPostgre import DatabaseConnector, _get_cached_connector
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
from modules.shared.dbRegistry import registerDatabase
|
|
||||||
from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp
|
from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp
|
||||||
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
|
from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
|
||||||
from modules.security.rbac import RbacClass
|
from modules.security.rbac import RbacClass
|
||||||
|
|
@ -49,9 +48,6 @@ from modules.datamodels.datamodelNotification import UserNotification
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
appDatabase = "poweron_app"
|
|
||||||
registerDatabase(appDatabase)
|
|
||||||
|
|
||||||
# Singleton factory for AppObjects instances per context
|
# Singleton factory for AppObjects instances per context
|
||||||
_gatewayInterfaces = {}
|
_gatewayInterfaces = {}
|
||||||
|
|
||||||
|
|
@ -137,7 +133,7 @@ class AppObjects:
|
||||||
try:
|
try:
|
||||||
# Get configuration values with defaults
|
# Get configuration values with defaults
|
||||||
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
||||||
dbDatabase = appDatabase
|
dbDatabase = "poweron_app"
|
||||||
dbUser = APP_CONFIG.get("DB_USER")
|
dbUser = APP_CONFIG.get("DB_USER")
|
||||||
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
||||||
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
||||||
|
|
@ -677,7 +673,6 @@ class AppObjects:
|
||||||
externalUsername: str = None,
|
externalUsername: str = None,
|
||||||
externalEmail: str = None,
|
externalEmail: str = None,
|
||||||
isSysAdmin: bool = False,
|
isSysAdmin: bool = False,
|
||||||
isPlatformAdmin: bool = False,
|
|
||||||
addExternalIdentityConnection: bool = True,
|
addExternalIdentityConnection: bool = True,
|
||||||
) -> User:
|
) -> User:
|
||||||
"""
|
"""
|
||||||
|
|
@ -715,7 +710,6 @@ class AppObjects:
|
||||||
language=language,
|
language=language,
|
||||||
enabled=enabled,
|
enabled=enabled,
|
||||||
isSysAdmin=isSysAdmin,
|
isSysAdmin=isSysAdmin,
|
||||||
isPlatformAdmin=isPlatformAdmin,
|
|
||||||
authenticationAuthority=authenticationAuthority,
|
authenticationAuthority=authenticationAuthority,
|
||||||
hashedPassword=self._getPasswordHash(password) if password else None,
|
hashedPassword=self._getPasswordHash(password) if password else None,
|
||||||
)
|
)
|
||||||
|
|
@ -757,21 +751,15 @@ class AppObjects:
|
||||||
logger.error(f"Unexpected error creating user: {str(e)}")
|
logger.error(f"Unexpected error creating user: {str(e)}")
|
||||||
raise ValueError(f"Failed to create user: {str(e)}")
|
raise ValueError(f"Failed to create user: {str(e)}")
|
||||||
|
|
||||||
def updateUser(
|
def updateUser(self, userId: str, updateData: Union[Dict[str, Any], User], allowSysAdminChange: bool = False) -> User:
|
||||||
self,
|
|
||||||
userId: str,
|
|
||||||
updateData: Union[Dict[str, Any], User],
|
|
||||||
allowAdminFlagChange: bool = False,
|
|
||||||
) -> User:
|
|
||||||
"""Update a user's information.
|
"""Update a user's information.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
userId: ID of the user to update
|
userId: ID of the user to update
|
||||||
updateData: User data to update (dict or User model)
|
updateData: User data to update (dict or User model)
|
||||||
allowAdminFlagChange: If True, allows changing the privileged platform
|
allowSysAdminChange: If True, allows changing isSysAdmin field.
|
||||||
flags ``isSysAdmin`` and ``isPlatformAdmin``.
|
Only set to True when called by a SysAdmin explicitly
|
||||||
Only set to True when called by a Platform Admin
|
changing another user's admin status.
|
||||||
explicitly changing another user's admin status.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Get user
|
# Get user
|
||||||
|
|
@ -779,35 +767,20 @@ class AppObjects:
|
||||||
if not user:
|
if not user:
|
||||||
raise ValueError(f"User {userId} not found")
|
raise ValueError(f"User {userId} not found")
|
||||||
|
|
||||||
# Convert updateData to dict if it's a User model.
|
# Convert updateData to dict if it's a User model
|
||||||
#
|
|
||||||
# IMPORTANT: When the route layer passes a Pydantic ``User`` instance,
|
|
||||||
# ``model_dump()`` returns ALL fields — including those the client
|
|
||||||
# never sent — populated with Pydantic defaults (e.g. ``isSysAdmin=False``).
|
|
||||||
# That historical pattern caused silent flag flips on inline-toggles.
|
|
||||||
#
|
|
||||||
# The PUT route now ships a plain dict carrying ONLY the explicitly
|
|
||||||
# changed fields, so this branch should rarely fire; however internal
|
|
||||||
# callers (``disableUser`` / ``enableUser`` / migration scripts) still
|
|
||||||
# use dict-style partials and must remain partial-safe.
|
|
||||||
if isinstance(updateData, User):
|
if isinstance(updateData, User):
|
||||||
updateDict = updateData.model_dump(exclude_unset=True)
|
updateDict = updateData.model_dump()
|
||||||
# Fallback for legacy callers that constructed a fully-defaulted
|
|
||||||
# User: if nothing was marked as explicitly set, treat the dump
|
|
||||||
# as authoritative but DROP privileged flags unconditionally
|
|
||||||
# unless allowAdminFlagChange is True.
|
|
||||||
if not updateDict:
|
|
||||||
updateDict = updateData.model_dump()
|
|
||||||
else:
|
else:
|
||||||
updateDict = updateData.copy() if isinstance(updateData, dict) else dict(updateData)
|
updateDict = updateData.copy() if isinstance(updateData, dict) else updateData
|
||||||
|
|
||||||
|
# Remove id field from updateDict if present - we'll use userId from parameter
|
||||||
updateDict.pop("id", None)
|
updateDict.pop("id", None)
|
||||||
|
|
||||||
# SECURITY: Protect privileged platform flags from accidental
|
# SECURITY: Protect sensitive fields from being overwritten by profile updates.
|
||||||
# overwrite via profile forms or partial payloads from clients
|
# These fields should only be changed explicitly by admins, not through
|
||||||
# whose model defaults could pull the value down to False.
|
# profile forms where they might be sent as default values (e.g., isSysAdmin=False).
|
||||||
protectedFields = ["isSysAdmin", "isPlatformAdmin"]
|
protectedFields = ["isSysAdmin"]
|
||||||
if not allowAdminFlagChange:
|
if not allowSysAdminChange:
|
||||||
for field in protectedFields:
|
for field in protectedFields:
|
||||||
updateDict.pop(field, None)
|
updateDict.pop(field, None)
|
||||||
|
|
||||||
|
|
@ -1479,56 +1452,16 @@ class AppObjects:
|
||||||
|
|
||||||
return Mandate(**filteredMandates[0])
|
return Mandate(**filteredMandates[0])
|
||||||
|
|
||||||
def _existingMandateNames(self, excludeId: Optional[str] = None) -> List[str]:
|
def createMandate(self, name: str, label: str = None, enabled: bool = True) -> Mandate:
|
||||||
"""Return all mandate.name values currently in the DB (optionally excluding one id)."""
|
|
||||||
out: List[str] = []
|
|
||||||
for r in self.db.getRecordset(Mandate):
|
|
||||||
if excludeId and str(r.get("id")) == str(excludeId):
|
|
||||||
continue
|
|
||||||
n = r.get("name")
|
|
||||||
if n:
|
|
||||||
out.append(n)
|
|
||||||
return out
|
|
||||||
|
|
||||||
def _generateUniqueMandateName(self, label: str, excludeId: Optional[str] = None) -> str:
|
|
||||||
"""Generate a slug from *label* that is unique across all mandates (Phase 3 helper)."""
|
|
||||||
from modules.shared.mandateNameUtils import allocateUniqueMandateSlug, slugifyMandateName
|
|
||||||
|
|
||||||
base = slugifyMandateName(label or "")
|
|
||||||
return allocateUniqueMandateSlug(base, self._existingMandateNames(excludeId=excludeId))
|
|
||||||
|
|
||||||
def createMandate(self, name: str = None, label: str = None, enabled: bool = True) -> Mandate:
|
|
||||||
"""
|
"""
|
||||||
Creates a new mandate if user has permission.
|
Creates a new mandate if user has permission.
|
||||||
Automatically copies system template roles (admin, user, viewer) to the new mandate.
|
Automatically copies system template roles (admin, user, viewer) to the new mandate.
|
||||||
|
|
||||||
``label`` (Voller Name) is required (non-empty). If ``name`` (Kurzzeichen) is omitted or empty,
|
|
||||||
a unique slug is generated from the label; otherwise it is validated and uniqueness-checked.
|
|
||||||
"""
|
"""
|
||||||
if not self.checkRbacPermission(Mandate, "create"):
|
if not self.checkRbacPermission(Mandate, "create"):
|
||||||
raise PermissionError("No permission to create mandates")
|
raise PermissionError("No permission to create mandates")
|
||||||
|
|
||||||
from modules.shared.mandateNameUtils import isValidMandateName
|
# Create mandate data using model
|
||||||
|
mandateData = Mandate(name=name, label=label, enabled=enabled)
|
||||||
effLabel = (label or "").strip() if label is not None else ""
|
|
||||||
if not effLabel and name:
|
|
||||||
effLabel = (name or "").strip()
|
|
||||||
if not effLabel:
|
|
||||||
raise ValueError("Mandate label (Voller Name) is required")
|
|
||||||
|
|
||||||
rawName = (name or "").strip() if name else ""
|
|
||||||
if not rawName:
|
|
||||||
rawName = self._generateUniqueMandateName(effLabel)
|
|
||||||
else:
|
|
||||||
if not isValidMandateName(rawName):
|
|
||||||
raise ValueError(
|
|
||||||
"Mandate Kurzzeichen must be 2–32 characters: lowercase a–z, digits, "
|
|
||||||
"hyphens only (single-hyphen segments)."
|
|
||||||
)
|
|
||||||
if rawName in self._existingMandateNames():
|
|
||||||
raise ValueError(f"Mandate Kurzzeichen '{rawName}' is already in use")
|
|
||||||
|
|
||||||
mandateData = Mandate(name=rawName, label=effLabel, enabled=enabled)
|
|
||||||
|
|
||||||
# Create mandate record
|
# Create mandate record
|
||||||
createdRecord = self.db.recordCreate(Mandate, mandateData)
|
createdRecord = self.db.recordCreate(Mandate, mandateData)
|
||||||
|
|
@ -1547,31 +1480,24 @@ class AppObjects:
|
||||||
|
|
||||||
return Mandate(**createdRecord)
|
return Mandate(**createdRecord)
|
||||||
|
|
||||||
def _provisionMandateForUser(self, userId: str, mandateLabel: str, planKey: str) -> Dict[str, Any]:
|
def _provisionMandateForUser(self, userId: str, mandateName: str, planKey: str) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Atomic provisioning: create Mandate + UserMandate + Subscription + auto-create FeatureInstances.
|
Atomic provisioning: create Mandate + UserMandate + Subscription + auto-create FeatureInstances.
|
||||||
Internal method — bypasses RBAC (used during registration when user has no permissions yet).
|
Internal method — bypasses RBAC (used during registration when user has no permissions yet).
|
||||||
|
|
||||||
``mandateLabel`` is the display name (Voller Name); a unique slug ``name`` (Kurzzeichen) is derived.
|
|
||||||
"""
|
"""
|
||||||
from modules.datamodels.datamodelSubscription import MandateSubscription, SubscriptionStatusEnum, BUILTIN_PLANS
|
from modules.datamodels.datamodelSubscription import MandateSubscription, SubscriptionStatusEnum, BUILTIN_PLANS
|
||||||
from modules.datamodels.datamodelFeatures import FeatureInstance
|
from modules.datamodels.datamodelFeatures import FeatureInstance
|
||||||
from modules.interfaces.interfaceBootstrap import copySystemRolesToMandate
|
from modules.interfaces.interfaceBootstrap import copySystemRolesToMandate
|
||||||
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
from modules.interfaces.interfaceFeatures import getFeatureInterface
|
||||||
from modules.system.registry import loadFeatureMainModules
|
from modules.system.registry import loadFeatureMainModules
|
||||||
|
|
||||||
plan = BUILTIN_PLANS.get(planKey)
|
plan = BUILTIN_PLANS.get(planKey)
|
||||||
if not plan:
|
if not plan:
|
||||||
raise ValueError(f"Unknown plan: {planKey}")
|
raise ValueError(f"Unknown plan: {planKey}")
|
||||||
|
|
||||||
effLabel = (mandateLabel or "").strip()
|
|
||||||
if not effLabel:
|
|
||||||
raise ValueError("mandateLabel (Voller Name) is required for provisioning")
|
|
||||||
|
|
||||||
uniqueName = self._generateUniqueMandateName(effLabel)
|
|
||||||
|
|
||||||
mandateData = Mandate(
|
mandateData = Mandate(
|
||||||
name=uniqueName,
|
name=mandateName,
|
||||||
label=effLabel,
|
label=mandateName,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
isSystem=False,
|
isSystem=False,
|
||||||
)
|
)
|
||||||
|
|
@ -1744,17 +1670,7 @@ class AppObjects:
|
||||||
return activated
|
return activated
|
||||||
|
|
||||||
def updateMandate(self, mandateId: str, updateData: Dict[str, Any]) -> Mandate:
|
def updateMandate(self, mandateId: str, updateData: Dict[str, Any]) -> Mandate:
|
||||||
"""
|
"""Updates a mandate if user has access."""
|
||||||
Updates a mandate if user has access.
|
|
||||||
|
|
||||||
Field-level rules:
|
|
||||||
- ``id`` always immutable.
|
|
||||||
- ``isSystem`` only sysadmin.
|
|
||||||
- ``name`` (Kurzzeichen) only platform/sysadmin; format and uniqueness are validated.
|
|
||||||
- ``label`` (Voller Name) must be non-empty if provided.
|
|
||||||
"""
|
|
||||||
from modules.shared.mandateNameUtils import isValidMandateName
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# First check if user has permission to modify mandates
|
# First check if user has permission to modify mandates
|
||||||
if not self.checkRbacPermission(Mandate, "update", mandateId):
|
if not self.checkRbacPermission(Mandate, "update", mandateId):
|
||||||
|
|
@ -1765,33 +1681,11 @@ class AppObjects:
|
||||||
if not mandate:
|
if not mandate:
|
||||||
raise ValueError(f"Mandate {mandateId} not found")
|
raise ValueError(f"Mandate {mandateId} not found")
|
||||||
|
|
||||||
_isSysAdmin = bool(getattr(self.currentUser, "isSysAdmin", False))
|
|
||||||
_isPlatformAdmin = bool(getattr(self.currentUser, "isPlatformAdmin", False))
|
|
||||||
|
|
||||||
_protectedFields = {"id"}
|
_protectedFields = {"id"}
|
||||||
if not _isSysAdmin:
|
if not getattr(self.currentUser, "isSysAdmin", False):
|
||||||
_protectedFields.add("isSystem")
|
_protectedFields.add("isSystem")
|
||||||
if not (_isSysAdmin or _isPlatformAdmin):
|
|
||||||
_protectedFields.add("name")
|
|
||||||
_sanitizedData = {k: v for k, v in updateData.items() if k not in _protectedFields}
|
_sanitizedData = {k: v for k, v in updateData.items() if k not in _protectedFields}
|
||||||
|
|
||||||
if "name" in _sanitizedData:
|
|
||||||
newName = (_sanitizedData["name"] or "").strip()
|
|
||||||
if not isValidMandateName(newName):
|
|
||||||
raise ValueError(
|
|
||||||
"Mandate Kurzzeichen must be 2–32 characters: lowercase a–z, digits, "
|
|
||||||
"hyphens only (single-hyphen segments)."
|
|
||||||
)
|
|
||||||
if newName != mandate.name and newName in self._existingMandateNames(excludeId=mandateId):
|
|
||||||
raise ValueError(f"Mandate Kurzzeichen '{newName}' is already in use")
|
|
||||||
_sanitizedData["name"] = newName
|
|
||||||
|
|
||||||
if "label" in _sanitizedData:
|
|
||||||
newLabel = (_sanitizedData["label"] or "").strip()
|
|
||||||
if not newLabel:
|
|
||||||
raise ValueError("Mandate Voller Name (label) must not be empty.")
|
|
||||||
_sanitizedData["label"] = newLabel
|
|
||||||
|
|
||||||
# Update mandate data using model
|
# Update mandate data using model
|
||||||
updatedData = mandate.model_dump()
|
updatedData = mandate.model_dump()
|
||||||
updatedData.update(_sanitizedData)
|
updatedData.update(_sanitizedData)
|
||||||
|
|
@ -2000,12 +1894,11 @@ class AppObjects:
|
||||||
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import (
|
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import (
|
||||||
AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask,
|
AutoWorkflow, AutoVersion, AutoRun, AutoStepLog, AutoTask,
|
||||||
)
|
)
|
||||||
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import graphicalEditorDatabase
|
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
from modules.connectors.connectorDbPostgre import DatabaseConnector
|
||||||
|
|
||||||
geDb = DatabaseConnector(
|
geDb = DatabaseConnector(
|
||||||
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
|
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
|
||||||
dbDatabase=graphicalEditorDatabase,
|
dbDatabase="poweron_graphicaleditor",
|
||||||
dbUser=APP_CONFIG.get("DB_USER"),
|
dbUser=APP_CONFIG.get("DB_USER"),
|
||||||
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
|
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
|
||||||
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
|
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ 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.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
|
||||||
|
|
@ -110,7 +109,6 @@ _billingInterfaces: Dict[str, "BillingObjects"] = {}
|
||||||
|
|
||||||
# Database name for billing
|
# Database name for billing
|
||||||
BILLING_DATABASE = "poweron_billing"
|
BILLING_DATABASE = "poweron_billing"
|
||||||
registerDatabase(BILLING_DATABASE)
|
|
||||||
|
|
||||||
|
|
||||||
def getInterface(currentUser: User, mandateId: str = None) -> "BillingObjects":
|
def getInterface(currentUser: User, mandateId: str = None) -> "BillingObjects":
|
||||||
|
|
@ -1542,40 +1540,16 @@ class BillingObjects:
|
||||||
if not accountIds:
|
if not accountIds:
|
||||||
return PaginatedResult(items=[], totalItems=0, totalPages=0)
|
return PaginatedResult(items=[], totalItems=0, totalPages=0)
|
||||||
|
|
||||||
# Extract free-text search term and run a custom query that covers
|
recordFilter: Dict[str, Any] = {"accountId": accountIds}
|
||||||
# enriched columns (mandateName, userName) and the numeric amount
|
if userId:
|
||||||
# column. The generic SQL search only covers TEXT columns of the
|
recordFilter["createdByUserId"] = userId
|
||||||
# BillingTransaction table, which excludes these fields.
|
|
||||||
searchTerm: Optional[str] = None
|
|
||||||
if mappedPagination and mappedPagination.filters:
|
|
||||||
raw = mappedPagination.filters.get("search")
|
|
||||||
if isinstance(raw, str) and raw.strip():
|
|
||||||
searchTerm = raw.strip()
|
|
||||||
|
|
||||||
if searchTerm:
|
result = self.db.getRecordsetPaginated(
|
||||||
searchResult = self._searchTransactionsPaginated(
|
BillingTransaction,
|
||||||
allAccounts=allAccounts,
|
pagination=mappedPagination,
|
||||||
accountIds=accountIds,
|
recordFilter=recordFilter,
|
||||||
userId=userId,
|
)
|
||||||
searchTerm=searchTerm,
|
pageItems = result.get("items", []) if isinstance(result, dict) else result.items
|
||||||
pagination=mappedPagination,
|
|
||||||
)
|
|
||||||
pageItems = searchResult["items"]
|
|
||||||
totalItems = searchResult["totalItems"]
|
|
||||||
totalPages = searchResult["totalPages"]
|
|
||||||
else:
|
|
||||||
recordFilter: Dict[str, Any] = {"accountId": accountIds}
|
|
||||||
if userId:
|
|
||||||
recordFilter["createdByUserId"] = userId
|
|
||||||
|
|
||||||
result = self.db.getRecordsetPaginated(
|
|
||||||
BillingTransaction,
|
|
||||||
pagination=mappedPagination,
|
|
||||||
recordFilter=recordFilter,
|
|
||||||
)
|
|
||||||
pageItems = result.get("items", []) if isinstance(result, dict) else result.items
|
|
||||||
totalItems = result.get("totalItems", 0) if isinstance(result, dict) else result.totalItems
|
|
||||||
totalPages = result.get("totalPages", 0) if isinstance(result, dict) else result.totalPages
|
|
||||||
|
|
||||||
accountMap = {a.get("id"): a for a in allAccounts}
|
accountMap = {a.get("id"): a for a in allAccounts}
|
||||||
|
|
||||||
|
|
@ -1618,186 +1592,15 @@ class BillingObjects:
|
||||||
row["userName"] = userMap.get(txUserId, txUserId) if txUserId else None
|
row["userName"] = userMap.get(txUserId, txUserId) if txUserId else None
|
||||||
enriched.append(row)
|
enriched.append(row)
|
||||||
|
|
||||||
|
totalItems = result.get("totalItems", 0) if isinstance(result, dict) else result.totalItems
|
||||||
|
totalPages = result.get("totalPages", 0) if isinstance(result, dict) else result.totalPages
|
||||||
|
|
||||||
return PaginatedResult(items=enriched, totalItems=totalItems, totalPages=totalPages)
|
return PaginatedResult(items=enriched, totalItems=totalItems, totalPages=totalPages)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error in getTransactionsForMandatesPaginated: {e}")
|
logger.error(f"Error in getTransactionsForMandatesPaginated: {e}")
|
||||||
return PaginatedResult(items=[], totalItems=0, totalPages=0)
|
return PaginatedResult(items=[], totalItems=0, totalPages=0)
|
||||||
|
|
||||||
def _searchTransactionsPaginated(
|
|
||||||
self,
|
|
||||||
allAccounts: List[Dict[str, Any]],
|
|
||||||
accountIds: List[str],
|
|
||||||
userId: Optional[str],
|
|
||||||
searchTerm: str,
|
|
||||||
pagination: PaginationParams,
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""
|
|
||||||
Custom paginated search for BillingTransaction that also covers the
|
|
||||||
enriched columns `mandateName` and `userName` as well as the numeric
|
|
||||||
`amount` column. Resolves matching mandate/user IDs via the app DB
|
|
||||||
first, then builds a single SQL query with OR-combined conditions.
|
|
||||||
"""
|
|
||||||
import math
|
|
||||||
from modules.connectors.connectorDbPostgre import _get_model_fields, _parseRecordFields
|
|
||||||
from modules.datamodels.datamodelUam import UserInDB
|
|
||||||
from modules.interfaces.interfaceDbApp import getInterface as getAppInterface
|
|
||||||
|
|
||||||
table = BillingTransaction.__name__
|
|
||||||
fields = _get_model_fields(BillingTransaction)
|
|
||||||
pattern = f"%{searchTerm}%"
|
|
||||||
|
|
||||||
# Resolve matching user / mandate IDs via the app DB (which is separate
|
|
||||||
# from the billing DB and hosts UserInDB / Mandate tables).
|
|
||||||
matchingUserIds: List[str] = []
|
|
||||||
matchingMandateIds: List[str] = []
|
|
||||||
try:
|
|
||||||
appInterface = getAppInterface(self.currentUser)
|
|
||||||
appInterface.db._ensure_connection()
|
|
||||||
with appInterface.db.connection.cursor() as cur:
|
|
||||||
if appInterface.db._ensureTableExists(UserInDB):
|
|
||||||
cur.execute(
|
|
||||||
'SELECT "id" FROM "UserInDB" WHERE '
|
|
||||||
'COALESCE("username", \'\') ILIKE %s OR '
|
|
||||||
'COALESCE("fullName", \'\') ILIKE %s OR '
|
|
||||||
'COALESCE("email", \'\') ILIKE %s',
|
|
||||||
(pattern, pattern, pattern),
|
|
||||||
)
|
|
||||||
matchingUserIds = [r["id"] for r in cur.fetchall() if r.get("id")]
|
|
||||||
|
|
||||||
if appInterface.db._ensureTableExists(Mandate):
|
|
||||||
cur.execute(
|
|
||||||
'SELECT "id" FROM "Mandate" WHERE '
|
|
||||||
'COALESCE("label", \'\') ILIKE %s OR '
|
|
||||||
'COALESCE("name", \'\') ILIKE %s',
|
|
||||||
(pattern, pattern),
|
|
||||||
)
|
|
||||||
matchingMandateIds = [r["id"] for r in cur.fetchall() if r.get("id")]
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"_searchTransactionsPaginated: user/mandate resolution failed: {e}")
|
|
||||||
|
|
||||||
matchingAccountIds = [
|
|
||||||
a.get("id") for a in allAccounts
|
|
||||||
if a.get("id") and a.get("mandateId") in set(matchingMandateIds)
|
|
||||||
]
|
|
||||||
|
|
||||||
# Try to interpret the search term as a number for amount matching.
|
|
||||||
amountVal: Optional[float] = None
|
|
||||||
try:
|
|
||||||
amountVal = float(searchTerm.replace(",", "."))
|
|
||||||
except Exception:
|
|
||||||
amountVal = None
|
|
||||||
|
|
||||||
whereParts: List[str] = ['"accountId" = ANY(%s)']
|
|
||||||
whereValues: List[Any] = [accountIds]
|
|
||||||
if userId:
|
|
||||||
whereParts.append('"createdByUserId" = %s')
|
|
||||||
whereValues.append(userId)
|
|
||||||
|
|
||||||
# Apply non-search filters from pagination (reuse existing builder for
|
|
||||||
# everything except the `search` key which we handle explicitly).
|
|
||||||
import copy
|
|
||||||
paginationWithoutSearch = copy.deepcopy(pagination) if pagination else None
|
|
||||||
if paginationWithoutSearch and paginationWithoutSearch.filters:
|
|
||||||
paginationWithoutSearch.filters = {
|
|
||||||
k: v for k, v in paginationWithoutSearch.filters.items() if k != "search"
|
|
||||||
}
|
|
||||||
|
|
||||||
orParts: List[str] = []
|
|
||||||
orValues: List[Any] = []
|
|
||||||
|
|
||||||
textCols = [c for c, t in fields.items() if t == "TEXT"]
|
|
||||||
for col in textCols:
|
|
||||||
orParts.append(f'COALESCE("{col}"::TEXT, \'\') ILIKE %s')
|
|
||||||
orValues.append(pattern)
|
|
||||||
|
|
||||||
if matchingUserIds:
|
|
||||||
orParts.append('"createdByUserId" = ANY(%s)')
|
|
||||||
orValues.append(matchingUserIds)
|
|
||||||
if matchingAccountIds:
|
|
||||||
orParts.append('"accountId" = ANY(%s)')
|
|
||||||
orValues.append(matchingAccountIds)
|
|
||||||
|
|
||||||
orParts.append('"amount"::TEXT ILIKE %s')
|
|
||||||
orValues.append(pattern)
|
|
||||||
if amountVal is not None:
|
|
||||||
orParts.append('"amount" = %s')
|
|
||||||
orValues.append(amountVal)
|
|
||||||
|
|
||||||
whereParts.append(f"({' OR '.join(orParts)})")
|
|
||||||
whereValues.extend(orValues)
|
|
||||||
|
|
||||||
# Apply remaining structured filters via the generic helper by feeding
|
|
||||||
# it a dummy pagination that does NOT include LIMIT/OFFSET. We only
|
|
||||||
# need the WHERE contribution for the non-search filters here.
|
|
||||||
extraWhere = ""
|
|
||||||
extraValues: List[Any] = []
|
|
||||||
if paginationWithoutSearch and paginationWithoutSearch.filters:
|
|
||||||
try:
|
|
||||||
fromPagination = copy.deepcopy(paginationWithoutSearch)
|
|
||||||
fromPagination.sort = []
|
|
||||||
fromPagination.page = 1
|
|
||||||
fromPagination.pageSize = 1
|
|
||||||
ew, _, _, values, _ = self.db._buildPaginationClauses(
|
|
||||||
BillingTransaction, fromPagination, recordFilter=None
|
|
||||||
)
|
|
||||||
if ew:
|
|
||||||
extraWhere = ew.replace(" WHERE ", " AND ", 1)
|
|
||||||
extraValues = list(values)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"_searchTransactionsPaginated: extra-filter build failed: {e}")
|
|
||||||
|
|
||||||
whereClause = " WHERE " + " AND ".join(whereParts) + extraWhere
|
|
||||||
whereValues.extend(extraValues)
|
|
||||||
|
|
||||||
# Build ORDER BY from pagination.sort
|
|
||||||
validColumns = set(fields.keys())
|
|
||||||
orderParts: List[str] = []
|
|
||||||
if pagination and pagination.sort:
|
|
||||||
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 validColumns:
|
|
||||||
direction = "DESC" if str(sfDir).lower() == "desc" else "ASC"
|
|
||||||
colType = fields.get(sfField, "TEXT")
|
|
||||||
if colType == "BOOLEAN":
|
|
||||||
orderParts.append(f'COALESCE("{sfField}", FALSE) {direction}')
|
|
||||||
else:
|
|
||||||
orderParts.append(f'"{sfField}" {direction} NULLS LAST')
|
|
||||||
if not orderParts:
|
|
||||||
orderParts.append('"id"')
|
|
||||||
orderClause = " ORDER BY " + ", ".join(orderParts)
|
|
||||||
|
|
||||||
pageSize = pagination.pageSize if pagination else 50
|
|
||||||
page = pagination.page if pagination else 1
|
|
||||||
offset = (page - 1) * pageSize
|
|
||||||
limitClause = f" LIMIT {pageSize} OFFSET {offset}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.db._ensure_connection()
|
|
||||||
with self.db.connection.cursor() as cur:
|
|
||||||
countSql = f'SELECT COUNT(*) FROM "{table}"{whereClause}'
|
|
||||||
cur.execute(countSql, whereValues)
|
|
||||||
totalItems = cur.fetchone()["count"]
|
|
||||||
|
|
||||||
dataSql = f'SELECT * FROM "{table}"{whereClause}{orderClause}{limitClause}'
|
|
||||||
cur.execute(dataSql, whereValues)
|
|
||||||
records = [dict(row) for row in cur.fetchall()]
|
|
||||||
|
|
||||||
for rec in records:
|
|
||||||
_parseRecordFields(rec, fields, f"search table {table}")
|
|
||||||
|
|
||||||
totalPages = math.ceil(totalItems / pageSize) if totalItems > 0 else 0
|
|
||||||
return {"items": records, "totalItems": totalItems, "totalPages": totalPages}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"_searchTransactionsPaginated SQL error: {e}", exc_info=True)
|
|
||||||
try:
|
|
||||||
self.db.connection.rollback()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return {"items": [], "totalItems": 0, "totalPages": 0}
|
|
||||||
|
|
||||||
def _buildScopeFilter(
|
def _buildScopeFilter(
|
||||||
self,
|
self,
|
||||||
mandateIds: Optional[List[str]],
|
mandateIds: Optional[List[str]],
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ 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.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
|
||||||
|
|
@ -38,9 +37,6 @@ from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
chatDatabase = "poweron_chat"
|
|
||||||
registerDatabase(chatDatabase)
|
|
||||||
|
|
||||||
# Singleton factory for Chat instances
|
# Singleton factory for Chat instances
|
||||||
_chatInterfaces = {}
|
_chatInterfaces = {}
|
||||||
|
|
||||||
|
|
@ -318,7 +314,7 @@ class ChatObjects:
|
||||||
try:
|
try:
|
||||||
# Get configuration values with defaults
|
# Get configuration values with defaults
|
||||||
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
||||||
dbDatabase = chatDatabase
|
dbDatabase = "poweron_chat"
|
||||||
dbUser = APP_CONFIG.get("DB_USER")
|
dbUser = APP_CONFIG.get("DB_USER")
|
||||||
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
||||||
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
||||||
|
|
@ -655,27 +651,17 @@ class ChatObjects:
|
||||||
totalPages=totalPages
|
totalPages=totalPages
|
||||||
)
|
)
|
||||||
|
|
||||||
def getLastMessageTimestamp(self, workflowId: str) -> Optional[float]:
|
def getLastMessageTimestamp(self, workflowId: str) -> Optional[str]:
|
||||||
"""
|
"""Return the latest publishedAt/sysCreatedAt from ChatMessage for a workflow."""
|
||||||
Return the latest publishedAt/sysCreatedAt from ChatMessage for a workflow
|
|
||||||
as UTC seconds (float) — matches the timestamp format used across the
|
|
||||||
rest of the chat data model (lastActivity, startedAt, publishedAt).
|
|
||||||
"""
|
|
||||||
messages = self._getRecordset(ChatMessage, recordFilter={"workflowId": workflowId})
|
messages = self._getRecordset(ChatMessage, recordFilter={"workflowId": workflowId})
|
||||||
if not messages:
|
if not messages:
|
||||||
return None
|
return None
|
||||||
latest: Optional[float] = None
|
latest = None
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
raw = msg.get("publishedAt") or msg.get("sysCreatedAt")
|
ts = msg.get("publishedAt") or msg.get("sysCreatedAt")
|
||||||
if raw is None:
|
if ts and (latest is None or str(ts) > str(latest)):
|
||||||
continue
|
|
||||||
try:
|
|
||||||
ts = float(raw)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
if latest is None or ts > latest:
|
|
||||||
latest = ts
|
latest = ts
|
||||||
return latest
|
return str(latest) if latest else None
|
||||||
|
|
||||||
def searchWorkflowsByContent(self, query: str, limit: int = 50) -> List[str]:
|
def searchWorkflowsByContent(self, query: str, limit: int = 50) -> List[str]:
|
||||||
"""Return workflow IDs whose messages contain the query string (case-insensitive)."""
|
"""Return workflow IDs whose messages contain the query string (case-insensitive)."""
|
||||||
|
|
@ -722,8 +708,6 @@ class ChatObjects:
|
||||||
|
|
||||||
return ChatWorkflow(
|
return ChatWorkflow(
|
||||||
id=workflow["id"],
|
id=workflow["id"],
|
||||||
featureInstanceId=workflow.get("featureInstanceId"),
|
|
||||||
linkedWorkflowId=workflow.get("linkedWorkflowId"),
|
|
||||||
status=workflow.get("status", "running"),
|
status=workflow.get("status", "running"),
|
||||||
name=workflow.get("name"),
|
name=workflow.get("name"),
|
||||||
currentRound=_toInt(workflow.get("currentRound")),
|
currentRound=_toInt(workflow.get("currentRound")),
|
||||||
|
|
@ -740,54 +724,6 @@ class ChatObjects:
|
||||||
logger.error(f"getWorkflow: data validation failed for {workflowId}: {e}")
|
logger.error(f"getWorkflow: data validation failed for {workflowId}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def getWorkflowByLink(
|
|
||||||
self,
|
|
||||||
featureInstanceId: str,
|
|
||||||
linkedWorkflowId: str,
|
|
||||||
) -> Optional[ChatWorkflow]:
|
|
||||||
"""Return the ChatWorkflow linked to (featureInstanceId, linkedWorkflowId), if any.
|
|
||||||
|
|
||||||
Used by editor-style features (e.g. GraphicalEditor AI editor chat) to
|
|
||||||
find the persisted chat for a specific external entity (Automation2Workflow).
|
|
||||||
Falls under the same RBAC as ``getWorkflow``.
|
|
||||||
"""
|
|
||||||
if not featureInstanceId or not linkedWorkflowId:
|
|
||||||
return None
|
|
||||||
rows = self._getRecordset(
|
|
||||||
ChatWorkflow,
|
|
||||||
recordFilter={
|
|
||||||
"featureInstanceId": featureInstanceId,
|
|
||||||
"linkedWorkflowId": linkedWorkflowId,
|
|
||||||
},
|
|
||||||
) or []
|
|
||||||
if not rows:
|
|
||||||
return None
|
|
||||||
# Return the most recently active one if multiple ever exist (defensive).
|
|
||||||
rows.sort(key=lambda r: float(r.get("lastActivity") or r.get("startedAt") or 0), reverse=True)
|
|
||||||
return self.getWorkflow(rows[0]["id"])
|
|
||||||
|
|
||||||
def getOrCreateLinkedWorkflow(
|
|
||||||
self,
|
|
||||||
featureInstanceId: str,
|
|
||||||
linkedWorkflowId: str,
|
|
||||||
name: Optional[str] = None,
|
|
||||||
) -> ChatWorkflow:
|
|
||||||
"""Find or create the ChatWorkflow linked to a specific external entity.
|
|
||||||
|
|
||||||
Editor-style features call this once at the start of a chat exchange to
|
|
||||||
guarantee a 1:1 mapping between (featureInstanceId, linkedWorkflowId)
|
|
||||||
and a persisted ChatWorkflow row.
|
|
||||||
"""
|
|
||||||
existing = self.getWorkflowByLink(featureInstanceId, linkedWorkflowId)
|
|
||||||
if existing:
|
|
||||||
return existing
|
|
||||||
return self.createWorkflow({
|
|
||||||
"featureInstanceId": featureInstanceId,
|
|
||||||
"linkedWorkflowId": linkedWorkflowId,
|
|
||||||
"status": "active",
|
|
||||||
"name": name or "",
|
|
||||||
})
|
|
||||||
|
|
||||||
def createWorkflow(self, workflowData: Dict[str, Any]) -> ChatWorkflow:
|
def createWorkflow(self, workflowData: Dict[str, Any]) -> ChatWorkflow:
|
||||||
"""Creates a new workflow if user has permission."""
|
"""Creates a new workflow if user has permission."""
|
||||||
if not self.checkRbacPermission(ChatWorkflow, "create"):
|
if not self.checkRbacPermission(ChatWorkflow, "create"):
|
||||||
|
|
@ -835,8 +771,6 @@ class ChatObjects:
|
||||||
# Convert to ChatWorkflow model (empty related data for new workflow)
|
# Convert to ChatWorkflow model (empty related data for new workflow)
|
||||||
return ChatWorkflow(
|
return ChatWorkflow(
|
||||||
id=created["id"],
|
id=created["id"],
|
||||||
featureInstanceId=created.get("featureInstanceId"),
|
|
||||||
linkedWorkflowId=created.get("linkedWorkflowId"),
|
|
||||||
status=created.get("status", "running"),
|
status=created.get("status", "running"),
|
||||||
name=created.get("name"),
|
name=created.get("name"),
|
||||||
currentRound=created.get("currentRound", 0) or 0,
|
currentRound=created.get("currentRound", 0) or 0,
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ 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 _get_cached_connector
|
from modules.connectors.connectorDbPostgre import _get_cached_connector
|
||||||
from modules.shared.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
|
||||||
|
|
@ -20,9 +19,6 @@ from modules.shared.timeUtils import getUtcTimestamp
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
knowledgeDatabase = "poweron_knowledge"
|
|
||||||
registerDatabase(knowledgeDatabase)
|
|
||||||
|
|
||||||
_instances: Dict[str, "KnowledgeObjects"] = {}
|
_instances: Dict[str, "KnowledgeObjects"] = {}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -38,7 +34,7 @@ class KnowledgeObjects:
|
||||||
|
|
||||||
def _initializeDatabase(self):
|
def _initializeDatabase(self):
|
||||||
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
||||||
dbDatabase = knowledgeDatabase
|
dbDatabase = "poweron_knowledge"
|
||||||
dbUser = APP_CONFIG.get("DB_USER")
|
dbUser = APP_CONFIG.get("DB_USER")
|
||||||
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
||||||
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ import mimetypes
|
||||||
from typing import Dict, Any, List, Optional, Union
|
from typing import Dict, Any, List, Optional, Union
|
||||||
|
|
||||||
from modules.connectors.connectorDbPostgre import DatabaseConnector, _get_cached_connector
|
from modules.connectors.connectorDbPostgre import DatabaseConnector, _get_cached_connector
|
||||||
from modules.shared.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
|
||||||
|
|
@ -35,9 +34,6 @@ from modules.datamodels.datamodelPagination import PaginationParams, PaginatedRe
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
managementDatabase = "poweron_management"
|
|
||||||
registerDatabase(managementDatabase)
|
|
||||||
|
|
||||||
# Singleton factory for Management instances with AI service per context
|
# Singleton factory for Management instances with AI service per context
|
||||||
_instancesManagement = {}
|
_instancesManagement = {}
|
||||||
|
|
||||||
|
|
@ -131,7 +127,7 @@ class ComponentObjects:
|
||||||
try:
|
try:
|
||||||
# Get configuration values with defaults
|
# Get configuration values with defaults
|
||||||
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data")
|
||||||
dbDatabase = managementDatabase
|
dbDatabase = "poweron_management"
|
||||||
dbUser = APP_CONFIG.get("DB_USER")
|
dbUser = APP_CONFIG.get("DB_USER")
|
||||||
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET")
|
||||||
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
dbPort = int(APP_CONFIG.get("DB_PORT", 5432))
|
||||||
|
|
@ -635,8 +631,12 @@ class ComponentObjects:
|
||||||
# Prompt methods
|
# Prompt methods
|
||||||
|
|
||||||
def _isSysAdmin(self) -> bool:
|
def _isSysAdmin(self) -> bool:
|
||||||
"""Check if the current user has the isSysAdmin flag (infrastructure operator)."""
|
"""Check if the current user has sysadmin role (or isSysAdmin flag as fallback)."""
|
||||||
return bool(getattr(self.currentUser, 'isSysAdmin', False))
|
from modules.auth.authentication import _hasSysAdminRole
|
||||||
|
userId = getattr(self.currentUser, 'id', None)
|
||||||
|
if userId and _hasSysAdminRole(str(userId)):
|
||||||
|
return True
|
||||||
|
return hasattr(self.currentUser, 'isSysAdmin') and self.currentUser.isSysAdmin
|
||||||
|
|
||||||
def _enrichPromptsWithPermissions(self, prompts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
def _enrichPromptsWithPermissions(self, prompts: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||||
"""Enrich prompts with row-level _permissions based on ownership and isSystem flag.
|
"""Enrich prompts with row-level _permissions based on ownership and isSystem flag.
|
||||||
|
|
@ -1087,32 +1087,29 @@ class ComponentObjects:
|
||||||
return newfileName
|
return newfileName
|
||||||
counter += 1
|
counter += 1
|
||||||
|
|
||||||
def createFile(self, name: str, mimeType: str, content: bytes, folderId: Optional[str] = None) -> FileItem:
|
def createFile(self, name: str, mimeType: str, content: bytes) -> FileItem:
|
||||||
"""Creates a new file entry if user has permission. Computes fileHash and fileSize from content.
|
"""Creates a new file entry if user has permission. Computes fileHash and fileSize from content.
|
||||||
|
|
||||||
Duplicate check: if a file with the same user + fileHash + fileName already exists,
|
Duplicate check: if a file with the same user + fileHash + fileName already exists,
|
||||||
the existing file is returned instead of creating a new one.
|
the existing file is returned instead of creating a new one.
|
||||||
Same hash with different name is allowed (intentional copy by user).
|
Same hash with different name is allowed (intentional copy by user).
|
||||||
|
|
||||||
Args:
|
|
||||||
folderId: Optional parent folder ID. None/empty means the root folder.
|
|
||||||
"""
|
"""
|
||||||
if not self.checkRbacPermission(FileItem, "create"):
|
if not self.checkRbacPermission(FileItem, "create"):
|
||||||
raise PermissionError("No permission to create files")
|
raise PermissionError("No permission to create files")
|
||||||
|
|
||||||
# Compute file size and hash
|
# Compute file size and hash
|
||||||
fileSize = len(content)
|
fileSize = len(content)
|
||||||
fileHash = hashlib.sha256(content).hexdigest()
|
fileHash = hashlib.sha256(content).hexdigest()
|
||||||
|
|
||||||
# Duplicate check: same user + same hash + same fileName → return existing
|
# Duplicate check: same user + same hash + same fileName → return existing
|
||||||
existingFile = self.checkForDuplicateFile(fileHash, name)
|
existingFile = self.checkForDuplicateFile(fileHash, name)
|
||||||
if existingFile:
|
if existingFile:
|
||||||
logger.info(f"Duplicate file detected in createFile: '{name}' (hash={fileHash[:12]}...) for user {self.userId} — returning existing file {existingFile.id}")
|
logger.info(f"Duplicate file detected in createFile: '{name}' (hash={fileHash[:12]}...) for user {self.userId} — returning existing file {existingFile.id}")
|
||||||
return existingFile
|
return existingFile
|
||||||
|
|
||||||
# Ensure fileName is unique
|
# Ensure fileName is unique
|
||||||
uniqueName = self._generateUniquefileName(name)
|
uniqueName = self._generateUniquefileName(name)
|
||||||
|
|
||||||
mandateId = self.mandateId or ""
|
mandateId = self.mandateId or ""
|
||||||
featureInstanceId = self.featureInstanceId or ""
|
featureInstanceId = self.featureInstanceId or ""
|
||||||
|
|
||||||
|
|
@ -1123,11 +1120,6 @@ class ComponentObjects:
|
||||||
else:
|
else:
|
||||||
scope = "personal"
|
scope = "personal"
|
||||||
|
|
||||||
# Normalize folderId: treat empty string as "no folder" (= root) – NULL in DB
|
|
||||||
normalizedFolderId: Optional[str] = folderId
|
|
||||||
if isinstance(normalizedFolderId, str) and not normalizedFolderId.strip():
|
|
||||||
normalizedFolderId = None
|
|
||||||
|
|
||||||
fileItem = FileItem(
|
fileItem = FileItem(
|
||||||
mandateId=mandateId,
|
mandateId=mandateId,
|
||||||
featureInstanceId=featureInstanceId,
|
featureInstanceId=featureInstanceId,
|
||||||
|
|
@ -1136,7 +1128,7 @@ class ComponentObjects:
|
||||||
mimeType=mimeType,
|
mimeType=mimeType,
|
||||||
fileSize=fileSize,
|
fileSize=fileSize,
|
||||||
fileHash=fileHash,
|
fileHash=fileHash,
|
||||||
folderId=normalizedFolderId,
|
folderId="",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Store in database
|
# Store in database
|
||||||
|
|
@ -1404,24 +1396,6 @@ class ComponentObjects:
|
||||||
self._validateFolderName(newName, folder.get("parentId"), excludeFolderId=folderId)
|
self._validateFolderName(newName, folder.get("parentId"), excludeFolderId=folderId)
|
||||||
return self.db.recordModify(FileFolder, folderId, {"name": newName})
|
return self.db.recordModify(FileFolder, folderId, {"name": newName})
|
||||||
|
|
||||||
def updateFolder(self, folderId: str, updateData: Dict[str, Any]) -> bool:
|
|
||||||
"""
|
|
||||||
Update folder metadata (e.g. ``scope``, ``neutralize``). Owner-only,
|
|
||||||
same access model as renameFolder/moveFolder. Use ``renameFolder`` for
|
|
||||||
``name`` changes (uniqueness validation) and ``moveFolder`` for
|
|
||||||
``parentId`` changes (cycle/uniqueness validation).
|
|
||||||
"""
|
|
||||||
if not updateData:
|
|
||||||
return True
|
|
||||||
folder = self.getFolder(folderId)
|
|
||||||
if not folder:
|
|
||||||
raise FileNotFoundError(f"Folder {folderId} not found")
|
|
||||||
forbiddenKeys = {"id", "sysCreatedBy", "sysCreatedAt", "sysUpdatedAt"}
|
|
||||||
cleaned: Dict[str, Any] = {k: v for k, v in updateData.items() if k not in forbiddenKeys}
|
|
||||||
if "name" in cleaned:
|
|
||||||
self._validateFolderName(cleaned["name"], folder.get("parentId"), excludeFolderId=folderId)
|
|
||||||
return self.db.recordModify(FileFolder, folderId, cleaned)
|
|
||||||
|
|
||||||
def moveFolder(self, folderId: str, targetParentId: Optional[str] = None) -> bool:
|
def moveFolder(self, folderId: str, targetParentId: Optional[str] = None) -> bool:
|
||||||
"""Move a folder to a new parent, with circular reference and unique name checks."""
|
"""Move a folder to a new parent, with circular reference and unique name checks."""
|
||||||
folder = self.getFolder(folderId)
|
folder = self.getFolder(folderId)
|
||||||
|
|
@ -1868,44 +1842,39 @@ class ComponentObjects:
|
||||||
logger.error(f"Error getting file content: {str(e)}")
|
logger.error(f"Error getting file content: {str(e)}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def saveUploadedFile(self, fileContent: bytes, fileName: str, folderId: Optional[str] = None) -> tuple[FileItem, str]:
|
def saveUploadedFile(self, fileContent: bytes, fileName: str) -> tuple[FileItem, str]:
|
||||||
"""Saves an uploaded file if user has permission.
|
"""Saves an uploaded file if user has permission."""
|
||||||
|
|
||||||
Args:
|
|
||||||
folderId: Optional parent folder ID. None means root folder.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
# Check file creation permission
|
# Check file creation permission
|
||||||
if not self.checkRbacPermission(FileItem, "create"):
|
if not self.checkRbacPermission(FileItem, "create"):
|
||||||
raise PermissionError("No permission to upload files")
|
raise PermissionError("No permission to upload files")
|
||||||
|
|
||||||
logger.debug(f"Starting upload process for file: {fileName} (folderId={folderId!r})")
|
logger.debug(f"Starting upload process for file: {fileName}")
|
||||||
|
|
||||||
if not isinstance(fileContent, bytes):
|
if not isinstance(fileContent, bytes):
|
||||||
logger.error(f"Invalid fileContent type: {type(fileContent)}")
|
logger.error(f"Invalid fileContent type: {type(fileContent)}")
|
||||||
raise ValueError(f"fileContent must be bytes, got {type(fileContent)}")
|
raise ValueError(f"fileContent must be bytes, got {type(fileContent)}")
|
||||||
|
|
||||||
# Compute file hash to check for duplicates before any DB writes
|
# Compute file hash to check for duplicates before any DB writes
|
||||||
fileHash = hashlib.sha256(fileContent).hexdigest()
|
fileHash = hashlib.sha256(fileContent).hexdigest()
|
||||||
|
|
||||||
# Duplicate check: same user + same fileHash + same fileName → return existing file
|
# Duplicate check: same user + same fileHash + same fileName → return existing file
|
||||||
# Same hash with different name is allowed (intentional copy by user)
|
# Same hash with different name is allowed (intentional copy by user)
|
||||||
existingFile = self.checkForDuplicateFile(fileHash, fileName)
|
existingFile = self.checkForDuplicateFile(fileHash, fileName)
|
||||||
if existingFile:
|
if existingFile:
|
||||||
logger.info(f"Duplicate detected for user {self.userId}: '{fileName}' with hash {fileHash[:12]}... — returning existing file {existingFile.id}")
|
logger.info(f"Duplicate detected for user {self.userId}: '{fileName}' with hash {fileHash[:12]}... — returning existing file {existingFile.id}")
|
||||||
return existingFile, "exact_duplicate"
|
return existingFile, "exact_duplicate"
|
||||||
|
|
||||||
# Determine MIME type
|
# Determine MIME type
|
||||||
mimeType = self.getMimeType(fileName)
|
mimeType = self.getMimeType(fileName)
|
||||||
|
|
||||||
# createFile handles its own duplicate check (for calls from other code paths)
|
# createFile handles its own duplicate check (for calls from other code paths)
|
||||||
# Here we already checked, so this will create a new file
|
# Here we already checked, so this will create a new file
|
||||||
logger.debug(f"Saving file metadata to database for file: {fileName}")
|
logger.debug(f"Saving file metadata to database for file: {fileName}")
|
||||||
fileItem = self.createFile(
|
fileItem = self.createFile(
|
||||||
name=fileName,
|
name=fileName,
|
||||||
mimeType=mimeType,
|
mimeType=mimeType,
|
||||||
content=fileContent,
|
content=fileContent
|
||||||
folderId=folderId,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save binary data
|
# Save binary data
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ 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.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 (
|
||||||
|
|
@ -32,7 +31,6 @@ from modules.datamodels.datamodelSubscription import (
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SUBSCRIPTION_DATABASE = "poweron_billing"
|
SUBSCRIPTION_DATABASE = "poweron_billing"
|
||||||
registerDatabase(SUBSCRIPTION_DATABASE)
|
|
||||||
|
|
||||||
_subscriptionInterfaces: Dict[str, "SubscriptionObjects"] = {}
|
_subscriptionInterfaces: Dict[str, "SubscriptionObjects"] = {}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -393,13 +393,6 @@ def getRecordsetPaginatedWithRBAC(
|
||||||
continue
|
continue
|
||||||
if key not in validColumns:
|
if key not in validColumns:
|
||||||
continue
|
continue
|
||||||
if val is None:
|
|
||||||
# val=None in pagination.filters means "match empty/null"
|
|
||||||
# (same convention as connectorDbPostgre._buildPaginationClauses).
|
|
||||||
# Covers both historical empty-string values and true NULLs
|
|
||||||
# e.g. root-folder files where folderId may be "" or NULL.
|
|
||||||
whereConditions.append(f'("{key}" IS NULL OR "{key}"::TEXT = \'\')')
|
|
||||||
continue
|
|
||||||
if isinstance(val, dict):
|
if isinstance(val, dict):
|
||||||
op = val.get("operator", "equals")
|
op = val.get("operator", "equals")
|
||||||
v = val.get("value", "")
|
v = val.get("value", "")
|
||||||
|
|
@ -576,13 +569,6 @@ def getDistinctColumnValuesWithRBAC(
|
||||||
continue
|
continue
|
||||||
if key not in validColumns:
|
if key not in validColumns:
|
||||||
continue
|
continue
|
||||||
if val is None:
|
|
||||||
# val=None in pagination.filters means "match empty/null"
|
|
||||||
# (same convention as connectorDbPostgre._buildPaginationClauses).
|
|
||||||
# Covers both historical empty-string values and true NULLs
|
|
||||||
# e.g. root-folder files where folderId may be "" or NULL.
|
|
||||||
whereConditions.append(f'("{key}" IS NULL OR "{key}"::TEXT = \'\')')
|
|
||||||
continue
|
|
||||||
if isinstance(val, dict):
|
if isinstance(val, dict):
|
||||||
op = val.get("operator", "equals")
|
op = val.get("operator", "equals")
|
||||||
v = val.get("value", "")
|
v = val.get("value", "")
|
||||||
|
|
|
||||||
|
|
@ -181,26 +181,21 @@ class VoiceObjects:
|
||||||
"error": str(e)
|
"error": str(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
async def translateText(self, text: str,
|
async def translateText(self, text: str, sourceLanguage: str = "de",
|
||||||
sourceLanguage: Optional[str] = None,
|
|
||||||
targetLanguage: str = "en") -> Dict[str, Any]:
|
targetLanguage: str = "en") -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Translate text using Google Cloud Translation API.
|
Translate text using Google Cloud Translation API.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
text: Text to translate
|
text: Text to translate
|
||||||
sourceLanguage: Source language ISO code (e.g. 'de', 'en'); pass None
|
sourceLanguage: Source language code (e.g., 'de', 'en')
|
||||||
or 'auto' to let Google auto-detect.
|
targetLanguage: Target language code (e.g., 'en', 'de')
|
||||||
targetLanguage: Target language ISO code (e.g. 'en', 'de')
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict containing translated text and metadata
|
Dict containing translated text and metadata
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logger.info(
|
logger.info(f"🌐 Translation request: '{text}' ({sourceLanguage} -> {targetLanguage})")
|
||||||
f"🌐 Translation request: '{text}' "
|
|
||||||
f"({sourceLanguage or 'auto'} -> {targetLanguage})"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not text.strip():
|
if not text.strip():
|
||||||
return {
|
return {
|
||||||
|
|
@ -338,11 +333,36 @@ class VoiceObjects:
|
||||||
"error": str(e)
|
"error": str(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
# Voice Information
|
# Language and Voice Information
|
||||||
# Note: Available languages live in the central voice catalog
|
|
||||||
# (modules.shared.voiceCatalog); voice picks per language stay live from
|
async def getAvailableLanguages(self) -> Dict[str, Any]:
|
||||||
# Google so users can see all available speakers per locale.
|
"""
|
||||||
|
Get available languages from Google Cloud Text-to-Speech.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict containing success status and list of available languages
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info("🌐 Getting available languages from Google Cloud TTS")
|
||||||
|
|
||||||
|
connector = self._getGoogleSpeechConnector()
|
||||||
|
result = await connector.getAvailableLanguages()
|
||||||
|
|
||||||
|
if result["success"]:
|
||||||
|
logger.info(f"✅ Found {len(result['languages'])} available languages")
|
||||||
|
else:
|
||||||
|
logger.warning(f"⚠️ Failed to get languages: {result.get('error', 'Unknown error')}")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error getting available languages: {e}")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"languages": []
|
||||||
|
}
|
||||||
|
|
||||||
async def getAvailableVoices(self, languageCode: Optional[str] = None) -> Dict[str, Any]:
|
async def getAvailableVoices(self, languageCode: Optional[str] = None) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Get available voices from Google Cloud Text-to-Speech.
|
Get available voices from Google Cloud Text-to-Speech.
|
||||||
|
|
|
||||||
|
|
@ -1,102 +0,0 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
|
||||||
# All rights reserved.
|
|
||||||
"""
|
|
||||||
SysAdmin API for database table statistics and FK orphan detection/cleanup.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from typing import Any, Dict, List, Optional
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
|
|
||||||
from modules.auth import limiter
|
|
||||||
from modules.auth.authentication import requireSysAdmin
|
|
||||||
from modules.datamodels.datamodelUam import User
|
|
||||||
from modules.system.databaseHealth import (
|
|
||||||
_cleanAllOrphans,
|
|
||||||
_cleanOrphans,
|
|
||||||
_getTableStats,
|
|
||||||
_scanOrphans,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
router = APIRouter(
|
|
||||||
prefix="/api/admin/database-health",
|
|
||||||
tags=["Admin Database Health"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class OrphanCleanRequest(BaseModel):
|
|
||||||
"""Body for deleting orphans for one FK relationship."""
|
|
||||||
|
|
||||||
db: str = Field(..., description="Source database name (e.g. poweron_app)")
|
|
||||||
table: str = Field(..., description="Source table (Pydantic model class name)")
|
|
||||||
column: str = Field(..., description="FK column on the source table")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats")
|
|
||||||
@limiter.limit("30/minute")
|
|
||||||
def getDatabaseTableStats(
|
|
||||||
request: Request,
|
|
||||||
db: Optional[str] = None,
|
|
||||||
currentUser: User = Depends(requireSysAdmin),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Table statistics from pg_stat_user_tables (optional filter by database name)."""
|
|
||||||
rows = _getTableStats(dbFilter=db)
|
|
||||||
return {"stats": rows}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/orphans")
|
|
||||||
@limiter.limit("10/minute")
|
|
||||||
def getDatabaseOrphans(
|
|
||||||
request: Request,
|
|
||||||
db: Optional[str] = None,
|
|
||||||
currentUser: User = Depends(requireSysAdmin),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""FK orphan scan (optional filter by source database name)."""
|
|
||||||
rows = _scanOrphans(dbFilter=db)
|
|
||||||
return {"orphans": rows}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/orphans/clean")
|
|
||||||
@limiter.limit("10/minute")
|
|
||||||
def postDatabaseOrphansClean(
|
|
||||||
request: Request,
|
|
||||||
body: OrphanCleanRequest,
|
|
||||||
currentUser: User = Depends(requireSysAdmin),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Delete orphaned rows for a single FK relationship."""
|
|
||||||
try:
|
|
||||||
deleted = _cleanOrphans(body.db, body.table, body.column)
|
|
||||||
except ValueError as e:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=str(e),
|
|
||||||
) from e
|
|
||||||
logger.info(
|
|
||||||
"SysAdmin orphan clean: user=%s db=%s table=%s column=%s deleted=%s",
|
|
||||||
currentUser.username,
|
|
||||||
body.db,
|
|
||||||
body.table,
|
|
||||||
body.column,
|
|
||||||
deleted,
|
|
||||||
)
|
|
||||||
return {"deleted": deleted}
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/orphans/clean-all")
|
|
||||||
@limiter.limit("2/minute")
|
|
||||||
def postDatabaseOrphansCleanAll(
|
|
||||||
request: Request,
|
|
||||||
currentUser: User = Depends(requireSysAdmin),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Run orphan cleanup for every relationship that currently has orphans."""
|
|
||||||
results: List[dict] = _cleanAllOrphans()
|
|
||||||
logger.info(
|
|
||||||
"SysAdmin orphan clean-all: user=%s batches=%s",
|
|
||||||
currentUser.username,
|
|
||||||
len(results),
|
|
||||||
)
|
|
||||||
return {"results": results}
|
|
||||||
|
|
@ -9,7 +9,7 @@ import logging
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
|
||||||
from modules.auth import limiter
|
from modules.auth import limiter
|
||||||
from modules.auth.authentication import requirePlatformAdmin
|
from modules.auth.authentication import requireSysAdminRole
|
||||||
from modules.datamodels.datamodelUam import User
|
from modules.datamodels.datamodelUam import User
|
||||||
from modules.security.rootAccess import getRootDbAppConnector
|
from modules.security.rootAccess import getRootDbAppConnector
|
||||||
|
|
||||||
|
|
@ -25,7 +25,7 @@ router = APIRouter(
|
||||||
@limiter.limit("30/minute")
|
@limiter.limit("30/minute")
|
||||||
def listDemoConfigs(
|
def listDemoConfigs(
|
||||||
request: Request,
|
request: Request,
|
||||||
currentUser: User = Depends(requirePlatformAdmin),
|
currentUser: User = Depends(requireSysAdminRole),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""List all available demo configurations."""
|
"""List all available demo configurations."""
|
||||||
from modules.demoConfigs import _getAvailableDemoConfigs
|
from modules.demoConfigs import _getAvailableDemoConfigs
|
||||||
|
|
@ -41,7 +41,7 @@ def listDemoConfigs(
|
||||||
def loadDemoConfig(
|
def loadDemoConfig(
|
||||||
code: str,
|
code: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
currentUser: User = Depends(requirePlatformAdmin),
|
currentUser: User = Depends(requireSysAdminRole),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Load (create) a demo configuration. Idempotent."""
|
"""Load (create) a demo configuration. Idempotent."""
|
||||||
from modules.demoConfigs import _getDemoConfigByCode
|
from modules.demoConfigs import _getDemoConfigByCode
|
||||||
|
|
@ -66,7 +66,7 @@ def loadDemoConfig(
|
||||||
def removeDemoConfig(
|
def removeDemoConfig(
|
||||||
code: str,
|
code: str,
|
||||||
request: Request,
|
request: Request,
|
||||||
currentUser: User = Depends(requirePlatformAdmin),
|
currentUser: User = Depends(requireSysAdminRole),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Remove all data created by a demo configuration."""
|
"""Remove all data created by a demo configuration."""
|
||||||
from modules.demoConfigs import _getDemoConfigByCode
|
from modules.demoConfigs import _getDemoConfigByCode
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ from pydantic import BaseModel, Field
|
||||||
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, handleFilterValuesInMemory, handleIdsInMemory
|
from modules.routes.routeHelpers import _applyFiltersAndSort, handleFilterValuesInMemory, handleIdsInMemory
|
||||||
|
|
||||||
from modules.auth import limiter, getRequestContext, RequestContext, requirePlatformAdmin
|
from modules.auth import limiter, getRequestContext, RequestContext, requireSysAdminRole
|
||||||
from modules.datamodels.datamodelUam import User, UserInDB
|
from modules.datamodels.datamodelUam import User, UserInDB
|
||||||
from modules.datamodels.datamodelFeatures import Feature, FeatureInstance
|
from modules.datamodels.datamodelFeatures import Feature, FeatureInstance
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
|
@ -95,18 +95,11 @@ def list_features(
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Features come from the RBAC Catalog (registered at startup from feature containers)
|
# Features come from the RBAC Catalog (registered at startup from feature containers)
|
||||||
# NOT from the database - features are code-defined, not user-created.
|
# NOT from the database - features are code-defined, not user-created
|
||||||
# Hide meta-features (instantiable=False, e.g. ``system``) and soft-
|
|
||||||
# disabled features (enabled=False) so they don't appear in selection
|
|
||||||
# dropdowns like Admin > Feature-Instanzen > Neue Instanz.
|
|
||||||
catalogService = getCatalogService()
|
catalogService = getCatalogService()
|
||||||
features = catalogService.getFeatureDefinitions()
|
features = catalogService.getFeatureDefinitions()
|
||||||
features = [
|
|
||||||
f for f in features
|
|
||||||
if f.get("instantiable", True) and f.get("enabled", True)
|
|
||||||
]
|
|
||||||
return features
|
return features
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error listing features: {e}")
|
logger.error(f"Error listing features: {e}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -358,7 +351,7 @@ def create_feature(
|
||||||
code: str = Query(..., description="Unique feature code"),
|
code: str = Query(..., description="Unique feature code"),
|
||||||
label: Dict[str, str] = None,
|
label: Dict[str, str] = None,
|
||||||
icon: str = Query("mdi-puzzle", description="Icon identifier"),
|
icon: str = Query("mdi-puzzle", description="Icon identifier"),
|
||||||
sysAdmin: User = Depends(requirePlatformAdmin)
|
sysAdmin: User = Depends(requireSysAdminRole)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Create a new feature definition.
|
Create a new feature definition.
|
||||||
|
|
@ -527,7 +520,7 @@ def get_feature_instance(
|
||||||
|
|
||||||
# Verify mandate access (unless SysAdmin)
|
# Verify mandate access (unless SysAdmin)
|
||||||
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
||||||
if not context.isPlatformAdmin:
|
if not context.hasSysAdminRole:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=routeApiMsg("Access denied to this feature instance")
|
detail=routeApiMsg("Access denied to this feature instance")
|
||||||
|
|
@ -667,14 +660,14 @@ def delete_feature_instance(
|
||||||
|
|
||||||
# Verify mandate access
|
# Verify mandate access
|
||||||
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
||||||
if not context.isPlatformAdmin:
|
if not context.hasSysAdminRole:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=routeApiMsg("Access denied to this feature instance")
|
detail=routeApiMsg("Access denied to this feature instance")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check mandate admin permission
|
# Check mandate admin permission
|
||||||
if not _hasMandateAdminRole(context) and not context.isPlatformAdmin:
|
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=routeApiMsg("Mandate-Admin role required to delete feature instances")
|
detail=routeApiMsg("Mandate-Admin role required to delete feature instances")
|
||||||
|
|
@ -734,14 +727,14 @@ def updateFeatureInstance(
|
||||||
|
|
||||||
# Verify mandate access
|
# Verify mandate access
|
||||||
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
||||||
if not context.isPlatformAdmin:
|
if not context.hasSysAdminRole:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=routeApiMsg("Access denied to this feature instance")
|
detail=routeApiMsg("Access denied to this feature instance")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check mandate admin permission
|
# Check mandate admin permission
|
||||||
if not _hasMandateAdminRole(context) and not context.isPlatformAdmin:
|
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=routeApiMsg("Mandate-Admin role required to update feature instances")
|
detail=routeApiMsg("Mandate-Admin role required to update feature instances")
|
||||||
|
|
@ -817,14 +810,14 @@ def sync_instance_roles(
|
||||||
|
|
||||||
# Verify mandate access
|
# Verify mandate access
|
||||||
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
||||||
if not context.isPlatformAdmin:
|
if not context.hasSysAdminRole:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=routeApiMsg("Access denied to this feature instance")
|
detail=routeApiMsg("Access denied to this feature instance")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check admin permission (Mandate-Admin or Feature-Admin)
|
# Check admin permission (Mandate-Admin or Feature-Admin)
|
||||||
if not _hasMandateAdminRole(context) and not context.isPlatformAdmin:
|
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=routeApiMsg("Admin role required to sync roles")
|
detail=routeApiMsg("Admin role required to sync roles")
|
||||||
|
|
@ -870,14 +863,10 @@ def _syncInstanceWorkflows(
|
||||||
instances created before template workflows were defined, or when
|
instances created before template workflows were defined, or when
|
||||||
the initial copy failed silently.
|
the initial copy failed silently.
|
||||||
|
|
||||||
PlatformAdmin only.
|
SysAdmin only.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
if not context.isPlatformAdmin:
|
requireSysAdminRole(context.user)
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail="Platform admin privileges required",
|
|
||||||
)
|
|
||||||
|
|
||||||
rootInterface = getRootInterface()
|
rootInterface = getRootInterface()
|
||||||
featureInterface = getFeatureInterface(rootInterface.db)
|
featureInterface = getFeatureInterface(rootInterface.db)
|
||||||
|
|
@ -986,7 +975,7 @@ def list_template_roles(
|
||||||
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
|
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
|
||||||
mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"),
|
mode: Optional[str] = Query(None, description="'filterValues' for distinct column values, 'ids' for all filtered IDs"),
|
||||||
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
||||||
sysAdmin: User = Depends(requirePlatformAdmin),
|
sysAdmin: User = Depends(requireSysAdminRole),
|
||||||
):
|
):
|
||||||
"""List global template roles with pagination support."""
|
"""List global template roles with pagination support."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -1046,7 +1035,7 @@ def create_template_role(
|
||||||
roleLabel: str = Query(..., description="Role label (e.g., 'admin', 'viewer')"),
|
roleLabel: str = Query(..., description="Role label (e.g., 'admin', 'viewer')"),
|
||||||
featureCode: str = Query(..., description="Feature code this role belongs to"),
|
featureCode: str = Query(..., description="Feature code this role belongs to"),
|
||||||
description: Dict[str, str] = None,
|
description: Dict[str, str] = None,
|
||||||
sysAdmin: User = Depends(requirePlatformAdmin)
|
sysAdmin: User = Depends(requireSysAdminRole)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Create a global template role for a feature.
|
Create a global template role for a feature.
|
||||||
|
|
@ -1156,7 +1145,7 @@ def list_feature_instance_users(
|
||||||
|
|
||||||
# Verify mandate access (unless SysAdmin)
|
# Verify mandate access (unless SysAdmin)
|
||||||
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
||||||
if not context.isPlatformAdmin:
|
if not context.hasSysAdminRole:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=routeApiMsg("Access denied to this feature instance")
|
detail=routeApiMsg("Access denied to this feature instance")
|
||||||
|
|
@ -1270,14 +1259,14 @@ def add_user_to_feature_instance(
|
||||||
|
|
||||||
# Verify mandate access
|
# Verify mandate access
|
||||||
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
||||||
if not context.isPlatformAdmin:
|
if not context.hasSysAdminRole:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=routeApiMsg("Access denied to this feature instance")
|
detail=routeApiMsg("Access denied to this feature instance")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check admin permission
|
# Check admin permission
|
||||||
if not _hasMandateAdminRole(context) and not context.isPlatformAdmin:
|
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=routeApiMsg("Admin role required to add users to feature instances")
|
detail=routeApiMsg("Admin role required to add users to feature instances")
|
||||||
|
|
@ -1378,14 +1367,14 @@ def remove_user_from_feature_instance(
|
||||||
|
|
||||||
# Verify mandate access
|
# Verify mandate access
|
||||||
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
||||||
if not context.isPlatformAdmin:
|
if not context.hasSysAdminRole:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=routeApiMsg("Access denied to this feature instance")
|
detail=routeApiMsg("Access denied to this feature instance")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check admin permission
|
# Check admin permission
|
||||||
if not _hasMandateAdminRole(context) and not context.isPlatformAdmin:
|
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=routeApiMsg("Admin role required to remove users from feature instances")
|
detail=routeApiMsg("Admin role required to remove users from feature instances")
|
||||||
|
|
@ -1468,14 +1457,14 @@ def update_feature_instance_user_roles(
|
||||||
|
|
||||||
# Verify mandate access
|
# Verify mandate access
|
||||||
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
||||||
if not context.isPlatformAdmin:
|
if not context.hasSysAdminRole:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=routeApiMsg("Access denied to this feature instance")
|
detail=routeApiMsg("Access denied to this feature instance")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check admin permission
|
# Check admin permission
|
||||||
if not _hasMandateAdminRole(context) and not context.isPlatformAdmin:
|
if not _hasMandateAdminRole(context) and not context.hasSysAdminRole:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=routeApiMsg("Admin role required to update user roles")
|
detail=routeApiMsg("Admin role required to update user roles")
|
||||||
|
|
@ -1576,7 +1565,7 @@ def get_feature_instance_available_roles(
|
||||||
|
|
||||||
# Verify mandate access
|
# Verify mandate access
|
||||||
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
if context.mandateId and str(instance.mandateId) != str(context.mandateId):
|
||||||
if not context.isPlatformAdmin:
|
if not context.hasSysAdminRole:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=routeApiMsg("Access denied to this feature instance")
|
detail=routeApiMsg("Access denied to this feature instance")
|
||||||
|
|
@ -1679,7 +1668,7 @@ def _renameFeatureInstance(
|
||||||
|
|
||||||
userId = str(context.user.id)
|
userId = str(context.user.id)
|
||||||
isInstanceAdmin = False
|
isInstanceAdmin = False
|
||||||
if context.isPlatformAdmin:
|
if context.hasSysAdminRole:
|
||||||
isInstanceAdmin = True
|
isInstanceAdmin = True
|
||||||
else:
|
else:
|
||||||
from modules.datamodels.datamodelMembership import FeatureAccess, FeatureAccessRole
|
from modules.datamodels.datamodelMembership import FeatureAccess, FeatureAccessRole
|
||||||
|
|
@ -1718,7 +1707,7 @@ def _hasMandateAdminRole(context: RequestContext) -> bool:
|
||||||
|
|
||||||
A user is mandate admin if they have the 'admin' role at mandate level.
|
A user is mandate admin if they have the 'admin' role at mandate level.
|
||||||
"""
|
"""
|
||||||
if context.isPlatformAdmin:
|
if context.hasSysAdminRole:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if not context.roleIds:
|
if not context.roleIds:
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Request, Query
|
from fastapi import APIRouter, HTTPException, Depends, Request, Query
|
||||||
from fastapi.responses import PlainTextResponse
|
from fastapi.responses import PlainTextResponse
|
||||||
from modules.auth import limiter, requireSysAdmin
|
from modules.auth import limiter, requireSysAdminRole
|
||||||
from modules.shared.configuration import APP_CONFIG
|
from modules.shared.configuration import APP_CONFIG
|
||||||
from modules.datamodels.datamodelUam import User
|
from modules.datamodels.datamodelUam import User
|
||||||
|
|
||||||
|
|
@ -63,7 +63,7 @@ def _readLastNLines(filePath: str, n: int) -> list[str]:
|
||||||
def getLogEntries(
|
def getLogEntries(
|
||||||
request: Request,
|
request: Request,
|
||||||
count: int = Query(default=200, ge=1, le=50000, description="Number of log entries to return"),
|
count: int = Query(default=200, ge=1, le=50000, description="Number of log entries to return"),
|
||||||
currentUser: User = Depends(requireSysAdmin),
|
currentUser: User = Depends(requireSysAdminRole),
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Get the last N log entries from the gateway log files.
|
Get the last N log entries from the gateway log files.
|
||||||
|
|
@ -104,7 +104,7 @@ def getLogEntries(
|
||||||
def downloadLog(
|
def downloadLog(
|
||||||
request: Request,
|
request: Request,
|
||||||
count: int = Query(default=1000, ge=1, le=100000, description="Number of log entries to download"),
|
count: int = Query(default=1000, ge=1, le=100000, description="Number of log entries to download"),
|
||||||
currentUser: User = Depends(requireSysAdmin),
|
currentUser: User = Depends(requireSysAdminRole),
|
||||||
) -> PlainTextResponse:
|
) -> PlainTextResponse:
|
||||||
"""
|
"""
|
||||||
Download the last N log entries as a plain text file.
|
Download the last N log entries as a plain text file.
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import logging
|
||||||
import json
|
import json
|
||||||
import math
|
import math
|
||||||
|
|
||||||
from modules.auth import limiter, getRequestContext, requirePlatformAdmin, RequestContext
|
from modules.auth import limiter, getRequestContext, requireSysAdminRole, RequestContext
|
||||||
from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel
|
from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel
|
||||||
from modules.datamodels.datamodelRbac import AccessRuleContext, AccessRule, Role
|
from modules.datamodels.datamodelRbac import AccessRuleContext, AccessRule, Role
|
||||||
from modules.datamodels.datamodelMembership import UserMandate
|
from modules.datamodels.datamodelMembership import UserMandate
|
||||||
|
|
@ -242,7 +242,7 @@ def get_all_permissions(
|
||||||
|
|
||||||
logger.debug(f"UI/RESOURCE permissions: User has {len(roleIds)} roles across all mandates")
|
logger.debug(f"UI/RESOURCE permissions: User has {len(roleIds)} roles across all mandates")
|
||||||
|
|
||||||
if not roleIds and not reqContext.isPlatformAdmin:
|
if not roleIds and not reqContext.hasSysAdminRole:
|
||||||
# No roles at all, return empty permissions
|
# No roles at all, return empty permissions
|
||||||
for ctx in contextsToFetch:
|
for ctx in contextsToFetch:
|
||||||
result[ctx.value.lower()] = {}
|
result[ctx.value.lower()] = {}
|
||||||
|
|
@ -362,7 +362,7 @@ def get_access_rules(
|
||||||
- List of AccessRule objects
|
- List of AccessRule objects
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
isSysAdmin = reqContext.isPlatformAdmin
|
isSysAdmin = reqContext.hasSysAdminRole
|
||||||
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
||||||
if not isSysAdmin and not adminMandateIds:
|
if not isSysAdmin and not adminMandateIds:
|
||||||
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
|
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
|
||||||
|
|
@ -487,7 +487,7 @@ def get_access_rules_by_role(
|
||||||
- List of AccessRule objects for the specified role
|
- List of AccessRule objects for the specified role
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
isSysAdmin = reqContext.isPlatformAdmin
|
isSysAdmin = reqContext.hasSysAdminRole
|
||||||
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
||||||
if not isSysAdmin and not adminMandateIds:
|
if not isSysAdmin and not adminMandateIds:
|
||||||
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
|
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
|
||||||
|
|
@ -534,7 +534,7 @@ def get_access_rule(
|
||||||
- AccessRule object
|
- AccessRule object
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
isSysAdmin = reqContext.isPlatformAdmin
|
isSysAdmin = reqContext.hasSysAdminRole
|
||||||
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
||||||
if not isSysAdmin and not adminMandateIds:
|
if not isSysAdmin and not adminMandateIds:
|
||||||
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
|
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
|
||||||
|
|
@ -585,7 +585,7 @@ def create_access_rule(
|
||||||
- Created AccessRule object
|
- Created AccessRule object
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
isSysAdmin = reqContext.isPlatformAdmin
|
isSysAdmin = reqContext.hasSysAdminRole
|
||||||
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
||||||
if not isSysAdmin and not adminMandateIds:
|
if not isSysAdmin and not adminMandateIds:
|
||||||
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
|
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
|
||||||
|
|
@ -665,7 +665,7 @@ def update_access_rule(
|
||||||
- Updated AccessRule object
|
- Updated AccessRule object
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
isSysAdmin = reqContext.isPlatformAdmin
|
isSysAdmin = reqContext.hasSysAdminRole
|
||||||
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
||||||
if not isSysAdmin and not adminMandateIds:
|
if not isSysAdmin and not adminMandateIds:
|
||||||
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
|
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
|
||||||
|
|
@ -753,7 +753,7 @@ def delete_access_rule(
|
||||||
- Success message
|
- Success message
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
isSysAdmin = reqContext.isPlatformAdmin
|
isSysAdmin = reqContext.hasSysAdminRole
|
||||||
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
||||||
if not isSysAdmin and not adminMandateIds:
|
if not isSysAdmin and not adminMandateIds:
|
||||||
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
|
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
|
||||||
|
|
@ -836,7 +836,7 @@ def list_roles(
|
||||||
- List of role dictionaries with role label, description, user count, and computed scopeType
|
- List of role dictionaries with role label, description, user count, and computed scopeType
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
isSysAdmin = reqContext.isPlatformAdmin
|
isSysAdmin = reqContext.hasSysAdminRole
|
||||||
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
||||||
if not isSysAdmin and not adminMandateIds:
|
if not isSysAdmin and not adminMandateIds:
|
||||||
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
|
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
|
||||||
|
|
@ -1017,7 +1017,7 @@ def create_role(
|
||||||
- Created role dictionary
|
- Created role dictionary
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
isSysAdmin = reqContext.isPlatformAdmin
|
isSysAdmin = reqContext.hasSysAdminRole
|
||||||
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
||||||
if not isSysAdmin and not adminMandateIds:
|
if not isSysAdmin and not adminMandateIds:
|
||||||
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
|
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
|
||||||
|
|
@ -1076,7 +1076,7 @@ def get_role(
|
||||||
- Role dictionary
|
- Role dictionary
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
isSysAdmin = reqContext.isPlatformAdmin
|
isSysAdmin = reqContext.hasSysAdminRole
|
||||||
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
||||||
if not isSysAdmin and not adminMandateIds:
|
if not isSysAdmin and not adminMandateIds:
|
||||||
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
|
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
|
||||||
|
|
@ -1137,7 +1137,7 @@ def update_role(
|
||||||
- Updated role dictionary
|
- Updated role dictionary
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
isSysAdmin = reqContext.isPlatformAdmin
|
isSysAdmin = reqContext.hasSysAdminRole
|
||||||
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
||||||
if not isSysAdmin and not adminMandateIds:
|
if not isSysAdmin and not adminMandateIds:
|
||||||
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
|
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
|
||||||
|
|
@ -1201,7 +1201,7 @@ def delete_role(
|
||||||
- Success message
|
- Success message
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
isSysAdmin = reqContext.isPlatformAdmin
|
isSysAdmin = reqContext.hasSysAdminRole
|
||||||
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext)
|
||||||
if not isSysAdmin and not adminMandateIds:
|
if not isSysAdmin and not adminMandateIds:
|
||||||
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
|
raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required"))
|
||||||
|
|
@ -1357,7 +1357,7 @@ def getCatalogObjects(
|
||||||
def cleanup_duplicate_access_rules(
|
def cleanup_duplicate_access_rules(
|
||||||
request: Request,
|
request: Request,
|
||||||
dryRun: bool = Query(True, description="If true, only report duplicates without deleting"),
|
dryRun: bool = Query(True, description="If true, only report duplicates without deleting"),
|
||||||
currentUser: User = Depends(requirePlatformAdmin)
|
currentUser: User = Depends(requireSysAdminRole)
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""
|
"""
|
||||||
Find and remove duplicate AccessRules.
|
Find and remove duplicate AccessRules.
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ def _hasMandateAdminRole(context: RequestContext) -> bool:
|
||||||
Loads roles independently from request context (context.roleIds may be empty
|
Loads roles independently from request context (context.roleIds may be empty
|
||||||
when no X-Mandate-Id header is sent, e.g., on admin pages).
|
when no X-Mandate-Id header is sent, e.g., on admin pages).
|
||||||
"""
|
"""
|
||||||
if context.isPlatformAdmin:
|
if context.hasSysAdminRole:
|
||||||
return True
|
return True
|
||||||
try:
|
try:
|
||||||
rootInterface = getRootInterface()
|
rootInterface = getRootInterface()
|
||||||
|
|
@ -123,7 +123,7 @@ def listUsersForOverview(
|
||||||
try:
|
try:
|
||||||
interface = getRootInterface()
|
interface = getRootInterface()
|
||||||
|
|
||||||
if context.isPlatformAdmin and not context.mandateId:
|
if context.hasSysAdminRole and not context.mandateId:
|
||||||
# SysAdmin without mandate context: all users
|
# SysAdmin without mandate context: all users
|
||||||
allUsers = interface.getAllUsers()
|
allUsers = interface.getAllUsers()
|
||||||
elif context.mandateId:
|
elif context.mandateId:
|
||||||
|
|
@ -164,7 +164,6 @@ def listUsersForOverview(
|
||||||
"email": userData.get("email"),
|
"email": userData.get("email"),
|
||||||
"fullName": userData.get("fullName"),
|
"fullName": userData.get("fullName"),
|
||||||
"isSysAdmin": userData.get("isSysAdmin", False),
|
"isSysAdmin": userData.get("isSysAdmin", False),
|
||||||
"isPlatformAdmin": userData.get("isPlatformAdmin", False),
|
|
||||||
"enabled": userData.get("enabled", True),
|
"enabled": userData.get("enabled", True),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -218,7 +217,7 @@ def getUserAccessOverview(
|
||||||
interface = getRootInterface()
|
interface = getRootInterface()
|
||||||
|
|
||||||
# MandateAdmin: verify the requested user shares at least one admin mandate
|
# MandateAdmin: verify the requested user shares at least one admin mandate
|
||||||
if not context.isPlatformAdmin:
|
if not context.hasSysAdminRole:
|
||||||
# Get admin's mandate IDs
|
# Get admin's mandate IDs
|
||||||
adminMandateIds = []
|
adminMandateIds = []
|
||||||
userMandates = interface.getUserMandates(str(context.user.id))
|
userMandates = interface.getUserMandates(str(context.user.id))
|
||||||
|
|
@ -259,7 +258,6 @@ def getUserAccessOverview(
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
"fullName": user.fullName,
|
"fullName": user.fullName,
|
||||||
"isSysAdmin": user.isSysAdmin,
|
"isSysAdmin": user.isSysAdmin,
|
||||||
"isPlatformAdmin": getattr(user, "isPlatformAdmin", False),
|
|
||||||
"enabled": user.enabled,
|
"enabled": user.enabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -483,8 +481,7 @@ def getUserAccessOverview(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"user": userInfo,
|
"user": userInfo,
|
||||||
"isSysAdmin": bool(getattr(user, "isSysAdmin", False)),
|
"isSysAdmin": False,
|
||||||
"isPlatformAdmin": bool(getattr(user, "isPlatformAdmin", False)),
|
|
||||||
"roles": allRoles,
|
"roles": allRoles,
|
||||||
"mandates": mandatesInfo,
|
"mandates": mandatesInfo,
|
||||||
"uiAccess": uiAccess,
|
"uiAccess": uiAccess,
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,9 @@ Provides three views:
|
||||||
RBAC: mandate-admin or compliance-viewer role required.
|
RBAC: mandate-admin or compliance-viewer role required.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
|
from fastapi import APIRouter, Depends, HTTPException, Query, Path, status
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
@ -28,110 +27,10 @@ routeApiMsg = apiRouteContext("routeAudit")
|
||||||
router = APIRouter(prefix="/api/audit", tags=["Audit"])
|
router = APIRouter(prefix="/api/audit", tags=["Audit"])
|
||||||
|
|
||||||
|
|
||||||
def _applySortFilterSearch(
|
|
||||||
items: List[Dict[str, Any]],
|
|
||||||
*,
|
|
||||||
sortJson: Optional[str] = None,
|
|
||||||
filtersJson: Optional[str] = None,
|
|
||||||
search: Optional[str] = None,
|
|
||||||
searchableKeys: Optional[List[str]] = None,
|
|
||||||
) -> List[Dict[str, Any]]:
|
|
||||||
"""Apply sort, filter and search to a list of dicts in-memory."""
|
|
||||||
if filtersJson:
|
|
||||||
try:
|
|
||||||
filters = json.loads(filtersJson) if isinstance(filtersJson, str) else filtersJson
|
|
||||||
if isinstance(filters, dict):
|
|
||||||
for key, val in filters.items():
|
|
||||||
if val is None or val == "":
|
|
||||||
continue
|
|
||||||
if isinstance(val, list):
|
|
||||||
items = [r for r in items if str(r.get(key, "")) in [str(v) for v in val]]
|
|
||||||
else:
|
|
||||||
items = [r for r in items if str(r.get(key, "")).lower() == str(val).lower()]
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
if search and searchableKeys:
|
|
||||||
needle = search.lower()
|
|
||||||
items = [r for r in items if any(needle in str(r.get(k, "")).lower() for k in searchableKeys)]
|
|
||||||
|
|
||||||
if sortJson:
|
|
||||||
try:
|
|
||||||
sortList = json.loads(sortJson) if isinstance(sortJson, str) else sortJson
|
|
||||||
if isinstance(sortList, list):
|
|
||||||
for sortDef in reversed(sortList):
|
|
||||||
field = sortDef.get("field", "")
|
|
||||||
desc = sortDef.get("direction", "asc") == "desc"
|
|
||||||
items.sort(key=lambda r, f=field: (r.get(f) is None, r.get(f, "")), reverse=desc)
|
|
||||||
except (json.JSONDecodeError, TypeError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return items
|
|
||||||
|
|
||||||
|
|
||||||
def _distinctColumnValues(items: List[Dict[str, Any]], column: str) -> List[str]:
|
|
||||||
"""Extract sorted distinct non-empty string values for a column."""
|
|
||||||
vals = set()
|
|
||||||
for r in items:
|
|
||||||
v = r.get(column)
|
|
||||||
if v is not None and v != "":
|
|
||||||
vals.add(str(v))
|
|
||||||
return sorted(vals)
|
|
||||||
|
|
||||||
|
|
||||||
def _enrichUserAndInstanceLabels(
|
|
||||||
items: List[Dict[str, Any]],
|
|
||||||
context: "RequestContext",
|
|
||||||
userKey: str = "userId",
|
|
||||||
usernameKey: str = "username",
|
|
||||||
instanceKey: str = "featureInstanceId",
|
|
||||||
instanceLabelKey: str = "instanceLabel",
|
|
||||||
) -> None:
|
|
||||||
"""Resolve userId → username and featureInstanceId → label in-place."""
|
|
||||||
userIds = set()
|
|
||||||
instanceIds = set()
|
|
||||||
for r in items:
|
|
||||||
uid = r.get(userKey)
|
|
||||||
if uid and not r.get(usernameKey):
|
|
||||||
userIds.add(uid)
|
|
||||||
iid = r.get(instanceKey)
|
|
||||||
if iid:
|
|
||||||
instanceIds.add(iid)
|
|
||||||
|
|
||||||
userMap: Dict[str, str] = {}
|
|
||||||
instanceMap: Dict[str, str] = {}
|
|
||||||
|
|
||||||
try:
|
|
||||||
from modules.interfaces.interfaceDbApp import getInterface
|
|
||||||
appIf = getInterface(
|
|
||||||
context.user,
|
|
||||||
mandateId=str(context.mandateId) if context.mandateId else None,
|
|
||||||
)
|
|
||||||
if userIds:
|
|
||||||
users = appIf.getUsersByIds(list(userIds))
|
|
||||||
for uid, u in users.items():
|
|
||||||
name = getattr(u, "displayName", None) or getattr(u, "email", None) or uid
|
|
||||||
userMap[uid] = name
|
|
||||||
if instanceIds:
|
|
||||||
for iid in instanceIds:
|
|
||||||
fi = appIf.getFeatureInstance(iid)
|
|
||||||
if fi:
|
|
||||||
instanceMap[iid] = getattr(fi, "label", None) or getattr(fi, "featureCode", None) or iid
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug("_enrichUserAndInstanceLabels: %s", e)
|
|
||||||
|
|
||||||
for r in items:
|
|
||||||
uid = r.get(userKey)
|
|
||||||
if uid and not r.get(usernameKey) and uid in userMap:
|
|
||||||
r[usernameKey] = userMap[uid]
|
|
||||||
iid = r.get(instanceKey)
|
|
||||||
if iid and iid in instanceMap:
|
|
||||||
r[instanceLabelKey] = instanceMap[iid]
|
|
||||||
|
|
||||||
|
|
||||||
def _requireAuditAccess(context: RequestContext):
|
def _requireAuditAccess(context: RequestContext):
|
||||||
"""Raise 403 unless user has mandate-admin or compliance-viewer access."""
|
"""Raise 403 unless user has mandate-admin or compliance-viewer access."""
|
||||||
if context.isPlatformAdmin:
|
from modules.auth.authentication import _hasSysAdminRole
|
||||||
|
if _hasSysAdminRole(str(context.user.id)):
|
||||||
return
|
return
|
||||||
|
|
||||||
from modules.interfaces.interfaceDbApp import getInterface
|
from modules.interfaces.interfaceDbApp import getInterface
|
||||||
|
|
@ -163,11 +62,6 @@ async def getAiAuditLog(
|
||||||
dateTo: Optional[float] = Query(None, description="UTC epoch seconds"),
|
dateTo: Optional[float] = Query(None, description="UTC epoch seconds"),
|
||||||
limit: int = Query(50, ge=1, le=500),
|
limit: int = Query(50, ge=1, le=500),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
sort: Optional[str] = Query(None, description='JSON array, e.g. [{"field":"timestamp","direction":"desc"}]'),
|
|
||||||
filters: Optional[str] = Query(None, description='JSON object, e.g. {"aiModel":"gpt-4o"}'),
|
|
||||||
search: Optional[str] = Query(None),
|
|
||||||
mode: Optional[str] = Query(None, description="'filterValues' to get distinct values for a column"),
|
|
||||||
column: Optional[str] = Query(None, description="Column key (required when mode=filterValues)"),
|
|
||||||
):
|
):
|
||||||
_requireAuditAccess(context)
|
_requireAuditAccess(context)
|
||||||
mandateId = str(context.mandateId) if context.mandateId else ""
|
mandateId = str(context.mandateId) if context.mandateId else ""
|
||||||
|
|
@ -175,35 +69,16 @@ async def getAiAuditLog(
|
||||||
raise HTTPException(status_code=400, detail=routeApiMsg("Mandanten-ID erforderlich"))
|
raise HTTPException(status_code=400, detail=routeApiMsg("Mandanten-ID erforderlich"))
|
||||||
|
|
||||||
from modules.shared.aiAuditLogger import aiAuditLogger
|
from modules.shared.aiAuditLogger import aiAuditLogger
|
||||||
result = aiAuditLogger.getAiAuditLogs(
|
return aiAuditLogger.getAiAuditLogs(
|
||||||
mandateId,
|
mandateId,
|
||||||
userId=userId,
|
userId=userId,
|
||||||
featureInstanceId=featureInstanceId,
|
featureInstanceId=featureInstanceId,
|
||||||
aiModel=aiModel,
|
aiModel=aiModel,
|
||||||
fromTimestamp=dateFrom,
|
fromTimestamp=dateFrom,
|
||||||
toTimestamp=dateTo,
|
toTimestamp=dateTo,
|
||||||
limit=9999,
|
limit=limit,
|
||||||
offset=0,
|
offset=offset,
|
||||||
)
|
)
|
||||||
items = result.get("items", [])
|
|
||||||
|
|
||||||
_enrichUserAndInstanceLabels(items, context)
|
|
||||||
|
|
||||||
if mode == "filterValues" and column:
|
|
||||||
items = _applySortFilterSearch(items, filtersJson=filters)
|
|
||||||
return _distinctColumnValues(items, column)
|
|
||||||
|
|
||||||
items = _applySortFilterSearch(
|
|
||||||
items,
|
|
||||||
sortJson=sort,
|
|
||||||
filtersJson=filters,
|
|
||||||
search=search,
|
|
||||||
searchableKeys=["username", "aiModel", "instanceLabel", "aiProvider", "operationType"],
|
|
||||||
)
|
|
||||||
|
|
||||||
totalItems = len(items)
|
|
||||||
page = items[offset: offset + limit]
|
|
||||||
return {"items": page, "totalItems": totalItems}
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/ai-log/{entryId}/content")
|
@router.get("/ai-log/{entryId}/content")
|
||||||
|
|
@ -259,11 +134,6 @@ async def getAuditLog(
|
||||||
dateTo: Optional[float] = Query(None),
|
dateTo: Optional[float] = Query(None),
|
||||||
limit: int = Query(100, ge=1, le=500),
|
limit: int = Query(100, ge=1, le=500),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
sort: Optional[str] = Query(None),
|
|
||||||
filters: Optional[str] = Query(None),
|
|
||||||
search: Optional[str] = Query(None),
|
|
||||||
mode: Optional[str] = Query(None),
|
|
||||||
column: Optional[str] = Query(None),
|
|
||||||
):
|
):
|
||||||
_requireAuditAccess(context)
|
_requireAuditAccess(context)
|
||||||
mandateId = str(context.mandateId) if context.mandateId else None
|
mandateId = str(context.mandateId) if context.mandateId else None
|
||||||
|
|
@ -276,23 +146,8 @@ async def getAuditLog(
|
||||||
action=action,
|
action=action,
|
||||||
fromTimestamp=dateFrom,
|
fromTimestamp=dateFrom,
|
||||||
toTimestamp=dateTo,
|
toTimestamp=dateTo,
|
||||||
limit=9999,
|
limit=limit + offset + 1,
|
||||||
)
|
)
|
||||||
|
|
||||||
_enrichUserAndInstanceLabels(records, context)
|
|
||||||
|
|
||||||
if mode == "filterValues" and column:
|
|
||||||
records = _applySortFilterSearch(records, filtersJson=filters)
|
|
||||||
return _distinctColumnValues(records, column)
|
|
||||||
|
|
||||||
records = _applySortFilterSearch(
|
|
||||||
records,
|
|
||||||
sortJson=sort,
|
|
||||||
filtersJson=filters,
|
|
||||||
search=search,
|
|
||||||
searchableKeys=["username", "action", "resourceType", "category"],
|
|
||||||
)
|
|
||||||
|
|
||||||
totalItems = len(records)
|
totalItems = len(records)
|
||||||
page = records[offset: offset + limit]
|
page = records[offset: offset + limit]
|
||||||
return {"items": page, "totalItems": totalItems}
|
return {"items": page, "totalItems": totalItems}
|
||||||
|
|
@ -326,11 +181,6 @@ async def getNeutralizationMappings(
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
limit: int = Query(200, ge=1, le=2000),
|
limit: int = Query(200, ge=1, le=2000),
|
||||||
offset: int = Query(0, ge=0),
|
offset: int = Query(0, ge=0),
|
||||||
sort: Optional[str] = Query(None),
|
|
||||||
filters: Optional[str] = Query(None),
|
|
||||||
search: Optional[str] = Query(None),
|
|
||||||
mode: Optional[str] = Query(None),
|
|
||||||
column: Optional[str] = Query(None),
|
|
||||||
):
|
):
|
||||||
_requireAuditAccess(context)
|
_requireAuditAccess(context)
|
||||||
mandateId = str(context.mandateId) if context.mandateId else ""
|
mandateId = str(context.mandateId) if context.mandateId else ""
|
||||||
|
|
@ -346,23 +196,7 @@ async def getNeutralizationMappings(
|
||||||
pType = item.get("patternType", "")
|
pType = item.get("patternType", "")
|
||||||
uid = item.get("id", "")
|
uid = item.get("id", "")
|
||||||
item["placeholder"] = f"[{pType}.{uid}]" if pType and uid else uid
|
item["placeholder"] = f"[{pType}.{uid}]" if pType and uid else uid
|
||||||
|
items.sort(key=lambda r: (r.get("patternType", ""), r.get("originalText", "")))
|
||||||
_enrichUserAndInstanceLabels(items, context)
|
|
||||||
|
|
||||||
if mode == "filterValues" and column:
|
|
||||||
items = _applySortFilterSearch(items, filtersJson=filters)
|
|
||||||
return _distinctColumnValues(items, column)
|
|
||||||
|
|
||||||
items = _applySortFilterSearch(
|
|
||||||
items,
|
|
||||||
sortJson=sort,
|
|
||||||
filtersJson=filters,
|
|
||||||
search=search,
|
|
||||||
searchableKeys=["placeholder", "originalText", "patternType"],
|
|
||||||
)
|
|
||||||
if not sort:
|
|
||||||
items.sort(key=lambda r: (r.get("patternType", ""), r.get("originalText", "")))
|
|
||||||
|
|
||||||
totalItems = len(items)
|
totalItems = len(items)
|
||||||
page = items[offset: offset + limit]
|
page = items[offset: offset + limit]
|
||||||
return {"items": page, "totalItems": totalItems}
|
return {"items": page, "totalItems": totalItems}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ from datetime import date, datetime, timezone
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
# Import auth module
|
# Import auth module
|
||||||
from modules.auth import limiter, requirePlatformAdmin, getRequestContext, RequestContext
|
from modules.auth import limiter, requireSysAdminRole, getRequestContext, RequestContext
|
||||||
|
|
||||||
# Import billing components
|
# Import billing components
|
||||||
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface, _getRootInterface
|
from modules.interfaces.interfaceDbBilling import getInterface as getBillingInterface, _getRootInterface
|
||||||
|
|
@ -86,7 +86,8 @@ def _getBillingDataScope(user) -> BillingDataScope:
|
||||||
"""
|
"""
|
||||||
scope = BillingDataScope(userId=user.id)
|
scope = BillingDataScope(userId=user.id)
|
||||||
|
|
||||||
if bool(getattr(user, "isPlatformAdmin", False)):
|
from modules.auth.authentication import _hasSysAdminRole
|
||||||
|
if _hasSysAdminRole(str(user.id)):
|
||||||
scope.isGlobalAdmin = True
|
scope.isGlobalAdmin = True
|
||||||
return scope
|
return scope
|
||||||
|
|
||||||
|
|
@ -140,8 +141,8 @@ def _getBillingDataScope(user) -> BillingDataScope:
|
||||||
|
|
||||||
|
|
||||||
def _isAdminOfMandate(ctx: RequestContext, targetMandateId: str) -> bool:
|
def _isAdminOfMandate(ctx: RequestContext, targetMandateId: str) -> bool:
|
||||||
"""Check if user is PlatformAdmin or admin of the specified mandate."""
|
"""Check if user is SysAdmin or admin of the specified mandate."""
|
||||||
if ctx.isPlatformAdmin:
|
if ctx.hasSysAdminRole:
|
||||||
return True
|
return True
|
||||||
try:
|
try:
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
|
@ -733,7 +734,7 @@ def addCredit(
|
||||||
targetMandateId: str = Path(..., description="Mandate ID"),
|
targetMandateId: str = Path(..., description="Mandate ID"),
|
||||||
creditRequest: CreditAddRequest = Body(...),
|
creditRequest: CreditAddRequest = Body(...),
|
||||||
ctx: RequestContext = Depends(getRequestContext),
|
ctx: RequestContext = Depends(getRequestContext),
|
||||||
_admin = Depends(requirePlatformAdmin)
|
_admin = Depends(requireSysAdminRole)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Add credit to a billing account (SysAdmin only).
|
Add credit to a billing account (SysAdmin only).
|
||||||
|
|
@ -1460,7 +1461,7 @@ def getTransactionsAdmin(
|
||||||
def getMandateViewBalances(
|
def getMandateViewBalances(
|
||||||
request: Request,
|
request: Request,
|
||||||
ctx: RequestContext = Depends(getRequestContext),
|
ctx: RequestContext = Depends(getRequestContext),
|
||||||
_admin = Depends(requirePlatformAdmin)
|
_admin = Depends(requireSysAdminRole)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get mandate-level balances (SysAdmin only).
|
Get mandate-level balances (SysAdmin only).
|
||||||
|
|
@ -1483,7 +1484,7 @@ def getMandateViewTransactions(
|
||||||
request: Request,
|
request: Request,
|
||||||
limit: int = Query(default=100, ge=1, le=1000),
|
limit: int = Query(default=100, ge=1, le=1000),
|
||||||
ctx: RequestContext = Depends(getRequestContext),
|
ctx: RequestContext = Depends(getRequestContext),
|
||||||
_admin = Depends(requirePlatformAdmin)
|
_admin = Depends(requireSysAdminRole)
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get all transactions across mandates (SysAdmin only).
|
Get all transactions across mandates (SysAdmin only).
|
||||||
|
|
|
||||||
|
|
@ -427,54 +427,14 @@ def update_connection(
|
||||||
detail=routeApiMsg("Connection not found")
|
detail=routeApiMsg("Connection not found")
|
||||||
)
|
)
|
||||||
|
|
||||||
# Merge incoming changes into a dict and re-validate via pydantic.
|
# Update connection fields
|
||||||
# Direct setattr() bypasses type coercion (PowerOnModel doesn't enable
|
|
||||||
# validate_assignment), which leaves enum fields as raw strings and
|
|
||||||
# later breaks .value access. Also filters out computed / unknown keys.
|
|
||||||
writableFields = set(UserConnection.model_fields.keys())
|
|
||||||
previous = connection.model_dump()
|
|
||||||
merged = dict(previous)
|
|
||||||
for field, value in connection_data.items():
|
for field, value in connection_data.items():
|
||||||
if field in writableFields:
|
if hasattr(connection, field):
|
||||||
merged[field] = value
|
setattr(connection, field, value)
|
||||||
merged["lastChecked"] = getUtcTimestamp()
|
|
||||||
connection = UserConnection.model_validate(merged)
|
# Update lastChecked timestamp using UTC timestamp
|
||||||
|
connection.lastChecked = getUtcTimestamp()
|
||||||
# If this is a remote (non-local) connection and any identity-bearing
|
|
||||||
# field changed, the stored OAuth tokens no longer match the account.
|
|
||||||
# Force the user to reconnect: mark PENDING and revoke existing tokens.
|
|
||||||
identityFields = ("externalUsername", "externalEmail", "externalId", "authority")
|
|
||||||
authorityValue = (
|
|
||||||
connection.authority.value
|
|
||||||
if hasattr(connection.authority, "value")
|
|
||||||
else str(connection.authority)
|
|
||||||
)
|
|
||||||
isRemote = authorityValue != AuthAuthority.LOCAL.value
|
|
||||||
identityChanged = any(
|
|
||||||
previous.get(field) != merged.get(field) for field in identityFields
|
|
||||||
)
|
|
||||||
if isRemote and identityChanged:
|
|
||||||
connection.status = ConnectionStatus.PENDING
|
|
||||||
connection.expiresAt = None
|
|
||||||
try:
|
|
||||||
existingTokens = interface.db.getRecordset(
|
|
||||||
Token, recordFilter={"connectionId": connectionId}
|
|
||||||
)
|
|
||||||
for token in existingTokens:
|
|
||||||
interface.revokeTokenById(
|
|
||||||
token["id"],
|
|
||||||
revokedBy=currentUser.id,
|
|
||||||
reason="connection identity changed",
|
|
||||||
)
|
|
||||||
logger.info(
|
|
||||||
f"Revoked {len(existingTokens)} token(s) for connection "
|
|
||||||
f"{connectionId} after identity change; reconnect required."
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(
|
|
||||||
f"Failed to revoke tokens for connection {connectionId}: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update connection - models now handle timestamp serialization automatically
|
# Update connection - models now handle timestamp serialization automatically
|
||||||
interface.db.recordModify(UserConnection, connectionId, connection.model_dump())
|
interface.db.recordModify(UserConnection, connectionId, connection.model_dump())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import json
|
||||||
|
|
||||||
# Import auth module
|
# Import auth module
|
||||||
from modules.auth import limiter, getCurrentUser, getRequestContext, RequestContext
|
from modules.auth import limiter, getCurrentUser, getRequestContext, RequestContext
|
||||||
|
from modules.auth.authentication import _hasSysAdminRole
|
||||||
|
|
||||||
# Import interfaces
|
# Import interfaces
|
||||||
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
|
import modules.interfaces.interfaceDbManagement as interfaceDbManagement
|
||||||
|
|
@ -242,16 +243,8 @@ def get_files(
|
||||||
|
|
||||||
recordFilter = None
|
recordFilter = None
|
||||||
if paginationParams and paginationParams.filters and "folderId" in paginationParams.filters:
|
if paginationParams and paginationParams.filters and "folderId" in paginationParams.filters:
|
||||||
fVal = paginationParams.filters.get("folderId")
|
fVal = paginationParams.filters.pop("folderId")
|
||||||
# For a concrete folderId we use recordFilter (exact equality).
|
recordFilter = {"folderId": fVal}
|
||||||
# For null / empty (= "root") we keep it in pagination.filters so the
|
|
||||||
# connector applies `IS NULL OR = ''` – files predating the folderId
|
|
||||||
# fix were stored with an empty string instead of NULL.
|
|
||||||
if fVal is None or (isinstance(fVal, str) and fVal.strip() == ""):
|
|
||||||
paginationParams.filters["folderId"] = None
|
|
||||||
else:
|
|
||||||
paginationParams.filters.pop("folderId")
|
|
||||||
recordFilter = {"folderId": fVal}
|
|
||||||
|
|
||||||
result = managementInterface.getAllFiles(pagination=paginationParams, recordFilter=recordFilter)
|
result = managementInterface.getAllFiles(pagination=paginationParams, recordFilter=recordFilter)
|
||||||
|
|
||||||
|
|
@ -289,19 +282,13 @@ async def upload_file(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
workflowId: Optional[str] = Form(None),
|
workflowId: Optional[str] = Form(None),
|
||||||
featureInstanceId: Optional[str] = Form(None),
|
featureInstanceId: Optional[str] = Form(None),
|
||||||
folderId: Optional[str] = Form(None),
|
currentUser: User = Depends(getCurrentUser)
|
||||||
currentUser: User = Depends(getCurrentUser),
|
|
||||||
context: RequestContext = Depends(getRequestContext),
|
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
# Add fileName property to UploadFile for consistency with backend model
|
# Add fileName property to UploadFile for consistency with backend model
|
||||||
file.fileName = file.filename
|
file.fileName = file.filename
|
||||||
"""Upload a file"""
|
"""Upload a file"""
|
||||||
try:
|
try:
|
||||||
managementInterface = interfaceDbManagement.getInterface(
|
managementInterface = interfaceDbManagement.getInterface(currentUser)
|
||||||
currentUser,
|
|
||||||
mandateId=str(context.mandateId) if context.mandateId else None,
|
|
||||||
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Read file
|
# Read file
|
||||||
fileContent = await file.read()
|
fileContent = await file.read()
|
||||||
|
|
@ -314,29 +301,12 @@ async def upload_file(
|
||||||
detail=f"File too large. Maximum size: {interfaceDbManagement.APP_CONFIG.get('File_Management_MAX_UPLOAD_SIZE_MB')}MB"
|
detail=f"File too large. Maximum size: {interfaceDbManagement.APP_CONFIG.get('File_Management_MAX_UPLOAD_SIZE_MB')}MB"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Normalize folderId: empty string / "null" / "root" → None (root folder)
|
|
||||||
normalizedFolderId: Optional[str] = folderId
|
|
||||||
if isinstance(normalizedFolderId, str):
|
|
||||||
trimmed = normalizedFolderId.strip()
|
|
||||||
if not trimmed or trimmed.lower() in {"null", "none", "root"}:
|
|
||||||
normalizedFolderId = None
|
|
||||||
else:
|
|
||||||
normalizedFolderId = trimmed
|
|
||||||
|
|
||||||
# Save file via LucyDOM interface in the database
|
# Save file via LucyDOM interface in the database
|
||||||
fileItem, duplicateType = managementInterface.saveUploadedFile(
|
fileItem, duplicateType = managementInterface.saveUploadedFile(fileContent, file.filename)
|
||||||
fileContent, file.filename, folderId=normalizedFolderId
|
|
||||||
)
|
|
||||||
|
|
||||||
if featureInstanceId and not fileItem.featureInstanceId:
|
if featureInstanceId and not fileItem.featureInstanceId:
|
||||||
managementInterface.updateFile(fileItem.id, {"featureInstanceId": featureInstanceId})
|
managementInterface.updateFile(fileItem.id, {"featureInstanceId": featureInstanceId})
|
||||||
fileItem.featureInstanceId = featureInstanceId
|
fileItem.featureInstanceId = featureInstanceId
|
||||||
|
|
||||||
# For exact duplicates we keep the existing record, but move it into the
|
|
||||||
# target folder so the user actually sees their upload land where they expect.
|
|
||||||
if duplicateType == "exact_duplicate" and normalizedFolderId != getattr(fileItem, "folderId", None):
|
|
||||||
managementInterface.updateFile(fileItem.id, {"folderId": normalizedFolderId})
|
|
||||||
fileItem.folderId = normalizedFolderId
|
|
||||||
|
|
||||||
# Determine response message based on duplicate type
|
# Determine response message based on duplicate type
|
||||||
if duplicateType == "exact_duplicate":
|
if duplicateType == "exact_duplicate":
|
||||||
|
|
@ -532,153 +502,6 @@ def move_folder(
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/folders/{folderId}/scope")
|
|
||||||
@limiter.limit("10/minute")
|
|
||||||
def _updateFolderScope(
|
|
||||||
request: Request,
|
|
||||||
folderId: str = Path(..., description="ID of the folder"),
|
|
||||||
scope: str = Body(..., embed=True),
|
|
||||||
context: RequestContext = Depends(getRequestContext),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Update the scope of a folder. Propagates to all files inside (recursively). Global scope requires sysAdmin."""
|
|
||||||
validScopes = {"personal", "featureInstance", "mandate", "global"}
|
|
||||||
if scope not in validScopes:
|
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid scope: {scope}. Must be one of {validScopes}")
|
|
||||||
if scope == "global" and not context.isSysAdmin:
|
|
||||||
raise HTTPException(status_code=403, detail=routeApiMsg("Only sysadmins can set global scope"))
|
|
||||||
try:
|
|
||||||
mgmt = interfaceDbManagement.getInterface(
|
|
||||||
context.user,
|
|
||||||
mandateId=str(context.mandateId) if context.mandateId else None,
|
|
||||||
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None,
|
|
||||||
)
|
|
||||||
folder = mgmt.getFolder(folderId)
|
|
||||||
if not folder:
|
|
||||||
raise HTTPException(status_code=404, detail=routeApiMsg("Folder not found"))
|
|
||||||
mgmt.updateFolder(folderId, {"scope": scope})
|
|
||||||
fileIds = _collectFolderFileIds(mgmt, folderId)
|
|
||||||
for fid in fileIds:
|
|
||||||
try:
|
|
||||||
mgmt.updateFile(fid, {"scope": scope})
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Folder scope propagation: failed to update file %s: %s", fid, e)
|
|
||||||
logger.info("Updated scope=%s for folder %s: %d files affected", scope, folderId, len(fileIds))
|
|
||||||
return {"folderId": folderId, "scope": scope, "filesUpdated": len(fileIds)}
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error updating folder scope: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/folders/{folderId}/neutralize")
|
|
||||||
@limiter.limit("10/minute")
|
|
||||||
def updateFolderNeutralize(
|
|
||||||
request: Request,
|
|
||||||
background_tasks: BackgroundTasks,
|
|
||||||
folderId: str = Path(..., description="ID of the folder"),
|
|
||||||
neutralize: bool = Body(..., embed=True),
|
|
||||||
context: RequestContext = Depends(getRequestContext),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Toggle neutralization on a folder. Propagates to all files inside (recursively).
|
|
||||||
|
|
||||||
When turning ON: all files in the folder get ``neutralize=True``, their
|
|
||||||
knowledge indexes are purged synchronously, and background re-indexing
|
|
||||||
is triggered.
|
|
||||||
When turning OFF: files revert to ``neutralize=False`` unless they were
|
|
||||||
individually marked (not implemented yet -- all are reverted).
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
mgmt = interfaceDbManagement.getInterface(
|
|
||||||
context.user,
|
|
||||||
mandateId=str(context.mandateId) if context.mandateId else None,
|
|
||||||
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
folder = mgmt.getFolder(folderId)
|
|
||||||
if not folder:
|
|
||||||
raise HTTPException(status_code=404, detail=routeApiMsg("Folder not found"))
|
|
||||||
|
|
||||||
mgmt.updateFolder(folderId, {"neutralize": neutralize})
|
|
||||||
|
|
||||||
fileIds = _collectFolderFileIds(mgmt, folderId)
|
|
||||||
logger.info("Folder neutralize toggle %s for folder %s: %d files affected", neutralize, folderId, len(fileIds))
|
|
||||||
|
|
||||||
from modules.interfaces.interfaceDbKnowledge import getInterface as getKnowledgeInterface
|
|
||||||
knowledgeDb = getKnowledgeInterface()
|
|
||||||
|
|
||||||
for fid in fileIds:
|
|
||||||
try:
|
|
||||||
mgmt.updateFile(fid, {"neutralize": neutralize})
|
|
||||||
if neutralize:
|
|
||||||
try:
|
|
||||||
knowledgeDb.deleteFileContentIndex(fid)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Folder neutralize: failed to purge index for file %s: %s", fid, e)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
from modules.datamodels.datamodelKnowledge import FileContentIndex
|
|
||||||
indices = knowledgeDb.db.getRecordset(FileContentIndex, recordFilter={"id": fid})
|
|
||||||
for idx in indices:
|
|
||||||
idxId = idx.get("id") if isinstance(idx, dict) else getattr(idx, "id", None)
|
|
||||||
if idxId:
|
|
||||||
knowledgeDb.db.recordModify(FileContentIndex, idxId, {
|
|
||||||
"neutralizationStatus": "original",
|
|
||||||
"isNeutralized": False,
|
|
||||||
})
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("Folder neutralize OFF: metadata update failed for %s: %s", fid, e)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Folder neutralize: failed to update file %s: %s", fid, e)
|
|
||||||
|
|
||||||
for fid in fileIds:
|
|
||||||
fileMeta = mgmt.getFile(fid)
|
|
||||||
if fileMeta:
|
|
||||||
fn = fileMeta.fileName if hasattr(fileMeta, "fileName") else fileMeta.get("fileName", "")
|
|
||||||
mt = fileMeta.mimeType if hasattr(fileMeta, "mimeType") else fileMeta.get("mimeType", "")
|
|
||||||
|
|
||||||
async def _reindex(fileId=fid, fileName=fn, mimeType=mt):
|
|
||||||
try:
|
|
||||||
await _autoIndexFile(fileId=fileId, fileName=fileName, mimeType=mimeType, user=context.user)
|
|
||||||
except Exception as ex:
|
|
||||||
logger.error("Folder neutralize re-index failed for %s: %s", fileId, ex)
|
|
||||||
|
|
||||||
background_tasks.add_task(_reindex)
|
|
||||||
|
|
||||||
return {"folderId": folderId, "neutralize": neutralize, "filesUpdated": len(fileIds)}
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error updating folder neutralize flag: {e}")
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
||||||
|
|
||||||
def _collectFolderFileIds(mgmt, folderId: str) -> List[str]:
|
|
||||||
"""Recursively collect all file IDs in a folder and its sub-folders."""
|
|
||||||
fileIds = []
|
|
||||||
try:
|
|
||||||
files = mgmt.listFiles(folderId=folderId)
|
|
||||||
if isinstance(files, dict):
|
|
||||||
files = files.get("files", [])
|
|
||||||
for f in (files or []):
|
|
||||||
fid = f.get("id") if isinstance(f, dict) else getattr(f, "id", None)
|
|
||||||
if fid:
|
|
||||||
fileIds.append(fid)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("_collectFolderFileIds: listFiles failed for folder %s: %s", folderId, e)
|
|
||||||
|
|
||||||
try:
|
|
||||||
subFolders = mgmt.listFolders(parentId=folderId)
|
|
||||||
for sf in (subFolders or []):
|
|
||||||
sfId = sf.get("id") if isinstance(sf, dict) else getattr(sf, "id", None)
|
|
||||||
if sfId:
|
|
||||||
fileIds.extend(_collectFolderFileIds(mgmt, sfId))
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("_collectFolderFileIds: listFolders failed for folder %s: %s", folderId, e)
|
|
||||||
|
|
||||||
return fileIds
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/folders/{folderId}/download")
|
@router.get("/folders/{folderId}/download")
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
def download_folder(
|
def download_folder(
|
||||||
|
|
@ -846,7 +669,7 @@ def updateFileScope(
|
||||||
if scope not in validScopes:
|
if scope not in validScopes:
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid scope: {scope}. Must be one of {validScopes}")
|
raise HTTPException(status_code=400, detail=f"Invalid scope: {scope}. Must be one of {validScopes}")
|
||||||
|
|
||||||
if scope == "global" and not context.isSysAdmin:
|
if scope == "global" and not context.hasSysAdminRole:
|
||||||
raise HTTPException(status_code=403, detail=routeApiMsg("Only sysadmins can set global scope"))
|
raise HTTPException(status_code=403, detail=routeApiMsg("Only sysadmins can set global scope"))
|
||||||
|
|
||||||
managementInterface = interfaceDbManagement.getInterface(
|
managementInterface = interfaceDbManagement.getInterface(
|
||||||
|
|
@ -1040,7 +863,7 @@ def update_file(
|
||||||
detail=f"File with ID {fileId} not found"
|
detail=f"File with ID {fileId} not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
if safeData.get("scope") == "global" and not getattr(currentUser, "isSysAdmin", False):
|
if safeData.get("scope") == "global" and not _hasSysAdminRole(str(currentUser.id)):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=routeApiMsg("Only sysadmins can set global scope"),
|
detail=routeApiMsg("Only sysadmins can set global scope"),
|
||||||
|
|
@ -1205,18 +1028,6 @@ def move_file(
|
||||||
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None,
|
featureInstanceId=str(context.featureInstanceId) if context.featureInstanceId else None,
|
||||||
)
|
)
|
||||||
mgmt.updateFile(fileId, {"folderId": targetFolderId})
|
mgmt.updateFile(fileId, {"folderId": targetFolderId})
|
||||||
|
|
||||||
if targetFolderId:
|
|
||||||
try:
|
|
||||||
targetFolder = mgmt.getFolder(targetFolderId)
|
|
||||||
folderNeut = (targetFolder.get("neutralize") if isinstance(targetFolder, dict)
|
|
||||||
else getattr(targetFolder, "neutralize", False)) if targetFolder else False
|
|
||||||
if folderNeut:
|
|
||||||
mgmt.updateFile(fileId, {"neutralize": True})
|
|
||||||
logger.info("File %s moved to neutralized folder %s — inherited neutralize=True", fileId, targetFolderId)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("File move: folder neutralize inheritance check failed for %s: %s", fileId, e)
|
|
||||||
|
|
||||||
return {"success": True, "fileId": fileId, "folderId": targetFolderId}
|
return {"success": True, "fileId": fileId, "folderId": targetFolderId}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error moving file: {e}")
|
logger.error(f"Error moving file: {e}")
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,7 @@ Mandate routes for the backend API.
|
||||||
Implements the endpoints for mandate management.
|
Implements the endpoints for mandate management.
|
||||||
|
|
||||||
MULTI-TENANT:
|
MULTI-TENANT:
|
||||||
- Mandate create/delete and cross-mandate ops require PlatformAdmin
|
- Mandate CRUD is SysAdmin-only (mandates are system resources)
|
||||||
- Mandate read/update: PlatformAdmin or Mandate-Admin (label-only for the latter)
|
|
||||||
- User management within mandates is Mandate-Admin (add/remove users)
|
- User management within mandates is Mandate-Admin (add/remove users)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
@ -18,7 +17,7 @@ import json
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
# Import auth module
|
# Import auth module
|
||||||
from modules.auth import limiter, requirePlatformAdmin, getRequestContext, getCurrentUser, RequestContext
|
from modules.auth import limiter, requireSysAdminRole, getRequestContext, RequestContext
|
||||||
|
|
||||||
# Import interfaces
|
# Import interfaces
|
||||||
import modules.interfaces.interfaceDbApp as interfaceDbApp
|
import modules.interfaces.interfaceDbApp as interfaceDbApp
|
||||||
|
|
@ -34,8 +33,6 @@ from modules.datamodels.datamodelPagination import PaginationParams, PaginatedRe
|
||||||
from modules.routes.routeNotifications import create_access_change_notification
|
from modules.routes.routeNotifications import create_access_change_notification
|
||||||
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
|
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException
|
||||||
from modules.shared.i18nRegistry import apiRouteContext
|
from modules.shared.i18nRegistry import apiRouteContext
|
||||||
from modules.shared.mandateNameUtils import isValidMandateName
|
|
||||||
|
|
||||||
routeApiMsg = apiRouteContext("routeDataMandates")
|
routeApiMsg = apiRouteContext("routeDataMandates")
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -104,8 +101,8 @@ def get_mandates(
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
# Check admin access
|
# Check admin access
|
||||||
isPlatformAdmin = context.isPlatformAdmin
|
isSysAdmin = context.hasSysAdminRole
|
||||||
if not isPlatformAdmin:
|
if not isSysAdmin:
|
||||||
adminMandateIds = _getAdminMandateIds(context)
|
adminMandateIds = _getAdminMandateIds(context)
|
||||||
if not adminMandateIds:
|
if not adminMandateIds:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -138,7 +135,7 @@ def get_mandates(
|
||||||
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")
|
||||||
if isPlatformAdmin:
|
if isSysAdmin:
|
||||||
crossPagination = parseCrossFilterPagination(column, pagination)
|
crossPagination = parseCrossFilterPagination(column, pagination)
|
||||||
try:
|
try:
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse
|
||||||
|
|
@ -158,7 +155,7 @@ def get_mandates(
|
||||||
return handleFilterValuesInMemory(mandateItems, column, pagination)
|
return handleFilterValuesInMemory(mandateItems, column, pagination)
|
||||||
|
|
||||||
if mode == "ids":
|
if mode == "ids":
|
||||||
if isPlatformAdmin:
|
if isSysAdmin:
|
||||||
return handleIdsMode(appInterface.db, Mandate, pagination)
|
return handleIdsMode(appInterface.db, Mandate, pagination)
|
||||||
else:
|
else:
|
||||||
mandateItems = []
|
mandateItems = []
|
||||||
|
|
@ -168,7 +165,7 @@ def get_mandates(
|
||||||
mandateItems.append(m.model_dump() if hasattr(m, 'model_dump') else m if isinstance(m, dict) else vars(m))
|
mandateItems.append(m.model_dump() if hasattr(m, 'model_dump') else m if isinstance(m, dict) else vars(m))
|
||||||
return handleIdsInMemory(mandateItems, pagination)
|
return handleIdsInMemory(mandateItems, pagination)
|
||||||
|
|
||||||
if isPlatformAdmin:
|
if isSysAdmin:
|
||||||
result = appInterface.getAllMandates(pagination=paginationParams)
|
result = appInterface.getAllMandates(pagination=paginationParams)
|
||||||
else:
|
else:
|
||||||
allMandates = []
|
allMandates = []
|
||||||
|
|
@ -226,7 +223,7 @@ def get_mandate(
|
||||||
try:
|
try:
|
||||||
mandateId = targetMandateId
|
mandateId = targetMandateId
|
||||||
# Check access
|
# Check access
|
||||||
if not context.isPlatformAdmin:
|
if not context.hasSysAdminRole:
|
||||||
adminMandateIds = _getAdminMandateIds(context)
|
adminMandateIds = _getAdminMandateIds(context)
|
||||||
if mandateId not in adminMandateIds:
|
if mandateId not in adminMandateIds:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -257,48 +254,37 @@ def get_mandate(
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
def create_mandate(
|
def create_mandate(
|
||||||
request: Request,
|
request: Request,
|
||||||
mandateData: dict = Body(..., description="Mandate data: label (Voller Name) required unless name alone is provided; name (Kurzzeichen) optional — auto-generated from label if omitted"),
|
mandateData: dict = Body(..., description="Mandate data with at least 'name' field"),
|
||||||
currentUser: User = Depends(requirePlatformAdmin)
|
currentUser: User = Depends(requireSysAdminRole)
|
||||||
) -> Mandate:
|
) -> Mandate:
|
||||||
"""
|
"""
|
||||||
Create a new mandate.
|
Create a new mandate.
|
||||||
MULTI-TENANT: PlatformAdmin-only.
|
MULTI-TENANT: SysAdmin-only.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
logger.debug(f"Creating mandate with data: {mandateData}")
|
logger.debug(f"Creating mandate with data: {mandateData}")
|
||||||
|
|
||||||
labelRaw = mandateData.get("label")
|
# Validate required fields
|
||||||
nameRaw = mandateData.get("name")
|
name = mandateData.get('name')
|
||||||
labelStripped = str(labelRaw).strip() if labelRaw is not None else ""
|
if not name or (isinstance(name, str) and name.strip() == ''):
|
||||||
if not labelStripped and nameRaw is not None:
|
|
||||||
labelStripped = str(nameRaw).strip()
|
|
||||||
if not labelStripped:
|
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail=routeApiMsg("Mandate Voller Name (label) is required"),
|
detail=routeApiMsg("Mandate name is required")
|
||||||
)
|
)
|
||||||
|
|
||||||
nameToPass = None
|
# Get optional fields with defaults
|
||||||
if nameRaw is not None and str(nameRaw).strip() != "":
|
label = mandateData.get('label')
|
||||||
nameToPass = str(nameRaw).strip()
|
enabled = mandateData.get('enabled', True)
|
||||||
if not isValidMandateName(nameToPass):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=routeApiMsg(
|
|
||||||
"Mandate Kurzzeichen (name) must be 2–32 characters: lowercase a–z, digits, hyphens only"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
enabled = mandateData.get("enabled", True)
|
|
||||||
|
|
||||||
appInterface = interfaceDbApp.getRootInterface()
|
appInterface = interfaceDbApp.getRootInterface()
|
||||||
|
|
||||||
|
# Create mandate
|
||||||
newMandate = appInterface.createMandate(
|
newMandate = appInterface.createMandate(
|
||||||
name=nameToPass,
|
name=name,
|
||||||
label=labelStripped,
|
label=label,
|
||||||
enabled=bool(enabled) if enabled is not None else True,
|
enabled=enabled
|
||||||
)
|
)
|
||||||
|
|
||||||
if not newMandate:
|
if not newMandate:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
|
@ -343,22 +329,11 @@ def create_mandate(
|
||||||
except Exception as subErr:
|
except Exception as subErr:
|
||||||
logger.error(f"Failed to create subscription for mandate {newMandate.id}: {subErr}")
|
logger.error(f"Failed to create subscription for mandate {newMandate.id}: {subErr}")
|
||||||
|
|
||||||
logger.info(f"Mandate {newMandate.id} created by PlatformAdmin {currentUser.id}")
|
logger.info(f"Mandate {newMandate.id} created by SysAdmin {currentUser.id}")
|
||||||
|
|
||||||
return newMandate
|
return newMandate
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except ValueError as ve:
|
|
||||||
logger.warning(f"Create mandate validation: {ve}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=routeApiMsg(str(ve)),
|
|
||||||
)
|
|
||||||
except PermissionError:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail=routeApiMsg("No permission to create mandates"),
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error creating mandate: {str(e)}")
|
logger.error(f"Error creating mandate: {str(e)}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -366,96 +341,32 @@ def create_mandate(
|
||||||
detail=f"Failed to create mandate: {str(e)}"
|
detail=f"Failed to create mandate: {str(e)}"
|
||||||
)
|
)
|
||||||
|
|
||||||
_MANDATE_ADMIN_EDITABLE_FIELDS = {"label"}
|
|
||||||
|
|
||||||
def _isUserAdminOfMandate(userId: str, targetMandateId: str) -> bool:
|
|
||||||
"""Check mandate-admin without RequestContext (avoids Header param conflicts)."""
|
|
||||||
try:
|
|
||||||
rootInterface = interfaceDbApp.getRootInterface()
|
|
||||||
userMandates = rootInterface.getUserMandates(userId)
|
|
||||||
for um in userMandates:
|
|
||||||
if str(getattr(um, 'mandateId', '')) != str(targetMandateId):
|
|
||||||
continue
|
|
||||||
umId = getattr(um, 'id', None)
|
|
||||||
if not umId:
|
|
||||||
continue
|
|
||||||
roleIds = rootInterface.getRoleIdsForUserMandate(str(umId))
|
|
||||||
for roleId in roleIds:
|
|
||||||
role = rootInterface.getRole(roleId)
|
|
||||||
if role and role.roleLabel == "admin" and not role.featureInstanceId:
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error checking mandate admin: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
@router.put("/{mandateId}", response_model=Mandate)
|
@router.put("/{mandateId}", response_model=Mandate)
|
||||||
@limiter.limit("10/minute")
|
@limiter.limit("10/minute")
|
||||||
def update_mandate(
|
def update_mandate(
|
||||||
request: Request,
|
request: Request,
|
||||||
mandateId: str = Path(..., description="ID of the mandate to update"),
|
mandateId: str = Path(..., description="ID of the mandate to update"),
|
||||||
mandateData: dict = Body(..., description="Mandate update data"),
|
mandateData: dict = Body(..., description="Mandate update data"),
|
||||||
currentUser: User = Depends(getCurrentUser)
|
currentUser: User = Depends(requireSysAdminRole)
|
||||||
) -> Mandate:
|
) -> Mandate:
|
||||||
"""
|
"""
|
||||||
Update an existing mandate.
|
Update an existing mandate.
|
||||||
MULTI-TENANT:
|
MULTI-TENANT: SysAdmin-only.
|
||||||
- PlatformAdmin: full update (including Kurzzeichen name)
|
|
||||||
- MandateAdmin: only label (Voller Name)
|
|
||||||
"""
|
"""
|
||||||
userId = str(currentUser.id)
|
|
||||||
isPlatformAdmin = bool(getattr(currentUser, "isPlatformAdmin", False))
|
|
||||||
|
|
||||||
if not isPlatformAdmin:
|
|
||||||
if not _isUserAdminOfMandate(userId, mandateId):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail=routeApiMsg("Admin role required to update mandate")
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.debug(f"Updating mandate {mandateId} with data: {mandateData}")
|
logger.debug(f"Updating mandate {mandateId} with data: {mandateData}")
|
||||||
|
|
||||||
appInterface = interfaceDbApp.getRootInterface()
|
appInterface = interfaceDbApp.getRootInterface()
|
||||||
|
|
||||||
|
# Check if mandate exists
|
||||||
existingMandate = appInterface.getMandate(mandateId)
|
existingMandate = appInterface.getMandate(mandateId)
|
||||||
if not existingMandate:
|
if not existingMandate:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail=f"Mandate with ID {mandateId} not found"
|
detail=f"Mandate with ID {mandateId} not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
if not isPlatformAdmin:
|
# Update mandate - mandateData is already a dict
|
||||||
mandateData = {k: v for k, v in mandateData.items() if k in _MANDATE_ADMIN_EDITABLE_FIELDS}
|
|
||||||
if not mandateData:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail=routeApiMsg("No editable fields submitted")
|
|
||||||
)
|
|
||||||
if "label" in mandateData:
|
|
||||||
lbl = mandateData["label"]
|
|
||||||
if lbl is None or str(lbl).strip() == "":
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=routeApiMsg("Mandate Voller Name (label) must not be empty"),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
if "name" in mandateData and mandateData["name"] is not None:
|
|
||||||
nm = str(mandateData["name"]).strip()
|
|
||||||
if nm and not isValidMandateName(nm):
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=routeApiMsg(
|
|
||||||
"Mandate Kurzzeichen (name) must be 2–32 characters: lowercase a–z, digits, hyphens only"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if "label" in mandateData and mandateData["label"] is not None:
|
|
||||||
lb = str(mandateData["label"]).strip()
|
|
||||||
if not lb:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=routeApiMsg("Mandate Voller Name (label) must not be empty"),
|
|
||||||
)
|
|
||||||
|
|
||||||
updatedMandate = appInterface.updateMandate(mandateId, mandateData)
|
updatedMandate = appInterface.updateMandate(mandateId, mandateData)
|
||||||
|
|
||||||
if not updatedMandate:
|
if not updatedMandate:
|
||||||
|
|
@ -464,22 +375,11 @@ def update_mandate(
|
||||||
detail=routeApiMsg("Failed to update mandate")
|
detail=routeApiMsg("Failed to update mandate")
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"Mandate {mandateId} updated by user {currentUser.id} (platformAdmin={isPlatformAdmin})")
|
logger.info(f"Mandate {mandateId} updated by SysAdmin {currentUser.id}")
|
||||||
|
|
||||||
return updatedMandate
|
return updatedMandate
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except ValueError as ve:
|
|
||||||
logger.warning(f"Update mandate validation: {ve}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=routeApiMsg(str(ve)),
|
|
||||||
)
|
|
||||||
except PermissionError:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
|
||||||
detail=routeApiMsg("No permission to update mandate"),
|
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error updating mandate {mandateId}: {str(e)}")
|
logger.error(f"Error updating mandate {mandateId}: {str(e)}")
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -493,7 +393,7 @@ def delete_mandate(
|
||||||
request: Request,
|
request: Request,
|
||||||
mandateId: str = Path(..., description="ID of the mandate to delete"),
|
mandateId: str = Path(..., description="ID of the mandate to delete"),
|
||||||
force: bool = Query(False, description="Hard-delete with full cascade (irreversible)"),
|
force: bool = Query(False, description="Hard-delete with full cascade (irreversible)"),
|
||||||
currentUser: User = Depends(requirePlatformAdmin)
|
currentUser: User = Depends(requireSysAdminRole)
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Delete a mandate.
|
Delete a mandate.
|
||||||
|
|
@ -566,7 +466,7 @@ def list_mandate_users(
|
||||||
pagination: Optional pagination parameters (page, pageSize, search, filters, sort)
|
pagination: Optional pagination parameters (page, pageSize, search, filters, sort)
|
||||||
"""
|
"""
|
||||||
# Check permission
|
# Check permission
|
||||||
if not _hasMandateAdminRole(context, targetMandateId) and not context.isPlatformAdmin:
|
if not _hasMandateAdminRole(context, targetMandateId) and not context.hasSysAdminRole:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=routeApiMsg("Mandate-Admin role required")
|
detail=routeApiMsg("Mandate-Admin role required")
|
||||||
|
|
@ -971,7 +871,7 @@ def update_user_roles_in_mandate(
|
||||||
# Add new role assignments
|
# Add new role assignments
|
||||||
for roleId in roleIds:
|
for roleId in roleIds:
|
||||||
rootInterface.addRoleToUserMandate(str(membership.id), roleId)
|
rootInterface.addRoleToUserMandate(str(membership.id), roleId)
|
||||||
|
|
||||||
# Audit - Log role assignment change
|
# Audit - Log role assignment change
|
||||||
audit_logger.logPermissionChange(
|
audit_logger.logPermissionChange(
|
||||||
userId=str(context.user.id),
|
userId=str(context.user.id),
|
||||||
|
|
@ -1079,7 +979,7 @@ def _hasMandateAdminRole(context: RequestContext, mandateId: str) -> bool:
|
||||||
Check if the user has mandate admin role for the specified mandate.
|
Check if the user has mandate admin role for the specified mandate.
|
||||||
Works with or without X-Mandate-Id header (admin pages don't send it).
|
Works with or without X-Mandate-Id header (admin pages don't send it).
|
||||||
"""
|
"""
|
||||||
if context.isPlatformAdmin:
|
if context.hasSysAdminRole:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# If mandate context matches, check roles from context directly
|
# If mandate context matches, check roles from context directly
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,11 @@
|
||||||
"""PATCH endpoints for DataSource and FeatureDataSource scope/neutralize tagging."""
|
"""PATCH endpoints for DataSource and FeatureDataSource scope/neutralize tagging."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Path, Request, Body
|
from fastapi import APIRouter, HTTPException, Depends, Path, Request, Body
|
||||||
from modules.auth import limiter, getRequestContext, RequestContext
|
from modules.auth import limiter, getRequestContext, RequestContext
|
||||||
|
from modules.auth.authentication import _hasSysAdminRole
|
||||||
from modules.datamodels.datamodelDataSource import DataSource
|
from modules.datamodels.datamodelDataSource import DataSource
|
||||||
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
|
from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource
|
||||||
from modules.shared.i18nRegistry import apiRouteContext
|
from modules.shared.i18nRegistry import apiRouteContext
|
||||||
|
|
@ -52,7 +53,7 @@ def _updateDataSourceScope(
|
||||||
if scope not in _VALID_SCOPES:
|
if scope not in _VALID_SCOPES:
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid scope: {scope}. Must be one of {_VALID_SCOPES}")
|
raise HTTPException(status_code=400, detail=f"Invalid scope: {scope}. Must be one of {_VALID_SCOPES}")
|
||||||
|
|
||||||
if scope == "global" and not context.isSysAdmin:
|
if scope == "global" and not _hasSysAdminRole(context.user):
|
||||||
raise HTTPException(status_code=403, detail=routeApiMsg("Only sysadmins can set global scope"))
|
raise HTTPException(status_code=403, detail=routeApiMsg("Only sysadmins can set global scope"))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -96,32 +97,3 @@ def _updateDataSourceNeutralize(
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Error updating datasource neutralize: %s", e)
|
logger.error("Error updating datasource neutralize: %s", e)
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{sourceId}/neutralize-fields")
|
|
||||||
@limiter.limit("30/minute")
|
|
||||||
def _updateNeutralizeFields(
|
|
||||||
request: Request,
|
|
||||||
sourceId: str = Path(..., description="ID of the FeatureDataSource"),
|
|
||||||
neutralizeFields: List[str] = Body(..., embed=True),
|
|
||||||
context: RequestContext = Depends(getRequestContext),
|
|
||||||
) -> Dict[str, Any]:
|
|
||||||
"""Update the list of field names to neutralize on a FeatureDataSource."""
|
|
||||||
try:
|
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
|
||||||
rootIf = getRootInterface()
|
|
||||||
rec = rootIf.db.getRecord(FeatureDataSource, sourceId)
|
|
||||||
if not rec:
|
|
||||||
raise HTTPException(status_code=404, detail=f"FeatureDataSource {sourceId} not found")
|
|
||||||
|
|
||||||
cleanFields = [f for f in neutralizeFields if f and isinstance(f, str)] if neutralizeFields else []
|
|
||||||
rootIf.db.recordModify(FeatureDataSource, sourceId, {
|
|
||||||
"neutralizeFields": cleanFields if cleanFields else None,
|
|
||||||
})
|
|
||||||
logger.info("Updated neutralizeFields=%s for FeatureDataSource %s", cleanFields, sourceId)
|
|
||||||
return {"sourceId": sourceId, "neutralizeFields": cleanFields, "updated": True}
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
logger.error("Error updating neutralizeFields: %s", e)
|
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ Implements the endpoints for user management.
|
||||||
|
|
||||||
MULTI-TENANT: User management requires RequestContext.
|
MULTI-TENANT: User management requires RequestContext.
|
||||||
- mandateId from X-Mandate-Id header determines which users are visible
|
- mandateId from X-Mandate-Id header determines which users are visible
|
||||||
- isPlatformAdmin can see all users across mandates
|
- SysAdmin can see all users across mandates
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response, Query
|
from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Response, Query
|
||||||
|
|
@ -34,10 +34,10 @@ logger = logging.getLogger(__name__)
|
||||||
def _isAdminForUser(context: RequestContext, targetUserId: str) -> bool:
|
def _isAdminForUser(context: RequestContext, targetUserId: str) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if the current user has admin rights for the target user.
|
Check if the current user has admin rights for the target user.
|
||||||
PlatformAdmin can manage all users. MandateAdmin can manage users in their mandates.
|
SysAdmin can manage all users. MandateAdmin can manage users in their mandates.
|
||||||
Works without X-Mandate-Id header (admin pages don't send it).
|
Works without X-Mandate-Id header (admin pages don't send it).
|
||||||
"""
|
"""
|
||||||
if context.isPlatformAdmin:
|
if context.hasSysAdminRole:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Find mandates where current user is admin
|
# Find mandates where current user is admin
|
||||||
|
|
@ -90,7 +90,7 @@ def _getUserFilterOrIds(context, paginationJson, column=None, idsMode=False):
|
||||||
return handleIdsInMemory(items, paginationJson)
|
return handleIdsInMemory(items, paginationJson)
|
||||||
return handleFilterValuesInMemory(items, column, paginationJson, requestLang)
|
return handleFilterValuesInMemory(items, column, paginationJson, requestLang)
|
||||||
|
|
||||||
if context.isPlatformAdmin:
|
if context.hasSysAdminRole:
|
||||||
rootInterface = getRootInterface()
|
rootInterface = getRootInterface()
|
||||||
if idsMode:
|
if idsMode:
|
||||||
return handleIdsMode(rootInterface.db, UserInDB, paginationJson)
|
return handleIdsMode(rootInterface.db, UserInDB, paginationJson)
|
||||||
|
|
@ -167,7 +167,7 @@ def get_user_options(
|
||||||
if context.mandateId:
|
if context.mandateId:
|
||||||
result = appInterface.getUsersByMandate(str(context.mandateId), None)
|
result = appInterface.getUsersByMandate(str(context.mandateId), None)
|
||||||
users = result.items if hasattr(result, 'items') else result
|
users = result.items if hasattr(result, 'items') else result
|
||||||
elif context.isPlatformAdmin:
|
elif context.hasSysAdminRole:
|
||||||
users = appInterface.getAllUsers()
|
users = appInterface.getAllUsers()
|
||||||
else:
|
else:
|
||||||
raise HTTPException(status_code=403, detail=routeApiMsg("Access denied"))
|
raise HTTPException(status_code=403, detail=routeApiMsg("Access denied"))
|
||||||
|
|
@ -256,8 +256,8 @@ def get_users(
|
||||||
items=users,
|
items=users,
|
||||||
pagination=None
|
pagination=None
|
||||||
)
|
)
|
||||||
elif context.isPlatformAdmin:
|
elif context.hasSysAdminRole:
|
||||||
# PlatformAdmin without mandateId — DB-level pagination via interface
|
# SysAdmin without mandateId — DB-level pagination via interface
|
||||||
result = appInterface.getAllUsers(paginationParams)
|
result = appInterface.getAllUsers(paginationParams)
|
||||||
|
|
||||||
if paginationParams and hasattr(result, 'items'):
|
if paginationParams and hasattr(result, 'items'):
|
||||||
|
|
@ -375,8 +375,8 @@ def get_user(
|
||||||
detail=f"User with ID {userId} not found"
|
detail=f"User with ID {userId} not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
# MULTI-TENANT: Verify user is in the same mandate (unless PlatformAdmin)
|
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
|
||||||
if context.mandateId and not context.isPlatformAdmin:
|
if context.mandateId and not context.hasSysAdminRole:
|
||||||
userMandate = appInterface.getUserMandate(userId, str(context.mandateId))
|
userMandate = appInterface.getUserMandate(userId, str(context.mandateId))
|
||||||
if not userMandate:
|
if not userMandate:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -402,7 +402,6 @@ class CreateUserRequest(BaseModel):
|
||||||
language: str = "de"
|
language: str = "de"
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
isSysAdmin: bool = False
|
isSysAdmin: bool = False
|
||||||
isPlatformAdmin: bool = False
|
|
||||||
password: Optional[str] = None
|
password: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -416,24 +415,10 @@ def create_user(
|
||||||
"""
|
"""
|
||||||
Create a new user.
|
Create a new user.
|
||||||
MULTI-TENANT: User is created and automatically added to the current mandate.
|
MULTI-TENANT: User is created and automatically added to the current mandate.
|
||||||
|
|
||||||
Privileged platform flags (isSysAdmin, isPlatformAdmin) may only be set
|
|
||||||
by a Platform Admin. Non-PlatformAdmin requests have these flags reset
|
|
||||||
to False with a warning.
|
|
||||||
"""
|
"""
|
||||||
appInterface = interfaceDbApp.getInterface(context.user)
|
appInterface = interfaceDbApp.getInterface(context.user)
|
||||||
|
|
||||||
callerIsPlatformAdmin = context.isPlatformAdmin
|
# Extract fields from request model and call createUser with individual parameters
|
||||||
requestedSysAdmin = bool(userData.isSysAdmin) and callerIsPlatformAdmin
|
|
||||||
requestedPlatformAdmin = bool(userData.isPlatformAdmin) and callerIsPlatformAdmin
|
|
||||||
|
|
||||||
if (userData.isSysAdmin or userData.isPlatformAdmin) and not callerIsPlatformAdmin:
|
|
||||||
logger.warning(
|
|
||||||
f"Non-PlatformAdmin {context.user.id} attempted to create user with "
|
|
||||||
f"privileged flags (isSysAdmin={userData.isSysAdmin}, "
|
|
||||||
f"isPlatformAdmin={userData.isPlatformAdmin}); flags reset to False"
|
|
||||||
)
|
|
||||||
|
|
||||||
newUser = appInterface.createUser(
|
newUser = appInterface.createUser(
|
||||||
username=userData.username,
|
username=userData.username,
|
||||||
password=userData.password,
|
password=userData.password,
|
||||||
|
|
@ -442,10 +427,9 @@ def create_user(
|
||||||
language=userData.language,
|
language=userData.language,
|
||||||
enabled=userData.enabled,
|
enabled=userData.enabled,
|
||||||
authenticationAuthority=AuthAuthority.LOCAL,
|
authenticationAuthority=AuthAuthority.LOCAL,
|
||||||
isSysAdmin=requestedSysAdmin,
|
isSysAdmin=userData.isSysAdmin
|
||||||
isPlatformAdmin=requestedPlatformAdmin,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# MULTI-TENANT: Add user to current mandate via UserMandate with default "user" role
|
# MULTI-TENANT: Add user to current mandate via UserMandate with default "user" role
|
||||||
if context.mandateId:
|
if context.mandateId:
|
||||||
userRole = appInterface.getRoleByLabel("user")
|
userRole = appInterface.getRoleByLabel("user")
|
||||||
|
|
@ -454,14 +438,14 @@ def create_user(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail=routeApiMsg("No 'user' role found in system — cannot assign user to mandate")
|
detail=routeApiMsg("No 'user' role found in system — cannot assign user to mandate")
|
||||||
)
|
)
|
||||||
|
|
||||||
appInterface.createUserMandate(
|
appInterface.createUserMandate(
|
||||||
userId=str(newUser.id),
|
userId=str(newUser.id),
|
||||||
mandateId=str(context.mandateId),
|
mandateId=str(context.mandateId),
|
||||||
roleIds=[str(userRole.id)]
|
roleIds=[str(userRole.id)]
|
||||||
)
|
)
|
||||||
logger.info(f"Created UserMandate for user {newUser.id} in mandate {context.mandateId}")
|
logger.info(f"Created UserMandate for user {newUser.id} in mandate {context.mandateId}")
|
||||||
|
|
||||||
return newUser
|
return newUser
|
||||||
|
|
||||||
@router.put("/{userId}", response_model=User)
|
@router.put("/{userId}", response_model=User)
|
||||||
|
|
@ -469,67 +453,43 @@ def create_user(
|
||||||
def update_user(
|
def update_user(
|
||||||
request: Request,
|
request: Request,
|
||||||
userId: str = Path(..., description="ID of the user to update"),
|
userId: str = Path(..., description="ID of the user to update"),
|
||||||
userData: Dict[str, Any] = Body(..., description="Partial user payload — only the fields present in the request body are updated."),
|
userData: User = Body(...),
|
||||||
context: RequestContext = Depends(getRequestContext)
|
context: RequestContext = Depends(getRequestContext)
|
||||||
) -> User:
|
) -> User:
|
||||||
"""
|
"""
|
||||||
Update an existing user (PARTIAL update).
|
Update an existing user.
|
||||||
|
|
||||||
The request body is treated as a **partial** patch: only the keys actually
|
|
||||||
sent are applied; missing keys leave the stored value untouched. This is
|
|
||||||
intentional — sending a full ``User`` body would overwrite unrelated fields
|
|
||||||
(e.g. ``isSysAdmin``/``isPlatformAdmin``) with Pydantic defaults whenever a
|
|
||||||
client only ships a subset, which has historically caused privileged flags
|
|
||||||
to flip silently when toggling a single inline cell.
|
|
||||||
|
|
||||||
Self-service: Users can update their own profile (language, fullName, etc.).
|
Self-service: Users can update their own profile (language, fullName, etc.).
|
||||||
Admin: MandateAdmin can update users in their mandates.
|
Admin: MandateAdmin can update users in their mandates. SysAdmin for all.
|
||||||
PlatformAdmin can update any user.
|
|
||||||
|
|
||||||
Privileged flag changes (isSysAdmin, isPlatformAdmin) require:
|
|
||||||
- caller has isPlatformAdmin=True, AND
|
|
||||||
- target is NOT the caller (Self-Protection).
|
|
||||||
"""
|
"""
|
||||||
isSelfUpdate = str(context.user.id) == str(userId)
|
isSelfUpdate = str(context.user.id) == str(userId)
|
||||||
|
|
||||||
|
# Non-self updates require admin permission
|
||||||
if not isSelfUpdate and not _isAdminForUser(context, userId):
|
if not isSelfUpdate and not _isAdminForUser(context, userId):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
detail=routeApiMsg("Admin role required to update other users")
|
detail=routeApiMsg("Admin role required to update other users")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Use rootInterface for user lookup/update (avoids RBAC filtering on User table)
|
||||||
rootInterface = getRootInterface()
|
rootInterface = getRootInterface()
|
||||||
|
|
||||||
|
# Check if the user exists
|
||||||
existingUser = rootInterface.getUser(userId)
|
existingUser = rootInterface.getUser(userId)
|
||||||
if not existingUser:
|
if not existingUser:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_404_NOT_FOUND,
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
detail=f"User with ID {userId} not found"
|
detail=f"User with ID {userId} not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
if not isinstance(userData, dict):
|
# Update user
|
||||||
raise HTTPException(
|
updatedUser = rootInterface.updateUser(userId, userData)
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
|
||||||
detail=routeApiMsg("User update payload must be a JSON object")
|
|
||||||
)
|
|
||||||
|
|
||||||
# Defensive: drop ``id`` from payload — userId comes from the path and
|
|
||||||
# tampering with it from the body must never silently rebind the row.
|
|
||||||
sanitizedPayload = {k: v for k, v in userData.items() if k != "id"}
|
|
||||||
|
|
||||||
callerIsPlatformAdmin = context.isPlatformAdmin
|
|
||||||
allowAdminFlagChange = callerIsPlatformAdmin and not isSelfUpdate
|
|
||||||
|
|
||||||
updatedUser = rootInterface.updateUser(
|
|
||||||
userId, sanitizedPayload, allowAdminFlagChange=allowAdminFlagChange
|
|
||||||
)
|
|
||||||
|
|
||||||
if not updatedUser:
|
if not updatedUser:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
detail=routeApiMsg("Error updating the user")
|
detail=routeApiMsg("Error updating the user")
|
||||||
)
|
)
|
||||||
|
|
||||||
return updatedUser
|
return updatedUser
|
||||||
|
|
||||||
@router.post("/{userId}/reset-password")
|
@router.post("/{userId}/reset-password")
|
||||||
|
|
@ -832,7 +792,7 @@ def delete_user(
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Delete a user.
|
Delete a user.
|
||||||
MULTI-TENANT: Can only delete users in the same mandate (unless PlatformAdmin).
|
MULTI-TENANT: Can only delete users in the same mandate (unless SysAdmin).
|
||||||
"""
|
"""
|
||||||
appInterface = interfaceDbApp.getInterface(context.user)
|
appInterface = interfaceDbApp.getInterface(context.user)
|
||||||
|
|
||||||
|
|
@ -844,8 +804,8 @@ def delete_user(
|
||||||
detail=f"User with ID {userId} not found"
|
detail=f"User with ID {userId} not found"
|
||||||
)
|
)
|
||||||
|
|
||||||
# MULTI-TENANT: Verify user is in the same mandate (unless PlatformAdmin)
|
# MULTI-TENANT: Verify user is in the same mandate (unless SysAdmin)
|
||||||
if context.mandateId and not context.isPlatformAdmin:
|
if context.mandateId and not context.hasSysAdminRole:
|
||||||
userMandate = appInterface.getUserMandate(userId, str(context.mandateId))
|
userMandate = appInterface.getUserMandate(userId, str(context.mandateId))
|
||||||
if not userMandate:
|
if not userMandate:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,9 @@
|
||||||
Public and authenticated routes for UI language sets (DB-backed i18n).
|
Public and authenticated routes for UI language sets (DB-backed i18n).
|
||||||
|
|
||||||
Architecture:
|
Architecture:
|
||||||
- xx = base set (meta): key = source plaintext (German or English, as written
|
- xx = base set (meta): key = German plaintext, value = UI context for AI
|
||||||
in the code via ``t("...")``), value = UI context for AI
|
|
||||||
- All languages (incl. de) are AI-generated translations from xx
|
- All languages (incl. de) are AI-generated translations from xx
|
||||||
- AI translation pipeline uses context from xx to disambiguate translations;
|
- AI translation pipeline uses context from xx to disambiguate translations
|
||||||
the prompt forces the output language to be exactly the requested target.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -25,7 +23,7 @@ from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, Re
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from modules.auth import getCurrentUser, requireSysAdmin, requirePlatformAdmin
|
from modules.auth import getCurrentUser, requireSysAdminRole
|
||||||
from modules.connectors.connectorDbPostgre import _get_cached_connector
|
from modules.connectors.connectorDbPostgre import _get_cached_connector
|
||||||
from modules.datamodels.datamodelAi import (
|
from modules.datamodels.datamodelAi import (
|
||||||
AiCallOptions,
|
AiCallOptions,
|
||||||
|
|
@ -236,31 +234,17 @@ async def _translateBatch(
|
||||||
jsonPayload = json.dumps(payload, ensure_ascii=False)
|
jsonPayload = json.dumps(payload, ensure_ascii=False)
|
||||||
|
|
||||||
systemPrompt = (
|
systemPrompt = (
|
||||||
f"You are a professional translator for software UI texts. "
|
f"Du bist ein professioneller Übersetzer für Software-UI-Texte. "
|
||||||
f"You receive a JSON array of objects: {{\"key\": \"source text\", \"context\": \"UI context\"}}. "
|
f"Du erhältst ein JSON-Array mit Objekten: {{\"key\": \"deutscher Text\", \"context\": \"UI-Kontext\"}}. "
|
||||||
f"The source text is written in German OR English. "
|
f"Der Kontext beschreibt, wo der Text in der Anwendung verwendet wird (Datei, Komponente). "
|
||||||
f"The context describes where the text is used in the application (file, component). "
|
f"Übersetze jeden «key» ins {targetLanguageLabel} (ISO {targetCode}). "
|
||||||
f"\n\n"
|
f"Behalte Platzhalter wie {{variable}} exakt bei. "
|
||||||
f"HARD REQUIREMENTS (must all be satisfied):\n"
|
f"Antworte NUR mit einem JSON-Objekt — Keys = deutsche Originaltexte, Values = Übersetzungen. "
|
||||||
f"1. OUTPUT LANGUAGE: every translated value MUST be written in {targetLanguageLabel} "
|
f"Kein Markdown, kein Kommentar."
|
||||||
f"(ISO code \"{targetCode}\"). Never output in German or English if that is not "
|
|
||||||
f"the target language. No mixing of languages.\n"
|
|
||||||
f"2. If the source is already in the target language, keep it (do not re-translate, "
|
|
||||||
f"do not paraphrase).\n"
|
|
||||||
f"3. KEEP the exact JSON keys from the input — do NOT translate or modify the keys.\n"
|
|
||||||
f"4. KEEP placeholders like {{variable}}, {{count}}, %s, %(name)s exactly as they are.\n"
|
|
||||||
f"5. Preserve leading/trailing whitespace, punctuation and capitalisation pattern.\n"
|
|
||||||
f"6. Answer ONLY with a JSON object mapping source-key -> translated value in "
|
|
||||||
f"{targetLanguageLabel}. No markdown fences, no comments, no explanations.\n"
|
|
||||||
f"7. If a key cannot be translated (empty, pure symbols, URLs), return the source unchanged."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
aiRequest = AiCallRequest(
|
aiRequest = AiCallRequest(
|
||||||
prompt=(
|
prompt=f"Übersetze diese UI-Labels:\n{jsonPayload}",
|
||||||
f"Translate the following UI labels into {targetLanguageLabel} "
|
|
||||||
f"(ISO {targetCode}). Source may be German or English. "
|
|
||||||
f"Respond with a pure JSON object only.\n{jsonPayload}"
|
|
||||||
),
|
|
||||||
context=systemPrompt,
|
context=systemPrompt,
|
||||||
options=AiCallOptions(
|
options=AiCallOptions(
|
||||||
operationType=OperationTypeEnum.DATA_GENERATE,
|
operationType=OperationTypeEnum.DATA_GENERATE,
|
||||||
|
|
@ -842,7 +826,7 @@ async def _syncLanguageWithXx(db, code: str, userId: Optional[str], adminUser: O
|
||||||
@router.put("/sets/sync-xx")
|
@router.put("/sets/sync-xx")
|
||||||
async def sync_xx_master(
|
async def sync_xx_master(
|
||||||
request: Request,
|
request: Request,
|
||||||
adminUser: User = Depends(requireSysAdmin),
|
adminUser: User = Depends(requireSysAdminRole),
|
||||||
):
|
):
|
||||||
"""Synchronise the xx base set from the frontend build artefact.
|
"""Synchronise the xx base set from the frontend build artefact.
|
||||||
|
|
||||||
|
|
@ -860,7 +844,7 @@ async def sync_xx_master(
|
||||||
@router.get("/sets/{code}/sync-diff")
|
@router.get("/sets/{code}/sync-diff")
|
||||||
async def get_language_sync_diff(
|
async def get_language_sync_diff(
|
||||||
code: str,
|
code: str,
|
||||||
adminUser: User = Depends(requirePlatformAdmin),
|
adminUser: User = Depends(requireSysAdminRole),
|
||||||
):
|
):
|
||||||
"""How many keys would be added/removed vs xx before running a full sync (SysAdmin)."""
|
"""How many keys would be added/removed vs xx before running a full sync (SysAdmin)."""
|
||||||
c = code.strip().lower()
|
c = code.strip().lower()
|
||||||
|
|
@ -873,7 +857,7 @@ async def get_language_sync_diff(
|
||||||
@router.put("/sets/{code}")
|
@router.put("/sets/{code}")
|
||||||
async def update_language_set(
|
async def update_language_set(
|
||||||
code: str,
|
code: str,
|
||||||
adminUser: User = Depends(requirePlatformAdmin),
|
adminUser: User = Depends(requireSysAdminRole),
|
||||||
):
|
):
|
||||||
c = code.strip().lower()
|
c = code.strip().lower()
|
||||||
if c in ("update-all", "sync-xx", "sync-de"):
|
if c in ("update-all", "sync-xx", "sync-de"):
|
||||||
|
|
@ -889,7 +873,7 @@ async def update_language_set(
|
||||||
@router.delete("/sets/{code}")
|
@router.delete("/sets/{code}")
|
||||||
async def delete_language_set(
|
async def delete_language_set(
|
||||||
code: str,
|
code: str,
|
||||||
adminUser: User = Depends(requirePlatformAdmin),
|
adminUser: User = Depends(requireSysAdminRole),
|
||||||
):
|
):
|
||||||
c = code.strip().lower()
|
c = code.strip().lower()
|
||||||
if c in _PROTECTED_CODES:
|
if c in _PROTECTED_CODES:
|
||||||
|
|
@ -927,7 +911,7 @@ async def download_language_set(
|
||||||
|
|
||||||
@router.get("/export")
|
@router.get("/export")
|
||||||
async def export_all_language_sets(
|
async def export_all_language_sets(
|
||||||
adminUser: User = Depends(requirePlatformAdmin),
|
adminUser: User = Depends(requireSysAdminRole),
|
||||||
):
|
):
|
||||||
db = getMgmtInterface(adminUser, mandateId=None).db
|
db = getMgmtInterface(adminUser, mandateId=None).db
|
||||||
rows = db.getRecordset(UiLanguageSet)
|
rows = db.getRecordset(UiLanguageSet)
|
||||||
|
|
@ -955,7 +939,7 @@ async def export_all_language_sets(
|
||||||
@router.post("/import")
|
@router.post("/import")
|
||||||
async def import_language_sets(
|
async def import_language_sets(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
adminUser: User = Depends(requirePlatformAdmin),
|
adminUser: User = Depends(requireSysAdminRole),
|
||||||
):
|
):
|
||||||
if not file.filename or not file.filename.endswith(".json"):
|
if not file.filename or not file.filename.endswith(".json"):
|
||||||
raise HTTPException(status_code=400, detail=routeApiMsg("Nur .json-Dateien erlaubt."))
|
raise HTTPException(status_code=400, detail=routeApiMsg("Nur .json-Dateien erlaubt."))
|
||||||
|
|
|
||||||
|
|
@ -186,7 +186,7 @@ def create_invitation(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check admin permission
|
# Check admin permission
|
||||||
if not context.isPlatformAdmin:
|
if not context.hasSysAdminRole:
|
||||||
if str(context.mandateId) != mandateId:
|
if str(context.mandateId) != mandateId:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
|
@ -891,7 +891,7 @@ def _hasMandateAdminRole(context: RequestContext) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if the user has mandate admin role in the current context.
|
Check if the user has mandate admin role in the current context.
|
||||||
"""
|
"""
|
||||||
if context.isPlatformAdmin:
|
if context.hasSysAdminRole:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if not context.roleIds:
|
if not context.roleIds:
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@ async def _validateInstanceAccess(instanceId: str, context: RequestContext) -> s
|
||||||
status_code=400,
|
status_code=400,
|
||||||
detail=f"Instance '{instanceId}' is not a realestate instance"
|
detail=f"Instance '{instanceId}' is not a realestate instance"
|
||||||
)
|
)
|
||||||
if not context.isPlatformAdmin:
|
if not context.hasSysAdminRole:
|
||||||
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
|
featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id))
|
||||||
hasAccess = any(
|
hasAccess = any(
|
||||||
str(fa.featureInstanceId) == instanceId and fa.enabled
|
str(fa.featureInstanceId) == instanceId and fa.enabled
|
||||||
|
|
|
||||||
|
|
@ -210,13 +210,13 @@ def _ensureHomeMandate(rootInterface, user) -> None:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Could not check pending invitations for {user.username}: {e}")
|
logger.warning(f"Could not check pending invitations for {user.username}: {e}")
|
||||||
|
|
||||||
homeMandateLabel = f"Home {user.username}"
|
homeMandateName = f"Home {user.username}"
|
||||||
rootInterface._provisionMandateForUser(
|
rootInterface._provisionMandateForUser(
|
||||||
userId=userId,
|
userId=userId,
|
||||||
mandateLabel=homeMandateLabel,
|
mandateName=homeMandateName,
|
||||||
planKey="TRIAL_14D",
|
planKey="TRIAL_14D",
|
||||||
)
|
)
|
||||||
logger.info(f"Created Home mandate '{homeMandateLabel}' for user {user.username}")
|
logger.info(f"Created Home mandate '{homeMandateName}' for user {user.username}")
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login")
|
@router.post("/login")
|
||||||
|
|
@ -464,10 +464,10 @@ def register_user(
|
||||||
provisionResult = None
|
provisionResult = None
|
||||||
if not hasPendingInvitations:
|
if not hasPendingInvitations:
|
||||||
try:
|
try:
|
||||||
homeMandateLabel = f"Home {user.username}"
|
homeMandateName = f"Home {user.username}"
|
||||||
provisionResult = appInterface._provisionMandateForUser(
|
provisionResult = appInterface._provisionMandateForUser(
|
||||||
userId=str(user.id),
|
userId=str(user.id),
|
||||||
mandateLabel=homeMandateLabel,
|
mandateName=homeMandateName,
|
||||||
planKey="TRIAL_14D",
|
planKey="TRIAL_14D",
|
||||||
)
|
)
|
||||||
logger.info(f"Provisioned Home mandate for user {user.id}: {provisionResult}")
|
logger.info(f"Provisioned Home mandate for user {user.id}: {provisionResult}")
|
||||||
|
|
@ -881,7 +881,7 @@ def onboarding_provision(
|
||||||
"alreadyProvisioned": True,
|
"alreadyProvisioned": True,
|
||||||
}
|
}
|
||||||
|
|
||||||
mandateLabel = (companyName.strip() if companyName and companyName.strip()
|
mandateName = (companyName.strip() if companyName and companyName.strip()
|
||||||
else f"Home {currentUser.username}")
|
else f"Home {currentUser.username}")
|
||||||
|
|
||||||
if planKey not in ("TRIAL_14D", "STARTER_MONTHLY", "STARTER_YEARLY", "PROFESSIONAL_MONTHLY", "PROFESSIONAL_YEARLY", "MAX_MONTHLY", "MAX_YEARLY"):
|
if planKey not in ("TRIAL_14D", "STARTER_MONTHLY", "STARTER_YEARLY", "PROFESSIONAL_MONTHLY", "PROFESSIONAL_YEARLY", "MAX_MONTHLY", "MAX_YEARLY"):
|
||||||
|
|
@ -889,7 +889,7 @@ def onboarding_provision(
|
||||||
|
|
||||||
result = appInterface._provisionMandateForUser(
|
result = appInterface._provisionMandateForUser(
|
||||||
userId=userId,
|
userId=userId,
|
||||||
mandateLabel=mandateLabel,
|
mandateName=mandateName,
|
||||||
planKey=planKey,
|
planKey=planKey,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -59,13 +59,7 @@ class StoreFeatureResponse(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
def _getStoreFeatures(catalogService) -> List[Dict[str, Any]]:
|
def _getStoreFeatures(catalogService) -> List[Dict[str, Any]]:
|
||||||
"""Get all features available in the store.
|
"""Get all features available in the store."""
|
||||||
|
|
||||||
Soft-disabled features (``enabled=False`` in their feature definition) are
|
|
||||||
skipped so that legacy or temporarily-deactivated modules do not appear in
|
|
||||||
the storefront, even if their ``resource.store.*`` catalog object is still
|
|
||||||
registered.
|
|
||||||
"""
|
|
||||||
resourceObjects = catalogService.getResourceObjects()
|
resourceObjects = catalogService.getResourceObjects()
|
||||||
storeFeatures = []
|
storeFeatures = []
|
||||||
for obj in resourceObjects:
|
for obj in resourceObjects:
|
||||||
|
|
@ -74,7 +68,7 @@ def _getStoreFeatures(catalogService) -> List[Dict[str, Any]]:
|
||||||
featureCode = meta.get("featureCode")
|
featureCode = meta.get("featureCode")
|
||||||
if featureCode:
|
if featureCode:
|
||||||
featureDef = catalogService.getFeatureDefinition(featureCode)
|
featureDef = catalogService.getFeatureDefinition(featureCode)
|
||||||
if featureDef and featureDef.get("enabled", True):
|
if featureDef:
|
||||||
storeFeatures.append(featureDef)
|
storeFeatures.append(featureDef)
|
||||||
return storeFeatures
|
return storeFeatures
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ def _resolveMandateId(context: RequestContext) -> str:
|
||||||
|
|
||||||
|
|
||||||
def _assertMandateAdmin(context: RequestContext, mandateId: str) -> None:
|
def _assertMandateAdmin(context: RequestContext, mandateId: str) -> None:
|
||||||
if context.isPlatformAdmin:
|
if context.hasSysAdminRole:
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
|
|
@ -303,7 +303,7 @@ def forceCancel(
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
"""Sysadmin: immediately expire any non-terminal subscription."""
|
"""Sysadmin: immediately expire any non-terminal subscription."""
|
||||||
if not context.isPlatformAdmin:
|
if not context.hasSysAdminRole:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Sysadmin role required"))
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Sysadmin role required"))
|
||||||
|
|
||||||
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import (
|
from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import (
|
||||||
|
|
@ -485,7 +485,7 @@ def getAllSubscriptions(
|
||||||
context: RequestContext = Depends(getRequestContext),
|
context: RequestContext = Depends(getRequestContext),
|
||||||
):
|
):
|
||||||
"""SysAdmin: list ALL subscriptions across all mandates with enriched metadata."""
|
"""SysAdmin: list ALL subscriptions across all mandates with enriched metadata."""
|
||||||
if not context.isPlatformAdmin:
|
if not context.hasSysAdminRole:
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Sysadmin role required"))
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Sysadmin role required"))
|
||||||
|
|
||||||
if mode == "filterValues":
|
if mode == "filterValues":
|
||||||
|
|
|
||||||
|
|
@ -478,7 +478,7 @@ def get_navigation(
|
||||||
Endpoint: GET /api/navigation
|
Endpoint: GET /api/navigation
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
isSysAdmin = reqContext.isPlatformAdmin
|
isSysAdmin = reqContext.hasSysAdminRole
|
||||||
userId = str(reqContext.user.id) if reqContext.user else None
|
userId = str(reqContext.user.id) if reqContext.user else None
|
||||||
|
|
||||||
# Get user's role IDs for permission checking
|
# Get user's role IDs for permission checking
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,6 @@ from typing import Optional, Dict, Any, List
|
||||||
from modules.auth import getCurrentUser, getRequestContext, RequestContext, limiter
|
from modules.auth import getCurrentUser, getRequestContext, RequestContext, limiter
|
||||||
from modules.datamodels.datamodelUam import User
|
from modules.datamodels.datamodelUam import User
|
||||||
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface, VoiceObjects
|
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface, VoiceObjects
|
||||||
from modules.shared.voiceCatalog import getCatalogPayload
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/voice-google", tags=["Voice Google"])
|
router = APIRouter(prefix="/voice-google", tags=["Voice Google"])
|
||||||
|
|
||||||
|
|
@ -62,15 +61,32 @@ def _getVoiceInterface(currentUser: User) -> VoiceObjects:
|
||||||
|
|
||||||
@router.get("/languages")
|
@router.get("/languages")
|
||||||
async def get_available_languages(currentUser: User = Depends(getCurrentUser)):
|
async def get_available_languages(currentUser: User = Depends(getCurrentUser)):
|
||||||
"""Return the curated voice/language catalog (single source of truth).
|
"""Get available languages from Google Cloud Text-to-Speech."""
|
||||||
|
try:
|
||||||
Each entry: {bcp47, iso, label, flag, defaultVoice}. Same payload as
|
logger.info("🌐 Getting available languages from Google Cloud TTS")
|
||||||
/api/voice/languages — both endpoints back the same catalog.
|
|
||||||
"""
|
voiceInterface = _getVoiceInterface(currentUser)
|
||||||
return {
|
result = await voiceInterface.getAvailableLanguages()
|
||||||
"success": True,
|
|
||||||
"languages": getCatalogPayload(),
|
if result["success"]:
|
||||||
}
|
return {
|
||||||
|
"success": True,
|
||||||
|
"languages": result["languages"]
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Failed to get languages: {result.get('error', 'Unknown error')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Get languages error: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"Failed to get available languages: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
@router.get("/voices")
|
@router.get("/voices")
|
||||||
async def get_available_voices(
|
async def get_available_voices(
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,6 @@ from modules.datamodels.datamodelUam import User, UserVoicePreferences, _normali
|
||||||
from modules.interfaces.interfaceDbApp import getRootInterface
|
from modules.interfaces.interfaceDbApp import getRootInterface
|
||||||
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
|
from modules.interfaces.interfaceVoiceObjects import getVoiceInterface
|
||||||
from modules.shared.i18nRegistry import apiRouteContext
|
from modules.shared.i18nRegistry import apiRouteContext
|
||||||
from modules.shared.voiceCatalog import getCatalogPayload
|
|
||||||
routeApiMsg = apiRouteContext("routeVoiceUser")
|
routeApiMsg = apiRouteContext("routeVoiceUser")
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -102,11 +101,11 @@ async def getVoiceLanguages(
|
||||||
request: Request,
|
request: Request,
|
||||||
currentUser: User = Depends(getCurrentUser),
|
currentUser: User = Depends(getCurrentUser),
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Return the curated voice/language catalog (single source of truth).
|
"""Return available TTS languages (user-level, no instance context needed)."""
|
||||||
|
voiceInterface = getVoiceInterface(currentUser)
|
||||||
Each entry: {bcp47, iso, label, flag, defaultVoice}.
|
languagesResult = await voiceInterface.getAvailableLanguages()
|
||||||
"""
|
languageList = languagesResult.get("languages", []) if isinstance(languagesResult, dict) else languagesResult
|
||||||
return {"languages": getCatalogPayload()}
|
return {"languages": languageList}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/voices")
|
@router.get("/voices")
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,6 @@ from modules.datamodels.datamodelPagination import PaginationParams, normalize_p
|
||||||
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import (
|
from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import (
|
||||||
AutoRun, AutoStepLog, AutoWorkflow, AutoTask, AutoVersion,
|
AutoRun, AutoStepLog, AutoWorkflow, AutoTask, AutoVersion,
|
||||||
)
|
)
|
||||||
from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import graphicalEditorDatabase
|
|
||||||
from modules.shared.i18nRegistry import apiRouteContext
|
from modules.shared.i18nRegistry import apiRouteContext
|
||||||
|
|
||||||
routeApiMsg = apiRouteContext("routeWorkflowDashboard")
|
routeApiMsg = apiRouteContext("routeWorkflowDashboard")
|
||||||
|
|
@ -36,11 +35,13 @@ limiter = Limiter(key_func=get_remote_address)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/system/workflow-runs", tags=["WorkflowDashboard"])
|
router = APIRouter(prefix="/api/system/workflow-runs", tags=["WorkflowDashboard"])
|
||||||
|
|
||||||
|
_GREENFIELD_DB = "poweron_graphicaleditor"
|
||||||
|
|
||||||
|
|
||||||
def _getDb() -> DatabaseConnector:
|
def _getDb() -> DatabaseConnector:
|
||||||
return DatabaseConnector(
|
return DatabaseConnector(
|
||||||
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
|
dbHost=APP_CONFIG.get("DB_HOST", "localhost"),
|
||||||
dbDatabase=graphicalEditorDatabase,
|
dbDatabase=_GREENFIELD_DB,
|
||||||
dbUser=APP_CONFIG.get("DB_USER"),
|
dbUser=APP_CONFIG.get("DB_USER"),
|
||||||
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
|
dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET") or APP_CONFIG.get("DB_PASSWORD"),
|
||||||
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
|
dbPort=int(APP_CONFIG.get("DB_PORT", 5432)),
|
||||||
|
|
@ -106,7 +107,7 @@ def _scopedRunFilter(context: RequestContext) -> Optional[dict]:
|
||||||
- mandate admin: mandateId IN user's mandates
|
- mandate admin: mandateId IN user's mandates
|
||||||
- normal user: ownerId = userId
|
- normal user: ownerId = userId
|
||||||
"""
|
"""
|
||||||
if context.isPlatformAdmin:
|
if context.hasSysAdminRole:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
userId = str(context.user.id) if context.user else None
|
userId = str(context.user.id) if context.user else None
|
||||||
|
|
@ -128,7 +129,7 @@ def _scopedWorkflowFilter(context: RequestContext) -> Optional[dict]:
|
||||||
- sysadmin: None (no filter, sees all)
|
- sysadmin: None (no filter, sees all)
|
||||||
- normal user: mandateId IN user's mandates
|
- normal user: mandateId IN user's mandates
|
||||||
"""
|
"""
|
||||||
if context.isPlatformAdmin:
|
if context.hasSysAdminRole:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
userId = str(context.user.id) if context.user else None
|
userId = str(context.user.id) if context.user else None
|
||||||
|
|
@ -144,7 +145,7 @@ def _scopedWorkflowFilter(context: RequestContext) -> Optional[dict]:
|
||||||
|
|
||||||
def _userMayDeleteWorkflow(context: RequestContext, wfMandateId: Optional[str]) -> bool:
|
def _userMayDeleteWorkflow(context: RequestContext, wfMandateId: Optional[str]) -> bool:
|
||||||
"""Same rules as canDelete on rows in get_system_workflows."""
|
"""Same rules as canDelete on rows in get_system_workflows."""
|
||||||
if context.isPlatformAdmin:
|
if context.hasSysAdminRole:
|
||||||
return True
|
return True
|
||||||
userId = str(context.user.id) if context.user else None
|
userId = str(context.user.id) if context.user else None
|
||||||
if not userId or not wfMandateId:
|
if not userId or not wfMandateId:
|
||||||
|
|
@ -477,7 +478,7 @@ def get_system_workflows(
|
||||||
|
|
||||||
userId = str(context.user.id) if context.user else None
|
userId = str(context.user.id) if context.user else None
|
||||||
adminMandateIds = []
|
adminMandateIds = []
|
||||||
if userId and not context.isPlatformAdmin:
|
if userId and not context.hasSysAdminRole:
|
||||||
userMandateIds = _getUserMandateIds(userId)
|
userMandateIds = _getUserMandateIds(userId)
|
||||||
adminMandateIds = _getAdminMandateIds(userId, userMandateIds)
|
adminMandateIds = _getAdminMandateIds(userId, userMandateIds)
|
||||||
|
|
||||||
|
|
@ -514,7 +515,7 @@ def get_system_workflows(
|
||||||
row["runCount"] = runCountMap.get(wfId, 0)
|
row["runCount"] = runCountMap.get(wfId, 0)
|
||||||
row["lastStartedAt"] = lastStartedMap.get(wfId)
|
row["lastStartedAt"] = lastStartedMap.get(wfId)
|
||||||
|
|
||||||
if context.isPlatformAdmin:
|
if context.hasSysAdminRole:
|
||||||
row["canEdit"] = True
|
row["canEdit"] = True
|
||||||
row["canDelete"] = True
|
row["canDelete"] = True
|
||||||
row["canExecute"] = True
|
row["canExecute"] = True
|
||||||
|
|
@ -670,7 +671,7 @@ def get_run_steps(
|
||||||
raise HTTPException(status_code=404, detail=routeApiMsg("Run not found"))
|
raise HTTPException(status_code=404, detail=routeApiMsg("Run not found"))
|
||||||
run = dict(runs[0])
|
run = dict(runs[0])
|
||||||
|
|
||||||
if not context.isPlatformAdmin:
|
if not context.hasSysAdminRole:
|
||||||
userId = str(context.user.id) if context.user else None
|
userId = str(context.user.id) if context.user else None
|
||||||
runOwner = run.get("ownerId")
|
runOwner = run.get("ownerId")
|
||||||
runMandate = run.get("mandateId")
|
runMandate = run.get("mandateId")
|
||||||
|
|
@ -711,7 +712,7 @@ async def get_run_stream(
|
||||||
raise HTTPException(status_code=404, detail=routeApiMsg("Run not found"))
|
raise HTTPException(status_code=404, detail=routeApiMsg("Run not found"))
|
||||||
run = dict(runs[0])
|
run = dict(runs[0])
|
||||||
|
|
||||||
if not context.isPlatformAdmin:
|
if not context.hasSysAdminRole:
|
||||||
userId = str(context.user.id) if context.user else None
|
userId = str(context.user.id) if context.user else None
|
||||||
runOwner = run.get("ownerId")
|
runOwner = run.get("ownerId")
|
||||||
runMandate = run.get("mandateId")
|
runMandate = run.get("mandateId")
|
||||||
|
|
@ -774,7 +775,7 @@ def stop_workflow_run(
|
||||||
raise HTTPException(status_code=404, detail=routeApiMsg("Run not found"))
|
raise HTTPException(status_code=404, detail=routeApiMsg("Run not found"))
|
||||||
run = dict(runs[0])
|
run = dict(runs[0])
|
||||||
|
|
||||||
if not context.isPlatformAdmin:
|
if not context.hasSysAdminRole:
|
||||||
userId = str(context.user.id) if context.user else None
|
userId = str(context.user.id) if context.user else None
|
||||||
runOwner = run.get("ownerId")
|
runOwner = run.get("ownerId")
|
||||||
runMandate = run.get("mandateId")
|
runMandate = run.get("mandateId")
|
||||||
|
|
|
||||||
|
|
@ -84,38 +84,10 @@ class RbacCatalogService:
|
||||||
logger.error(f"Failed to register DATA object {objectKey}: {e}")
|
logger.error(f"Failed to register DATA object {objectKey}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def registerFeatureDefinition(
|
def registerFeatureDefinition(self, featureCode: str, label: str, icon: str) -> bool:
|
||||||
self,
|
"""Register a feature definition."""
|
||||||
featureCode: str,
|
|
||||||
label: str,
|
|
||||||
icon: str,
|
|
||||||
*,
|
|
||||||
instantiable: bool = True,
|
|
||||||
enabled: bool = True,
|
|
||||||
) -> bool:
|
|
||||||
"""Register a feature definition.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
featureCode: Stable code (e.g. ``"trustee"``).
|
|
||||||
label: Display label.
|
|
||||||
icon: Display icon.
|
|
||||||
instantiable: ``False`` for meta-features that must NOT be exposed
|
|
||||||
as a creatable Feature-Instance (e.g. the ``system`` umbrella
|
|
||||||
feature which only owns global UI/DATA/RESOURCE catalog
|
|
||||||
objects). Defaults to ``True``.
|
|
||||||
enabled: ``False`` to soft-disable a feature so it is filtered out
|
|
||||||
of selection lists (Store / Admin Feature-Instances dropdown)
|
|
||||||
without removing its catalog objects, role templates or
|
|
||||||
already-provisioned instances. Defaults to ``True``.
|
|
||||||
"""
|
|
||||||
try:
|
try:
|
||||||
self._featureDefinitions[featureCode] = {
|
self._featureDefinitions[featureCode] = {"code": featureCode, "label": label, "icon": icon}
|
||||||
"code": featureCode,
|
|
||||||
"label": label,
|
|
||||||
"icon": icon,
|
|
||||||
"instantiable": bool(instantiable),
|
|
||||||
"enabled": bool(enabled),
|
|
||||||
}
|
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to register feature definition {featureCode}: {e}")
|
logger.error(f"Failed to register feature definition {featureCode}: {e}")
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResul
|
||||||
from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry
|
from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry
|
||||||
|
|
||||||
from modules.serviceCenter.services.serviceAgent.coreTools._helpers import (
|
from modules.serviceCenter.services.serviceAgent.coreTools._helpers import (
|
||||||
_buildResolverDbFromServices,
|
|
||||||
_getOrCreateTempFolder,
|
_getOrCreateTempFolder,
|
||||||
_looksLikeBinary,
|
_looksLikeBinary,
|
||||||
_resolveFileScope,
|
_resolveFileScope,
|
||||||
|
|
@ -23,6 +22,20 @@ def _registerConnectionTools(registry: ToolRegistry, services):
|
||||||
"""Auto-extracted from registerCoreTools."""
|
"""Auto-extracted from registerCoreTools."""
|
||||||
# ---- Connection tools (external data sources) ----
|
# ---- Connection tools (external data sources) ----
|
||||||
|
|
||||||
|
def _buildResolverDb():
|
||||||
|
"""Build a DB adapter that ConnectorResolver can use to load UserConnections.
|
||||||
|
interfaceDbApp has getUserConnectionById; ConnectorResolver expects getUserConnection."""
|
||||||
|
chatService = services.chat
|
||||||
|
appIf = getattr(chatService, "interfaceDbApp", None)
|
||||||
|
if appIf and hasattr(appIf, "getUserConnectionById"):
|
||||||
|
class _Adapter:
|
||||||
|
def __init__(self, app):
|
||||||
|
self._app = app
|
||||||
|
def getUserConnection(self, connectionId: str):
|
||||||
|
return self._app.getUserConnectionById(connectionId)
|
||||||
|
return _Adapter(appIf)
|
||||||
|
return getattr(chatService, "interfaceDbComponent", None)
|
||||||
|
|
||||||
async def _listConnections(args: Dict[str, Any], context: Dict[str, Any]):
|
async def _listConnections(args: Dict[str, Any], context: Dict[str, Any]):
|
||||||
try:
|
try:
|
||||||
chatService = services.chat
|
chatService = services.chat
|
||||||
|
|
@ -36,12 +49,7 @@ def _registerConnectionTools(registry: ToolRegistry, services):
|
||||||
authorityVal = authority.value if hasattr(authority, "value") else str(authority)
|
authorityVal = authority.value if hasattr(authority, "value") else str(authority)
|
||||||
username = conn.get("externalUsername", "") if isinstance(conn, dict) else getattr(conn, "externalUsername", "")
|
username = conn.get("externalUsername", "") if isinstance(conn, dict) else getattr(conn, "externalUsername", "")
|
||||||
email = conn.get("externalEmail", "") if isinstance(conn, dict) else getattr(conn, "externalEmail", "")
|
email = conn.get("externalEmail", "") if isinstance(conn, dict) else getattr(conn, "externalEmail", "")
|
||||||
cid = conn.get("id", "") if isinstance(conn, dict) else getattr(conn, "id", "")
|
lines.append(f"- connectionId: {connId} | {authorityVal} | {username} ({email})")
|
||||||
ref = f"connection:{authorityVal}:{username}"
|
|
||||||
lines.append(
|
|
||||||
f"- {ref} connectionId={cid} ({email}) "
|
|
||||||
f"(use this full connection: line or connectionId as connectionReference)"
|
|
||||||
)
|
|
||||||
return ToolResult(toolCallId="", toolName="listConnections", success=True, data="\n".join(lines))
|
return ToolResult(toolCallId="", toolName="listConnections", success=True, data="\n".join(lines))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return ToolResult(toolCallId="", toolName="listConnections", success=False, error=str(e))
|
return ToolResult(toolCallId="", toolName="listConnections", success=False, error=str(e))
|
||||||
|
|
@ -57,7 +65,7 @@ def _registerConnectionTools(registry: ToolRegistry, services):
|
||||||
from modules.connectors.connectorResolver import ConnectorResolver
|
from modules.connectors.connectorResolver import ConnectorResolver
|
||||||
resolver = ConnectorResolver(
|
resolver = ConnectorResolver(
|
||||||
services.getService("security"),
|
services.getService("security"),
|
||||||
_buildResolverDbFromServices(services),
|
_buildResolverDb(),
|
||||||
)
|
)
|
||||||
adapter = await resolver.resolveService(connectionId, service)
|
adapter = await resolver.resolveService(connectionId, service)
|
||||||
chatService = services.chat
|
chatService = services.chat
|
||||||
|
|
@ -107,7 +115,7 @@ def _registerConnectionTools(registry: ToolRegistry, services):
|
||||||
from modules.connectors.connectorResolver import ConnectorResolver
|
from modules.connectors.connectorResolver import ConnectorResolver
|
||||||
resolver = ConnectorResolver(
|
resolver = ConnectorResolver(
|
||||||
services.getService("security"),
|
services.getService("security"),
|
||||||
_buildResolverDbFromServices(services),
|
_buildResolverDb(),
|
||||||
)
|
)
|
||||||
adapter = await resolver.resolveService(connectionId, "outlook")
|
adapter = await resolver.resolveService(connectionId, "outlook")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ from modules.serviceCenter.services.serviceAgent.datamodelAgent import ToolResul
|
||||||
from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry
|
from modules.serviceCenter.services.serviceAgent.toolRegistry import ToolRegistry
|
||||||
|
|
||||||
from modules.serviceCenter.services.serviceAgent.coreTools._helpers import (
|
from modules.serviceCenter.services.serviceAgent.coreTools._helpers import (
|
||||||
_buildResolverDbFromServices,
|
|
||||||
_getOrCreateTempFolder,
|
_getOrCreateTempFolder,
|
||||||
_looksLikeBinary,
|
_looksLikeBinary,
|
||||||
_resolveFileScope,
|
_resolveFileScope,
|
||||||
|
|
@ -89,7 +88,7 @@ def _registerDataSourceTools(registry: ToolRegistry, services):
|
||||||
from modules.connectors.connectorResolver import ConnectorResolver
|
from modules.connectors.connectorResolver import ConnectorResolver
|
||||||
resolver = ConnectorResolver(
|
resolver = ConnectorResolver(
|
||||||
services.getService("security"),
|
services.getService("security"),
|
||||||
_buildResolverDbFromServices(services),
|
_buildResolverDb(),
|
||||||
)
|
)
|
||||||
adapter = await resolver.resolveService(connectionId, service)
|
adapter = await resolver.resolveService(connectionId, service)
|
||||||
entries = await adapter.browse(browsePath, filter=args.get("filter"))
|
entries = await adapter.browse(browsePath, filter=args.get("filter"))
|
||||||
|
|
@ -125,7 +124,7 @@ def _registerDataSourceTools(registry: ToolRegistry, services):
|
||||||
from modules.connectors.connectorResolver import ConnectorResolver
|
from modules.connectors.connectorResolver import ConnectorResolver
|
||||||
resolver = ConnectorResolver(
|
resolver = ConnectorResolver(
|
||||||
services.getService("security"),
|
services.getService("security"),
|
||||||
_buildResolverDbFromServices(services),
|
_buildResolverDb(),
|
||||||
)
|
)
|
||||||
adapter = await resolver.resolveService(connectionId, service)
|
adapter = await resolver.resolveService(connectionId, service)
|
||||||
entries = await adapter.search(query, path=basePath)
|
entries = await adapter.search(query, path=basePath)
|
||||||
|
|
@ -161,7 +160,7 @@ def _registerDataSourceTools(registry: ToolRegistry, services):
|
||||||
fullPath = filePath if filePath.startswith("/") else f"{basePath.rstrip('/')}/{filePath}"
|
fullPath = filePath if filePath.startswith("/") else f"{basePath.rstrip('/')}/{filePath}"
|
||||||
resolver = ConnectorResolver(
|
resolver = ConnectorResolver(
|
||||||
services.getService("security"),
|
services.getService("security"),
|
||||||
_buildResolverDbFromServices(services),
|
_buildResolverDb(),
|
||||||
)
|
)
|
||||||
adapter = await resolver.resolveService(connectionId, service)
|
adapter = await resolver.resolveService(connectionId, service)
|
||||||
result = await adapter.download(fullPath)
|
result = await adapter.download(fullPath)
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""Document and vision tools (containers, content objects, image description)."""
|
"""Document and vision tools (containers, content objects, image description)."""
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
|
@ -19,76 +18,6 @@ from modules.serviceCenter.services.serviceAgent.coreTools._helpers import (
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _parseUdmJson(raw: Any) -> Optional[Dict[str, Any]]:
|
|
||||||
if raw is None:
|
|
||||||
return None
|
|
||||||
if isinstance(raw, dict):
|
|
||||||
return raw
|
|
||||||
if isinstance(raw, str) and raw.strip():
|
|
||||||
try:
|
|
||||||
data = json.loads(raw)
|
|
||||||
return data if isinstance(data, dict) else None
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return None
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _walkUdmBlocksImpl(udm: Dict[str, Any], out: List[Dict[str, Any]], path: str) -> None:
|
|
||||||
if udm.get("contentType"):
|
|
||||||
raw = udm.get("raw") or ""
|
|
||||||
preview = raw[:240] + ("…" if len(raw) > 240 else "")
|
|
||||||
out.append({
|
|
||||||
"path": path,
|
|
||||||
"id": udm.get("id"),
|
|
||||||
"contentType": udm.get("contentType"),
|
|
||||||
"rawPreview": preview,
|
|
||||||
})
|
|
||||||
children = udm.get("children") or []
|
|
||||||
for i, ch in enumerate(children):
|
|
||||||
if isinstance(ch, dict):
|
|
||||||
role = ch.get("role") or "node"
|
|
||||||
label = f"{path}/children[{i}]"
|
|
||||||
if ch.get("role") in ("page", "section", "slide", "sheet"):
|
|
||||||
label = f"{path}/{role}[{ch.get('index', i)}]"
|
|
||||||
_walkUdmBlocksImpl(ch, out, label)
|
|
||||||
|
|
||||||
|
|
||||||
def _getUdmStructureText(udm: Dict[str, Any]) -> str:
|
|
||||||
lines = [
|
|
||||||
f"id: {udm.get('id', '?')}",
|
|
||||||
f"role: {udm.get('role', '?')}",
|
|
||||||
f"sourceType: {udm.get('sourceType', '?')}",
|
|
||||||
f"sourcePath: {udm.get('sourcePath', '')}",
|
|
||||||
]
|
|
||||||
nodes = udm.get("children") or []
|
|
||||||
lines.append(f"structuralNodes (top-level): {len(nodes)}")
|
|
||||||
for i, sn in enumerate(nodes[:80]):
|
|
||||||
if isinstance(sn, dict):
|
|
||||||
role = sn.get("role", "?")
|
|
||||||
idx = sn.get("index", i)
|
|
||||||
lab = sn.get("label") or ""
|
|
||||||
blocks = sn.get("children") or []
|
|
||||||
lines.append(f" [{i}] {role} index={idx} label={lab!r} contentBlocks={len(blocks)}")
|
|
||||||
if len(nodes) > 80:
|
|
||||||
lines.append(f" … and {len(nodes) - 80} more structural nodes")
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def _filterUdmByTypeImpl(udm: Dict[str, Any], content_type: str) -> Dict[str, Any]:
|
|
||||||
hits: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
def collect(node: Any) -> None:
|
|
||||||
if not isinstance(node, dict):
|
|
||||||
return
|
|
||||||
if node.get("contentType") == content_type:
|
|
||||||
hits.append(dict(node))
|
|
||||||
for child in node.get("children") or []:
|
|
||||||
collect(child)
|
|
||||||
|
|
||||||
collect(udm)
|
|
||||||
return {"nodes": hits, "count": len(hits), "contentType": content_type}
|
|
||||||
|
|
||||||
|
|
||||||
def _registerDocumentTools(registry: ToolRegistry, services):
|
def _registerDocumentTools(registry: ToolRegistry, services):
|
||||||
"""Auto-extracted from registerCoreTools."""
|
"""Auto-extracted from registerCoreTools."""
|
||||||
# ---- Document tools (Smart Documents / Container Handling) ----
|
# ---- Document tools (Smart Documents / Container Handling) ----
|
||||||
|
|
@ -276,91 +205,6 @@ def _registerDocumentTools(registry: ToolRegistry, services):
|
||||||
readOnly=True,
|
readOnly=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ---- UDM (Unified Document Model) tools ----
|
|
||||||
|
|
||||||
async def _getUdmStructure(args: Dict[str, Any], context: Dict[str, Any]):
|
|
||||||
udm = _parseUdmJson(args.get("udmJson") or args.get("udm"))
|
|
||||||
if not udm:
|
|
||||||
return ToolResult(toolCallId="", toolName="getUdmStructure", success=False, error="udmJson must be a JSON object or string")
|
|
||||||
text = _getUdmStructureText(udm)
|
|
||||||
return ToolResult(toolCallId="", toolName="getUdmStructure", success=True, data=text)
|
|
||||||
|
|
||||||
async def _walkUdmBlocks(args: Dict[str, Any], context: Dict[str, Any]):
|
|
||||||
udm = _parseUdmJson(args.get("udmJson") or args.get("udm"))
|
|
||||||
if not udm:
|
|
||||||
return ToolResult(toolCallId="", toolName="walkUdmBlocks", success=False, error="udmJson must be a JSON object or string")
|
|
||||||
blocks: List[Dict[str, Any]] = []
|
|
||||||
_walkUdmBlocksImpl(udm, blocks, "document")
|
|
||||||
max_n = int(args.get("maxResults") or 200)
|
|
||||||
trimmed = blocks[:max_n]
|
|
||||||
lines = [f"Total content blocks found: {len(blocks)} (showing {len(trimmed)})"]
|
|
||||||
for b in trimmed:
|
|
||||||
lines.append(f"{b.get('path')} | {b.get('contentType')} | id={b.get('id')}")
|
|
||||||
if b.get("rawPreview"):
|
|
||||||
lines.append(f" preview: {b['rawPreview'][:120]}")
|
|
||||||
if len(blocks) > max_n:
|
|
||||||
lines.append(f"... {len(blocks) - max_n} more not shown (increase maxResults)")
|
|
||||||
return ToolResult(toolCallId="", toolName="walkUdmBlocks", success=True, data="\n".join(lines))
|
|
||||||
|
|
||||||
async def _filterUdmByType(args: Dict[str, Any], context: Dict[str, Any]):
|
|
||||||
udm = _parseUdmJson(args.get("udmJson") or args.get("udm"))
|
|
||||||
content_type = (args.get("contentType") or "").strip()
|
|
||||||
if not udm:
|
|
||||||
return ToolResult(toolCallId="", toolName="filterUdmByType", success=False, error="udmJson is required")
|
|
||||||
if not content_type:
|
|
||||||
return ToolResult(toolCallId="", toolName="filterUdmByType", success=False, error="contentType is required")
|
|
||||||
filtered = _filterUdmByTypeImpl(udm, content_type)
|
|
||||||
return ToolResult(
|
|
||||||
toolCallId="",
|
|
||||||
toolName="filterUdmByType",
|
|
||||||
success=True,
|
|
||||||
data=json.dumps(filtered, ensure_ascii=False, default=str)[:_MAX_TOOL_RESULT_CHARS],
|
|
||||||
)
|
|
||||||
|
|
||||||
registry.register(
|
|
||||||
"getUdmStructure",
|
|
||||||
_getUdmStructure,
|
|
||||||
description="Summarize hierarchy of a Unified Document Model (UDM) JSON: ids, sourceType, structural nodes and block counts. Pass udmJson as stringified JSON.",
|
|
||||||
parameters={
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"udmJson": {"type": "string", "description": "Stringified UDM document object (Document → StructuralNode → ContentBlock)"},
|
|
||||||
},
|
|
||||||
"required": ["udmJson"],
|
|
||||||
},
|
|
||||||
readOnly=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
registry.register(
|
|
||||||
"walkUdmBlocks",
|
|
||||||
_walkUdmBlocks,
|
|
||||||
description="Depth-first walk over a UDM tree; lists each ContentBlock with path, id, type, and short text preview.",
|
|
||||||
parameters={
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"udmJson": {"type": "string", "description": "Stringified UDM document"},
|
|
||||||
"maxResults": {"type": "integer", "description": "Max blocks to return (default 200)"},
|
|
||||||
},
|
|
||||||
"required": ["udmJson"],
|
|
||||||
},
|
|
||||||
readOnly=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
registry.register(
|
|
||||||
"filterUdmByType",
|
|
||||||
_filterUdmByType,
|
|
||||||
description="Return all ContentBlocks in a UDM tree whose contentType matches (e.g. table, image, text).",
|
|
||||||
parameters={
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"udmJson": {"type": "string", "description": "Stringified UDM document"},
|
|
||||||
"contentType": {"type": "string", "description": "contentType to match (text, image, table, code, media, link, formula)"},
|
|
||||||
},
|
|
||||||
"required": ["udmJson", "contentType"],
|
|
||||||
},
|
|
||||||
readOnly=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ---- Vision tool ----
|
# ---- Vision tool ----
|
||||||
|
|
||||||
async def _describeImage(args: Dict[str, Any], context: Dict[str, Any]):
|
async def _describeImage(args: Dict[str, Any], context: Dict[str, Any]):
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue