From 58b13ff7c60e050801a193e10766e53fe82dd642 Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Tue, 13 Jan 2026 20:01:50 +0100
Subject: [PATCH] hotfixes for work with nyla
---
app.py | 3 +
modules/connectors/connectorDbPostgre.py | 8 +-
modules/datamodels/FIELD_NAMES.md | 314 +++++++
modules/datamodels/datamodelChat.py | 6 +
modules/datamodels/datamodelTrustee.py | 579 ++++++++++++
modules/datamodels/datamodelUtils.py | 2 +-
modules/interfaces/interfaceBootstrap.py | 56 +-
modules/interfaces/interfaceDbAppObjects.py | 19 +-
.../interfaces/interfaceDbTrusteeObjects.py | 836 +++++++++++++++++
modules/routes/routeChatbot.py | 16 +-
modules/routes/routeDataAutomation.py | 6 +-
modules/routes/routeDataMandates.py | 33 +-
modules/routes/routeDataTrustee.py | 846 ++++++++++++++++++
modules/routes/routeDataUsers.py | 131 ++-
modules/routes/routeRbac.py | 36 +-
modules/routes/routeWorkflows.py | 17 +-
16 files changed, 2848 insertions(+), 60 deletions(-)
create mode 100644 modules/datamodels/FIELD_NAMES.md
create mode 100644 modules/datamodels/datamodelTrustee.py
create mode 100644 modules/interfaces/interfaceDbTrusteeObjects.py
create mode 100644 modules/routes/routeDataTrustee.py
diff --git a/app.py b/app.py
index 84324a90..94d70136 100644
--- a/app.py
+++ b/app.py
@@ -455,3 +455,6 @@ app.include_router(messagingRouter)
from modules.routes.routeChatbot import router as chatbotRouter
app.include_router(chatbotRouter)
+from modules.routes.routeDataTrustee import router as trusteeRouter
+app.include_router(trusteeRouter)
+
diff --git a/modules/connectors/connectorDbPostgre.py b/modules/connectors/connectorDbPostgre.py
index 7de3bf17..ba452891 100644
--- a/modules/connectors/connectorDbPostgre.py
+++ b/modules/connectors/connectorDbPostgre.py
@@ -59,10 +59,12 @@ def _get_model_fields(model_class) -> Dict[str, str]:
hasattr(field_type, "__origin__")
and field_type.__origin__ in (dict, list)
)
- # Check if field type is a Pydantic BaseModel (for nested models)
+ # Check if field type is directly a Pydantic BaseModel subclass (for nested models like TextMultilingual)
+ or (isinstance(field_type, type) and issubclass(field_type, BaseModel))
+ # Check if field type is Optional[BaseModel] (Union with None)
or (hasattr(field_type, "__origin__") and get_origin(field_type) is Union
- and any(hasattr(arg, "__bases__") and BaseModel in getattr(arg, "__bases__", ())
- for arg in get_args(field_type)))
+ and any(isinstance(arg, type) and issubclass(arg, BaseModel)
+ for arg in get_args(field_type) if arg is not type(None)))
):
fields[field_name] = "JSONB"
# Simple type mapping
diff --git a/modules/datamodels/FIELD_NAMES.md b/modules/datamodels/FIELD_NAMES.md
new file mode 100644
index 00000000..e75899ed
--- /dev/null
+++ b/modules/datamodels/FIELD_NAMES.md
@@ -0,0 +1,314 @@
+| Field Name | Type Pattern | Models Using It |
+|------------|--------------|-----------------|
+| `accumulatedJsonString` | str | JsonAccumulationState |
+| `action` | str | ActionDefinition |
+| `actionId` | str | ChatDocument, ChatMessage |
+| `actionList` | List | TaskItem |
+| `actionMethod` | str | ChatMessage |
+| `actionName` | str | ChatMessage |
+| `actionNumber` | int | ChatLog, ChatDocument, ChatMessage |
+| `actionObjective` | str | ActionDefinition, TaskContext |
+| `actionProgress` | str | ChatMessage |
+| `actionResult` | Any | TaskResult |
+| `active` | bool | AutomationDefinition |
+| `additionalData` | Dict | AiResponseMetadata |
+| `aiPrompt` | str | AiProcessParameters |
+| `allSections` | List | JsonAccumulationState |
+| `apiUrl` | str | AiModel |
+| `authenticationAuthority` | AuthAuthority | User |
+| `authority` | AuthAuthority | UserConnection, Token |
+| `availableConnections` | list | TaskContext |
+| `availableDocuments` | str | TaskContext |
+| `base64Encoded` | bool | ContentMetadata, FileData |
+| `bytesSent` | int | ChatStat, AiCallResponse |
+| `bytesReceived` | int | ChatStat, AiCallResponse |
+| `classes` | List | CodeContentPromptArgs |
+| `colorMode` | str | ContentMetadata |
+| `compressContext` | bool | AiCallOptions |
+| `compressPrompt` | bool | AiCallOptions |
+| `condition` | str | SelectionRule |
+| `confidence` | float | ReviewResult |
+| `connectedAt` | float | UserConnection |
+| `connectionId` | str | Token |
+| `connectionReference` | str | ActionDefinition |
+| `connectorType` | str | AiModel |
+| `content` | str | Prompt, ContentItem, AiResponse, AiCallResponse, FilePreview |
+| `contentAnalysis` | Dict | Observation |
+| `contentParts` | List | AiCallRequest, AiProcessParameters, SectionPromptArgs, ChapterStructurePromptArgs, CodeContentPromptArgs, CodeStructurePromptArgs |
+| `contentSize` | str | ObservationPreview |
+| `contentValidation` | Dict | Observation |
+| `contents` | List | ChatContentExtracted |
+| `context` | Dict, AccessRuleContext | TaskHandover, UnderstandingResult, AccessRule |
+| `contextInfo` | str | CodeContentPromptArgs |
+| `costPer1kTokensInput` | float | AiModel |
+| `costPer1kTokensOutput` | float | AiModel |
+| `country` | str | AiCallPromptWebSearch |
+| `create` | AccessLevel | AccessRule, UserPermissions |
+| `created` | str | ObservationPreview |
+| `createdAt` | float | Token |
+| `creationDate` | float | FileItem |
+| `criteriaProgress` | dict | TaskContext |
+| `currentAction` | int | ChatWorkflow |
+| `currentRound` | int | ChatWorkflow |
+| `currentTask` | int | ChatWorkflow |
+| `data` | str | ContentItem, FileData |
+| `dataType` | str | TaskStep |
+| `delete` | AccessLevel | AccessRule, UserPermissions |
+| `deliverable` | Dict | TaskDefinition |
+| `delivered_summary` | str | ContinuationContext |
+| `dependencies` | List | TaskItem, TaskStep, CodeContentPromptArgs |
+| `description` | TextMultilingual | Role |
+| `details` | str | AuthEvent |
+| `detectedComplexity` | str | RequestContext |
+| `displayName` | str | AiModel |
+| `documentData` | Any | ActionDocument, DocumentData |
+| `documentId` | str | ObservationPreview |
+| `documentList` | DocumentReferenceList | ActionDefinition, ExtractContentParameters |
+| `documentName` | str | ActionDocument, DocumentData |
+| `documentReferences` | List | UnderstandingResult |
+| `documents` | List | ChatMessage, ActionResult, AiResponse |
+| `documentsCount` | int | Observation |
+| `documentsLabel` | str | ChatMessage, DocumentExchange |
+| `durationSec` | float | ContentMetadata |
+| `email` | EmailStr | User |
+| `enabled` | bool | Mandate, User |
+| `encoding` | str | FilePreview |
+| `engine` | str | ChatStat |
+| `error` | str | ContentMetadata, ActionResult, ActionItem, TaskItem, TaskResult |
+| `errorCount` | int | ChatStat, AiCallResponse |
+| `estimatedComplexity` | str | TaskStep |
+| `eventId` | str | AutomationDefinition |
+| `eventType` | str | AuthEvent |
+| `execAction` | str | ActionItem |
+| `execMethod` | str | ActionItem |
+| `execParameters` | Dict | ActionItem |
+| `execResultLabel` | str | ActionItem |
+| `executedActions` | list | TaskContext |
+| `executionLogs` | List | AutomationDefinition |
+| `expectedDocumentFormats` | List | ActionItem |
+| `expectedFormats` | List | ChatWorkflow, TaskStep |
+| `expectedOutputFormat` | str | RequestContext |
+| `expectedOutputType` | str | RequestContext |
+| `expiresAt` | float | UserConnection, Token |
+| `externalEmail` | EmailStr | UserConnection |
+| `externalId` | str | UserConnection |
+| `externalUsername` | str | UserConnection |
+| `extractionMethod` | str | AiResponseMetadata |
+| `extractionOptions` | Any | TaskDefinition, ExtractContentParameters |
+| `failedActions` | list | TaskContext |
+| `failurePatterns` | list | TaskContext |
+| `feedback` | str | TaskResult, TaskItem |
+| `fileHash` | str | FileItem |
+| `fileId` | str | ChatDocument |
+| `fileName` | str | FileItem, FilePreview, ChatDocument |
+| `fileSize` | int | FileItem, ChatDocument |
+| `fileType` | str | CodeContentPromptArgs |
+| `filename` | str | AiResponseMetadata, CodeContentPromptArgs |
+| `finishedAt` | float | TaskItem |
+| `fps` | float | ContentMetadata |
+| `fullName` | str | User |
+| `functionCall` | Callable | AiModel |
+| `functions` | List | CodeContentPromptArgs |
+| `generationHint` | str | SectionPromptArgs |
+| `handoverType` | str | TaskHandover |
+| `hashedPassword` | str | UserInDB |
+| `height` | int | ContentMetadata |
+| `hierarchyContext` | str | JsonContinuationContexts |
+| `hierarchyContextForPrompt` | str | JsonContinuationContexts |
+| `id` | str | *Most models* |
+| `improvements` | List | TaskHandover, TaskContext, ReviewResult |
+| `incomplete_part` | str | ContinuationContext |
+| `inputDocuments` | List | TaskHandover |
+| `instruction` | str | AiCallPromptWebSearch, AiCallPromptWebCrawl |
+| `intention` | Dict | UnderstandingResult |
+| `ipAddress` | str | AuthEvent |
+| `isAccumulationMode` | bool | JsonAccumulationState |
+| `isAggregation` | bool | SectionPromptArgs |
+| `isAvailable` | bool | AiModel |
+| `isRegeneration` | bool | TaskContext |
+| `isSystemRole` | bool | Role |
+| `isText` | bool | FilePreview |
+| `item` | str | AccessRule |
+| `jsonParsingSuccess` | bool | JsonContinuationContexts |
+| `kpis` | List | JsonAccumulationState |
+| `label` | str | ContentItem, AutomationDefinition |
+| `language` | str | Mandate, User, SectionPromptArgs, AiCallPromptWebSearch |
+| `last_complete_part` | str | ContinuationContext |
+| `last_raw_json` | str | ContinuationContext |
+| `lastActivity` | float | ChatWorkflow |
+| `lastChecked` | float | UserConnection |
+| `lastParsedResult` | Dict | JsonAccumulationState |
+| `lastUpdated` | str | AiModel |
+| `learnings` | List | ActionDefinition, TaskContext |
+| `listFileId` | List | UserInputRequest |
+| `logs` | List | ChatWorkflow |
+| `mandateId` | str | ChatWorkflow, FileItem, Prompt, User, AutomationDefinition, Token |
+| `maxCost` | float | SelectionRule, AiCallOptions |
+| `maxDepth` | int | AiCallPromptWebCrawl |
+| `maxNumberPages` | int | AiCallPromptWebSearch |
+| `maxParts` | int | AiCallOptions |
+| `maxProcessingTime` | int | AiCallOptions |
+| `maxSteps` | int | ChatWorkflow |
+| `maxTokens` | int | AiModel |
+| `maxWidth` | int | AiCallPromptWebCrawl |
+| `message` | str | ChatLog, ChatMessage |
+| `messageHistory` | List | TaskHandover |
+| `messageId` | str | ChatDocument |
+| `messages` | List | ChatWorkflow, AiModelCall |
+| `metadata` | ContentMetadata, AiResponseMetadata, Dict | ContentItem, AiResponse, AiModelResponse, CodeContentPromptArgs |
+| `metCriteria` | List | ReviewResult |
+| `method` | str | ActionSelection |
+| `mime` | str | ObservationPreview |
+| `mimeType` | str | ContentMetadata, FileItem, FilePreview, ChatDocument, ActionDocument, DocumentData |
+| `minContextLength` | int | AiModel, SelectionRule |
+| `minQualityRating` | int | SelectionRule |
+| `missingOutputs` | List | ReviewResult |
+| `model` | AiModel | AiModelCall |
+| `modelId` | str | AiModelResponse |
+| `modelName` | str | AiCallResponse |
+| `modified` | str | ObservationPreview |
+| `name` | str | Mandate, Prompt, ActionSelection, AiModel, SelectionRule, ObservationPreview |
+| `nextAction` | str | ReviewResult |
+| `nextActionGuidance` | Dict | TaskContext |
+| `nextActionObjective` | str | ReviewResult |
+| `nextActionParameters` | Dict | ReviewResult |
+| `notes` | List | Observation |
+| `objective` | str | TaskStep, TaskDefinition |
+| `operationId` | str | ChatLog |
+| `operationType` | OperationTypeEnum, str | OperationTypeRating, AiCallOptions, AiResponseMetadata |
+| `operationTypes` | List | AiModel, SelectionRule |
+| `options` | AiCallOptions | AiCallRequest, AiModelCall |
+| `originalPrompt` | str | RequestContext |
+| `outputDocuments` | List | TaskHandover |
+| `outputFormat` | str | ChapterStructurePromptArgs |
+| `overlapContext` | str | JsonContinuationContexts |
+| `overlap_context` | str | ContinuationContext |
+| `overview` | str | TaskPlan |
+| `pages` | int | ContentMetadata |
+| `parameters` | Dict | ActionParameters, UnderstandingResult, ActionDefinition |
+| `parametersContext` | str | ActionDefinition, TaskContext |
+| `parentId` | str | ChatLog |
+| `parentMessageId` | str | ChatMessage |
+| `performance` | Dict | ChatLog |
+| `placeholders` | List, Dict | PromptBundle, AutomationDefinition |
+| `priceUsd` | float | ChatStat, AiCallResponse |
+| `previews` | List | Observation |
+| `previousActionResults` | list | TaskContext |
+| `previousHandover` | TaskHandover | TaskContext |
+| `previousResults` | List | TaskHandover, TaskContext, ReviewContext |
+| `previousReviewResult` | dict | TaskContext |
+| `priority` | PriorityEnum | AiModel, SelectionRule, AiCallOptions |
+| `process` | str | ChatStat |
+| `processDocumentsIndividually` | bool | AiCallOptions |
+| `processingMode` | ProcessingModeEnum | AiModel, AiCallOptions |
+| `processingTime` | float | ChatStat, ActionItem, TaskItem, AiCallResponse, AiModelResponse |
+| `progress` | float | ChatLog |
+| `prompt` | str | UserInputRequest, PromptBundle, AiCallPromptImage |
+| `publishedAt` | float | ChatMessage |
+| `qualityRating` | int | AiModel |
+| `qualityRequirements` | Dict | TaskStep |
+| `qualityScore` | float | ReviewResult |
+| `quality` | str | AiCallPromptImage |
+| `rating` | int | OperationTypeRating |
+| `read` | AccessLevel | AccessRule, UserPermissions |
+| `reason` | str | Token, ReviewResult |
+| `reference` | str | ObservationPreview |
+| `requiredDocuments` | List | TaskDefinition |
+| `requiresAnalysis` | bool | RequestContext |
+| `requiresContentGeneration` | bool | TaskDefinition |
+| `requiresDocumentAnalysis` | bool | TaskDefinition |
+| `requiresDocuments` | bool | RequestContext |
+| `requiresWebResearch` | bool | RequestContext, TaskDefinition |
+| `researchDepth` | str | AiCallPromptWebSearch |
+| `resetToken` | str | UserInDB |
+| `resetTokenExpires` | float | UserInDB |
+| `result` | str | ActionItem |
+| `resultFormat` | str | AiCallOptions |
+| `resultLabel` | str | ActionResult, Observation |
+| `resultLabels` | Dict | TaskItem |
+| `resultType` | str | AiProcessParameters |
+| `retryCount` | int | ActionItem, TaskItem, TaskContext |
+| `retryMax` | int | ActionItem, TaskItem |
+| `revokedAt` | float | Token |
+| `revokedBy` | str | Token |
+| `role` | str | ChatMessage |
+| `roleLabel` | str | Role, AccessRule |
+| `roleLabels` | List | User |
+| `rollbackOnFailure` | bool | TaskItem |
+| `roundNumber` | int | ChatLog, ChatDocument, ChatMessage |
+| `safetyMargin` | float | AiCallOptions |
+| `schedule` | str | AutomationDefinition |
+| `schemaVersion` | str | AiResponseMetadata |
+| `section` | Dict | SectionPromptArgs |
+| `section_count` | int | ContinuationContext |
+| `sectionIndex` | int | SectionPromptArgs |
+| `sequenceNr` | int | ChatMessage |
+| `sessionId` | str | Token |
+| `size` | int, str | ContentMetadata, FilePreview, AiCallPromptImage, ObservationPreview |
+| `snippet` | str | ObservationPreview |
+| `sourceDocuments` | List | AiResponseMetadata |
+| `sourceJson` | Dict | ActionDocument, DocumentData |
+| `sourceTask` | str | TaskHandover |
+| `speedRating` | int | AiModel |
+| `stage1Selection` | dict | TaskContext |
+| `startedAt` | float | ChatWorkflow, TaskItem |
+| `stats` | List | ChatWorkflow |
+| `status` | str, TokenStatus, TaskStatus, ConnectionStatus | ChatLog, ChatWorkflow, ChatMessage, UserConnection, AutomationDefinition, ActionItem, TaskItem, TaskResult, ReviewResult, Token |
+| `style` | str | AiCallPromptImage |
+| `success` | bool | ChatMessage, ActionResult, Observation, TaskResult, AuthEvent, AiModelResponse |
+| `successCriteria` | list | TaskStep |
+| `successfulActions` | list | TaskContext |
+| `summary` | str | ChatMessage |
+| `summaryAllowed` | bool | PromptPlaceholder |
+| `taskActions` | list | ReviewContext |
+| `taskId` | str | TaskResult, TaskHandover |
+| `taskNumber` | int | ChatLog, ChatDocument, ChatMessage |
+| `taskProgress` | str | ChatMessage |
+| `tasks` | List | ChatWorkflow, TaskPlan, UnderstandingResult |
+| `taskStep` | TaskStep | TaskContext, ReviewContext |
+| `temperature` | float | AiModel, AiCallOptions |
+| `template` | str | AutomationDefinition |
+| `template_structure` | str | ContinuationContext |
+| `timestamp` | float | ChatLog, ActionItem, TaskHandover, AuthEvent |
+| `title` | str | AiResponseMetadata |
+| `tokenAccess` | str | Token |
+| `tokenExpiresAt` | float | UserConnection |
+| `tokenRefresh` | str | Token |
+| `tokensUsed` | Dict | AiModelResponse |
+| `tokenStatus` | str | UserConnection |
+| `tokenType` | str | Token |
+| `totalActions` | int | ChatWorkflow |
+| `totalTasks` | int | ChatWorkflow |
+| `type` | str | ChatLog |
+| `typeGroup` | str | ObservationPreview |
+| `unmetCriteria` | List | ReviewResult |
+| `update` | AccessLevel | AccessRule, UserPermissions |
+| `url` | str | AiCallPromptWebCrawl |
+| `userAgent` | str | AuthEvent |
+| `userId` | str | UserConnection, Token, AuthEvent |
+| `userInput` | str | TaskItem |
+| `userLanguage` | str | UserInputRequest, RequestContext |
+| `userMessage` | str | ActionItem, TaskStep, ReviewResult, TaskPlan, ActionDefinition |
+| `userPrompt` | str | SectionPromptArgs, ChapterStructurePromptArgs, CodeContentPromptArgs, CodeStructurePromptArgs |
+| `username` | str | User |
+| `validationMetadata` | Dict | ActionDocument |
+| `version` | str | AiModel |
+| `view` | bool | AccessRule, UserPermissions |
+| `weight` | float | SelectionRule |
+| `width` | int | ContentMetadata |
+| `workflow` | ChatWorkflow | TaskContext |
+| `workflowId` | str | ChatStat, ChatLog, ChatMessage, ChatWorkflow, TaskItem, TaskContext, ReviewContext |
+| `workflowMode` | WorkflowModeEnum | ChatWorkflow |
+| `workflowSummary` | str | TaskHandover |
+
+
+
+---
+
+Can you adapt following fields to Multilingual Fields (`TextMultilingual`):
+
+| Field Name | Models |
+|------------|--------|
+| `description` | Role | (is already)
+
+
diff --git a/modules/datamodels/datamodelChat.py b/modules/datamodels/datamodelChat.py
index 33e2b3b3..7860658c 100644
--- a/modules/datamodels/datamodelChat.py
+++ b/modules/datamodels/datamodelChat.py
@@ -278,6 +278,7 @@ class WorkflowModeEnum(str, Enum):
WORKFLOW_DYNAMIC = "Dynamic"
WORKFLOW_AUTOMATION = "Automation"
WORKFLOW_CHATBOT = "Chatbot"
+ WORKFLOW_REACT = "React" # Legacy mode - kept for backward compatibility
registerModelLabels(
@@ -287,6 +288,7 @@ registerModelLabels(
"WORKFLOW_DYNAMIC": {"en": "Dynamic", "fr": "Dynamique"},
"WORKFLOW_AUTOMATION": {"en": "Automation", "fr": "Automatisation"},
"WORKFLOW_CHATBOT": {"en": "Chatbot", "fr": "Chatbot"},
+ "WORKFLOW_REACT": {"en": "React (Legacy)", "fr": "React (Hérité)"},
},
)
@@ -325,6 +327,10 @@ class ChatWorkflow(BaseModel):
"value": WorkflowModeEnum.WORKFLOW_CHATBOT.value,
"label": {"en": "Chatbot", "fr": "Chatbot"},
},
+ {
+ "value": WorkflowModeEnum.WORKFLOW_REACT.value,
+ "label": {"en": "React (Legacy)", "fr": "React (Hérité)"},
+ },
]})
maxSteps: int = Field(default=10, description="Maximum number of iterations in dynamic mode", json_schema_extra={"frontend_type": "integer", "frontend_readonly": False, "frontend_required": False})
expectedFormats: Optional[List[str]] = Field(None, description="List of expected file format extensions from user request (e.g., ['xlsx', 'pdf']). Extracted during intent analysis.", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
diff --git a/modules/datamodels/datamodelTrustee.py b/modules/datamodels/datamodelTrustee.py
new file mode 100644
index 00000000..21a3d3cc
--- /dev/null
+++ b/modules/datamodels/datamodelTrustee.py
@@ -0,0 +1,579 @@
+# Copyright (c) 2025 Patrick Motsch
+# All rights reserved.
+"""Trustee models: TrusteeOrganisation, TrusteeRole, TrusteeAccess, TrusteeContract, TrusteeDocument, TrusteePosition, TrusteePositionDocument."""
+
+from typing import Optional
+from pydantic import BaseModel, Field
+from modules.shared.attributeUtils import registerModelLabels
+import uuid
+
+
+class TrusteeOrganisation(BaseModel):
+ """Represents trustee organisations (companies) within the Trustee feature."""
+ id: str = Field( # Unique string label (PK), not UUID
+ description="Unique organisation identifier (label)",
+ json_schema_extra={
+ "frontend_type": "text",
+ "frontend_readonly": False, # Editable at creation, then readonly
+ "frontend_required": True
+ }
+ )
+ label: str = Field(
+ description="Company name",
+ json_schema_extra={
+ "frontend_type": "text",
+ "frontend_readonly": False,
+ "frontend_required": True
+ }
+ )
+ enabled: bool = Field(
+ default=True,
+ description="Whether the organisation is enabled",
+ json_schema_extra={
+ "frontend_type": "checkbox",
+ "frontend_readonly": False,
+ "frontend_required": False
+ }
+ )
+ mandateId: Optional[str] = Field(
+ default=None,
+ description="Mandate ID (system-level organisation)",
+ json_schema_extra={
+ "frontend_type": "text",
+ "frontend_readonly": True,
+ "frontend_required": False
+ }
+ )
+ # System attributes are automatically set by DatabaseConnector:
+ # _createdAt, _modifiedAt, _createdBy, _modifiedBy
+
+
+registerModelLabels(
+ "TrusteeOrganisation",
+ {"en": "Organisation", "fr": "Organisation", "de": "Organisation"},
+ {
+ "id": {"en": "ID", "fr": "ID", "de": "ID"},
+ "label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"},
+ "enabled": {"en": "Enabled", "fr": "Activé", "de": "Aktiviert"},
+ "mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
+ },
+)
+
+
+class TrusteeRole(BaseModel):
+ """Defines roles within the Trustee feature."""
+ id: str = Field( # Unique string label (PK), not UUID
+ description="Unique role identifier (label)",
+ json_schema_extra={
+ "frontend_type": "text",
+ "frontend_readonly": False,
+ "frontend_required": True
+ }
+ )
+ desc: str = Field(
+ description="Role description",
+ json_schema_extra={
+ "frontend_type": "textarea",
+ "frontend_readonly": False,
+ "frontend_required": True
+ }
+ )
+ mandateId: Optional[str] = Field(
+ default=None,
+ description="Mandate ID",
+ json_schema_extra={
+ "frontend_type": "text",
+ "frontend_readonly": True,
+ "frontend_required": False
+ }
+ )
+ # System attributes are automatically set by DatabaseConnector
+
+
+registerModelLabels(
+ "TrusteeRole",
+ {"en": "Role", "fr": "Rôle", "de": "Rolle"},
+ {
+ "id": {"en": "ID", "fr": "ID", "de": "ID"},
+ "desc": {"en": "Description", "fr": "Description", "de": "Beschreibung"},
+ "mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
+ },
+)
+
+
+class TrusteeAccess(BaseModel):
+ """Defines user access to organisations with specific roles."""
+ id: str = Field(
+ default_factory=lambda: str(uuid.uuid4()),
+ description="Unique access ID",
+ json_schema_extra={
+ "frontend_type": "text",
+ "frontend_readonly": True,
+ "frontend_required": False
+ }
+ )
+ organisationId: str = Field(
+ description="Reference to TrusteeOrganisation.id",
+ json_schema_extra={
+ "frontend_type": "select",
+ "frontend_readonly": False,
+ "frontend_required": True,
+ "frontend_options": "TrusteeOrganisation"
+ }
+ )
+ roleId: str = Field(
+ description="Reference to TrusteeRole.id",
+ json_schema_extra={
+ "frontend_type": "select",
+ "frontend_readonly": False,
+ "frontend_required": True,
+ "frontend_options": "TrusteeRole"
+ }
+ )
+ userId: str = Field(
+ description="User ID assigned to this role",
+ json_schema_extra={
+ "frontend_type": "select",
+ "frontend_readonly": False,
+ "frontend_required": True,
+ "frontend_options": "User"
+ }
+ )
+ contractId: Optional[str] = Field(
+ default=None,
+ description="Optional reference to TrusteeContract.id. If None, access is for full organisation. If set, access is limited to this specific contract.",
+ json_schema_extra={
+ "frontend_type": "select",
+ "frontend_readonly": False,
+ "frontend_required": False,
+ "frontend_options": "TrusteeContract",
+ "frontend_depends_on": "organisationId"
+ }
+ )
+ mandateId: Optional[str] = Field(
+ default=None,
+ description="Mandate ID",
+ json_schema_extra={
+ "frontend_type": "text",
+ "frontend_readonly": True,
+ "frontend_required": False
+ }
+ )
+ # System attributes are automatically set by DatabaseConnector
+
+
+registerModelLabels(
+ "TrusteeAccess",
+ {"en": "Access", "fr": "Accès", "de": "Zugriff"},
+ {
+ "id": {"en": "ID", "fr": "ID", "de": "ID"},
+ "organisationId": {"en": "Organisation", "fr": "Organisation", "de": "Organisation"},
+ "roleId": {"en": "Role", "fr": "Rôle", "de": "Rolle"},
+ "userId": {"en": "User", "fr": "Utilisateur", "de": "Benutzer"},
+ "contractId": {"en": "Contract (optional)", "fr": "Contrat (optionnel)", "de": "Vertrag (optional)"},
+ "mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
+ },
+)
+
+
+class TrusteeContract(BaseModel):
+ """Defines customer contracts within organisations."""
+ id: str = Field(
+ default_factory=lambda: str(uuid.uuid4()),
+ description="Unique contract ID",
+ json_schema_extra={
+ "frontend_type": "text",
+ "frontend_readonly": True,
+ "frontend_required": False
+ }
+ )
+ organisationId: str = Field(
+ description="Reference to TrusteeOrganisation.id (immutable after creation)",
+ json_schema_extra={
+ "frontend_type": "select",
+ "frontend_readonly": False, # Editable at creation, then readonly
+ "frontend_required": True,
+ "frontend_options": "TrusteeOrganisation"
+ }
+ )
+ label: str = Field(
+ description="Label for the customer contract (e.g., 'Muster AG 2026')",
+ json_schema_extra={
+ "frontend_type": "text",
+ "frontend_readonly": False,
+ "frontend_required": True
+ }
+ )
+ enabled: bool = Field(
+ default=True,
+ description="Whether the contract is enabled",
+ json_schema_extra={
+ "frontend_type": "checkbox",
+ "frontend_readonly": False,
+ "frontend_required": False
+ }
+ )
+ mandateId: Optional[str] = Field(
+ default=None,
+ description="Mandate ID",
+ json_schema_extra={
+ "frontend_type": "text",
+ "frontend_readonly": True,
+ "frontend_required": False
+ }
+ )
+ # System attributes are automatically set by DatabaseConnector
+
+
+registerModelLabels(
+ "TrusteeContract",
+ {"en": "Contract", "fr": "Contrat", "de": "Vertrag"},
+ {
+ "id": {"en": "ID", "fr": "ID", "de": "ID"},
+ "organisationId": {"en": "Organisation", "fr": "Organisation", "de": "Organisation"},
+ "label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"},
+ "enabled": {"en": "Enabled", "fr": "Activé", "de": "Aktiviert"},
+ "mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
+ },
+)
+
+
+class TrusteeDocument(BaseModel):
+ """Contains document references and receipts for bookings."""
+ id: str = Field(
+ default_factory=lambda: str(uuid.uuid4()),
+ description="Unique document ID",
+ json_schema_extra={
+ "frontend_type": "text",
+ "frontend_readonly": True,
+ "frontend_required": False
+ }
+ )
+ organisationId: str = Field(
+ description="Reference to TrusteeOrganisation.id",
+ json_schema_extra={
+ "frontend_type": "select",
+ "frontend_readonly": False,
+ "frontend_required": True,
+ "frontend_options": "TrusteeOrganisation"
+ }
+ )
+ contractId: str = Field(
+ description="Reference to TrusteeContract.id",
+ json_schema_extra={
+ "frontend_type": "select",
+ "frontend_readonly": False,
+ "frontend_required": True,
+ "frontend_options": "TrusteeContract",
+ "frontend_depends_on": "organisationId"
+ }
+ )
+ documentData: Optional[bytes] = Field(
+ default=None,
+ description="The file content (binary)",
+ json_schema_extra={
+ "frontend_type": "file",
+ "frontend_readonly": False,
+ "frontend_required": False
+ }
+ )
+ documentName: str = Field(
+ description="File name (e.g., 'Beleg.pdf')",
+ json_schema_extra={
+ "frontend_type": "text",
+ "frontend_readonly": False,
+ "frontend_required": True
+ }
+ )
+ documentMimeType: str = Field(
+ default="application/octet-stream",
+ description="MIME type of the document",
+ json_schema_extra={
+ "frontend_type": "select",
+ "frontend_readonly": False,
+ "frontend_required": True,
+ "frontend_options": [
+ {"value": "application/pdf", "label": {"en": "PDF", "fr": "PDF", "de": "PDF"}},
+ {"value": "image/jpeg", "label": {"en": "JPEG", "fr": "JPEG", "de": "JPEG"}},
+ {"value": "image/png", "label": {"en": "PNG", "fr": "PNG", "de": "PNG"}},
+ {"value": "application/octet-stream", "label": {"en": "Other", "fr": "Autre", "de": "Andere"}},
+ ]
+ }
+ )
+ mandateId: Optional[str] = Field(
+ default=None,
+ description="Mandate ID",
+ json_schema_extra={
+ "frontend_type": "text",
+ "frontend_readonly": True,
+ "frontend_required": False
+ }
+ )
+ # System attributes are automatically set by DatabaseConnector
+
+
+registerModelLabels(
+ "TrusteeDocument",
+ {"en": "Document", "fr": "Document", "de": "Dokument"},
+ {
+ "id": {"en": "ID", "fr": "ID", "de": "ID"},
+ "organisationId": {"en": "Organisation", "fr": "Organisation", "de": "Organisation"},
+ "contractId": {"en": "Contract", "fr": "Contrat", "de": "Vertrag"},
+ "documentData": {"en": "Document Data", "fr": "Données du document", "de": "Dokumentdaten"},
+ "documentName": {"en": "Document Name", "fr": "Nom du document", "de": "Dokumentname"},
+ "documentMimeType": {"en": "MIME Type", "fr": "Type MIME", "de": "MIME-Typ"},
+ "mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
+ },
+)
+
+
+class TrusteePosition(BaseModel):
+ """Contains booking positions (expense entries)."""
+ id: str = Field(
+ default_factory=lambda: str(uuid.uuid4()),
+ description="Unique position ID",
+ json_schema_extra={
+ "frontend_type": "text",
+ "frontend_readonly": True,
+ "frontend_required": False
+ }
+ )
+ organisationId: str = Field(
+ description="Reference to TrusteeOrganisation.id",
+ json_schema_extra={
+ "frontend_type": "select",
+ "frontend_readonly": False,
+ "frontend_required": True,
+ "frontend_options": "TrusteeOrganisation"
+ }
+ )
+ contractId: str = Field(
+ description="Reference to TrusteeContract.id",
+ json_schema_extra={
+ "frontend_type": "select",
+ "frontend_readonly": False,
+ "frontend_required": True,
+ "frontend_options": "TrusteeContract",
+ "frontend_depends_on": "organisationId"
+ }
+ )
+ valuta: Optional[str] = Field(
+ default=None,
+ description="Value date (ISO format: YYYY-MM-DD)",
+ json_schema_extra={
+ "frontend_type": "date",
+ "frontend_readonly": False,
+ "frontend_required": True
+ }
+ )
+ transactionDateTime: Optional[float] = Field(
+ default=None,
+ description="Transaction timestamp (UTC timestamp in seconds)",
+ json_schema_extra={
+ "frontend_type": "timestamp",
+ "frontend_readonly": False,
+ "frontend_required": True
+ }
+ )
+ company: str = Field(
+ default="",
+ description="Company name",
+ json_schema_extra={
+ "frontend_type": "text",
+ "frontend_readonly": False,
+ "frontend_required": False
+ }
+ )
+ desc: str = Field(
+ default="",
+ description="Description",
+ json_schema_extra={
+ "frontend_type": "textarea",
+ "frontend_readonly": False,
+ "frontend_required": False
+ }
+ )
+ tags: str = Field(
+ default="",
+ description="Tags (comma-separated)",
+ json_schema_extra={
+ "frontend_type": "text",
+ "frontend_readonly": False,
+ "frontend_required": False
+ }
+ )
+ bookingCurrency: str = Field(
+ default="CHF",
+ description="Booking currency code",
+ json_schema_extra={
+ "frontend_type": "select",
+ "frontend_readonly": False,
+ "frontend_required": True,
+ "frontend_options": [
+ {"value": "CHF", "label": {"en": "CHF", "fr": "CHF", "de": "CHF"}},
+ {"value": "EUR", "label": {"en": "EUR", "fr": "EUR", "de": "EUR"}},
+ {"value": "USD", "label": {"en": "USD", "fr": "USD", "de": "USD"}},
+ {"value": "GBP", "label": {"en": "GBP", "fr": "GBP", "de": "GBP"}},
+ ]
+ }
+ )
+ bookingAmount: float = Field(
+ default=0.0,
+ description="Booking amount",
+ json_schema_extra={
+ "frontend_type": "number",
+ "frontend_readonly": False,
+ "frontend_required": True
+ }
+ )
+ originalCurrency: str = Field(
+ default="CHF",
+ description="Original currency code",
+ json_schema_extra={
+ "frontend_type": "select",
+ "frontend_readonly": False,
+ "frontend_required": True,
+ "frontend_options": [
+ {"value": "CHF", "label": {"en": "CHF", "fr": "CHF", "de": "CHF"}},
+ {"value": "EUR", "label": {"en": "EUR", "fr": "EUR", "de": "EUR"}},
+ {"value": "USD", "label": {"en": "USD", "fr": "USD", "de": "USD"}},
+ {"value": "GBP", "label": {"en": "GBP", "fr": "GBP", "de": "GBP"}},
+ ]
+ }
+ )
+ originalAmount: float = Field(
+ default=0.0,
+ description="Original amount (manual input, no automatic currency conversion)",
+ json_schema_extra={
+ "frontend_type": "number",
+ "frontend_readonly": False,
+ "frontend_required": True
+ }
+ )
+ vatPercentage: float = Field(
+ default=0.0,
+ description="VAT percentage",
+ json_schema_extra={
+ "frontend_type": "number",
+ "frontend_readonly": False,
+ "frontend_required": False
+ }
+ )
+ vatAmount: float = Field(
+ default=0.0,
+ description="VAT amount (calculated: bookingAmount * vatPercentage / 100, can be manually overridden)",
+ json_schema_extra={
+ "frontend_type": "number",
+ "frontend_readonly": False,
+ "frontend_required": False
+ }
+ )
+ mandateId: Optional[str] = Field(
+ default=None,
+ description="Mandate ID",
+ json_schema_extra={
+ "frontend_type": "text",
+ "frontend_readonly": True,
+ "frontend_required": False
+ }
+ )
+ # System attributes are automatically set by DatabaseConnector
+
+
+registerModelLabels(
+ "TrusteePosition",
+ {"en": "Position", "fr": "Position", "de": "Position"},
+ {
+ "id": {"en": "ID", "fr": "ID", "de": "ID"},
+ "organisationId": {"en": "Organisation", "fr": "Organisation", "de": "Organisation"},
+ "contractId": {"en": "Contract", "fr": "Contrat", "de": "Vertrag"},
+ "valuta": {"en": "Value Date", "fr": "Date de valeur", "de": "Valutadatum"},
+ "transactionDateTime": {"en": "Transaction Date/Time", "fr": "Date/Heure de transaction", "de": "Transaktionszeitpunkt"},
+ "company": {"en": "Company", "fr": "Entreprise", "de": "Firma"},
+ "desc": {"en": "Description", "fr": "Description", "de": "Beschreibung"},
+ "tags": {"en": "Tags", "fr": "Tags", "de": "Tags"},
+ "bookingCurrency": {"en": "Booking Currency", "fr": "Devise de comptabilisation", "de": "Buchungswährung"},
+ "bookingAmount": {"en": "Booking Amount", "fr": "Montant de comptabilisation", "de": "Buchungsbetrag"},
+ "originalCurrency": {"en": "Original Currency", "fr": "Devise d'origine", "de": "Originalwährung"},
+ "originalAmount": {"en": "Original Amount", "fr": "Montant d'origine", "de": "Originalbetrag"},
+ "vatPercentage": {"en": "VAT Percentage", "fr": "Pourcentage TVA", "de": "MwSt-Prozentsatz"},
+ "vatAmount": {"en": "VAT Amount", "fr": "Montant TVA", "de": "MwSt-Betrag"},
+ "mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
+ },
+)
+
+
+class TrusteePositionDocument(BaseModel):
+ """Cross-reference table linking positions to documents (many-to-many)."""
+ id: str = Field(
+ default_factory=lambda: str(uuid.uuid4()),
+ description="Unique link ID",
+ json_schema_extra={
+ "frontend_type": "text",
+ "frontend_readonly": True,
+ "frontend_required": False
+ }
+ )
+ organisationId: str = Field(
+ description="Reference to TrusteeOrganisation.id",
+ json_schema_extra={
+ "frontend_type": "select",
+ "frontend_readonly": False,
+ "frontend_required": True,
+ "frontend_options": "TrusteeOrganisation"
+ }
+ )
+ contractId: str = Field(
+ description="Reference to TrusteeContract.id",
+ json_schema_extra={
+ "frontend_type": "select",
+ "frontend_readonly": False,
+ "frontend_required": True,
+ "frontend_options": "TrusteeContract",
+ "frontend_depends_on": "organisationId"
+ }
+ )
+ documentId: str = Field(
+ description="Reference to TrusteeDocument.id",
+ json_schema_extra={
+ "frontend_type": "select",
+ "frontend_readonly": False,
+ "frontend_required": True,
+ "frontend_options": "TrusteeDocument",
+ "frontend_depends_on": "contractId"
+ }
+ )
+ positionId: str = Field(
+ description="Reference to TrusteePosition.id",
+ json_schema_extra={
+ "frontend_type": "select",
+ "frontend_readonly": False,
+ "frontend_required": True,
+ "frontend_options": "TrusteePosition",
+ "frontend_depends_on": "contractId"
+ }
+ )
+ mandateId: Optional[str] = Field(
+ default=None,
+ description="Mandate ID",
+ json_schema_extra={
+ "frontend_type": "text",
+ "frontend_readonly": True,
+ "frontend_required": False
+ }
+ )
+ # System attributes are automatically set by DatabaseConnector
+
+
+registerModelLabels(
+ "TrusteePositionDocument",
+ {"en": "Position-Document Link", "fr": "Lien Position-Document", "de": "Position-Dokument-Verknüpfung"},
+ {
+ "id": {"en": "ID", "fr": "ID", "de": "ID"},
+ "organisationId": {"en": "Organisation", "fr": "Organisation", "de": "Organisation"},
+ "contractId": {"en": "Contract", "fr": "Contrat", "de": "Vertrag"},
+ "documentId": {"en": "Document", "fr": "Document", "de": "Dokument"},
+ "positionId": {"en": "Position", "fr": "Position", "de": "Position"},
+ "mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"},
+ },
+)
diff --git a/modules/datamodels/datamodelUtils.py b/modules/datamodels/datamodelUtils.py
index bc81444b..1ac9ad33 100644
--- a/modules/datamodels/datamodelUtils.py
+++ b/modules/datamodels/datamodelUtils.py
@@ -10,7 +10,7 @@ import uuid
class Prompt(BaseModel):
id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
- mandateId: str = Field(description="ID of the mandate this prompt belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
+ mandateId: str = Field(default="", description="ID of the mandate this prompt belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False})
content: str = Field(description="Content of the prompt", json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": True})
name: str = Field(description="Name of the prompt", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True})
registerModelLabels(
diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py
index 3e7b043f..f17f2cd7 100644
--- a/modules/interfaces/interfaceBootstrap.py
+++ b/modules/interfaces/interfaceBootstrap.py
@@ -951,7 +951,12 @@ def _addMissingTableRules(db: DatabaseConnector, existingRules: List[Dict[str, A
existingRoles = {rule.get("roleLabel") for rule in existingRules}
# Tables that need rules
- requiredTables = ["ChatWorkflow", "Prompt", "Projekt", "Parzelle", "Dokument", "Gemeinde", "Kanton", "Land"]
+ requiredTables = [
+ "ChatWorkflow", "Prompt", "Projekt", "Parzelle", "Dokument", "Gemeinde", "Kanton", "Land",
+ # Trustee tables
+ "TrusteeOrganisation", "TrusteeRole", "TrusteeAccess", "TrusteeContract",
+ "TrusteeDocument", "TrusteePosition", "TrusteePositionDocument"
+ ]
requiredRoles = ["sysadmin", "admin", "user", "viewer"]
newRules = []
@@ -1100,6 +1105,55 @@ def _addMissingTableRules(db: DatabaseConnector, existingRules: List[Dict[str, A
update=AccessLevel.NONE,
delete=AccessLevel.NONE,
))
+ # Trustee tables rules
+ elif table in ["TrusteeOrganisation", "TrusteeRole", "TrusteeAccess", "TrusteeContract",
+ "TrusteeDocument", "TrusteePosition", "TrusteePositionDocument"]:
+ for roleLabel in requiredRoles:
+ if roleLabel == "sysadmin":
+ newRules.append(AccessRule(
+ roleLabel=roleLabel,
+ context=AccessRuleContext.DATA,
+ item=table,
+ view=True,
+ read=AccessLevel.ALL,
+ create=AccessLevel.ALL,
+ update=AccessLevel.ALL,
+ delete=AccessLevel.ALL,
+ ))
+ elif roleLabel == "admin":
+ newRules.append(AccessRule(
+ roleLabel=roleLabel,
+ context=AccessRuleContext.DATA,
+ item=table,
+ view=True,
+ read=AccessLevel.GROUP,
+ create=AccessLevel.GROUP,
+ update=AccessLevel.GROUP,
+ delete=AccessLevel.GROUP,
+ ))
+ elif roleLabel == "user":
+ # User role: Access to MY records (feature-specific access via trustee.access)
+ newRules.append(AccessRule(
+ roleLabel=roleLabel,
+ context=AccessRuleContext.DATA,
+ item=table,
+ view=True,
+ read=AccessLevel.MY,
+ create=AccessLevel.MY,
+ update=AccessLevel.MY,
+ delete=AccessLevel.MY,
+ ))
+ elif roleLabel == "viewer":
+ newRules.append(AccessRule(
+ roleLabel=roleLabel,
+ context=AccessRuleContext.DATA,
+ item=table,
+ view=True,
+ read=AccessLevel.MY,
+ create=AccessLevel.NONE,
+ update=AccessLevel.NONE,
+ delete=AccessLevel.NONE,
+ ))
# Create missing rules
if newRules:
diff --git a/modules/interfaces/interfaceDbAppObjects.py b/modules/interfaces/interfaceDbAppObjects.py
index cab79d76..ab792b08 100644
--- a/modules/interfaces/interfaceDbAppObjects.py
+++ b/modules/interfaces/interfaceDbAppObjects.py
@@ -272,8 +272,12 @@ class AppObjects:
record_value = record.get(field_name)
# Handle simple value (equals operator)
+ # Compare as strings to handle type mismatches (frontend sends strings)
if not isinstance(filter_value, dict):
- if record_value != filter_value:
+ # Convert both to strings for comparison (case-insensitive for strings)
+ recordStr = str(record_value).lower() if record_value is not None else ""
+ filterStr = str(filter_value).lower() if filter_value is not None else ""
+ if recordStr != filterStr:
matches = False
break
continue
@@ -283,7 +287,10 @@ class AppObjects:
filter_val = filter_value.get("value")
if operator in ["equals", "eq"]:
- if record_value != filter_val:
+ # Convert both to strings for comparison
+ recordStr = str(record_value).lower() if record_value is not None else ""
+ filterStr = str(filter_val).lower() if filter_val is not None else ""
+ if recordStr != filterStr:
matches = False
break
@@ -1866,7 +1873,8 @@ class AppObjects:
Updated AccessRule object
"""
try:
- updatedRule = self.db.recordModify(AccessRule, ruleId, accessRule.model_dump())
+ # Exclude id from model_dump - the URL ruleId is authoritative
+ updatedRule = self.db.recordModify(AccessRule, ruleId, accessRule.model_dump(exclude={"id"}))
logger.info(f"Updated access rule with ID {ruleId}")
return AccessRule(**updatedRule)
except Exception as e:
@@ -2149,7 +2157,8 @@ class AppObjects:
if conflictingRole and conflictingRole.id != roleId:
raise ValueError(f"Role with label '{role.roleLabel}' already exists")
- updatedRole = self.db.recordModify(Role, roleId, role.model_dump())
+ # Exclude id from model_dump - the URL roleId is authoritative
+ updatedRole = self.db.recordModify(Role, roleId, role.model_dump(exclude={"id"}))
logger.info(f"Updated role with ID {roleId}")
return Role(**updatedRole)
except Exception as e:
@@ -2177,7 +2186,7 @@ class AppObjects:
raise ValueError(f"Cannot delete system role '{role.roleLabel}'")
# Check if role is assigned to any users
- allUsers = self.getUsers()
+ allUsers = self.getUsersByMandate(None) # Get all users across all mandates
for user in allUsers:
if role.roleLabel in (user.roleLabels or []):
raise ValueError(f"Cannot delete role '{role.roleLabel}' - it is assigned to users")
diff --git a/modules/interfaces/interfaceDbTrusteeObjects.py b/modules/interfaces/interfaceDbTrusteeObjects.py
new file mode 100644
index 00000000..2cfa517e
--- /dev/null
+++ b/modules/interfaces/interfaceDbTrusteeObjects.py
@@ -0,0 +1,836 @@
+# Copyright (c) 2025 Patrick Motsch
+# All rights reserved.
+"""
+Interface to Trustee database.
+Manages trustee organisations, roles, access, contracts, documents, and positions.
+"""
+
+import logging
+import math
+from typing import Dict, Any, List, Optional
+
+from modules.connectors.connectorDbPostgre import DatabaseConnector
+from modules.shared.configuration import APP_CONFIG
+from modules.interfaces.interfaceRbac import getRecordsetWithRBAC
+from modules.security.rbac import RbacClass
+from modules.datamodels.datamodelUam import User, AccessLevel
+from modules.datamodels.datamodelRbac import AccessRuleContext
+from modules.datamodels.datamodelTrustee import (
+ TrusteeOrganisation,
+ TrusteeRole,
+ TrusteeAccess,
+ TrusteeContract,
+ TrusteeDocument,
+ TrusteePosition,
+ TrusteePositionDocument,
+)
+from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult
+
+logger = logging.getLogger(__name__)
+
+# Singleton factory for TrusteeObjects instances per context
+_trusteeInterfaces = {}
+
+
+def getInterface(currentUser: User) -> "TrusteeObjects":
+ """Get or create a TrusteeObjects instance for the given user context."""
+ global _trusteeInterfaces
+
+ if not currentUser or not currentUser.id:
+ raise ValueError("Valid user context required")
+
+ cacheKey = f"{currentUser.id}_{currentUser.mandateId}"
+
+ if cacheKey not in _trusteeInterfaces:
+ _trusteeInterfaces[cacheKey] = TrusteeObjects(currentUser)
+ else:
+ # Update user context if needed
+ _trusteeInterfaces[cacheKey].setUserContext(currentUser)
+
+ return _trusteeInterfaces[cacheKey]
+
+
+class TrusteeObjects:
+ """
+ Interface to Trustee database.
+ Manages trustee organisations, roles, access, contracts, documents, and positions.
+ """
+
+ def __init__(self, currentUser: Optional[User] = None):
+ """Initializes the Trustee Interface."""
+ self.currentUser = currentUser
+ self.userId = currentUser.id if currentUser else None
+ self.mandateId = currentUser.mandateId if currentUser else None
+ self.rbac = None
+
+ # Initialize database
+ self._initializeDatabase()
+
+ # Set user context if provided
+ if currentUser:
+ self.setUserContext(currentUser)
+
+ def setUserContext(self, currentUser: User):
+ """Sets the user context for the interface."""
+ if not currentUser:
+ logger.info("Initializing interface without user context")
+ return
+
+ self.currentUser = currentUser
+ self.userId = currentUser.id
+ self.mandateId = currentUser.mandateId
+
+ if not self.userId or not self.mandateId:
+ raise ValueError("Invalid user context: id and mandateId are required")
+
+ self.userLanguage = currentUser.language
+
+ # Initialize RBAC interface
+ from modules.security.rootAccess import getRootDbAppConnector
+ dbApp = getRootDbAppConnector()
+ self.rbac = RbacClass(self.db, dbApp=dbApp)
+
+ # Update database context
+ self.db.updateContext(self.userId)
+
+ def __del__(self):
+ """Cleanup method to close database connection."""
+ if hasattr(self, "db") and self.db is not None:
+ try:
+ self.db.close()
+ except Exception as e:
+ logger.error(f"Error closing database connection: {e}")
+
+ def _initializeDatabase(self):
+ """Initializes the database connection directly."""
+ try:
+ dbHost = APP_CONFIG.get("DB_TRUSTEE_HOST", APP_CONFIG.get("DB_CHAT_HOST", "_no_config_default_data"))
+ dbDatabase = APP_CONFIG.get("DB_TRUSTEE_DATABASE", "trustee")
+ dbUser = APP_CONFIG.get("DB_TRUSTEE_USER", APP_CONFIG.get("DB_CHAT_USER"))
+ dbPassword = APP_CONFIG.get("DB_TRUSTEE_PASSWORD_SECRET", APP_CONFIG.get("DB_CHAT_PASSWORD_SECRET"))
+ dbPort = int(APP_CONFIG.get("DB_TRUSTEE_PORT", APP_CONFIG.get("DB_CHAT_PORT", 5432)))
+
+ self.db = DatabaseConnector(
+ dbHost=dbHost,
+ dbDatabase=dbDatabase,
+ dbUser=dbUser,
+ dbPassword=dbPassword,
+ dbPort=dbPort,
+ userId=self.userId,
+ )
+
+ self.db.initDbSystem()
+ logger.info(f"Trustee database initialized successfully for user {self.userId}")
+ except Exception as e:
+ logger.error(f"Failed to initialize Trustee database: {str(e)}")
+ raise
+
+ def checkRbacPermission(
+ self,
+ modelClass: type,
+ operation: str,
+ recordId: Optional[str] = None
+ ) -> bool:
+ """Check RBAC permission for a specific operation on a table."""
+ if not self.rbac or not self.currentUser:
+ return False
+
+ tableName = modelClass.__name__
+ permissions = self.rbac.getUserPermissions(
+ self.currentUser,
+ AccessRuleContext.DATA,
+ tableName
+ )
+
+ if not permissions.view:
+ return False
+
+ permLevel = getattr(permissions, operation, AccessLevel.NONE)
+ if permLevel == AccessLevel.NONE:
+ return False
+
+ return True
+
+ # ===== Organisation CRUD =====
+
+ def createOrganisation(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ """Create a new organisation."""
+ if not self.checkRbacPermission(TrusteeOrganisation, "create"):
+ logger.warning(f"User {self.userId} lacks permission to create organisation")
+ return None
+
+ # Set mandateId from current user
+ data["mandateId"] = self.mandateId
+
+ # Validate ID format (alphanumeric, hyphens, underscores, 3-50 chars)
+ orgId = data.get("id", "")
+ if not orgId or len(orgId) < 3 or len(orgId) > 50:
+ logger.error(f"Invalid organisation ID length: {len(orgId)}")
+ return None
+
+ import re
+ if not re.match(r'^[a-zA-Z0-9_-]+$', orgId):
+ logger.error(f"Invalid organisation ID format: {orgId}")
+ return None
+
+ success = self.db.saveRecord(TrusteeOrganisation, orgId, data)
+ if success:
+ return self.db.getRecord(TrusteeOrganisation, orgId)
+ return None
+
+ def getOrganisation(self, orgId: str) -> Optional[Dict[str, Any]]:
+ """Get a single organisation by ID."""
+ return self.db.getRecord(TrusteeOrganisation, orgId)
+
+ def getAllOrganisations(self, params: Optional[PaginationParams] = None) -> PaginatedResult:
+ """Get all organisations with RBAC filtering."""
+ records = getRecordsetWithRBAC(
+ connector=self.db,
+ modelClass=TrusteeOrganisation,
+ currentUser=self.currentUser,
+ recordFilter=None,
+ orderBy="id"
+ )
+
+ # Apply pagination
+ totalItems = len(records)
+ if params:
+ pageSize = params.pageSize or 20
+ page = params.page or 1
+ startIdx = (page - 1) * pageSize
+ endIdx = startIdx + pageSize
+ items = records[startIdx:endIdx]
+ totalPages = math.ceil(totalItems / pageSize) if pageSize > 0 else 1
+ else:
+ items = records
+ totalPages = 1
+ page = 1
+ pageSize = totalItems
+
+ return PaginatedResult(
+ items=items,
+ totalItems=totalItems,
+ totalPages=totalPages
+ )
+
+ def updateOrganisation(self, orgId: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ """Update an organisation."""
+ if not self.checkRbacPermission(TrusteeOrganisation, "update"):
+ logger.warning(f"User {self.userId} lacks permission to update organisation")
+ return None
+
+ # ID cannot be changed after creation
+ if "id" in data and data["id"] != orgId:
+ logger.error("Organisation ID cannot be changed")
+ return None
+
+ data["id"] = orgId
+ success = self.db.saveRecord(TrusteeOrganisation, orgId, data)
+ if success:
+ return self.db.getRecord(TrusteeOrganisation, orgId)
+ return None
+
+ def deleteOrganisation(self, orgId: str) -> bool:
+ """Delete an organisation."""
+ if not self.checkRbacPermission(TrusteeOrganisation, "delete"):
+ logger.warning(f"User {self.userId} lacks permission to delete organisation")
+ return False
+
+ return self.db.deleteRecord(TrusteeOrganisation, orgId)
+
+ # ===== Role CRUD =====
+
+ def createRole(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ """Create a new role (sysadmin only)."""
+ if not self.checkRbacPermission(TrusteeRole, "create"):
+ logger.warning(f"User {self.userId} lacks permission to create role")
+ return None
+
+ data["mandateId"] = self.mandateId
+ roleId = data.get("id", "")
+
+ if not roleId:
+ logger.error("Role ID is required")
+ return None
+
+ success = self.db.saveRecord(TrusteeRole, roleId, data)
+ if success:
+ return self.db.getRecord(TrusteeRole, roleId)
+ return None
+
+ def getRole(self, roleId: str) -> Optional[Dict[str, Any]]:
+ """Get a single role by ID."""
+ return self.db.getRecord(TrusteeRole, roleId)
+
+ def getAllRoles(self, params: Optional[PaginationParams] = None) -> PaginatedResult:
+ """Get all roles with RBAC filtering."""
+ records = getRecordsetWithRBAC(
+ connector=self.db,
+ modelClass=TrusteeRole,
+ currentUser=self.currentUser,
+ recordFilter=None,
+ orderBy="id"
+ )
+
+ totalItems = len(records)
+ if params:
+ pageSize = params.pageSize or 20
+ page = params.page or 1
+ startIdx = (page - 1) * pageSize
+ endIdx = startIdx + pageSize
+ items = records[startIdx:endIdx]
+ totalPages = math.ceil(totalItems / pageSize) if pageSize > 0 else 1
+ else:
+ items = records
+ totalPages = 1
+ page = 1
+ pageSize = totalItems
+
+ return PaginatedResult(
+ items=items,
+ totalItems=totalItems,
+ totalPages=totalPages
+ )
+
+ def updateRole(self, roleId: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ """Update a role (sysadmin only)."""
+ if not self.checkRbacPermission(TrusteeRole, "update"):
+ logger.warning(f"User {self.userId} lacks permission to update role")
+ return None
+
+ data["id"] = roleId
+ success = self.db.saveRecord(TrusteeRole, roleId, data)
+ if success:
+ return self.db.getRecord(TrusteeRole, roleId)
+ return None
+
+ def deleteRole(self, roleId: str) -> bool:
+ """Delete a role (sysadmin only, not if in use)."""
+ if not self.checkRbacPermission(TrusteeRole, "delete"):
+ logger.warning(f"User {self.userId} lacks permission to delete role")
+ return False
+
+ # Check if role is in use
+ accessRecords = self.db.getRecordset(TrusteeAccess, {"roleId": roleId})
+ if accessRecords:
+ logger.error(f"Cannot delete role {roleId}: still in use")
+ return False
+
+ return self.db.deleteRecord(TrusteeRole, roleId)
+
+ # ===== Access CRUD =====
+
+ def createAccess(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ """Create a new access record."""
+ if not self.checkRbacPermission(TrusteeAccess, "create"):
+ logger.warning(f"User {self.userId} lacks permission to create access")
+ return None
+
+ data["mandateId"] = self.mandateId
+
+ import uuid
+ accessId = data.get("id") or str(uuid.uuid4())
+ data["id"] = accessId
+
+ success = self.db.saveRecord(TrusteeAccess, accessId, data)
+ if success:
+ return self.db.getRecord(TrusteeAccess, accessId)
+ return None
+
+ def getAccess(self, accessId: str) -> Optional[Dict[str, Any]]:
+ """Get a single access record by ID."""
+ return self.db.getRecord(TrusteeAccess, accessId)
+
+ def getAllAccess(self, params: Optional[PaginationParams] = None) -> PaginatedResult:
+ """Get all access records with RBAC filtering."""
+ records = getRecordsetWithRBAC(
+ connector=self.db,
+ modelClass=TrusteeAccess,
+ currentUser=self.currentUser,
+ recordFilter=None,
+ orderBy="id"
+ )
+
+ totalItems = len(records)
+ if params:
+ pageSize = params.pageSize or 20
+ page = params.page or 1
+ startIdx = (page - 1) * pageSize
+ endIdx = startIdx + pageSize
+ items = records[startIdx:endIdx]
+ totalPages = math.ceil(totalItems / pageSize) if pageSize > 0 else 1
+ else:
+ items = records
+ totalPages = 1
+ page = 1
+ pageSize = totalItems
+
+ return PaginatedResult(
+ items=items,
+ totalItems=totalItems,
+ totalPages=totalPages
+ )
+
+ def getAccessByOrganisation(self, organisationId: str) -> List[Dict[str, Any]]:
+ """Get all access records for a specific organisation."""
+ return getRecordsetWithRBAC(
+ connector=self.db,
+ modelClass=TrusteeAccess,
+ currentUser=self.currentUser,
+ recordFilter={"organisationId": organisationId},
+ orderBy="id"
+ )
+
+ def getAccessByUser(self, userId: str) -> List[Dict[str, Any]]:
+ """Get all access records for a specific user."""
+ return getRecordsetWithRBAC(
+ connector=self.db,
+ modelClass=TrusteeAccess,
+ currentUser=self.currentUser,
+ recordFilter={"userId": userId},
+ orderBy="id"
+ )
+
+ def updateAccess(self, accessId: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ """Update an access record."""
+ if not self.checkRbacPermission(TrusteeAccess, "update"):
+ logger.warning(f"User {self.userId} lacks permission to update access")
+ return None
+
+ data["id"] = accessId
+ success = self.db.saveRecord(TrusteeAccess, accessId, data)
+ if success:
+ return self.db.getRecord(TrusteeAccess, accessId)
+ return None
+
+ def deleteAccess(self, accessId: str) -> bool:
+ """Delete an access record."""
+ if not self.checkRbacPermission(TrusteeAccess, "delete"):
+ logger.warning(f"User {self.userId} lacks permission to delete access")
+ return False
+
+ return self.db.deleteRecord(TrusteeAccess, accessId)
+
+ # ===== Contract CRUD =====
+
+ def createContract(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ """Create a new contract."""
+ if not self.checkRbacPermission(TrusteeContract, "create"):
+ logger.warning(f"User {self.userId} lacks permission to create contract")
+ return None
+
+ data["mandateId"] = self.mandateId
+
+ import uuid
+ contractId = data.get("id") or str(uuid.uuid4())
+ data["id"] = contractId
+
+ success = self.db.saveRecord(TrusteeContract, contractId, data)
+ if success:
+ return self.db.getRecord(TrusteeContract, contractId)
+ return None
+
+ def getContract(self, contractId: str) -> Optional[Dict[str, Any]]:
+ """Get a single contract by ID."""
+ return self.db.getRecord(TrusteeContract, contractId)
+
+ def getAllContracts(self, params: Optional[PaginationParams] = None) -> PaginatedResult:
+ """Get all contracts with RBAC filtering."""
+ records = getRecordsetWithRBAC(
+ connector=self.db,
+ modelClass=TrusteeContract,
+ currentUser=self.currentUser,
+ recordFilter=None,
+ orderBy="id"
+ )
+
+ totalItems = len(records)
+ if params:
+ pageSize = params.pageSize or 20
+ page = params.page or 1
+ startIdx = (page - 1) * pageSize
+ endIdx = startIdx + pageSize
+ items = records[startIdx:endIdx]
+ totalPages = math.ceil(totalItems / pageSize) if pageSize > 0 else 1
+ else:
+ items = records
+ totalPages = 1
+ page = 1
+ pageSize = totalItems
+
+ return PaginatedResult(
+ items=items,
+ totalItems=totalItems,
+ totalPages=totalPages
+ )
+
+ def getContractsByOrganisation(self, organisationId: str) -> List[Dict[str, Any]]:
+ """Get all contracts for a specific organisation."""
+ return getRecordsetWithRBAC(
+ connector=self.db,
+ modelClass=TrusteeContract,
+ currentUser=self.currentUser,
+ recordFilter={"organisationId": organisationId},
+ orderBy="label"
+ )
+
+ def updateContract(self, contractId: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ """Update a contract (organisationId is immutable)."""
+ if not self.checkRbacPermission(TrusteeContract, "update"):
+ logger.warning(f"User {self.userId} lacks permission to update contract")
+ return None
+
+ # Check if organisationId is being changed
+ existing = self.db.getRecord(TrusteeContract, contractId)
+ if existing and "organisationId" in data:
+ if data["organisationId"] != existing.get("organisationId"):
+ logger.error("Contract organisationId cannot be changed after creation")
+ return None
+
+ data["id"] = contractId
+ success = self.db.saveRecord(TrusteeContract, contractId, data)
+ if success:
+ return self.db.getRecord(TrusteeContract, contractId)
+ return None
+
+ def deleteContract(self, contractId: str) -> bool:
+ """Delete a contract."""
+ if not self.checkRbacPermission(TrusteeContract, "delete"):
+ logger.warning(f"User {self.userId} lacks permission to delete contract")
+ return False
+
+ return self.db.deleteRecord(TrusteeContract, contractId)
+
+ # ===== Document CRUD =====
+
+ def createDocument(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ """Create a new document."""
+ if not self.checkRbacPermission(TrusteeDocument, "create"):
+ logger.warning(f"User {self.userId} lacks permission to create document")
+ return None
+
+ data["mandateId"] = self.mandateId
+
+ import uuid
+ documentId = data.get("id") or str(uuid.uuid4())
+ data["id"] = documentId
+
+ success = self.db.saveRecord(TrusteeDocument, documentId, data)
+ if success:
+ return self.db.getRecord(TrusteeDocument, documentId)
+ return None
+
+ def getDocument(self, documentId: str) -> Optional[Dict[str, Any]]:
+ """Get a single document by ID (metadata only)."""
+ record = self.db.getRecord(TrusteeDocument, documentId)
+ if record:
+ # Remove binary data from response
+ record.pop("documentData", None)
+ return record
+
+ def getDocumentData(self, documentId: str) -> Optional[bytes]:
+ """Get document binary data."""
+ record = self.db.getRecord(TrusteeDocument, documentId)
+ if record:
+ return record.get("documentData")
+ return None
+
+ def getAllDocuments(self, params: Optional[PaginationParams] = None) -> PaginatedResult:
+ """Get all documents with RBAC filtering (metadata only)."""
+ records = getRecordsetWithRBAC(
+ connector=self.db,
+ modelClass=TrusteeDocument,
+ currentUser=self.currentUser,
+ recordFilter=None,
+ orderBy="documentName"
+ )
+
+ # Remove binary data from responses
+ for record in records:
+ record.pop("documentData", None)
+
+ totalItems = len(records)
+ if params:
+ pageSize = params.pageSize or 20
+ page = params.page or 1
+ startIdx = (page - 1) * pageSize
+ endIdx = startIdx + pageSize
+ items = records[startIdx:endIdx]
+ totalPages = math.ceil(totalItems / pageSize) if pageSize > 0 else 1
+ else:
+ items = records
+ totalPages = 1
+ page = 1
+ pageSize = totalItems
+
+ return PaginatedResult(
+ items=items,
+ totalItems=totalItems,
+ totalPages=totalPages
+ )
+
+ def getDocumentsByContract(self, contractId: str) -> List[Dict[str, Any]]:
+ """Get all documents for a specific contract."""
+ records = getRecordsetWithRBAC(
+ connector=self.db,
+ modelClass=TrusteeDocument,
+ currentUser=self.currentUser,
+ recordFilter={"contractId": contractId},
+ orderBy="documentName"
+ )
+ for record in records:
+ record.pop("documentData", None)
+ return records
+
+ def updateDocument(self, documentId: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ """Update a document."""
+ if not self.checkRbacPermission(TrusteeDocument, "update"):
+ logger.warning(f"User {self.userId} lacks permission to update document")
+ return None
+
+ data["id"] = documentId
+ success = self.db.saveRecord(TrusteeDocument, documentId, data)
+ if success:
+ result = self.db.getRecord(TrusteeDocument, documentId)
+ if result:
+ result.pop("documentData", None)
+ return result
+ return None
+
+ def deleteDocument(self, documentId: str) -> bool:
+ """Delete a document."""
+ if not self.checkRbacPermission(TrusteeDocument, "delete"):
+ logger.warning(f"User {self.userId} lacks permission to delete document")
+ return False
+
+ return self.db.deleteRecord(TrusteeDocument, documentId)
+
+ # ===== Position CRUD =====
+
+ def createPosition(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ """Create a new position."""
+ if not self.checkRbacPermission(TrusteePosition, "create"):
+ logger.warning(f"User {self.userId} lacks permission to create position")
+ return None
+
+ data["mandateId"] = self.mandateId
+
+ # Calculate VAT amount if not provided
+ if "vatAmount" not in data or data.get("vatAmount") == 0:
+ bookingAmount = data.get("bookingAmount", 0)
+ vatPercentage = data.get("vatPercentage", 0)
+ data["vatAmount"] = bookingAmount * vatPercentage / 100
+
+ import uuid
+ positionId = data.get("id") or str(uuid.uuid4())
+ data["id"] = positionId
+
+ success = self.db.saveRecord(TrusteePosition, positionId, data)
+ if success:
+ return self.db.getRecord(TrusteePosition, positionId)
+ return None
+
+ def getPosition(self, positionId: str) -> Optional[Dict[str, Any]]:
+ """Get a single position by ID."""
+ return self.db.getRecord(TrusteePosition, positionId)
+
+ def getAllPositions(self, params: Optional[PaginationParams] = None) -> PaginatedResult:
+ """Get all positions with RBAC filtering."""
+ records = getRecordsetWithRBAC(
+ connector=self.db,
+ modelClass=TrusteePosition,
+ currentUser=self.currentUser,
+ recordFilter=None,
+ orderBy="valuta"
+ )
+
+ totalItems = len(records)
+ if params:
+ pageSize = params.pageSize or 20
+ page = params.page or 1
+ startIdx = (page - 1) * pageSize
+ endIdx = startIdx + pageSize
+ items = records[startIdx:endIdx]
+ totalPages = math.ceil(totalItems / pageSize) if pageSize > 0 else 1
+ else:
+ items = records
+ totalPages = 1
+ page = 1
+ pageSize = totalItems
+
+ return PaginatedResult(
+ items=items,
+ totalItems=totalItems,
+ totalPages=totalPages
+ )
+
+ def getPositionsByContract(self, contractId: str) -> List[Dict[str, Any]]:
+ """Get all positions for a specific contract."""
+ return getRecordsetWithRBAC(
+ connector=self.db,
+ modelClass=TrusteePosition,
+ currentUser=self.currentUser,
+ recordFilter={"contractId": contractId},
+ orderBy="valuta"
+ )
+
+ def getPositionsByOrganisation(self, organisationId: str) -> List[Dict[str, Any]]:
+ """Get all positions for a specific organisation."""
+ return getRecordsetWithRBAC(
+ connector=self.db,
+ modelClass=TrusteePosition,
+ currentUser=self.currentUser,
+ recordFilter={"organisationId": organisationId},
+ orderBy="valuta"
+ )
+
+ def updatePosition(self, positionId: str, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ """Update a position."""
+ if not self.checkRbacPermission(TrusteePosition, "update"):
+ logger.warning(f"User {self.userId} lacks permission to update position")
+ return None
+
+ data["id"] = positionId
+ success = self.db.saveRecord(TrusteePosition, positionId, data)
+ if success:
+ return self.db.getRecord(TrusteePosition, positionId)
+ return None
+
+ def deletePosition(self, positionId: str) -> bool:
+ """Delete a position."""
+ if not self.checkRbacPermission(TrusteePosition, "delete"):
+ logger.warning(f"User {self.userId} lacks permission to delete position")
+ return False
+
+ return self.db.deleteRecord(TrusteePosition, positionId)
+
+ # ===== Position-Document Link CRUD =====
+
+ def createPositionDocument(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
+ """Create a new position-document link."""
+ if not self.checkRbacPermission(TrusteePositionDocument, "create"):
+ logger.warning(f"User {self.userId} lacks permission to create position-document link")
+ return None
+
+ data["mandateId"] = self.mandateId
+
+ import uuid
+ linkId = data.get("id") or str(uuid.uuid4())
+ data["id"] = linkId
+
+ success = self.db.saveRecord(TrusteePositionDocument, linkId, data)
+ if success:
+ return self.db.getRecord(TrusteePositionDocument, linkId)
+ return None
+
+ def getPositionDocument(self, linkId: str) -> Optional[Dict[str, Any]]:
+ """Get a single position-document link by ID."""
+ return self.db.getRecord(TrusteePositionDocument, linkId)
+
+ def getAllPositionDocuments(self, params: Optional[PaginationParams] = None) -> PaginatedResult:
+ """Get all position-document links with RBAC filtering."""
+ records = getRecordsetWithRBAC(
+ connector=self.db,
+ modelClass=TrusteePositionDocument,
+ currentUser=self.currentUser,
+ recordFilter=None,
+ orderBy="id"
+ )
+
+ totalItems = len(records)
+ if params:
+ pageSize = params.pageSize or 20
+ page = params.page or 1
+ startIdx = (page - 1) * pageSize
+ endIdx = startIdx + pageSize
+ items = records[startIdx:endIdx]
+ totalPages = math.ceil(totalItems / pageSize) if pageSize > 0 else 1
+ else:
+ items = records
+ totalPages = 1
+ page = 1
+ pageSize = totalItems
+
+ return PaginatedResult(
+ items=items,
+ totalItems=totalItems,
+ totalPages=totalPages
+ )
+
+ def getDocumentsForPosition(self, positionId: str) -> List[Dict[str, Any]]:
+ """Get all documents linked to a position."""
+ links = getRecordsetWithRBAC(
+ connector=self.db,
+ modelClass=TrusteePositionDocument,
+ currentUser=self.currentUser,
+ recordFilter={"positionId": positionId},
+ orderBy="id"
+ )
+ return links
+
+ def getPositionsForDocument(self, documentId: str) -> List[Dict[str, Any]]:
+ """Get all positions linked to a document."""
+ links = getRecordsetWithRBAC(
+ connector=self.db,
+ modelClass=TrusteePositionDocument,
+ currentUser=self.currentUser,
+ recordFilter={"documentId": documentId},
+ orderBy="id"
+ )
+ return links
+
+ def deletePositionDocument(self, linkId: str) -> bool:
+ """Delete a position-document link."""
+ if not self.checkRbacPermission(TrusteePositionDocument, "delete"):
+ logger.warning(f"User {self.userId} lacks permission to delete position-document link")
+ return False
+
+ return self.db.deleteRecord(TrusteePositionDocument, linkId)
+
+ # ===== Trustee-specific Access Check =====
+
+ def getUserAccessForOrganisation(self, userId: str, organisationId: str) -> List[Dict[str, Any]]:
+ """Get all access records for a user in a specific organisation."""
+ return self.db.getRecordset(
+ TrusteeAccess,
+ {"userId": userId, "organisationId": organisationId}
+ )
+
+ def checkUserTrusteePermission(
+ self,
+ userId: str,
+ organisationId: str,
+ requiredRole: str,
+ contractId: Optional[str] = None
+ ) -> bool:
+ """
+ Check if a user has a specific role for an organisation (and optionally contract).
+
+ Args:
+ userId: User ID to check
+ organisationId: Organisation ID
+ requiredRole: Required role (userreport, admin, operate)
+ contractId: Optional contract ID for contract-specific access
+
+ Returns:
+ True if user has the required role
+ """
+ accessRecords = self.getUserAccessForOrganisation(userId, organisationId)
+
+ for access in accessRecords:
+ if access.get("roleId") == requiredRole:
+ accessContractId = access.get("contractId")
+
+ # If access has no contractId, it grants access to all contracts
+ if accessContractId is None:
+ return True
+
+ # If checking for specific contract, match it
+ if contractId and accessContractId == contractId:
+ return True
+
+ # If no specific contract requested and access is contract-specific, deny
+ if contractId is None:
+ continue
+
+ return False
diff --git a/modules/routes/routeChatbot.py b/modules/routes/routeChatbot.py
index 4975bf45..86cbb940 100644
--- a/modules/routes/routeChatbot.py
+++ b/modules/routes/routeChatbot.py
@@ -401,24 +401,12 @@ async def get_chatbot_threads(
# Apply pagination (skip/limit)
startIdx = (paginationParams.page - 1) * paginationParams.pageSize
endIdx = startIdx + paginationParams.pageSize
- workflows_data = chatbot_workflows_data[startIdx:endIdx]
+ workflows = chatbot_workflows_data[startIdx:endIdx]
else:
- workflows_data = chatbot_workflows_data
+ workflows = chatbot_workflows_data
totalItems = len(chatbot_workflows_data)
totalPages = 1
- # Convert raw dictionaries to ChatWorkflow objects
- workflows = []
- for workflow_data in workflows_data:
- try:
- # Load the workflow properly
- workflow = interfaceDbChat.getWorkflow(workflow_data["id"])
- if workflow:
- workflows.append(workflow)
- except Exception as e:
- logger.warning(f"Error loading workflow {workflow_data.get('id')}: {e}")
- continue
-
# Create paginated response
from modules.datamodels.datamodelPagination import PaginationMetadata
metadata = PaginationMetadata(
diff --git a/modules/routes/routeDataAutomation.py b/modules/routes/routeDataAutomation.py
index e8418bb4..12ed265c 100644
--- a/modules/routes/routeDataAutomation.py
+++ b/modules/routes/routeDataAutomation.py
@@ -16,7 +16,7 @@ import json
from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface
from modules.auth import getCurrentUser, limiter
from modules.datamodels.datamodelChat import AutomationDefinition, ChatWorkflow
-from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata
+from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
from modules.shared.attributeUtils import getModelAttributeDefinitions
from modules.features.workflow import executeAutomation
from modules.features.workflow.subAutomationTemplates import getAutomationTemplates
@@ -59,7 +59,9 @@ async def get_automations(
if pagination:
try:
paginationDict = json.loads(pagination)
- paginationParams = PaginationParams(**paginationDict) if paginationDict else None
+ if paginationDict:
+ paginationDict = normalize_pagination_dict(paginationDict)
+ paginationParams = PaginationParams(**paginationDict)
except (json.JSONDecodeError, ValueError) as e:
raise HTTPException(
status_code=400,
diff --git a/modules/routes/routeDataMandates.py b/modules/routes/routeDataMandates.py
index 685cf8c3..8a0c310a 100644
--- a/modules/routes/routeDataMandates.py
+++ b/modules/routes/routeDataMandates.py
@@ -20,7 +20,7 @@ from modules.shared.attributeUtils import getModelAttributeDefinitions
# Import the model classes
from modules.datamodels.datamodelUam import Mandate, User
-from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata
+from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
# Configure logger
logger = logging.getLogger(__name__)
@@ -58,7 +58,9 @@ async def get_mandates(
if pagination:
try:
paginationDict = json.loads(pagination)
- paginationParams = PaginationParams(**paginationDict) if paginationDict else None
+ if paginationDict:
+ paginationDict = normalize_pagination_dict(paginationDict)
+ paginationParams = PaginationParams(**paginationDict)
except (json.JSONDecodeError, ValueError) as e:
raise HTTPException(
status_code=400,
@@ -128,17 +130,30 @@ async def get_mandate(
@limiter.limit("10/minute")
async def create_mandate(
request: Request,
- mandateData: Mandate = Body(...),
+ mandateData: dict = Body(..., description="Mandate data with at least 'name' field"),
currentUser: User = Depends(getCurrentUser)
) -> Mandate:
"""Create a new mandate"""
try:
+ logger.debug(f"Creating mandate with data: {mandateData}")
+
+ # Validate required fields
+ name = mandateData.get('name')
+ if not name or (isinstance(name, str) and name.strip() == ''):
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="Mandate name is required"
+ )
+
+ # Get optional fields with defaults
+ language = mandateData.get('language', 'en')
+
appInterface = interfaceDbAppObjects.getInterface(currentUser)
# Create mandate
newMandate = appInterface.createMandate(
- name=mandateData.name,
- language=mandateData.language
+ name=name,
+ language=language
)
if not newMandate:
@@ -162,11 +177,13 @@ async def create_mandate(
async def update_mandate(
request: Request,
mandateId: str = Path(..., description="ID of the mandate to update"),
- mandateData: Mandate = Body(...),
+ mandateData: dict = Body(..., description="Mandate update data"),
currentUser: User = Depends(getCurrentUser)
) -> Mandate:
"""Update an existing mandate"""
try:
+ logger.debug(f"Updating mandate {mandateId} with data: {mandateData}")
+
appInterface = interfaceDbAppObjects.getInterface(currentUser)
# Check if mandate exists
@@ -177,8 +194,8 @@ async def update_mandate(
detail=f"Mandate with ID {mandateId} not found"
)
- # Update mandate
- updatedMandate = appInterface.updateMandate(mandateId, mandateData.model_dump())
+ # Update mandate - mandateData is already a dict
+ updatedMandate = appInterface.updateMandate(mandateId, mandateData)
if not updatedMandate:
raise HTTPException(
diff --git a/modules/routes/routeDataTrustee.py b/modules/routes/routeDataTrustee.py
new file mode 100644
index 00000000..f00c3128
--- /dev/null
+++ b/modules/routes/routeDataTrustee.py
@@ -0,0 +1,846 @@
+# Copyright (c) 2025 Patrick Motsch
+# All rights reserved.
+"""
+Routes for Trustee feature data management.
+Implements CRUD operations for organisations, roles, access, contracts, documents, and positions.
+"""
+
+from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Query, Response
+from fastapi.responses import StreamingResponse
+from typing import List, Dict, Any, Optional
+from fastapi import status
+import logging
+import json
+import io
+
+from modules.auth import limiter, getCurrentUser
+from modules.interfaces.interfaceDbTrusteeObjects import getInterface
+from modules.datamodels.datamodelTrustee import (
+ TrusteeOrganisation,
+ TrusteeRole,
+ TrusteeAccess,
+ TrusteeContract,
+ TrusteeDocument,
+ TrusteePosition,
+ TrusteePositionDocument,
+)
+from modules.datamodels.datamodelUam import User
+from modules.datamodels.datamodelPagination import (
+ PaginationParams,
+ PaginatedResponse,
+ PaginationMetadata,
+ normalize_pagination_dict,
+)
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter(
+ prefix="/api/trustee",
+ tags=["Trustee"],
+ responses={404: {"description": "Not found"}}
+)
+
+
+# ===== Helper Functions =====
+
+def _parsePagination(pagination: Optional[str]) -> Optional[PaginationParams]:
+ """Parse pagination parameter from JSON string."""
+ if not pagination:
+ return None
+ try:
+ paginationDict = json.loads(pagination)
+ if paginationDict:
+ paginationDict = normalize_pagination_dict(paginationDict)
+ return PaginationParams(**paginationDict)
+ except (json.JSONDecodeError, ValueError) as e:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Invalid pagination parameter: {str(e)}"
+ )
+ return None
+
+
+# ===== Organisation Routes =====
+
+@router.get("/organisations", response_model=PaginatedResponse[TrusteeOrganisation])
+@limiter.limit("30/minute")
+async def getOrganisations(
+ request: Request,
+ pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"),
+ currentUser: User = Depends(getCurrentUser)
+) -> PaginatedResponse[TrusteeOrganisation]:
+ """Get all organisations with optional pagination."""
+ paginationParams = _parsePagination(pagination)
+ interface = getInterface(currentUser)
+ result = interface.getAllOrganisations(paginationParams)
+
+ if paginationParams:
+ return PaginatedResponse(
+ items=result.items,
+ pagination=PaginationMetadata(
+ currentPage=paginationParams.page or 1,
+ pageSize=paginationParams.pageSize or 20,
+ totalItems=result.totalItems,
+ totalPages=result.totalPages,
+ sort=paginationParams.sort if paginationParams else [],
+ filters=paginationParams.filters if paginationParams else None
+ )
+ )
+ return PaginatedResponse(items=result.items, pagination=None)
+
+
+@router.get("/organisations/{orgId}", response_model=TrusteeOrganisation)
+@limiter.limit("30/minute")
+async def getOrganisation(
+ request: Request,
+ orgId: str = Path(..., description="Organisation ID"),
+ currentUser: User = Depends(getCurrentUser)
+) -> TrusteeOrganisation:
+ """Get a single organisation by ID."""
+ interface = getInterface(currentUser)
+ org = interface.getOrganisation(orgId)
+ if not org:
+ raise HTTPException(status_code=404, detail=f"Organisation {orgId} not found")
+ return TrusteeOrganisation(**org)
+
+
+@router.post("/organisations", response_model=TrusteeOrganisation, status_code=201)
+@limiter.limit("10/minute")
+async def createOrganisation(
+ request: Request,
+ data: TrusteeOrganisation = Body(...),
+ currentUser: User = Depends(getCurrentUser)
+) -> TrusteeOrganisation:
+ """Create a new organisation."""
+ interface = getInterface(currentUser)
+ result = interface.createOrganisation(data.model_dump())
+ if not result:
+ raise HTTPException(status_code=400, detail="Failed to create organisation")
+ return TrusteeOrganisation(**result)
+
+
+@router.put("/organisations/{orgId}", response_model=TrusteeOrganisation)
+@limiter.limit("10/minute")
+async def updateOrganisation(
+ request: Request,
+ orgId: str = Path(..., description="Organisation ID"),
+ data: TrusteeOrganisation = Body(...),
+ currentUser: User = Depends(getCurrentUser)
+) -> TrusteeOrganisation:
+ """Update an organisation."""
+ interface = getInterface(currentUser)
+ existing = interface.getOrganisation(orgId)
+ if not existing:
+ raise HTTPException(status_code=404, detail=f"Organisation {orgId} not found")
+
+ result = interface.updateOrganisation(orgId, data.model_dump(exclude={"id"}))
+ if not result:
+ raise HTTPException(status_code=400, detail="Failed to update organisation")
+ return TrusteeOrganisation(**result)
+
+
+@router.delete("/organisations/{orgId}")
+@limiter.limit("10/minute")
+async def deleteOrganisation(
+ request: Request,
+ orgId: str = Path(..., description="Organisation ID"),
+ currentUser: User = Depends(getCurrentUser)
+) -> Dict[str, Any]:
+ """Delete an organisation."""
+ interface = getInterface(currentUser)
+ existing = interface.getOrganisation(orgId)
+ if not existing:
+ raise HTTPException(status_code=404, detail=f"Organisation {orgId} not found")
+
+ success = interface.deleteOrganisation(orgId)
+ if not success:
+ raise HTTPException(status_code=400, detail="Failed to delete organisation")
+ return {"message": f"Organisation {orgId} deleted"}
+
+
+# ===== Role Routes =====
+
+@router.get("/roles", response_model=PaginatedResponse[TrusteeRole])
+@limiter.limit("30/minute")
+async def getRoles(
+ request: Request,
+ pagination: Optional[str] = Query(None),
+ currentUser: User = Depends(getCurrentUser)
+) -> PaginatedResponse[TrusteeRole]:
+ """Get all roles with optional pagination."""
+ paginationParams = _parsePagination(pagination)
+ interface = getInterface(currentUser)
+ result = interface.getAllRoles(paginationParams)
+
+ if paginationParams:
+ return PaginatedResponse(
+ items=result.items,
+ pagination=PaginationMetadata(
+ currentPage=paginationParams.page or 1,
+ pageSize=paginationParams.pageSize or 20,
+ totalItems=result.totalItems,
+ totalPages=result.totalPages,
+ sort=paginationParams.sort if paginationParams else [],
+ filters=paginationParams.filters if paginationParams else None
+ )
+ )
+ return PaginatedResponse(items=result.items, pagination=None)
+
+
+@router.get("/roles/{roleId}", response_model=TrusteeRole)
+@limiter.limit("30/minute")
+async def getRole(
+ request: Request,
+ roleId: str = Path(..., description="Role ID"),
+ currentUser: User = Depends(getCurrentUser)
+) -> TrusteeRole:
+ """Get a single role by ID."""
+ interface = getInterface(currentUser)
+ role = interface.getRole(roleId)
+ if not role:
+ raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
+ return TrusteeRole(**role)
+
+
+@router.post("/roles", response_model=TrusteeRole, status_code=201)
+@limiter.limit("10/minute")
+async def createRole(
+ request: Request,
+ data: TrusteeRole = Body(...),
+ currentUser: User = Depends(getCurrentUser)
+) -> TrusteeRole:
+ """Create a new role (sysadmin only)."""
+ interface = getInterface(currentUser)
+ result = interface.createRole(data.model_dump())
+ if not result:
+ raise HTTPException(status_code=400, detail="Failed to create role")
+ return TrusteeRole(**result)
+
+
+@router.put("/roles/{roleId}", response_model=TrusteeRole)
+@limiter.limit("10/minute")
+async def updateRole(
+ request: Request,
+ roleId: str = Path(...),
+ data: TrusteeRole = Body(...),
+ currentUser: User = Depends(getCurrentUser)
+) -> TrusteeRole:
+ """Update a role (sysadmin only)."""
+ interface = getInterface(currentUser)
+ existing = interface.getRole(roleId)
+ if not existing:
+ raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
+
+ result = interface.updateRole(roleId, data.model_dump(exclude={"id"}))
+ if not result:
+ raise HTTPException(status_code=400, detail="Failed to update role")
+ return TrusteeRole(**result)
+
+
+@router.delete("/roles/{roleId}")
+@limiter.limit("10/minute")
+async def deleteRole(
+ request: Request,
+ roleId: str = Path(...),
+ currentUser: User = Depends(getCurrentUser)
+) -> Dict[str, Any]:
+ """Delete a role (sysadmin only, fails if in use)."""
+ interface = getInterface(currentUser)
+ existing = interface.getRole(roleId)
+ if not existing:
+ raise HTTPException(status_code=404, detail=f"Role {roleId} not found")
+
+ success = interface.deleteRole(roleId)
+ if not success:
+ raise HTTPException(status_code=400, detail="Failed to delete role (may be in use)")
+ return {"message": f"Role {roleId} deleted"}
+
+
+# ===== Access Routes =====
+
+@router.get("/access", response_model=PaginatedResponse[TrusteeAccess])
+@limiter.limit("30/minute")
+async def getAllAccess(
+ request: Request,
+ pagination: Optional[str] = Query(None),
+ currentUser: User = Depends(getCurrentUser)
+) -> PaginatedResponse[TrusteeAccess]:
+ """Get all access records with optional pagination."""
+ paginationParams = _parsePagination(pagination)
+ interface = getInterface(currentUser)
+ result = interface.getAllAccess(paginationParams)
+
+ if paginationParams:
+ return PaginatedResponse(
+ items=result.items,
+ pagination=PaginationMetadata(
+ currentPage=paginationParams.page or 1,
+ pageSize=paginationParams.pageSize or 20,
+ totalItems=result.totalItems,
+ totalPages=result.totalPages,
+ sort=paginationParams.sort if paginationParams else [],
+ filters=paginationParams.filters if paginationParams else None
+ )
+ )
+ return PaginatedResponse(items=result.items, pagination=None)
+
+
+@router.get("/access/{accessId}", response_model=TrusteeAccess)
+@limiter.limit("30/minute")
+async def getAccess(
+ request: Request,
+ accessId: str = Path(...),
+ currentUser: User = Depends(getCurrentUser)
+) -> TrusteeAccess:
+ """Get a single access record by ID."""
+ interface = getInterface(currentUser)
+ access = interface.getAccess(accessId)
+ if not access:
+ raise HTTPException(status_code=404, detail=f"Access {accessId} not found")
+ return TrusteeAccess(**access)
+
+
+@router.get("/access/organisation/{orgId}", response_model=List[TrusteeAccess])
+@limiter.limit("30/minute")
+async def getAccessByOrganisation(
+ request: Request,
+ orgId: str = Path(...),
+ currentUser: User = Depends(getCurrentUser)
+) -> List[TrusteeAccess]:
+ """Get all access records for an organisation."""
+ interface = getInterface(currentUser)
+ return [TrusteeAccess(**a) for a in interface.getAccessByOrganisation(orgId)]
+
+
+@router.get("/access/user/{userId}", response_model=List[TrusteeAccess])
+@limiter.limit("30/minute")
+async def getAccessByUser(
+ request: Request,
+ userId: str = Path(...),
+ currentUser: User = Depends(getCurrentUser)
+) -> List[TrusteeAccess]:
+ """Get all access records for a user."""
+ interface = getInterface(currentUser)
+ return [TrusteeAccess(**a) for a in interface.getAccessByUser(userId)]
+
+
+@router.post("/access", response_model=TrusteeAccess, status_code=201)
+@limiter.limit("10/minute")
+async def createAccess(
+ request: Request,
+ data: TrusteeAccess = Body(...),
+ currentUser: User = Depends(getCurrentUser)
+) -> TrusteeAccess:
+ """Create a new access record."""
+ interface = getInterface(currentUser)
+ result = interface.createAccess(data.model_dump())
+ if not result:
+ raise HTTPException(status_code=400, detail="Failed to create access")
+ return TrusteeAccess(**result)
+
+
+@router.put("/access/{accessId}", response_model=TrusteeAccess)
+@limiter.limit("10/minute")
+async def updateAccess(
+ request: Request,
+ accessId: str = Path(...),
+ data: TrusteeAccess = Body(...),
+ currentUser: User = Depends(getCurrentUser)
+) -> TrusteeAccess:
+ """Update an access record."""
+ interface = getInterface(currentUser)
+ existing = interface.getAccess(accessId)
+ if not existing:
+ raise HTTPException(status_code=404, detail=f"Access {accessId} not found")
+
+ result = interface.updateAccess(accessId, data.model_dump(exclude={"id"}))
+ if not result:
+ raise HTTPException(status_code=400, detail="Failed to update access")
+ return TrusteeAccess(**result)
+
+
+@router.delete("/access/{accessId}")
+@limiter.limit("10/minute")
+async def deleteAccess(
+ request: Request,
+ accessId: str = Path(...),
+ currentUser: User = Depends(getCurrentUser)
+) -> Dict[str, Any]:
+ """Delete an access record."""
+ interface = getInterface(currentUser)
+ existing = interface.getAccess(accessId)
+ if not existing:
+ raise HTTPException(status_code=404, detail=f"Access {accessId} not found")
+
+ success = interface.deleteAccess(accessId)
+ if not success:
+ raise HTTPException(status_code=400, detail="Failed to delete access")
+ return {"message": f"Access {accessId} deleted"}
+
+
+# ===== Contract Routes =====
+
+@router.get("/contracts", response_model=PaginatedResponse[TrusteeContract])
+@limiter.limit("30/minute")
+async def getContracts(
+ request: Request,
+ pagination: Optional[str] = Query(None),
+ currentUser: User = Depends(getCurrentUser)
+) -> PaginatedResponse[TrusteeContract]:
+ """Get all contracts with optional pagination."""
+ paginationParams = _parsePagination(pagination)
+ interface = getInterface(currentUser)
+ result = interface.getAllContracts(paginationParams)
+
+ if paginationParams:
+ return PaginatedResponse(
+ items=result.items,
+ pagination=PaginationMetadata(
+ currentPage=paginationParams.page or 1,
+ pageSize=paginationParams.pageSize or 20,
+ totalItems=result.totalItems,
+ totalPages=result.totalPages,
+ sort=paginationParams.sort if paginationParams else [],
+ filters=paginationParams.filters if paginationParams else None
+ )
+ )
+ return PaginatedResponse(items=result.items, pagination=None)
+
+
+@router.get("/contracts/{contractId}", response_model=TrusteeContract)
+@limiter.limit("30/minute")
+async def getContract(
+ request: Request,
+ contractId: str = Path(...),
+ currentUser: User = Depends(getCurrentUser)
+) -> TrusteeContract:
+ """Get a single contract by ID."""
+ interface = getInterface(currentUser)
+ contract = interface.getContract(contractId)
+ if not contract:
+ raise HTTPException(status_code=404, detail=f"Contract {contractId} not found")
+ return TrusteeContract(**contract)
+
+
+@router.get("/contracts/organisation/{orgId}", response_model=List[TrusteeContract])
+@limiter.limit("30/minute")
+async def getContractsByOrganisation(
+ request: Request,
+ orgId: str = Path(...),
+ currentUser: User = Depends(getCurrentUser)
+) -> List[TrusteeContract]:
+ """Get all contracts for an organisation."""
+ interface = getInterface(currentUser)
+ return [TrusteeContract(**c) for c in interface.getContractsByOrganisation(orgId)]
+
+
+@router.post("/contracts", response_model=TrusteeContract, status_code=201)
+@limiter.limit("10/minute")
+async def createContract(
+ request: Request,
+ data: TrusteeContract = Body(...),
+ currentUser: User = Depends(getCurrentUser)
+) -> TrusteeContract:
+ """Create a new contract."""
+ interface = getInterface(currentUser)
+ result = interface.createContract(data.model_dump())
+ if not result:
+ raise HTTPException(status_code=400, detail="Failed to create contract")
+ return TrusteeContract(**result)
+
+
+@router.put("/contracts/{contractId}", response_model=TrusteeContract)
+@limiter.limit("10/minute")
+async def updateContract(
+ request: Request,
+ contractId: str = Path(...),
+ data: TrusteeContract = Body(...),
+ currentUser: User = Depends(getCurrentUser)
+) -> TrusteeContract:
+ """Update a contract (organisationId is immutable)."""
+ interface = getInterface(currentUser)
+ existing = interface.getContract(contractId)
+ if not existing:
+ raise HTTPException(status_code=404, detail=f"Contract {contractId} not found")
+
+ result = interface.updateContract(contractId, data.model_dump(exclude={"id"}))
+ if not result:
+ raise HTTPException(status_code=400, detail="Failed to update contract (organisationId cannot be changed)")
+ return TrusteeContract(**result)
+
+
+@router.delete("/contracts/{contractId}")
+@limiter.limit("10/minute")
+async def deleteContract(
+ request: Request,
+ contractId: str = Path(...),
+ currentUser: User = Depends(getCurrentUser)
+) -> Dict[str, Any]:
+ """Delete a contract."""
+ interface = getInterface(currentUser)
+ existing = interface.getContract(contractId)
+ if not existing:
+ raise HTTPException(status_code=404, detail=f"Contract {contractId} not found")
+
+ success = interface.deleteContract(contractId)
+ if not success:
+ raise HTTPException(status_code=400, detail="Failed to delete contract")
+ return {"message": f"Contract {contractId} deleted"}
+
+
+# ===== Document Routes =====
+
+@router.get("/documents", response_model=PaginatedResponse[TrusteeDocument])
+@limiter.limit("30/minute")
+async def getDocuments(
+ request: Request,
+ pagination: Optional[str] = Query(None),
+ currentUser: User = Depends(getCurrentUser)
+) -> PaginatedResponse[TrusteeDocument]:
+ """Get all documents (metadata only) with optional pagination."""
+ paginationParams = _parsePagination(pagination)
+ interface = getInterface(currentUser)
+ result = interface.getAllDocuments(paginationParams)
+
+ if paginationParams:
+ return PaginatedResponse(
+ items=result.items,
+ pagination=PaginationMetadata(
+ currentPage=paginationParams.page or 1,
+ pageSize=paginationParams.pageSize or 20,
+ totalItems=result.totalItems,
+ totalPages=result.totalPages,
+ sort=paginationParams.sort if paginationParams else [],
+ filters=paginationParams.filters if paginationParams else None
+ )
+ )
+ return PaginatedResponse(items=result.items, pagination=None)
+
+
+@router.get("/documents/{documentId}", response_model=TrusteeDocument)
+@limiter.limit("30/minute")
+async def getDocument(
+ request: Request,
+ documentId: str = Path(...),
+ currentUser: User = Depends(getCurrentUser)
+) -> TrusteeDocument:
+ """Get document metadata by ID."""
+ interface = getInterface(currentUser)
+ doc = interface.getDocument(documentId)
+ if not doc:
+ raise HTTPException(status_code=404, detail=f"Document {documentId} not found")
+ return TrusteeDocument(**doc)
+
+
+@router.get("/documents/{documentId}/data")
+@limiter.limit("10/minute")
+async def getDocumentData(
+ request: Request,
+ documentId: str = Path(...),
+ currentUser: User = Depends(getCurrentUser)
+):
+ """Download document binary data."""
+ interface = getInterface(currentUser)
+ doc = interface.getDocument(documentId)
+ if not doc:
+ raise HTTPException(status_code=404, detail=f"Document {documentId} not found")
+
+ data = interface.getDocumentData(documentId)
+ if not data:
+ raise HTTPException(status_code=404, detail="Document data not found")
+
+ return StreamingResponse(
+ io.BytesIO(data),
+ media_type=doc.get("documentMimeType", "application/octet-stream"),
+ headers={"Content-Disposition": f"attachment; filename={doc.get('documentName', 'document')}"}
+ )
+
+
+@router.get("/documents/contract/{contractId}", response_model=List[TrusteeDocument])
+@limiter.limit("30/minute")
+async def getDocumentsByContract(
+ request: Request,
+ contractId: str = Path(...),
+ currentUser: User = Depends(getCurrentUser)
+) -> List[TrusteeDocument]:
+ """Get all documents for a contract."""
+ interface = getInterface(currentUser)
+ return [TrusteeDocument(**d) for d in interface.getDocumentsByContract(contractId)]
+
+
+@router.post("/documents", response_model=TrusteeDocument, status_code=201)
+@limiter.limit("10/minute")
+async def createDocument(
+ request: Request,
+ data: TrusteeDocument = Body(...),
+ currentUser: User = Depends(getCurrentUser)
+) -> TrusteeDocument:
+ """Create a new document."""
+ interface = getInterface(currentUser)
+ result = interface.createDocument(data.model_dump())
+ if not result:
+ raise HTTPException(status_code=400, detail="Failed to create document")
+ return TrusteeDocument(**result)
+
+
+@router.put("/documents/{documentId}", response_model=TrusteeDocument)
+@limiter.limit("10/minute")
+async def updateDocument(
+ request: Request,
+ documentId: str = Path(...),
+ data: TrusteeDocument = Body(...),
+ currentUser: User = Depends(getCurrentUser)
+) -> TrusteeDocument:
+ """Update document metadata."""
+ interface = getInterface(currentUser)
+ existing = interface.getDocument(documentId)
+ if not existing:
+ raise HTTPException(status_code=404, detail=f"Document {documentId} not found")
+
+ result = interface.updateDocument(documentId, data.model_dump(exclude={"id"}))
+ if not result:
+ raise HTTPException(status_code=400, detail="Failed to update document")
+ return TrusteeDocument(**result)
+
+
+@router.delete("/documents/{documentId}")
+@limiter.limit("10/minute")
+async def deleteDocument(
+ request: Request,
+ documentId: str = Path(...),
+ currentUser: User = Depends(getCurrentUser)
+) -> Dict[str, Any]:
+ """Delete a document."""
+ interface = getInterface(currentUser)
+ existing = interface.getDocument(documentId)
+ if not existing:
+ raise HTTPException(status_code=404, detail=f"Document {documentId} not found")
+
+ success = interface.deleteDocument(documentId)
+ if not success:
+ raise HTTPException(status_code=400, detail="Failed to delete document")
+ return {"message": f"Document {documentId} deleted"}
+
+
+# ===== Position Routes =====
+
+@router.get("/positions", response_model=PaginatedResponse[TrusteePosition])
+@limiter.limit("30/minute")
+async def getPositions(
+ request: Request,
+ pagination: Optional[str] = Query(None),
+ currentUser: User = Depends(getCurrentUser)
+) -> PaginatedResponse[TrusteePosition]:
+ """Get all positions with optional pagination."""
+ paginationParams = _parsePagination(pagination)
+ interface = getInterface(currentUser)
+ result = interface.getAllPositions(paginationParams)
+
+ if paginationParams:
+ return PaginatedResponse(
+ items=result.items,
+ pagination=PaginationMetadata(
+ currentPage=paginationParams.page or 1,
+ pageSize=paginationParams.pageSize or 20,
+ totalItems=result.totalItems,
+ totalPages=result.totalPages,
+ sort=paginationParams.sort if paginationParams else [],
+ filters=paginationParams.filters if paginationParams else None
+ )
+ )
+ return PaginatedResponse(items=result.items, pagination=None)
+
+
+@router.get("/positions/{positionId}", response_model=TrusteePosition)
+@limiter.limit("30/minute")
+async def getPosition(
+ request: Request,
+ positionId: str = Path(...),
+ currentUser: User = Depends(getCurrentUser)
+) -> TrusteePosition:
+ """Get a single position by ID."""
+ interface = getInterface(currentUser)
+ position = interface.getPosition(positionId)
+ if not position:
+ raise HTTPException(status_code=404, detail=f"Position {positionId} not found")
+ return TrusteePosition(**position)
+
+
+@router.get("/positions/contract/{contractId}", response_model=List[TrusteePosition])
+@limiter.limit("30/minute")
+async def getPositionsByContract(
+ request: Request,
+ contractId: str = Path(...),
+ currentUser: User = Depends(getCurrentUser)
+) -> List[TrusteePosition]:
+ """Get all positions for a contract."""
+ interface = getInterface(currentUser)
+ return [TrusteePosition(**p) for p in interface.getPositionsByContract(contractId)]
+
+
+@router.get("/positions/organisation/{orgId}", response_model=List[TrusteePosition])
+@limiter.limit("30/minute")
+async def getPositionsByOrganisation(
+ request: Request,
+ orgId: str = Path(...),
+ currentUser: User = Depends(getCurrentUser)
+) -> List[TrusteePosition]:
+ """Get all positions for an organisation."""
+ interface = getInterface(currentUser)
+ return [TrusteePosition(**p) for p in interface.getPositionsByOrganisation(orgId)]
+
+
+@router.post("/positions", response_model=TrusteePosition, status_code=201)
+@limiter.limit("10/minute")
+async def createPosition(
+ request: Request,
+ data: TrusteePosition = Body(...),
+ currentUser: User = Depends(getCurrentUser)
+) -> TrusteePosition:
+ """Create a new position."""
+ interface = getInterface(currentUser)
+ result = interface.createPosition(data.model_dump())
+ if not result:
+ raise HTTPException(status_code=400, detail="Failed to create position")
+ return TrusteePosition(**result)
+
+
+@router.put("/positions/{positionId}", response_model=TrusteePosition)
+@limiter.limit("10/minute")
+async def updatePosition(
+ request: Request,
+ positionId: str = Path(...),
+ data: TrusteePosition = Body(...),
+ currentUser: User = Depends(getCurrentUser)
+) -> TrusteePosition:
+ """Update a position."""
+ interface = getInterface(currentUser)
+ existing = interface.getPosition(positionId)
+ if not existing:
+ raise HTTPException(status_code=404, detail=f"Position {positionId} not found")
+
+ result = interface.updatePosition(positionId, data.model_dump(exclude={"id"}))
+ if not result:
+ raise HTTPException(status_code=400, detail="Failed to update position")
+ return TrusteePosition(**result)
+
+
+@router.delete("/positions/{positionId}")
+@limiter.limit("10/minute")
+async def deletePosition(
+ request: Request,
+ positionId: str = Path(...),
+ currentUser: User = Depends(getCurrentUser)
+) -> Dict[str, Any]:
+ """Delete a position."""
+ interface = getInterface(currentUser)
+ existing = interface.getPosition(positionId)
+ if not existing:
+ raise HTTPException(status_code=404, detail=f"Position {positionId} not found")
+
+ success = interface.deletePosition(positionId)
+ if not success:
+ raise HTTPException(status_code=400, detail="Failed to delete position")
+ return {"message": f"Position {positionId} deleted"}
+
+
+# ===== Position-Document Link Routes =====
+
+@router.get("/position-documents", response_model=PaginatedResponse[TrusteePositionDocument])
+@limiter.limit("30/minute")
+async def getPositionDocuments(
+ request: Request,
+ pagination: Optional[str] = Query(None),
+ currentUser: User = Depends(getCurrentUser)
+) -> PaginatedResponse[TrusteePositionDocument]:
+ """Get all position-document links with optional pagination."""
+ paginationParams = _parsePagination(pagination)
+ interface = getInterface(currentUser)
+ result = interface.getAllPositionDocuments(paginationParams)
+
+ if paginationParams:
+ return PaginatedResponse(
+ items=result.items,
+ pagination=PaginationMetadata(
+ currentPage=paginationParams.page or 1,
+ pageSize=paginationParams.pageSize or 20,
+ totalItems=result.totalItems,
+ totalPages=result.totalPages,
+ sort=paginationParams.sort if paginationParams else [],
+ filters=paginationParams.filters if paginationParams else None
+ )
+ )
+ return PaginatedResponse(items=result.items, pagination=None)
+
+
+@router.get("/position-documents/{linkId}", response_model=TrusteePositionDocument)
+@limiter.limit("30/minute")
+async def getPositionDocument(
+ request: Request,
+ linkId: str = Path(...),
+ currentUser: User = Depends(getCurrentUser)
+) -> TrusteePositionDocument:
+ """Get a single position-document link by ID."""
+ interface = getInterface(currentUser)
+ link = interface.getPositionDocument(linkId)
+ if not link:
+ raise HTTPException(status_code=404, detail=f"Link {linkId} not found")
+ return TrusteePositionDocument(**link)
+
+
+@router.get("/position-documents/position/{positionId}", response_model=List[TrusteePositionDocument])
+@limiter.limit("30/minute")
+async def getDocumentsForPosition(
+ request: Request,
+ positionId: str = Path(...),
+ currentUser: User = Depends(getCurrentUser)
+) -> List[TrusteePositionDocument]:
+ """Get all document links for a position."""
+ interface = getInterface(currentUser)
+ return [TrusteePositionDocument(**l) for l in interface.getDocumentsForPosition(positionId)]
+
+
+@router.get("/position-documents/document/{documentId}", response_model=List[TrusteePositionDocument])
+@limiter.limit("30/minute")
+async def getPositionsForDocument(
+ request: Request,
+ documentId: str = Path(...),
+ currentUser: User = Depends(getCurrentUser)
+) -> List[TrusteePositionDocument]:
+ """Get all position links for a document."""
+ interface = getInterface(currentUser)
+ return [TrusteePositionDocument(**l) for l in interface.getPositionsForDocument(documentId)]
+
+
+@router.post("/position-documents", response_model=TrusteePositionDocument, status_code=201)
+@limiter.limit("10/minute")
+async def createPositionDocument(
+ request: Request,
+ data: TrusteePositionDocument = Body(...),
+ currentUser: User = Depends(getCurrentUser)
+) -> TrusteePositionDocument:
+ """Create a new position-document link."""
+ interface = getInterface(currentUser)
+ result = interface.createPositionDocument(data.model_dump())
+ if not result:
+ raise HTTPException(status_code=400, detail="Failed to create link")
+ return TrusteePositionDocument(**result)
+
+
+@router.delete("/position-documents/{linkId}")
+@limiter.limit("10/minute")
+async def deletePositionDocument(
+ request: Request,
+ linkId: str = Path(...),
+ currentUser: User = Depends(getCurrentUser)
+) -> Dict[str, Any]:
+ """Delete a position-document link."""
+ interface = getInterface(currentUser)
+ existing = interface.getPositionDocument(linkId)
+ if not existing:
+ raise HTTPException(status_code=404, detail=f"Link {linkId} not found")
+
+ success = interface.deletePositionDocument(linkId)
+ if not success:
+ raise HTTPException(status_code=400, detail="Failed to delete link")
+ return {"message": f"Link {linkId} deleted"}
diff --git a/modules/routes/routeDataUsers.py b/modules/routes/routeDataUsers.py
index f9e275e3..525651c7 100644
--- a/modules/routes/routeDataUsers.py
+++ b/modules/routes/routeDataUsers.py
@@ -17,7 +17,7 @@ from modules.auth import getCurrentUser, limiter
# Import the attribute definition and helper functions
from modules.datamodels.datamodelUam import User
-from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata
+from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
# Configure logger
logger = logging.getLogger(__name__)
@@ -53,7 +53,9 @@ async def get_users(
if pagination:
try:
paginationDict = json.loads(pagination)
- paginationParams = PaginationParams(**paginationDict) if paginationDict else None
+ if paginationDict:
+ paginationDict = normalize_pagination_dict(paginationDict)
+ paginationParams = PaginationParams(**paginationDict)
except (json.JSONDecodeError, ValueError) as e:
raise HTTPException(
status_code=400,
@@ -338,6 +340,131 @@ async def change_password(
detail=f"Password change failed: {str(e)}"
)
+@router.post("/{userId}/send-password-link")
+@limiter.limit("10/minute")
+async def sendPasswordLink(
+ request: Request,
+ userId: str = Path(..., description="ID of the user to send password setup link"),
+ frontendUrl: str = Body(..., embed=True),
+ currentUser: User = Depends(getCurrentUser)
+) -> Dict[str, Any]:
+ """Send password setup/reset link to a user (admin function).
+
+ This allows admins to send a magic link to users to set or reset their password.
+ Used when creating users without password or to help users who forgot their password.
+
+ Args:
+ userId: ID of the user to send the link to
+ frontendUrl: The frontend URL to use in the magic link
+ """
+ try:
+ from modules.shared.configuration import APP_CONFIG
+ from modules.interfaces.interfaceDbAppObjects import getRootInterface
+
+ # Get user interface
+ appInterface = interfaceDbAppObjects.getInterface(currentUser)
+
+ # Get target user
+ targetUser = appInterface.getUser(userId)
+ if not targetUser:
+ raise HTTPException(
+ status_code=status.HTTP_404_NOT_FOUND,
+ detail="User not found"
+ )
+
+ # Check if user has an email
+ if not targetUser.email:
+ raise HTTPException(
+ status_code=status.HTTP_400_BAD_REQUEST,
+ detail="User has no email address configured"
+ )
+
+ # Use root interface for token operations
+ rootInterface = getRootInterface()
+
+ # Generate reset token
+ token, expires = rootInterface.generateResetTokenAndExpiry()
+
+ # Set reset token (don't clear password - user might have one already)
+ rootInterface.setResetToken(userId, token, expires, clearPassword=False)
+
+ # Send email with magic link
+ baseUrl = frontendUrl.rstrip("/")
+ magicLink = f"{baseUrl}/reset?token={token}"
+ expiryHours = int(APP_CONFIG.get("Auth_RESET_TOKEN_EXPIRY_HOURS", "24"))
+
+ try:
+ from modules.services import Services
+ services = Services(targetUser)
+
+ emailSubject = "PowerOn - Passwort setzen"
+ emailBody = f"""
+Hallo {targetUser.fullName or targetUser.username},
+
+Ein Administrator hat einen Link zum Setzen Ihres Passworts angefordert.
+
+Ihr Benutzername: {targetUser.username}
+
+Klicken Sie auf den folgenden Link, um Ihr Passwort zu setzen:
+{magicLink}
+
+Dieser Link ist {expiryHours} Stunden gültig.
+
+Falls Sie diese Anforderung nicht erwartet haben, kontaktieren Sie bitte Ihren Administrator.
+"""
+
+ emailSent = services.messaging.sendEmailDirect(
+ recipient=targetUser.email,
+ subject=emailSubject,
+ message=emailBody,
+ userId=str(targetUser.id)
+ )
+
+ if not emailSent:
+ logger.warning(f"Failed to send password setup email to {targetUser.email}")
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Failed to send email"
+ )
+
+ except HTTPException:
+ raise
+ except Exception as emailErr:
+ logger.error(f"Error sending password setup email: {str(emailErr)}")
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"Failed to send email: {str(emailErr)}"
+ )
+
+ # Log the action
+ try:
+ from modules.shared.auditLogger import audit_logger
+ audit_logger.logSecurityEvent(
+ userId=str(currentUser.id),
+ mandateId=str(currentUser.mandateId),
+ action="send_password_link",
+ details=f"Sent password setup link to user {userId} ({targetUser.email})"
+ )
+ except Exception:
+ pass
+
+ logger.info(f"Password setup link sent to {targetUser.email} for user {targetUser.username} by admin {currentUser.username}")
+
+ return {
+ "message": f"Password setup link sent to {targetUser.email}",
+ "userId": userId,
+ "email": targetUser.email
+ }
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"Error sending password link: {str(e)}")
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail=f"Failed to send password link: {str(e)}"
+ )
+
@router.delete("/{userId}", response_model=Dict[str, Any])
@limiter.limit("10/minute")
async def delete_user(
diff --git a/modules/routes/routeRbac.py b/modules/routes/routeRbac.py
index 8b5cf3e7..3c940b72 100644
--- a/modules/routes/routeRbac.py
+++ b/modules/routes/routeRbac.py
@@ -14,7 +14,7 @@ import math
from modules.auth import getCurrentUser, limiter
from modules.datamodels.datamodelUam import User, UserPermissions, AccessLevel
from modules.datamodels.datamodelRbac import AccessRuleContext, AccessRule, Role
-from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata
+from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
from modules.interfaces.interfaceDbAppObjects import getInterface
# Configure logger
@@ -28,7 +28,7 @@ router = APIRouter(
@router.get("/permissions", response_model=UserPermissions)
-@limiter.limit("60/minute")
+@limiter.limit("300/minute") # Raised from 60 - sidebar checks many pages individually
async def getPermissions(
request: Request,
context: str = Query(..., description="Context type: DATA, UI, or RESOURCE"),
@@ -90,7 +90,7 @@ async def getPermissions(
@router.get("/permissions/all", response_model=Dict[str, Any])
-@limiter.limit("30/minute")
+@limiter.limit("120/minute") # Raised from 30 - optimized endpoint for bulk permission fetch
async def getAllPermissions(
request: Request,
context: Optional[str] = Query(None, description="Context type: UI or RESOURCE (if not provided, returns both)"),
@@ -277,7 +277,9 @@ async def getAccessRules(
if pagination:
try:
paginationDict = json.loads(pagination)
- paginationParams = PaginationParams(**paginationDict) if paginationDict else None
+ if paginationDict:
+ paginationDict = normalize_pagination_dict(paginationDict)
+ paginationParams = PaginationParams(**paginationDict)
except (json.JSONDecodeError, ValueError) as e:
raise HTTPException(
status_code=400,
@@ -427,18 +429,26 @@ async def createAccessRule(
# Validate and parse access rule data
try:
+ logger.debug(f"Creating access rule with data: {accessRuleData}")
+
# Parse context if provided as string
if "context" in accessRuleData and isinstance(accessRuleData["context"], str):
accessRuleData["context"] = AccessRuleContext(accessRuleData["context"].upper())
# Parse AccessLevel fields if provided as strings
+ # Handle empty strings by converting to None
for field in ["read", "create", "update", "delete"]:
- if field in accessRuleData and isinstance(accessRuleData[field], str):
- accessRuleData[field] = AccessLevel(accessRuleData[field])
+ if field in accessRuleData:
+ value = accessRuleData[field]
+ if value == "" or value is None:
+ accessRuleData[field] = None
+ elif isinstance(value, str):
+ accessRuleData[field] = AccessLevel(value)
# Create AccessRule object
accessRule = AccessRule(**accessRuleData)
except ValueError as e:
+ logger.error(f"Invalid access rule data: {accessRuleData} - Error: {str(e)}")
raise HTTPException(
status_code=400,
detail=f"Invalid access rule data: {str(e)}"
@@ -526,9 +536,14 @@ async def updateAccessRule(
updateData["context"] = AccessRuleContext(updateData["context"].upper())
# Parse AccessLevel fields if provided as strings
+ # Handle empty strings by converting to None
for field in ["read", "create", "update", "delete"]:
- if field in updateData and isinstance(updateData[field], str):
- updateData[field] = AccessLevel(updateData[field])
+ if field in updateData:
+ value = updateData[field]
+ if value == "" or value is None:
+ updateData[field] = None
+ elif isinstance(value, str):
+ updateData[field] = AccessLevel(value)
# Ensure ID is set correctly
updateData["id"] = ruleId
@@ -536,6 +551,7 @@ async def updateAccessRule(
# Create AccessRule object
accessRule = AccessRule(**updateData)
except ValueError as e:
+ logger.error(f"Invalid access rule update data: {updateData} - Error: {str(e)}")
raise HTTPException(
status_code=400,
detail=f"Invalid access rule data: {str(e)}"
@@ -671,7 +687,9 @@ async def listRoles(
if pagination:
try:
paginationDict = json.loads(pagination)
- paginationParams = PaginationParams(**paginationDict) if paginationDict else None
+ if paginationDict:
+ paginationDict = normalize_pagination_dict(paginationDict)
+ paginationParams = PaginationParams(**paginationDict)
except (json.JSONDecodeError, ValueError) as e:
raise HTTPException(
status_code=400,
diff --git a/modules/routes/routeWorkflows.py b/modules/routes/routeWorkflows.py
index 3c97883e..6d2f78ee 100644
--- a/modules/routes/routeWorkflows.py
+++ b/modules/routes/routeWorkflows.py
@@ -84,30 +84,17 @@ async def get_workflows(
appInterface = getInterface(currentUser)
result = appInterface.getWorkflows(pagination=paginationParams)
- # Convert raw dictionaries to ChatWorkflow objects by loading each workflow properly
# If pagination was requested, result is PaginatedResult with items as dicts
# If no pagination, result is List[Dict]
if paginationParams:
- workflows_data = result.items
+ workflows = result.items
totalItems = result.totalItems
totalPages = result.totalPages
else:
- workflows_data = result
+ workflows = result
totalItems = len(result)
totalPages = 1
- workflows = []
- for workflow_data in workflows_data:
- try:
- # Load the workflow properly using the same method as individual workflow endpoint
- workflow = appInterface.getWorkflow(workflow_data["id"])
- if workflow:
- workflows.append(workflow)
- except Exception as e:
- logger.warning(f"Error loading workflow {workflow_data.get('id', 'unknown')}: {str(e)}")
- # Skip invalid workflows instead of failing the entire request
- continue
-
if paginationParams:
return PaginatedResponse(
items=workflows,