hotfixes for work with nyla

This commit is contained in:
ValueOn AG 2026-01-13 20:01:50 +01:00
parent be14c6c84d
commit 58b13ff7c6
16 changed files with 2848 additions and 60 deletions

3
app.py
View file

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

View file

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

View file

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

View file

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

View file

@ -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"},
},
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"}

View file

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

View file

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

View file

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