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