From df4c60fc990331d0291bd5b3ddaedcde7cac8bd3 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Sat, 24 Jan 2026 18:01:28 +0100 Subject: [PATCH] fixes --- app.py | 10 + modules/datamodels/datamodelUam.py | 35 ++- .../chatbot/interfaceFeatureChatbot.py | 7 +- .../realEstate/interfaceFeatureRealEstate.py | 7 +- .../trustee/datamodelFeatureTrustee.py | 127 ++++------- .../trustee/interfaceFeatureTrustee.py | 171 +++++++------- .../features/trustee/routeFeatureTrustee.py | 208 +++++++++++++----- modules/interfaces/interfaceDbApp.py | 62 ++++-- modules/interfaces/interfaceDbChat.py | 7 +- modules/interfaces/interfaceFeatures.py | 39 +++- modules/interfaces/interfaceRbac.py | 42 +++- modules/routes/routeAdminFeatures.py | 178 +++++++++++++-- modules/routes/routeAdminRbacExport.py | 8 +- modules/routes/routeAdminRbacRoles.py | 24 +- modules/routes/routeAdminRbacRules.py | 28 +-- modules/routes/routeDataConnections.py | 4 +- modules/routes/routeDataMandates.py | 8 +- modules/routes/routeDataUsers.py | 4 +- modules/routes/routeGdpr.py | 8 +- modules/routes/routeInvitations.py | 12 +- modules/routes/routeMessaging.py | 28 +-- modules/routes/routeSecurityLocal.py | 30 +-- modules/routes/routeVoiceGoogle.py | 2 +- tests/functional/test03_ai_operations.py | 7 +- tests/functional/test04_ai_behavior.py | 5 +- .../test05_workflow_with_documents.py | 5 +- .../test06_workflow_prompt_variations.py | 5 +- .../test09_document_generation_formats.py | 5 +- .../test10_document_generation_formats.py | 5 +- .../test11_code_generation_formats.py | 5 +- 30 files changed, 705 insertions(+), 381 deletions(-) diff --git a/app.py b/app.py index 57274338..a73cbcbc 100644 --- a/app.py +++ b/app.py @@ -335,6 +335,15 @@ async def lifespan(app: FastAPI): logger.info("Application has been shut down") +# Custom function to generate readable operation IDs for Swagger UI +# Uses snake_case function names directly instead of auto-generated IDs +def _generateOperationId(route) -> str: + """Generate operation ID from route function name (snake_case).""" + if hasattr(route, "endpoint") and hasattr(route.endpoint, "__name__"): + return route.endpoint.__name__ + return route.name if route.name else "unknown" + + # START APP app = FastAPI( title="PowerOn | Data Platform API", @@ -343,6 +352,7 @@ app = FastAPI( swagger_ui_init_oauth={ "usePkceWithAuthorizationCodeGrant": True, }, + generate_unique_id_function=_generateOperationId, ) # Configure OpenAPI security scheme for Swagger UI diff --git a/modules/datamodels/datamodelUam.py b/modules/datamodels/datamodelUam.py index 42659159..b7878532 100644 --- a/modules/datamodels/datamodelUam.py +++ b/modules/datamodels/datamodelUam.py @@ -166,15 +166,38 @@ class User(BaseModel): json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False} ) language: str = Field( - default="en", - description="Preferred language of the user", + default="de", + description="Preferred language of the user (ISO 639-1 code: de, en, fr, it)", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_options": [ - {"value": "de", "label": {"en": "Deutsch", "fr": "Allemand"}}, - {"value": "en", "label": {"en": "English", "fr": "Anglais"}}, - {"value": "fr", "label": {"en": "Français", "fr": "Français"}}, - {"value": "it", "label": {"en": "Italiano", "fr": "Italien"}}, + {"value": "de", "label": {"en": "Deutsch", "de": "Deutsch", "fr": "Allemand"}}, + {"value": "en", "label": {"en": "English", "de": "Englisch", "fr": "Anglais"}}, + {"value": "fr", "label": {"en": "Français", "de": "Französisch", "fr": "Français"}}, + {"value": "it", "label": {"en": "Italiano", "de": "Italienisch", "fr": "Italien"}}, ]} ) + + @field_validator('language', mode='before') + @classmethod + def _normalizeLanguage(cls, v): + """Normalize language to valid ISO 639-1 code.""" + if v is None: + return "de" + # Map common variations to standard codes + langMap = { + 'english': 'en', 'englisch': 'en', + 'german': 'de', 'deutsch': 'de', + 'french': 'fr', 'französisch': 'fr', 'francais': 'fr', + 'italian': 'it', 'italienisch': 'it', 'italiano': 'it', + } + normalized = str(v).lower().strip() + if normalized in langMap: + return langMap[normalized] + # If already a valid code, return as-is + if normalized in ['de', 'en', 'fr', 'it']: + return normalized + # Default fallback + return "de" + enabled: bool = Field( default=True, description="Indicates whether the user is enabled", diff --git a/modules/features/chatbot/interfaceFeatureChatbot.py b/modules/features/chatbot/interfaceFeatureChatbot.py index 685a1a4e..7db04c46 100644 --- a/modules/features/chatbot/interfaceFeatureChatbot.py +++ b/modules/features/chatbot/interfaceFeatureChatbot.py @@ -282,9 +282,10 @@ class ChatObjects: if not self.userId: raise ValueError("Invalid user context: id is required") - # mandateId can be None for sysadmins performing cross-mandate operations - if not self.mandateId and not getattr(currentUser, 'isSysAdmin', False): - raise ValueError("Invalid user context: mandateId is required for non-sysadmin users") + # Note: mandateId is ALWAYS optional here - it comes from Request-Context, not from User. + # Users are NOT assigned to mandates by design - they get mandate context from the request. + # sysAdmin users can additionally perform cross-mandate operations. + # Without mandateId, operations will be filtered to accessible mandates via RBAC. # Add language settings self.userLanguage = currentUser.language # Default user language diff --git a/modules/features/realEstate/interfaceFeatureRealEstate.py b/modules/features/realEstate/interfaceFeatureRealEstate.py index 545be2c0..5a8c6d70 100644 --- a/modules/features/realEstate/interfaceFeatureRealEstate.py +++ b/modules/features/realEstate/interfaceFeatureRealEstate.py @@ -126,9 +126,10 @@ class RealEstateObjects: if not self.userId: raise ValueError("Invalid user context: id is required") - # mandateId can be None for sysadmins performing cross-mandate operations - if not self.mandateId and not getattr(currentUser, 'isSysAdmin', False): - raise ValueError("Invalid user context: mandateId is required for non-sysadmin users") + # Note: mandateId is ALWAYS optional here - it comes from Request-Context, not from User. + # Users are NOT assigned to mandates by design - they get mandate context from the request. + # sysAdmin users can additionally perform cross-mandate operations. + # Without mandateId, operations will be filtered to accessible mandates via RBAC. # Initialize RBAC interface if not self.currentUser: diff --git a/modules/features/trustee/datamodelFeatureTrustee.py b/modules/features/trustee/datamodelFeatureTrustee.py index c64ec506..8b13dff1 100644 --- a/modules/features/trustee/datamodelFeatureTrustee.py +++ b/modules/features/trustee/datamodelFeatureTrustee.py @@ -279,7 +279,12 @@ registerModelLabels( class TrusteeDocument(BaseModel): - """Contains document references and receipts for bookings.""" + """Contains document references and receipts for bookings. + + Note: organisationId and contractId removed as per architecture decision: + - The feature instance IS the organisation + - Contracts are eliminated from the model + """ id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Unique document ID", @@ -289,25 +294,6 @@ class TrusteeDocument(BaseModel): "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": "/api/trustee/{instanceId}/organisations/options" - } - ) - contractId: str = Field( - description="Reference to TrusteeContract.id", - json_schema_extra={ - "frontend_type": "select", - "frontend_readonly": False, - "frontend_required": True, - "frontend_options": "/api/trustee/{instanceId}/contracts/options", - "frontend_depends_on": "organisationId" - } - ) documentData: Optional[bytes] = Field( default=None, description="The file content (binary)", @@ -332,30 +318,27 @@ class TrusteeDocument(BaseModel): "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"}}, - ] + "frontend_options": "/api/trustee/mime-types/options" } ) mandateId: Optional[str] = Field( default=None, - description="Mandate ID", + description="Mandate ID (auto-set from context)", json_schema_extra={ "frontend_type": "text", "frontend_readonly": True, - "frontend_required": False + "frontend_required": False, + "frontend_hidden": True } ) featureInstanceId: Optional[str] = Field( default=None, - description="Feature Instance ID for instance-level isolation", + description="Feature Instance ID for instance-level isolation (auto-set from context)", json_schema_extra={ "frontend_type": "text", "frontend_readonly": True, - "frontend_required": False + "frontend_required": False, + "frontend_hidden": True } ) # System attributes are automatically set by DatabaseConnector @@ -366,8 +349,6 @@ registerModelLabels( {"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"}, @@ -378,7 +359,12 @@ registerModelLabels( class TrusteePosition(BaseModel): - """Contains booking positions (expense entries).""" + """Contains booking positions (expense entries). + + Note: organisationId and contractId removed as per architecture decision: + - The feature instance IS the organisation + - Contracts are eliminated from the model + """ id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Unique position ID", @@ -388,25 +374,6 @@ class TrusteePosition(BaseModel): "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": "/api/trustee/{instanceId}/organisations/options" - } - ) - contractId: str = Field( - description="Reference to TrusteeContract.id", - json_schema_extra={ - "frontend_type": "select", - "frontend_readonly": False, - "frontend_required": True, - "frontend_options": "/api/trustee/{instanceId}/contracts/options", - "frontend_depends_on": "organisationId" - } - ) valuta: Optional[str] = Field( default=None, description="Value date (ISO format: YYYY-MM-DD)", @@ -520,20 +487,22 @@ class TrusteePosition(BaseModel): ) mandateId: Optional[str] = Field( default=None, - description="Mandate ID", + description="Mandate ID (auto-set from context)", json_schema_extra={ "frontend_type": "text", "frontend_readonly": True, - "frontend_required": False + "frontend_required": False, + "frontend_hidden": True } ) featureInstanceId: Optional[str] = Field( default=None, - description="Feature Instance ID for instance-level isolation", + description="Feature Instance ID for instance-level isolation (auto-set from context)", json_schema_extra={ "frontend_type": "text", "frontend_readonly": True, - "frontend_required": False + "frontend_required": False, + "frontend_hidden": True } ) # System attributes are automatically set by DatabaseConnector @@ -544,8 +513,6 @@ registerModelLabels( {"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"}, @@ -564,7 +531,12 @@ registerModelLabels( class TrusteePositionDocument(BaseModel): - """Cross-reference table linking positions to documents (many-to-many).""" + """Cross-reference table linking positions to documents (many-to-many). + + Note: organisationId and contractId removed as per architecture decision: + - The feature instance IS the organisation + - Contracts are eliminated from the model + """ id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Unique link ID", @@ -574,33 +546,13 @@ class TrusteePositionDocument(BaseModel): "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": "/api/trustee/{instanceId}/organisations/options" - } - ) - contractId: str = Field( - description="Reference to TrusteeContract.id", - json_schema_extra={ - "frontend_type": "select", - "frontend_readonly": False, - "frontend_required": True, - "frontend_options": "/api/trustee/{instanceId}/contracts/options", - "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": "/api/trustee/{instanceId}/documents/options", - "frontend_depends_on": "contractId" + "frontend_options": "/api/trustee/{instanceId}/documents/options" } ) positionId: str = Field( @@ -609,26 +561,27 @@ class TrusteePositionDocument(BaseModel): "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, - "frontend_options": "/api/trustee/{instanceId}/positions/options", - "frontend_depends_on": "contractId" + "frontend_options": "/api/trustee/{instanceId}/positions/options" } ) mandateId: Optional[str] = Field( default=None, - description="Mandate ID", + description="Mandate ID (auto-set from context)", json_schema_extra={ "frontend_type": "text", "frontend_readonly": True, - "frontend_required": False + "frontend_required": False, + "frontend_hidden": True } ) featureInstanceId: Optional[str] = Field( default=None, - description="Feature Instance ID for instance-level isolation", + description="Feature Instance ID for instance-level isolation (auto-set from context)", json_schema_extra={ "frontend_type": "text", "frontend_readonly": True, - "frontend_required": False + "frontend_required": False, + "frontend_hidden": True } ) # System attributes are automatically set by DatabaseConnector @@ -639,8 +592,6 @@ registerModelLabels( {"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/features/trustee/interfaceFeatureTrustee.py b/modules/features/trustee/interfaceFeatureTrustee.py index dbd8c8aa..7fc90bdb 100644 --- a/modules/features/trustee/interfaceFeatureTrustee.py +++ b/modules/features/trustee/interfaceFeatureTrustee.py @@ -110,9 +110,10 @@ class TrusteeObjects: if not self.userId: raise ValueError("Invalid user context: id is required") - # mandateId can be None for sysadmins performing cross-mandate operations - if not self.mandateId and not getattr(currentUser, 'isSysAdmin', False): - raise ValueError("Invalid user context: mandateId is required for non-sysadmin users") + # Note: mandateId is ALWAYS optional here - it comes from Request-Context, not from User. + # Users are NOT assigned to mandates by design - they get mandate context from the request. + # sysAdmin users can additionally perform cross-mandate operations. + # Without mandateId, operations will be filtered to accessible mandates via RBAC. self.userLanguage = currentUser.language @@ -734,18 +735,19 @@ class TrusteeObjects: # ===== Document CRUD ===== def createDocument(self, data: Dict[str, Any]) -> Optional[TrusteeDocument]: - """Create a new document.""" - organisationId = data.get("organisationId") - contractId = data.get("contractId") + """Create a new document. - # Check combined permission (system RBAC + feature-level) - if not self.checkCombinedPermission(TrusteeDocument, "create", organisationId, contractId): - logger.warning(f"User {self.userId} lacks permission to create document in org {organisationId}") + Note: organisationId and contractId removed - feature instance IS the organisation. + Permission is checked via system RBAC (feature-level access). + """ + # Check system RBAC permission + if not self.checkCombinedPermission(TrusteeDocument, "create"): + logger.warning(f"User {self.userId} lacks permission to create document") return None + # Auto-set context fields data["mandateId"] = self.mandateId - if "featureInstanceId" not in data: - data["featureInstanceId"] = self.featureInstanceId + data["featureInstanceId"] = self.featureInstanceId import uuid documentId = data.get("id") or str(uuid.uuid4()) @@ -790,20 +792,22 @@ class TrusteeObjects: # This applies userreport filtering (only own records) records = self.filterRecordsByTrusteeAccess(records, TrusteeDocument) - # Remove binary data from responses + # Convert dicts to Pydantic objects (remove binary data and internal fields) + pydanticItems = [] for record in records: - record.pop("documentData", None) + cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_") and k != "documentData"} + pydanticItems.append(TrusteeDocument(**cleanedRecord)) - totalItems = len(records) + totalItems = len(pydanticItems) if params: pageSize = params.pageSize or 20 page = params.page or 1 startIdx = (page - 1) * pageSize endIdx = startIdx + pageSize - items = records[startIdx:endIdx] + items = pydanticItems[startIdx:endIdx] totalPages = math.ceil(totalItems / pageSize) if pageSize > 0 else 1 else: - items = records + items = pydanticItems totalPages = 1 page = 1 pageSize = totalItems @@ -835,8 +839,11 @@ class TrusteeObjects: return result def updateDocument(self, documentId: str, data: Dict[str, Any]) -> Optional[TrusteeDocument]: - """Update a document.""" - # Get existing document to check organisation and creator + """Update a document. + + Note: organisationId and contractId removed - feature instance IS the organisation. + """ + # Get existing document to check creator existingRecords = self.db.getRecordset(TrusteeDocument, recordFilter={"id": documentId}) existing = existingRecords[0] if existingRecords else None @@ -844,14 +851,11 @@ class TrusteeObjects: logger.warning(f"Document {documentId} not found") return None - organisationId = existing.get("organisationId") - contractId = existing.get("contractId") createdBy = existing.get("_createdBy") - # Check combined permission (system RBAC + feature-level) - # For userreport, this checks if they created the record - if not self.checkCombinedPermission(TrusteeDocument, "update", organisationId, contractId, createdBy): - logger.warning(f"User {self.userId} lacks permission to update document in org {organisationId}") + # Check system RBAC permission (userreport can only edit their own records) + if not self.checkCombinedPermission(TrusteeDocument, "update", recordCreatedBy=createdBy): + logger.warning(f"User {self.userId} lacks permission to update document") return None data["id"] = documentId @@ -862,8 +866,11 @@ class TrusteeObjects: return TrusteeDocument(**cleanedRecord) def deleteDocument(self, documentId: str) -> bool: - """Delete a document.""" - # Get existing document to check organisation and creator + """Delete a document. + + Note: organisationId and contractId removed - feature instance IS the organisation. + """ + # Get existing document to check creator existingRecords = self.db.getRecordset(TrusteeDocument, recordFilter={"id": documentId}) existing = existingRecords[0] if existingRecords else None @@ -871,14 +878,11 @@ class TrusteeObjects: logger.warning(f"Document {documentId} not found") return False - organisationId = existing.get("organisationId") - contractId = existing.get("contractId") createdBy = existing.get("_createdBy") - # Check combined permission (system RBAC + feature-level) - # For userreport, this checks if they created the record - if not self.checkCombinedPermission(TrusteeDocument, "delete", organisationId, contractId, createdBy): - logger.warning(f"User {self.userId} lacks permission to delete document in org {organisationId}") + # Check system RBAC permission (userreport can only delete their own records) + if not self.checkCombinedPermission(TrusteeDocument, "delete", recordCreatedBy=createdBy): + logger.warning(f"User {self.userId} lacks permission to delete document") return False return self.db.recordDelete(TrusteeDocument, documentId) @@ -886,18 +890,19 @@ class TrusteeObjects: # ===== Position CRUD ===== def createPosition(self, data: Dict[str, Any]) -> Optional[TrusteePosition]: - """Create a new position.""" - organisationId = data.get("organisationId") - contractId = data.get("contractId") + """Create a new position. - # Check combined permission (system RBAC + feature-level) - if not self.checkCombinedPermission(TrusteePosition, "create", organisationId, contractId): - logger.warning(f"User {self.userId} lacks permission to create position in org {organisationId}") + Note: organisationId and contractId removed - feature instance IS the organisation. + Permission is checked via system RBAC (feature-level access). + """ + # Check system RBAC permission + if not self.checkCombinedPermission(TrusteePosition, "create"): + logger.warning(f"User {self.userId} lacks permission to create position") return None + # Auto-set context fields data["mandateId"] = self.mandateId - if "featureInstanceId" not in data: - data["featureInstanceId"] = self.featureInstanceId + data["featureInstanceId"] = self.featureInstanceId # Calculate VAT amount if not provided if "vatAmount" not in data or data.get("vatAmount") == 0: @@ -936,16 +941,22 @@ class TrusteeObjects: # This applies userreport filtering (only own records) records = self.filterRecordsByTrusteeAccess(records, TrusteePosition) - totalItems = len(records) + # Convert dicts to Pydantic objects (remove internal fields) + pydanticItems = [] + for record in records: + cleanedRecord = {k: v for k, v in record.items() if not k.startswith("_")} + pydanticItems.append(TrusteePosition(**cleanedRecord)) + + totalItems = len(pydanticItems) if params: pageSize = params.pageSize or 20 page = params.page or 1 startIdx = (page - 1) * pageSize endIdx = startIdx + pageSize - items = records[startIdx:endIdx] + items = pydanticItems[startIdx:endIdx] totalPages = math.ceil(totalItems / pageSize) if pageSize > 0 else 1 else: - items = records + items = pydanticItems totalPages = 1 page = 1 pageSize = totalItems @@ -987,8 +998,11 @@ class TrusteeObjects: return [TrusteePosition(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in filtered] def updatePosition(self, positionId: str, data: Dict[str, Any]) -> Optional[TrusteePosition]: - """Update a position.""" - # Get existing position to check organisation and creator + """Update a position. + + Note: organisationId and contractId removed - feature instance IS the organisation. + """ + # Get existing position to check creator existingRecords = self.db.getRecordset(TrusteePosition, recordFilter={"id": positionId}) existing = existingRecords[0] if existingRecords else None @@ -996,14 +1010,11 @@ class TrusteeObjects: logger.warning(f"Position {positionId} not found") return None - organisationId = existing.get("organisationId") - contractId = existing.get("contractId") createdBy = existing.get("_createdBy") - # Check combined permission (system RBAC + feature-level) - # For userreport, this checks if they created the record - if not self.checkCombinedPermission(TrusteePosition, "update", organisationId, contractId, createdBy): - logger.warning(f"User {self.userId} lacks permission to update position in org {organisationId}") + # Check system RBAC permission (userreport can only edit their own records) + if not self.checkCombinedPermission(TrusteePosition, "update", recordCreatedBy=createdBy): + logger.warning(f"User {self.userId} lacks permission to update position") return None data["id"] = positionId @@ -1013,8 +1024,11 @@ class TrusteeObjects: return TrusteePosition(**{k: v for k, v in updatedRecord.items() if not k.startswith("_")}) def deletePosition(self, positionId: str) -> bool: - """Delete a position.""" - # Get existing position to check organisation and creator + """Delete a position. + + Note: organisationId and contractId removed - feature instance IS the organisation. + """ + # Get existing position to check creator existingRecords = self.db.getRecordset(TrusteePosition, recordFilter={"id": positionId}) existing = existingRecords[0] if existingRecords else None @@ -1022,14 +1036,11 @@ class TrusteeObjects: logger.warning(f"Position {positionId} not found") return False - organisationId = existing.get("organisationId") - contractId = existing.get("contractId") createdBy = existing.get("_createdBy") - # Check combined permission (system RBAC + feature-level) - # For userreport, this checks if they created the record - if not self.checkCombinedPermission(TrusteePosition, "delete", organisationId, contractId, createdBy): - logger.warning(f"User {self.userId} lacks permission to delete position in org {organisationId}") + # Check system RBAC permission (userreport can only delete their own records) + if not self.checkCombinedPermission(TrusteePosition, "delete", recordCreatedBy=createdBy): + logger.warning(f"User {self.userId} lacks permission to delete position") return False return self.db.recordDelete(TrusteePosition, positionId) @@ -1037,18 +1048,19 @@ class TrusteeObjects: # ===== Position-Document Link CRUD ===== def createPositionDocument(self, data: Dict[str, Any]) -> Optional[TrusteePositionDocument]: - """Create a new position-document link.""" - organisationId = data.get("organisationId") - contractId = data.get("contractId") + """Create a new position-document link. - # Check combined permission (system RBAC + feature-level) - if not self.checkCombinedPermission(TrusteePositionDocument, "create", organisationId, contractId): - logger.warning(f"User {self.userId} lacks permission to create position-document link in org {organisationId}") + Note: organisationId and contractId removed - feature instance IS the organisation. + Permission is checked via system RBAC (feature-level access). + """ + # Check system RBAC permission + if not self.checkCombinedPermission(TrusteePositionDocument, "create"): + logger.warning(f"User {self.userId} lacks permission to create position-document link") return None + # Auto-set context fields data["mandateId"] = self.mandateId - if "featureInstanceId" not in data: - data["featureInstanceId"] = self.featureInstanceId + data["featureInstanceId"] = self.featureInstanceId import uuid linkId = data.get("id") or str(uuid.uuid4()) @@ -1132,8 +1144,11 @@ class TrusteeObjects: return [TrusteePositionDocument(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in filtered] def deletePositionDocument(self, linkId: str) -> bool: - """Delete a position-document link.""" - # Get existing link to check organisation and creator + """Delete a position-document link. + + Note: organisationId and contractId removed - feature instance IS the organisation. + """ + # Get existing link to check creator existingRecords = self.db.getRecordset(TrusteePositionDocument, recordFilter={"id": linkId}) existing = existingRecords[0] if existingRecords else None @@ -1141,14 +1156,11 @@ class TrusteeObjects: logger.warning(f"Position-document link {linkId} not found") return False - organisationId = existing.get("organisationId") - contractId = existing.get("contractId") createdBy = existing.get("_createdBy") - # Check combined permission (system RBAC + feature-level) - # For userreport, this checks if they created the record - if not self.checkCombinedPermission(TrusteePositionDocument, "delete", organisationId, contractId, createdBy): - logger.warning(f"User {self.userId} lacks permission to delete position-document link in org {organisationId}") + # Check system RBAC permission (userreport can only delete their own records) + if not self.checkCombinedPermission(TrusteePositionDocument, "delete", recordCreatedBy=createdBy): + logger.warning(f"User {self.userId} lacks permission to delete position-document link") return False return self.db.recordDelete(TrusteePositionDocument, linkId) @@ -1317,6 +1329,15 @@ class TrusteeObjects: if accessLevel == AccessLevel.ALL: return records + # NEW: Feature-instance based access (new system) + # If featureInstanceId is set, user has access via FeatureAccess system. + # Data is already filtered by featureInstanceId in getRecordsetWithRBAC. + # The old TrusteeAccess system (organisation-based) is not used for + # feature-instance scoped data. + if self.featureInstanceId: + return records # User already has access to this feature instance + + # LEGACY: TrusteeAccess based filtering (for backwards compatibility) # Get all user's access records userAccess = self.getAllUserAccess(self.userId) diff --git a/modules/features/trustee/routeFeatureTrustee.py b/modules/features/trustee/routeFeatureTrustee.py index 4e796a7e..f2b2ead2 100644 --- a/modules/features/trustee/routeFeatureTrustee.py +++ b/modules/features/trustee/routeFeatureTrustee.py @@ -9,13 +9,14 @@ URL Structure: /api/trustee/{instanceId}/{entity} - This ensures proper multi-tenant isolation at the URL level """ -from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Query, Response +from fastapi import APIRouter, HTTPException, Depends, Body, Path, Request, Query, Response, UploadFile, File, Form from fastapi.responses import StreamingResponse from typing import List, Dict, Any, Optional from fastapi import status import logging import json import io +import base64 from modules.auth import limiter, getRequestContext, RequestContext from .interfaceFeatureTrustee import getInterface @@ -133,7 +134,7 @@ _TRUSTEE_ENTITY_MODELS = { @router.get("/{instanceId}/attributes/{entityType}") @limiter.limit("30/minute") -async def getEntityAttributes( +async def get_entity_attributes( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), entityType: str = Path(..., description="Entity type (e.g., TrusteeDocument)"), @@ -179,9 +180,44 @@ async def getEntityAttributes( # OPTIONS ENDPOINTS (for dropdowns) # ============================================================================ +@router.get("/mime-types/options", response_model=List[Dict[str, Any]]) +@limiter.limit("60/minute") +async def get_mime_type_options( + request: Request, + context: RequestContext = Depends(getRequestContext) +) -> List[Dict[str, Any]]: + """Get supported MIME types from the document extraction service. + Returns: [{ value: "mime/type", label: "Description" }] + """ + from modules.services.serviceExtraction.subRegistry import ExtractorRegistry + + registry = ExtractorRegistry() + formats = registry.getSupportedFormats() + + # Collect all unique MIME types + allMimeTypes = set() + for mimeList in formats.get("mime_types", {}).values(): + allMimeTypes.update(mimeList) + + # Sort and create options with labels + result = [] + for mimeType in sorted(allMimeTypes): + # Create readable label from mime type + parts = mimeType.split("/") + if len(parts) == 2: + mainType, subType = parts + # Clean up subtype for label + label = subType.replace("vnd.", "").replace("x-", "").replace("-", " ").title() + result.append({"value": mimeType, "label": f"{label} ({mimeType})"}) + else: + result.append({"value": mimeType, "label": mimeType}) + + return result + + @router.get("/{instanceId}/organisations/options", response_model=List[Dict[str, Any]]) @limiter.limit("60/minute") -async def getOrganisationOptions( +async def get_organisation_options( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), context: RequestContext = Depends(getRequestContext) @@ -196,7 +232,7 @@ async def getOrganisationOptions( @router.get("/{instanceId}/roles/options", response_model=List[Dict[str, Any]]) @limiter.limit("60/minute") -async def getRoleOptions( +async def get_role_options( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), context: RequestContext = Depends(getRequestContext) @@ -211,7 +247,7 @@ async def getRoleOptions( @router.get("/{instanceId}/contracts/options", response_model=List[Dict[str, Any]]) @limiter.limit("60/minute") -async def getContractOptions( +async def get_contract_options( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), organisationId: Optional[str] = Query(None, description="Optional: Filter by organisation ID"), @@ -241,7 +277,7 @@ async def getContractOptions( @router.get("/{instanceId}/documents/options", response_model=List[Dict[str, Any]]) @limiter.limit("60/minute") -async def getDocumentOptions( +async def get_document_options( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), context: RequestContext = Depends(getRequestContext) @@ -256,7 +292,7 @@ async def getDocumentOptions( @router.get("/{instanceId}/positions/options", response_model=List[Dict[str, Any]]) @limiter.limit("60/minute") -async def getPositionOptions( +async def get_position_options( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), context: RequestContext = Depends(getRequestContext) @@ -266,8 +302,8 @@ async def getPositionOptions( interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) result = interface.getAllPositions(None) items = result.items if hasattr(result, 'items') else result - # Erstelle Label aus Datum, Firma und Beschreibung - def _makePositionLabel(p): + + def _makePositionLabel(p: TrusteePosition) -> str: parts = [] if p.valuta: parts.append(str(p.valuta)[:10]) # Datum ohne Zeit @@ -276,6 +312,7 @@ async def getPositionOptions( if p.desc: parts.append(p.desc[:30]) return " - ".join(parts) if parts else p.id + return [{"value": p.id, "label": _makePositionLabel(p)} for p in items] @@ -287,7 +324,7 @@ async def getPositionOptions( @router.get("/{instanceId}/organisations", response_model=PaginatedResponse[TrusteeOrganisation]) @limiter.limit("30/minute") -async def getOrganisations( +async def get_organisations( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams"), @@ -317,7 +354,7 @@ async def getOrganisations( @router.get("/{instanceId}/organisations/{orgId}", response_model=TrusteeOrganisation) @limiter.limit("30/minute") -async def getOrganisation( +async def get_organisation( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), orgId: str = Path(..., description="Organisation ID"), @@ -335,7 +372,7 @@ async def getOrganisation( @router.post("/{instanceId}/organisations", response_model=TrusteeOrganisation, status_code=201) @limiter.limit("10/minute") -async def createOrganisation( +async def create_organisation( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), data: TrusteeOrganisation = Body(...), @@ -353,7 +390,7 @@ async def createOrganisation( @router.put("/{instanceId}/organisations/{orgId}", response_model=TrusteeOrganisation) @limiter.limit("10/minute") -async def updateOrganisation( +async def update_organisation( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), orgId: str = Path(..., description="Organisation ID"), @@ -376,7 +413,7 @@ async def updateOrganisation( @router.delete("/{instanceId}/organisations/{orgId}") @limiter.limit("10/minute") -async def deleteOrganisation( +async def delete_organisation( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), orgId: str = Path(..., description="Organisation ID"), @@ -400,7 +437,7 @@ async def deleteOrganisation( @router.get("/{instanceId}/roles", response_model=PaginatedResponse[TrusteeRole]) @limiter.limit("30/minute") -async def getRoles( +async def get_roles( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), pagination: Optional[str] = Query(None), @@ -430,7 +467,7 @@ async def getRoles( @router.get("/{instanceId}/roles/{roleId}", response_model=TrusteeRole) @limiter.limit("30/minute") -async def getRole( +async def get_role( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), roleId: str = Path(..., description="Role ID"), @@ -448,7 +485,7 @@ async def getRole( @router.post("/{instanceId}/roles", response_model=TrusteeRole, status_code=201) @limiter.limit("10/minute") -async def createRole( +async def create_role( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), data: TrusteeRole = Body(...), @@ -466,7 +503,7 @@ async def createRole( @router.put("/{instanceId}/roles/{roleId}", response_model=TrusteeRole) @limiter.limit("10/minute") -async def updateRole( +async def update_role( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), roleId: str = Path(...), @@ -489,7 +526,7 @@ async def updateRole( @router.delete("/{instanceId}/roles/{roleId}") @limiter.limit("10/minute") -async def deleteRole( +async def delete_role( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), roleId: str = Path(...), @@ -513,7 +550,7 @@ async def deleteRole( @router.get("/{instanceId}/access", response_model=PaginatedResponse[TrusteeAccess]) @limiter.limit("30/minute") -async def getAllAccess( +async def get_all_access( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), pagination: Optional[str] = Query(None), @@ -543,7 +580,7 @@ async def getAllAccess( @router.get("/{instanceId}/access/{accessId}", response_model=TrusteeAccess) @limiter.limit("30/minute") -async def getAccess( +async def get_access( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), accessId: str = Path(...), @@ -561,7 +598,7 @@ async def getAccess( @router.get("/{instanceId}/access/organisation/{orgId}", response_model=List[TrusteeAccess]) @limiter.limit("30/minute") -async def getAccessByOrganisation( +async def get_access_by_organisation( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), orgId: str = Path(...), @@ -576,7 +613,7 @@ async def getAccessByOrganisation( @router.get("/{instanceId}/access/user/{userId}", response_model=List[TrusteeAccess]) @limiter.limit("30/minute") -async def getAccessByUser( +async def get_access_by_user( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), userId: str = Path(...), @@ -591,7 +628,7 @@ async def getAccessByUser( @router.post("/{instanceId}/access", response_model=TrusteeAccess, status_code=201) @limiter.limit("10/minute") -async def createAccess( +async def create_access( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), data: TrusteeAccess = Body(...), @@ -609,7 +646,7 @@ async def createAccess( @router.put("/{instanceId}/access/{accessId}", response_model=TrusteeAccess) @limiter.limit("10/minute") -async def updateAccess( +async def update_access( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), accessId: str = Path(...), @@ -632,7 +669,7 @@ async def updateAccess( @router.delete("/{instanceId}/access/{accessId}") @limiter.limit("10/minute") -async def deleteAccess( +async def delete_access( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), accessId: str = Path(...), @@ -656,7 +693,7 @@ async def deleteAccess( @router.get("/{instanceId}/contracts", response_model=PaginatedResponse[TrusteeContract]) @limiter.limit("30/minute") -async def getContracts( +async def get_contracts( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), pagination: Optional[str] = Query(None), @@ -686,7 +723,7 @@ async def getContracts( @router.get("/{instanceId}/contracts/{contractId}", response_model=TrusteeContract) @limiter.limit("30/minute") -async def getContract( +async def get_contract( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), contractId: str = Path(...), @@ -704,7 +741,7 @@ async def getContract( @router.get("/{instanceId}/contracts/organisation/{orgId}", response_model=List[TrusteeContract]) @limiter.limit("30/minute") -async def getContractsByOrganisation( +async def get_contracts_by_organisation( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), orgId: str = Path(...), @@ -719,7 +756,7 @@ async def getContractsByOrganisation( @router.post("/{instanceId}/contracts", response_model=TrusteeContract, status_code=201) @limiter.limit("10/minute") -async def createContract( +async def create_contract( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), data: TrusteeContract = Body(...), @@ -737,7 +774,7 @@ async def createContract( @router.put("/{instanceId}/contracts/{contractId}", response_model=TrusteeContract) @limiter.limit("10/minute") -async def updateContract( +async def update_contract( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), contractId: str = Path(...), @@ -760,7 +797,7 @@ async def updateContract( @router.delete("/{instanceId}/contracts/{contractId}") @limiter.limit("10/minute") -async def deleteContract( +async def delete_contract( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), contractId: str = Path(...), @@ -784,7 +821,7 @@ async def deleteContract( @router.get("/{instanceId}/documents", response_model=PaginatedResponse[TrusteeDocument]) @limiter.limit("30/minute") -async def getDocuments( +async def get_documents( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), pagination: Optional[str] = Query(None), @@ -814,7 +851,7 @@ async def getDocuments( @router.get("/{instanceId}/documents/{documentId}", response_model=TrusteeDocument) @limiter.limit("30/minute") -async def getDocument( +async def get_document( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), documentId: str = Path(...), @@ -832,7 +869,7 @@ async def getDocument( @router.get("/{instanceId}/documents/{documentId}/data") @limiter.limit("10/minute") -async def getDocumentData( +async def get_document_data( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), documentId: str = Path(...), @@ -859,7 +896,7 @@ async def getDocumentData( @router.get("/{instanceId}/documents/contract/{contractId}", response_model=List[TrusteeDocument]) @limiter.limit("30/minute") -async def getDocumentsByContract( +async def get_documents_by_contract( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), contractId: str = Path(...), @@ -874,17 +911,66 @@ async def getDocumentsByContract( @router.post("/{instanceId}/documents", response_model=TrusteeDocument, status_code=201) @limiter.limit("10/minute") -async def createDocument( +async def create_document( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), - data: TrusteeDocument = Body(...), context: RequestContext = Depends(getRequestContext) ) -> TrusteeDocument: - """Create a new document.""" + """Create a new document. Accepts JSON body with optional base64-encoded documentData.""" mandateId = await _validateInstanceAccess(instanceId, context) + # Parse JSON body + body = await request.json() + + # Handle documentData: convert base64 string to bytes if present + if "documentData" in body and body["documentData"]: + dataValue = body["documentData"] + if isinstance(dataValue, str): + # Base64-encoded data from frontend + try: + body["documentData"] = base64.b64decode(dataValue) + except Exception as e: + logger.warning(f"Failed to decode base64 documentData: {e}") + body["documentData"] = None + elif isinstance(dataValue, bytes): + # Already bytes + pass + else: + # Unknown format (e.g., File object serialized wrong) + body["documentData"] = None + interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) - result = interface.createDocument(data.model_dump()) + result = interface.createDocument(body) + if not result: + raise HTTPException(status_code=400, detail="Failed to create document") + return result + + +@router.post("/{instanceId}/documents/upload", response_model=TrusteeDocument, status_code=201) +@limiter.limit("10/minute") +async def upload_document( + request: Request, + instanceId: str = Path(..., description="Feature Instance ID"), + file: UploadFile = File(..., description="Document file"), + documentName: str = Form(..., description="Document name"), + documentMimeType: str = Form(default="application/octet-stream", description="MIME type"), + context: RequestContext = Depends(getRequestContext) +) -> TrusteeDocument: + """Upload a document with multipart/form-data.""" + mandateId = await _validateInstanceAccess(instanceId, context) + + # Read file content + fileContent = await file.read() + + # Build document data + docData = { + "documentName": documentName, + "documentMimeType": documentMimeType or file.content_type or "application/octet-stream", + "documentData": fileContent + } + + interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) + result = interface.createDocument(docData) if not result: raise HTTPException(status_code=400, detail="Failed to create document") return result @@ -892,7 +978,7 @@ async def createDocument( @router.put("/{instanceId}/documents/{documentId}", response_model=TrusteeDocument) @limiter.limit("10/minute") -async def updateDocument( +async def update_document( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), documentId: str = Path(...), @@ -915,7 +1001,7 @@ async def updateDocument( @router.delete("/{instanceId}/documents/{documentId}") @limiter.limit("10/minute") -async def deleteDocument( +async def delete_document( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), documentId: str = Path(...), @@ -939,7 +1025,7 @@ async def deleteDocument( @router.get("/{instanceId}/positions", response_model=PaginatedResponse[TrusteePosition]) @limiter.limit("30/minute") -async def getPositions( +async def get_positions( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), pagination: Optional[str] = Query(None), @@ -969,7 +1055,7 @@ async def getPositions( @router.get("/{instanceId}/positions/{positionId}", response_model=TrusteePosition) @limiter.limit("30/minute") -async def getPosition( +async def get_position( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), positionId: str = Path(...), @@ -987,7 +1073,7 @@ async def getPosition( @router.get("/{instanceId}/positions/contract/{contractId}", response_model=List[TrusteePosition]) @limiter.limit("30/minute") -async def getPositionsByContract( +async def get_positions_by_contract( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), contractId: str = Path(...), @@ -1002,7 +1088,7 @@ async def getPositionsByContract( @router.get("/{instanceId}/positions/organisation/{orgId}", response_model=List[TrusteePosition]) @limiter.limit("30/minute") -async def getPositionsByOrganisation( +async def get_positions_by_organisation( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), orgId: str = Path(...), @@ -1017,7 +1103,7 @@ async def getPositionsByOrganisation( @router.post("/{instanceId}/positions", response_model=TrusteePosition, status_code=201) @limiter.limit("10/minute") -async def createPosition( +async def create_position( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), data: TrusteePosition = Body(...), @@ -1035,7 +1121,7 @@ async def createPosition( @router.put("/{instanceId}/positions/{positionId}", response_model=TrusteePosition) @limiter.limit("10/minute") -async def updatePosition( +async def update_position( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), positionId: str = Path(...), @@ -1058,7 +1144,7 @@ async def updatePosition( @router.delete("/{instanceId}/positions/{positionId}") @limiter.limit("10/minute") -async def deletePosition( +async def delete_position( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), positionId: str = Path(...), @@ -1082,7 +1168,7 @@ async def deletePosition( @router.get("/{instanceId}/position-documents", response_model=PaginatedResponse[TrusteePositionDocument]) @limiter.limit("30/minute") -async def getPositionDocuments( +async def get_position_documents( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), pagination: Optional[str] = Query(None), @@ -1112,7 +1198,7 @@ async def getPositionDocuments( @router.get("/{instanceId}/position-documents/{linkId}", response_model=TrusteePositionDocument) @limiter.limit("30/minute") -async def getPositionDocument( +async def get_position_document( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), linkId: str = Path(...), @@ -1130,7 +1216,7 @@ async def getPositionDocument( @router.get("/{instanceId}/position-documents/position/{positionId}", response_model=List[TrusteePositionDocument]) @limiter.limit("30/minute") -async def getDocumentsForPosition( +async def get_documents_for_position( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), positionId: str = Path(...), @@ -1145,7 +1231,7 @@ async def getDocumentsForPosition( @router.get("/{instanceId}/position-documents/document/{documentId}", response_model=List[TrusteePositionDocument]) @limiter.limit("30/minute") -async def getPositionsForDocument( +async def get_positions_for_document( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), documentId: str = Path(...), @@ -1160,7 +1246,7 @@ async def getPositionsForDocument( @router.post("/{instanceId}/position-documents", response_model=TrusteePositionDocument, status_code=201) @limiter.limit("10/minute") -async def createPositionDocument( +async def create_position_document( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), data: TrusteePositionDocument = Body(...), @@ -1178,7 +1264,7 @@ async def createPositionDocument( @router.delete("/{instanceId}/position-documents/{linkId}") @limiter.limit("10/minute") -async def deletePositionDocument( +async def delete_position_document( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), linkId: str = Path(...), @@ -1240,7 +1326,7 @@ async def _validateInstanceAdmin(instanceId: str, context: RequestContext) -> st @router.get("/{instanceId}/instance-roles", response_model=PaginatedResponse) @limiter.limit("30/minute") -async def getInstanceRoles( +async def get_instance_roles( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), context: RequestContext = Depends(getRequestContext) @@ -1270,7 +1356,7 @@ async def getInstanceRoles( @router.get("/{instanceId}/instance-roles/{roleId}", response_model=Dict[str, Any]) @limiter.limit("30/minute") -async def getInstanceRole( +async def get_instance_role( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), roleId: str = Path(..., description="Role ID"), @@ -1296,7 +1382,7 @@ async def getInstanceRole( @router.get("/{instanceId}/instance-roles/{roleId}/rules", response_model=PaginatedResponse) @limiter.limit("30/minute") -async def getInstanceRoleRules( +async def get_instance_role_rules( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), roleId: str = Path(..., description="Role ID"), @@ -1329,7 +1415,7 @@ async def getInstanceRoleRules( @router.post("/{instanceId}/instance-roles/{roleId}/rules", response_model=Dict[str, Any], status_code=201) @limiter.limit("10/minute") -async def createInstanceRoleRule( +async def create_instance_role_rule( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), roleId: str = Path(..., description="Role ID"), @@ -1378,7 +1464,7 @@ async def createInstanceRoleRule( @router.put("/{instanceId}/instance-roles/{roleId}/rules/{ruleId}", response_model=Dict[str, Any]) @limiter.limit("10/minute") -async def updateInstanceRoleRule( +async def update_instance_role_rule( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), roleId: str = Path(..., description="Role ID"), @@ -1431,7 +1517,7 @@ async def updateInstanceRoleRule( @router.delete("/{instanceId}/instance-roles/{roleId}/rules/{ruleId}") @limiter.limit("10/minute") -async def deleteInstanceRoleRule( +async def delete_instance_role_rule( request: Request, instanceId: str = Path(..., description="Feature Instance ID"), roleId: str = Path(..., description="Role ID"), diff --git a/modules/interfaces/interfaceDbApp.py b/modules/interfaces/interfaceDbApp.py index 14a251c7..9c769e7c 100644 --- a/modules/interfaces/interfaceDbApp.py +++ b/modules/interfaces/interfaceDbApp.py @@ -107,11 +107,9 @@ class AppObjects: if not self.userId: raise ValueError("Invalid user context: id is required") - # mandateId is optional for isSysAdmin users doing system-level operations - isSysAdmin = getattr(currentUser, 'isSysAdmin', False) - if not self.mandateId and not isSysAdmin: - # Non-sysadmin users MUST have a mandateId for tenant-scoped operations - logger.warning(f"User {self.userId} has no mandateId context") + # Note: mandateId is ALWAYS optional here - it comes from Request-Context, not from User. + # Users are NOT assigned to mandates by design - they get mandate context from the request. + # sysAdmin users can additionally perform cross-mandate operations. # Add language settings self.userLanguage = currentUser.language # Default user language @@ -599,26 +597,51 @@ class AppObjects: logger.error(f"Error getting user by ID: {str(e)}") return None + def _getUserForAuthentication(self, username: str) -> Optional[Dict[str, Any]]: + """ + Get user record by username for authentication purposes. + + SECURITY NOTE: This method bypasses RBAC intentionally because: + 1. Users are NOT mandate-bound (Multi-Tenant Design) + 2. Authentication must work regardless of mandate context + 3. RBAC filtering for User table requires mandate context which doesn't exist at login time + + This method should ONLY be used for authentication flows. + For all other user queries, use getUserByUsername() which applies RBAC. + + Returns: + Full UserInDB record as dict, or None if not found + """ + try: + users = self.db.getRecordset(UserInDB, recordFilter={"username": username}) + if not users: + return None + return users[0] + except Exception as e: + logger.error(f"Error getting user for authentication: {str(e)}") + return None + def authenticateLocalUser(self, username: str, password: str) -> Optional[User]: - """Authenticates a user by username and password using local authentication.""" - # Clear the users table from cache and reload it + """ + Authenticates a user by username and password using local authentication. + + SECURITY NOTE: Uses _getUserForAuthentication() which bypasses RBAC. + This is intentional because users are mandate-independent. + """ + # Get full user record directly (bypasses RBAC - see _getUserForAuthentication docstring) + userRecord = self._getUserForAuthentication(username) - # Get user by username - user = self.getUserByUsername(username) - - if not user: + if not userRecord: raise ValueError("User not found") # Check if the user is enabled - if not user.enabled: + if not userRecord.get("enabled", True): raise ValueError("User is disabled") # Verify that the user has local authentication enabled - if user.authenticationAuthority != AuthAuthority.LOCAL: + authAuthority = userRecord.get("authenticationAuthority", AuthAuthority.LOCAL) + if authAuthority != AuthAuthority.LOCAL and authAuthority != AuthAuthority.LOCAL.value: raise ValueError("User does not have local authentication enabled") - - # Get the full user record with password hash for verification - userRecord = self.db.getRecordset(UserInDB, recordFilter={"id": user.id})[0] # Check if user has a reset token set (password reset required) if userRecord.get("resetToken"): @@ -630,7 +653,12 @@ class AppObjects: if not self._verifyPassword(password, userRecord["hashedPassword"]): raise ValueError("Invalid password") - return user + # Return clean User object (without password hash and internal fields) + cleanedUser = {k: v for k, v in userRecord.items() if not k.startswith("_") and k != "hashedPassword" and k != "resetToken" and k != "resetTokenExpires"} + # Ensure roleLabels is always a list + if cleanedUser.get("roleLabels") is None: + cleanedUser["roleLabels"] = [] + return User(**cleanedUser) def createUser( self, diff --git a/modules/interfaces/interfaceDbChat.py b/modules/interfaces/interfaceDbChat.py index de16d6af..25b3d3d6 100644 --- a/modules/interfaces/interfaceDbChat.py +++ b/modules/interfaces/interfaceDbChat.py @@ -282,9 +282,10 @@ class ChatObjects: if not self.userId: raise ValueError("Invalid user context: id is required") - # mandateId can be None for sysadmins performing cross-mandate operations - if not self.mandateId and not getattr(currentUser, 'isSysAdmin', False): - raise ValueError("Invalid user context: mandateId is required for non-sysadmin users") + # Note: mandateId is ALWAYS optional here - it comes from Request-Context, not from User. + # Users are NOT assigned to mandates by design - they get mandate context from the request. + # sysAdmin users can additionally perform cross-mandate operations. + # Without mandateId, operations will be filtered to accessible mandates via RBAC. # Add language settings self.userLanguage = currentUser.language # Default user language diff --git a/modules/interfaces/interfaceFeatures.py b/modules/interfaces/interfaceFeatures.py index 697658b3..3a82a8f3 100644 --- a/modules/interfaces/interfaceFeatures.py +++ b/modules/interfaces/interfaceFeatures.py @@ -156,6 +156,7 @@ class FeatureInterface: featureCode: str, mandateId: str, label: str, + enabled: bool = True, copyTemplateRoles: bool = True ) -> FeatureInstance: """ @@ -171,6 +172,7 @@ class FeatureInterface: featureCode: Feature code (e.g., "trustee") mandateId: Mandate ID label: Instance label (e.g., "Buchhaltung 2025") + enabled: Whether the instance is enabled copyTemplateRoles: Whether to copy template roles Returns: @@ -182,7 +184,7 @@ class FeatureInterface: featureCode=featureCode, mandateId=mandateId, label=label, - enabled=True + enabled=enabled ) createdInstance = self.db.recordCreate(FeatureInstance, instance.model_dump()) @@ -382,6 +384,41 @@ class FeatureInterface: logger.error(f"Error syncing roles from template: {e}") raise ValueError(f"Failed to sync roles: {e}") + def updateFeatureInstance(self, instanceId: str, updateData: Dict[str, Any]) -> Optional[FeatureInstance]: + """ + Update a feature instance. + + Only label and enabled fields can be updated. + featureCode and mandateId are immutable. + + Args: + instanceId: FeatureInstance ID + updateData: Dictionary with fields to update (label, enabled) + + Returns: + Updated FeatureInstance object or None if not found + """ + try: + instance = self.getFeatureInstance(instanceId) + if not instance: + return None + + # Only allow updating specific fields + allowedFields = {"label", "enabled"} + filteredData = {k: v for k, v in updateData.items() if k in allowedFields} + + if not filteredData: + return instance + + updated = self.db.recordModify(FeatureInstance, instanceId, filteredData) + if updated: + cleanedRecord = {k: v for k, v in updated.items() if not k.startswith("_")} + return FeatureInstance(**cleanedRecord) + return None + except Exception as e: + logger.error(f"Error updating feature instance {instanceId}: {e}") + raise ValueError(f"Failed to update feature instance: {e}") + def deleteFeatureInstance(self, instanceId: str) -> bool: """ Delete a feature instance. diff --git a/modules/interfaces/interfaceRbac.py b/modules/interfaces/interfaceRbac.py index a99bfd0b..8ceefa6a 100644 --- a/modules/interfaces/interfaceRbac.py +++ b/modules/interfaces/interfaceRbac.py @@ -239,23 +239,40 @@ def buildRbacWhereClause( logger.warning(f"User {currentUser.id} has no mandateId for GROUP access") return {"condition": "1 = 0", "values": []} - # For UserInDB, filter by mandateId directly + # For UserInDB: Filter via UserMandate junction table + # Multi-Tenant Design: Users do NOT have mandateId - they are linked via UserMandate if table == "UserInDB": - return { - "condition": '"mandateId" = %s', - "values": [effectiveMandateId] - } - # For UserConnection, need to join with UserInDB or filter by mandateId in user - elif table == "UserConnection": - # Get all user IDs in the same mandate using direct SQL query try: with connector.connection.cursor() as cursor: + # Get all user IDs that are members of the current mandate cursor.execute( - 'SELECT "id" FROM "UserInDB" WHERE "mandateId" = %s', + 'SELECT "userId" FROM "UserMandate" WHERE "mandateId" = %s AND "enabled" = true', (effectiveMandateId,) ) - users = cursor.fetchall() - userIds = [u["id"] for u in users] + userMandates = cursor.fetchall() + userIds = [um["userId"] for um in userMandates] + if not userIds: + return {"condition": "1 = 0", "values": []} + placeholders = ",".join(["%s"] * len(userIds)) + return { + "condition": f'"id" IN ({placeholders})', + "values": userIds + } + except Exception as e: + logger.error(f"Error building GROUP filter for UserInDB via UserMandate: {e}") + return {"condition": "1 = 0", "values": []} + + # For UserConnection: Filter via UserMandate junction table + elif table == "UserConnection": + try: + with connector.connection.cursor() as cursor: + # Get all user IDs that are members of the current mandate + cursor.execute( + 'SELECT "userId" FROM "UserMandate" WHERE "mandateId" = %s AND "enabled" = true', + (effectiveMandateId,) + ) + userMandates = cursor.fetchall() + userIds = [um["userId"] for um in userMandates] if not userIds: return {"condition": "1 = 0", "values": []} placeholders = ",".join(["%s"] * len(userIds)) @@ -266,7 +283,8 @@ def buildRbacWhereClause( except Exception as e: logger.error(f"Error building GROUP filter for UserConnection: {e}") return {"condition": "1 = 0", "values": []} - # For other tables, filter by mandateId + + # For other tables, filter by mandateId field else: return { "condition": '"mandateId" = %s', diff --git a/modules/routes/routeAdminFeatures.py b/modules/routes/routeAdminFeatures.py index 265a3a5c..2c8408e3 100644 --- a/modules/routes/routeAdminFeatures.py +++ b/modules/routes/routeAdminFeatures.py @@ -39,6 +39,7 @@ class FeatureInstanceCreate(BaseModel): """Request model for creating a feature instance""" featureCode: str = Field(..., description="Feature code (e.g., 'trustee', 'chatbot')") label: str = Field(..., description="Instance label (e.g., 'Buchhaltung 2025')") + enabled: bool = Field(True, description="Whether this feature instance is enabled") copyTemplateRoles: bool = Field(True, description="Whether to copy template roles on creation") @@ -64,7 +65,7 @@ class SyncRolesResult(BaseModel): @router.get("/", response_model=List[Dict[str, Any]]) @limiter.limit("60/minute") -async def listFeatures( +async def list_features( request: Request, context: RequestContext = Depends(getRequestContext) ) -> List[Dict[str, Any]]: @@ -101,7 +102,7 @@ class FeaturesMyResponse(BaseModel): @router.get("/my", response_model=FeaturesMyResponse) @limiter.limit("60/minute") -async def getMyFeatureInstances( +async def get_my_feature_instances( request: Request, context: RequestContext = Depends(getRequestContext) ) -> FeaturesMyResponse: @@ -239,11 +240,12 @@ def _getInstancePermissions(rootInterface, userId: str, instanceId: str) -> Dict permissions = { "tables": {}, "views": {}, - "fields": {} + "fields": {}, + "isAdmin": False # Flag if user has admin role } try: - from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext + from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext, Role from modules.datamodels.datamodelMembership import FeatureAccess, FeatureAccessRole # Get FeatureAccess for this user and instance @@ -272,6 +274,15 @@ def _getInstancePermissions(rootInterface, userId: str, instanceId: str) -> Dict logger.debug(f"_getInstancePermissions: No roles found for FeatureAccess {featureAccessId}") return permissions + # Check if user has admin role + for roleId in roleIds: + roles = rootInterface.db.getRecordset(Role, recordFilter={"id": roleId}) + if roles: + roleLabel = roles[0].get("roleLabel", "").lower() + if "admin" in roleLabel: + permissions["isAdmin"] = True + break + # Get permissions (AccessRules) for all roles for roleId in roleIds: accessRules = rootInterface.db.getRecordset( @@ -323,6 +334,10 @@ def _getInstancePermissions(rootInterface, userId: str, instanceId: str) -> Dict # item=None means all views - set a wildcard flag permissions["views"]["_all"] = True + # Derive view permissions from table permissions + # This allows UI navigation to be controlled by data access rights + _deriveViewPermissions(permissions) + return permissions except Exception as e: @@ -330,6 +345,51 @@ def _getInstancePermissions(rootInterface, userId: str, instanceId: str) -> Dict return permissions +def _deriveViewPermissions(permissions: Dict[str, Any]) -> None: + """ + Derive UI view permissions from table/data permissions. + + Mapping: + - trustee-dashboard: always visible (basic access) + - trustee-positions: visible if READ on TrusteePosition + - trustee-documents: visible if READ on TrusteeDocument + - trustee-position-documents: visible if READ on TrusteePositionDocument + - trustee-instance-roles: visible only for admin roles + + This function modifies permissions["views"] in place. + """ + tables = permissions.get("tables", {}) + views = permissions.get("views", {}) + isAdmin = permissions.get("isAdmin", False) + + # If user has _all views permission, skip derivation + if views.get("_all"): + return + + # Dashboard is always visible for users with any access + if "trustee-dashboard" not in views: + views["trustee-dashboard"] = True + + # Positions view: requires READ on TrusteePosition + if "trustee-positions" not in views: + positionPerms = tables.get("TrusteePosition", {}) + views["trustee-positions"] = positionPerms.get("read", "n") != "n" + + # Documents view: requires READ on TrusteeDocument + if "trustee-documents" not in views: + documentPerms = tables.get("TrusteeDocument", {}) + views["trustee-documents"] = documentPerms.get("read", "n") != "n" + + # Position-Documents view: requires READ on TrusteePositionDocument + if "trustee-position-documents" not in views: + linkPerms = tables.get("TrusteePositionDocument", {}) + views["trustee-position-documents"] = linkPerms.get("read", "n") != "n" + + # Instance-roles (admin) view: requires admin role + if "trustee-instance-roles" not in views: + views["trustee-instance-roles"] = isAdmin + + def _mergeAccessLevel(current: str, new: str) -> str: """Merge two access levels, returning the highest.""" levels = {"n": 0, "m": 1, "g": 2, "a": 3} @@ -343,7 +403,7 @@ def _mergeAccessLevel(current: str, new: str) -> str: @router.post("/", response_model=Dict[str, Any]) @limiter.limit("10/minute") -async def createFeature( +async def create_feature( request: Request, code: str = Query(..., description="Unique feature code"), label: Dict[str, str] = None, @@ -397,7 +457,7 @@ async def createFeature( @router.get("/instances", response_model=List[Dict[str, Any]]) @limiter.limit("60/minute") -async def listFeatureInstances( +async def list_feature_instances( request: Request, featureCode: Optional[str] = Query(None, description="Filter by feature code"), context: RequestContext = Depends(getRequestContext) @@ -439,7 +499,7 @@ async def listFeatureInstances( @router.get("/instances/{instanceId}", response_model=Dict[str, Any]) @limiter.limit("60/minute") -async def getFeatureInstance( +async def get_feature_instance( request: Request, instanceId: str, context: RequestContext = Depends(getRequestContext) @@ -483,7 +543,7 @@ async def getFeatureInstance( @router.post("/instances", response_model=Dict[str, Any]) @limiter.limit("10/minute") -async def createFeatureInstance( +async def create_feature_instance( request: Request, data: FeatureInstanceCreate, context: RequestContext = Depends(getRequestContext) @@ -525,6 +585,7 @@ async def createFeatureInstance( featureCode=data.featureCode, mandateId=str(context.mandateId), label=data.label, + enabled=data.enabled, copyTemplateRoles=data.copyTemplateRoles ) @@ -547,7 +608,7 @@ async def createFeatureInstance( @router.delete("/instances/{instanceId}", response_model=Dict[str, str]) @limiter.limit("10/minute") -async def deleteFeatureInstance( +async def delete_feature_instance( request: Request, instanceId: str, context: RequestContext = Depends(getRequestContext) @@ -603,9 +664,90 @@ async def deleteFeatureInstance( ) +class FeatureInstanceUpdate(BaseModel): + """Request model for updating a feature instance.""" + label: Optional[str] = Field(None, description="New label for the instance") + enabled: Optional[bool] = Field(None, description="Enable/disable the instance") + + +@router.put("/instances/{instanceId}", response_model=Dict[str, Any]) +@limiter.limit("30/minute") +async def updateFeatureInstance( + request: Request, + instanceId: str, + data: FeatureInstanceUpdate, + context: RequestContext = Depends(getRequestContext) +) -> Dict[str, Any]: + """ + Update a feature instance (label, enabled). + + Requires Mandate-Admin role. + + Args: + instanceId: FeatureInstance ID + data: Fields to update (label, enabled) + """ + try: + rootInterface = getRootInterface() + featureInterface = getFeatureInterface(rootInterface.db) + + # Verify instance exists + instance = featureInterface.getFeatureInstance(instanceId) + if not instance: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Feature instance '{instanceId}' not found" + ) + + # Verify mandate access + if context.mandateId and str(instance.mandateId) != str(context.mandateId): + if not context.isSysAdmin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Access denied to this feature instance" + ) + + # Check mandate admin permission + if not _hasMandateAdminRole(context) and not context.isSysAdmin: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="Mandate-Admin role required to update feature instances" + ) + + # Build update data (only non-None values) + updateData = {} + if data.label is not None: + updateData["label"] = data.label + if data.enabled is not None: + updateData["enabled"] = data.enabled + + if not updateData: + return instance.model_dump() + + updated = featureInterface.updateFeatureInstance(instanceId, updateData) + if not updated: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to update feature instance" + ) + + logger.info(f"User {context.user.id} updated feature instance {instanceId}: {updateData}") + + return updated.model_dump() + + except HTTPException: + raise + except Exception as e: + logger.error(f"Error updating feature instance {instanceId}: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to update feature instance: {str(e)}" + ) + + @router.post("/instances/{instanceId}/sync-roles", response_model=SyncRolesResult) @limiter.limit("10/minute") -async def syncInstanceRoles( +async def sync_instance_roles( request: Request, instanceId: str, addOnly: bool = Query(True, description="Only add missing roles, don't remove extras"), @@ -672,7 +814,7 @@ async def syncInstanceRoles( @router.get("/templates/roles", response_model=List[Dict[str, Any]]) @limiter.limit("60/minute") -async def listTemplateRoles( +async def list_template_roles( request: Request, featureCode: Optional[str] = Query(None, description="Filter by feature code"), sysAdmin: User = Depends(requireSysAdmin) @@ -702,7 +844,7 @@ async def listTemplateRoles( @router.post("/templates/roles", response_model=Dict[str, Any]) @limiter.limit("10/minute") -async def createTemplateRole( +async def create_template_role( request: Request, roleLabel: str = Query(..., description="Role label (e.g., 'admin', 'viewer')"), featureCode: str = Query(..., description="Feature code this role belongs to"), @@ -780,7 +922,7 @@ class FeatureInstanceUserResponse(BaseModel): @router.get("/instances/{instanceId}/users", response_model=List[FeatureInstanceUserResponse]) @limiter.limit("60/minute") -async def listFeatureInstanceUsers( +async def list_feature_instance_users( request: Request, instanceId: str, context: RequestContext = Depends(getRequestContext) @@ -872,7 +1014,7 @@ async def listFeatureInstanceUsers( @router.post("/instances/{instanceId}/users", response_model=Dict[str, Any]) @limiter.limit("30/minute") -async def addUserToFeatureInstance( +async def add_user_to_feature_instance( request: Request, instanceId: str, data: FeatureInstanceUserCreate, @@ -976,7 +1118,7 @@ async def addUserToFeatureInstance( @router.delete("/instances/{instanceId}/users/{userId}", response_model=Dict[str, str]) @limiter.limit("30/minute") -async def removeUserFromFeatureInstance( +async def remove_user_from_feature_instance( request: Request, instanceId: str, userId: str, @@ -1057,7 +1199,7 @@ async def removeUserFromFeatureInstance( @router.put("/instances/{instanceId}/users/{userId}/roles", response_model=Dict[str, Any]) @limiter.limit("30/minute") -async def updateFeatureInstanceUserRoles( +async def update_feature_instance_user_roles( request: Request, instanceId: str, userId: str, @@ -1154,7 +1296,7 @@ async def updateFeatureInstanceUserRoles( @router.get("/instances/{instanceId}/available-roles", response_model=List[Dict[str, Any]]) @limiter.limit("60/minute") -async def getFeatureInstanceAvailableRoles( +async def get_feature_instance_available_roles( request: Request, instanceId: str, context: RequestContext = Depends(getRequestContext) @@ -1222,7 +1364,7 @@ async def getFeatureInstanceAvailableRoles( @router.get("/{featureCode}", response_model=Dict[str, Any]) @limiter.limit("60/minute") -async def getFeature( +async def get_feature( request: Request, featureCode: str, context: RequestContext = Depends(getRequestContext) diff --git a/modules/routes/routeAdminRbacExport.py b/modules/routes/routeAdminRbacExport.py index 11932f18..c44c3b6b 100644 --- a/modules/routes/routeAdminRbacExport.py +++ b/modules/routes/routeAdminRbacExport.py @@ -72,7 +72,7 @@ class RbacImportResult(BaseModel): @router.get("/export/global", response_model=RbacExportData) @limiter.limit("10/minute") -async def exportGlobalRbac( +async def export_global_rbac( request: Request, sysAdmin: User = Depends(requireSysAdmin) ) -> RbacExportData: @@ -138,7 +138,7 @@ async def exportGlobalRbac( @router.post("/import/global", response_model=RbacImportResult) @limiter.limit("5/minute") -async def importGlobalRbac( +async def import_global_rbac( request: Request, file: UploadFile = File(..., description="JSON file with RBAC export data"), updateExisting: bool = False, @@ -285,7 +285,7 @@ async def importGlobalRbac( @router.get("/export/mandate", response_model=RbacExportData) @limiter.limit("10/minute") -async def exportMandateRbac( +async def export_mandate_rbac( request: Request, includeFeatureInstances: bool = True, context: RequestContext = Depends(getRequestContext) @@ -380,7 +380,7 @@ async def exportMandateRbac( @router.post("/import/mandate", response_model=RbacImportResult) @limiter.limit("5/minute") -async def importMandateRbac( +async def import_mandate_rbac( request: Request, file: UploadFile = File(..., description="JSON file with RBAC export data"), updateExisting: bool = False, diff --git a/modules/routes/routeAdminRbacRoles.py b/modules/routes/routeAdminRbacRoles.py index 70d0beaa..ad8a0de5 100644 --- a/modules/routes/routeAdminRbacRoles.py +++ b/modules/routes/routeAdminRbacRoles.py @@ -76,7 +76,7 @@ router = APIRouter( @router.get("/", response_model=List[Dict[str, Any]]) @limiter.limit("60/minute") -async def listRoles( +async def list_roles( request: Request, currentUser: User = Depends(requireSysAdmin) ) -> List[Dict[str, Any]]: @@ -121,7 +121,7 @@ async def listRoles( @router.get("/options", response_model=List[Dict[str, Any]]) @limiter.limit("60/minute") -async def getRoleOptions( +async def get_role_options( request: Request, currentUser: User = Depends(requireSysAdmin) ) -> List[Dict[str, Any]]: @@ -162,7 +162,7 @@ async def getRoleOptions( @router.post("/", response_model=Dict[str, Any]) @limiter.limit("30/minute") -async def createRole( +async def create_role( request: Request, role: Role = Body(...), currentUser: User = Depends(requireSysAdmin) @@ -206,7 +206,7 @@ async def createRole( @router.get("/{roleId}", response_model=Dict[str, Any]) @limiter.limit("60/minute") -async def getRole( +async def get_role( request: Request, roleId: str = Path(..., description="Role ID"), currentUser: User = Depends(requireSysAdmin) @@ -250,7 +250,7 @@ async def getRole( @router.put("/{roleId}", response_model=Dict[str, Any]) @limiter.limit("30/minute") -async def updateRole( +async def update_role( request: Request, roleId: str = Path(..., description="Role ID"), role: Role = Body(...), @@ -298,7 +298,7 @@ async def updateRole( @router.delete("/{roleId}", response_model=Dict[str, str]) @limiter.limit("30/minute") -async def deleteRole( +async def delete_role( request: Request, roleId: str = Path(..., description="Role ID"), currentUser: User = Depends(requireSysAdmin) @@ -342,7 +342,7 @@ async def deleteRole( @router.get("/users", response_model=List[Dict[str, Any]]) @limiter.limit("60/minute") -async def listUsersWithRoles( +async def list_users_with_roles( request: Request, roleLabel: Optional[str] = Query(None, description="Filter by role label"), mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"), @@ -412,7 +412,7 @@ async def listUsersWithRoles( @router.get("/users/{userId}", response_model=Dict[str, Any]) @limiter.limit("60/minute") -async def getUserRoles( +async def get_user_roles( request: Request, userId: str = Path(..., description="User ID"), currentUser: User = Depends(requireSysAdmin) @@ -462,7 +462,7 @@ async def getUserRoles( @router.put("/users/{userId}/roles", response_model=Dict[str, Any]) @limiter.limit("30/minute") -async def updateUserRoles( +async def update_user_roles( request: Request, userId: str = Path(..., description="User ID"), newRoleLabels: List[str] = Body(..., description="List of role labels to assign"), @@ -559,7 +559,7 @@ async def updateUserRoles( @router.post("/users/{userId}/roles/{roleLabel}", response_model=Dict[str, Any]) @limiter.limit("30/minute") -async def addUserRole( +async def add_user_role( request: Request, userId: str = Path(..., description="User ID"), roleLabel: str = Path(..., description="Role label to add"), @@ -641,7 +641,7 @@ async def addUserRole( @router.delete("/users/{userId}/roles/{roleLabel}", response_model=Dict[str, Any]) @limiter.limit("30/minute") -async def removeUserRole( +async def remove_user_role( request: Request, userId: str = Path(..., description="User ID"), roleLabel: str = Path(..., description="Role label to remove"), @@ -721,7 +721,7 @@ async def removeUserRole( @router.get("/roles/{roleLabel}/users", response_model=List[Dict[str, Any]]) @limiter.limit("60/minute") -async def getUsersWithRole( +async def get_users_with_role( request: Request, roleLabel: str = Path(..., description="Role label"), mandateId: Optional[str] = Query(None, description="Filter by mandate ID (via UserMandate)"), diff --git a/modules/routes/routeAdminRbacRules.py b/modules/routes/routeAdminRbacRules.py index dcd32bbc..a1990543 100644 --- a/modules/routes/routeAdminRbacRules.py +++ b/modules/routes/routeAdminRbacRules.py @@ -34,7 +34,7 @@ router = APIRouter( @router.get("/permissions", response_model=UserPermissions) @limiter.limit("300/minute") # Raised from 60 - sidebar checks many pages individually -async def getPermissions( +async def get_permissions( request: Request, context: str = Query(..., description="Context type: DATA, UI, or RESOURCE"), item: Optional[str] = Query(None, description="Item identifier (table name, UI path, or resource path)"), @@ -98,7 +98,7 @@ async def getPermissions( @router.get("/permissions/all", response_model=Dict[str, Any]) @limiter.limit("120/minute") # Raised from 30 - optimized endpoint for bulk permission fetch -async def getAllPermissions( +async def get_all_permissions( request: Request, context: Optional[str] = Query(None, description="Context type: UI or RESOURCE (if not provided, returns both)"), reqContext: RequestContext = Depends(getRequestContext) @@ -224,7 +224,7 @@ async def getAllPermissions( @router.get("/rules", response_model=PaginatedResponse) @limiter.limit("30/minute") -async def getAccessRules( +async def get_access_rules( request: Request, roleLabel: Optional[str] = Query(None, description="Filter by role label"), context: Optional[str] = Query(None, description="Filter by context (DATA, UI, RESOURCE)"), @@ -313,7 +313,7 @@ async def getAccessRules( @router.get("/rules/by-role/{roleId}", response_model=PaginatedResponse) @limiter.limit("30/minute") -async def getAccessRulesByRole( +async def get_access_rules_by_role( request: Request, roleId: str = Path(..., description="Role ID to get rules for"), currentUser: User = Depends(requireSysAdmin) @@ -357,7 +357,7 @@ async def getAccessRulesByRole( @router.get("/rules/{ruleId}", response_model=dict) @limiter.limit("30/minute") -async def getAccessRule( +async def get_access_rule( request: Request, ruleId: str = Path(..., description="Access rule ID"), currentUser: User = Depends(requireSysAdmin) @@ -399,7 +399,7 @@ async def getAccessRule( @router.post("/rules", response_model=dict) @limiter.limit("30/minute") -async def createAccessRule( +async def create_access_rule( request: Request, accessRuleData: dict = Body(..., description="Access rule data"), currentUser: User = Depends(requireSysAdmin) @@ -465,7 +465,7 @@ async def createAccessRule( @router.put("/rules/{ruleId}", response_model=dict) @limiter.limit("30/minute") -async def updateAccessRule( +async def update_access_rule( request: Request, ruleId: str = Path(..., description="Access rule ID"), accessRuleData: dict = Body(..., description="Updated access rule data"), @@ -548,7 +548,7 @@ async def updateAccessRule( @router.delete("/rules/{ruleId}") @limiter.limit("30/minute") -async def deleteAccessRule( +async def delete_access_rule( request: Request, ruleId: str = Path(..., description="Access rule ID"), currentUser: User = Depends(requireSysAdmin) @@ -606,7 +606,7 @@ async def deleteAccessRule( @router.get("/roles", response_model=PaginatedResponse) @limiter.limit("60/minute") -async def listRoles( +async def list_roles( request: Request, pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), includeTemplates: bool = Query(False, description="Include feature template roles"), @@ -775,7 +775,7 @@ async def listRoles( @router.get("/roles/options", response_model=List[Dict[str, Any]]) @limiter.limit("60/minute") -async def getRoleOptions( +async def get_role_options( request: Request, currentUser: User = Depends(requireSysAdmin) ) -> List[Dict[str, Any]]: @@ -816,7 +816,7 @@ async def getRoleOptions( @router.post("/roles", response_model=Dict[str, Any]) @limiter.limit("30/minute") -async def createRole( +async def create_role( request: Request, role: Role = Body(...), currentUser: User = Depends(requireSysAdmin) @@ -865,7 +865,7 @@ async def createRole( @router.get("/roles/{roleId}", response_model=Dict[str, Any]) @limiter.limit("60/minute") -async def getRole( +async def get_role( request: Request, roleId: str = Path(..., description="Role ID"), currentUser: User = Depends(requireSysAdmin) @@ -912,7 +912,7 @@ async def getRole( @router.put("/roles/{roleId}", response_model=Dict[str, Any]) @limiter.limit("30/minute") -async def updateRole( +async def update_role( request: Request, roleId: str = Path(..., description="Role ID"), role: Role = Body(...), @@ -965,7 +965,7 @@ async def updateRole( @router.delete("/roles/{roleId}", response_model=Dict[str, str]) @limiter.limit("30/minute") -async def deleteRole( +async def delete_role( request: Request, roleId: str = Path(..., description="Role ID"), currentUser: User = Depends(requireSysAdmin) diff --git a/modules/routes/routeDataConnections.py b/modules/routes/routeDataConnections.py index 2dade569..37200186 100644 --- a/modules/routes/routeDataConnections.py +++ b/modules/routes/routeDataConnections.py @@ -100,7 +100,7 @@ router = APIRouter( @router.get("/statuses/options", response_model=List[Dict[str, Any]]) @limiter.limit("60/minute") -async def getConnectionStatusOptions( +async def get_connection_status_options( request: Request, currentUser: User = Depends(getCurrentUser) ) -> List[Dict[str, Any]]: @@ -116,7 +116,7 @@ async def getConnectionStatusOptions( @router.get("/authorities/options", response_model=List[Dict[str, Any]]) @limiter.limit("60/minute") -async def getAuthAuthorityOptions( +async def get_auth_authority_options( request: Request, currentUser: User = Depends(getCurrentUser) ) -> List[Dict[str, Any]]: diff --git a/modules/routes/routeDataMandates.py b/modules/routes/routeDataMandates.py index 37c871ab..23358947 100644 --- a/modules/routes/routeDataMandates.py +++ b/modules/routes/routeDataMandates.py @@ -324,7 +324,7 @@ async def delete_mandate( @router.get("/{targetMandateId}/users") @limiter.limit("60/minute") -async def listMandateUsers( +async def list_mandate_users( request: Request, targetMandateId: str = Path(..., description="ID of the mandate"), pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), @@ -486,7 +486,7 @@ async def listMandateUsers( @router.post("/{targetMandateId}/users", response_model=UserMandateResponse) @limiter.limit("30/minute") -async def addUserToMandate( +async def add_user_to_mandate( request: Request, targetMandateId: str = Path(..., description="ID of the mandate"), data: UserMandateCreate = Body(...), @@ -602,7 +602,7 @@ async def addUserToMandate( @router.delete("/{targetMandateId}/users/{targetUserId}", response_model=Dict[str, str]) @limiter.limit("30/minute") -async def removeUserFromMandate( +async def remove_user_from_mandate( request: Request, targetMandateId: str = Path(..., description="ID of the mandate"), targetUserId: str = Path(..., description="ID of the user to remove"), @@ -680,7 +680,7 @@ async def removeUserFromMandate( @router.put("/{targetMandateId}/users/{targetUserId}/roles", response_model=UserMandateResponse) @limiter.limit("30/minute") -async def updateUserRolesInMandate( +async def update_user_roles_in_mandate( request: Request, targetMandateId: str = Path(..., description="ID of the mandate"), targetUserId: str = Path(..., description="ID of the user"), diff --git a/modules/routes/routeDataUsers.py b/modules/routes/routeDataUsers.py index b3da0c2e..f963a33c 100644 --- a/modules/routes/routeDataUsers.py +++ b/modules/routes/routeDataUsers.py @@ -152,7 +152,7 @@ router = APIRouter( @router.get("/options", response_model=List[Dict[str, Any]]) @limiter.limit("60/minute") -async def getUserOptions( +async def get_user_options( request: Request, context: RequestContext = Depends(getRequestContext) ) -> List[Dict[str, Any]]: @@ -631,7 +631,7 @@ async def change_password( @router.post("/{userId}/send-password-link") @limiter.limit("10/minute") -async def sendPasswordLink( +async def send_password_link( request: Request, userId: str = Path(..., description="ID of the user to send password setup link"), frontendUrl: str = Body(..., embed=True), diff --git a/modules/routes/routeGdpr.py b/modules/routes/routeGdpr.py index 4f5f2aa4..c0b219ec 100644 --- a/modules/routes/routeGdpr.py +++ b/modules/routes/routeGdpr.py @@ -73,7 +73,7 @@ class DeletionResult(BaseModel): @router.get("/data-export", response_model=DataExportResponse) @limiter.limit("5/minute") -async def exportUserData( +async def export_user_data( request: Request, currentUser: User = Depends(getCurrentUser) ) -> DataExportResponse: @@ -238,7 +238,7 @@ async def exportUserData( @router.get("/data-portability") @limiter.limit("5/minute") -async def exportPortableData( +async def export_portable_data( request: Request, currentUser: User = Depends(getCurrentUser) ) -> JSONResponse: @@ -333,7 +333,7 @@ async def exportPortableData( @router.delete("/", response_model=DeletionResult) @limiter.limit("1/hour") -async def deleteAccount( +async def delete_account( request: Request, confirmDeletion: bool = False, currentUser: User = Depends(getCurrentUser) @@ -465,7 +465,7 @@ async def deleteAccount( @router.get("/consent-info", response_model=Dict[str, Any]) @limiter.limit("30/minute") -async def getConsentInfo( +async def get_consent_info( request: Request, currentUser: User = Depends(getCurrentUser) ) -> Dict[str, Any]: diff --git a/modules/routes/routeInvitations.py b/modules/routes/routeInvitations.py index 3b059f9c..0e0259eb 100644 --- a/modules/routes/routeInvitations.py +++ b/modules/routes/routeInvitations.py @@ -108,7 +108,7 @@ class RegisterAndAcceptResponse(BaseModel): @router.post("/", response_model=InvitationResponse) @limiter.limit("30/minute") -async def createInvitation( +async def create_invitation( request: Request, data: InvitationCreate, context: RequestContext = Depends(getRequestContext) @@ -234,7 +234,7 @@ async def createInvitation( @router.get("/", response_model=List[Dict[str, Any]]) @limiter.limit("60/minute") -async def listInvitations( +async def list_invitations( request: Request, includeUsed: bool = Query(False, description="Include already used invitations"), includeExpired: bool = Query(False, description="Include expired invitations"), @@ -313,7 +313,7 @@ async def listInvitations( @router.delete("/{invitationId}", response_model=Dict[str, str]) @limiter.limit("30/minute") -async def revokeInvitation( +async def revoke_invitation( request: Request, invitationId: str, context: RequestContext = Depends(getRequestContext) @@ -397,7 +397,7 @@ async def revokeInvitation( @router.get("/validate/{token}", response_model=InvitationValidation) @limiter.limit("30/minute") -async def validateInvitation( +async def validate_invitation( request: Request, token: str ) -> InvitationValidation: @@ -482,7 +482,7 @@ async def validateInvitation( @router.post("/accept/{token}", response_model=Dict[str, Any]) @limiter.limit("10/minute") -async def acceptInvitation( +async def accept_invitation( request: Request, token: str, currentUser: User = Depends(getCurrentUser) @@ -614,7 +614,7 @@ async def acceptInvitation( @router.post("/register-and-accept", response_model=RegisterAndAcceptResponse) @limiter.limit("10/minute") # Stricter rate limit for registration -async def registerAndAcceptInvitation( +async def register_and_accept_invitation( request: Request, data: RegisterAndAcceptRequest ) -> RegisterAndAcceptResponse: diff --git a/modules/routes/routeMessaging.py b/modules/routes/routeMessaging.py index 953dd5f2..753fb16f 100644 --- a/modules/routes/routeMessaging.py +++ b/modules/routes/routeMessaging.py @@ -38,7 +38,7 @@ router = APIRouter( @router.get("/subscriptions", response_model=PaginatedResponse[MessagingSubscription]) @limiter.limit("60/minute") -async def getSubscriptions( +async def get_subscriptions( request: Request, pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), currentUser: User = Depends(getCurrentUser) @@ -79,7 +79,7 @@ async def getSubscriptions( @router.post("/subscriptions", response_model=MessagingSubscription) @limiter.limit("60/minute") -async def createSubscription( +async def create_subscription( request: Request, subscription: MessagingSubscription, currentUser: User = Depends(getCurrentUser) @@ -95,7 +95,7 @@ async def createSubscription( @router.get("/subscriptions/{subscriptionId}", response_model=MessagingSubscription) @limiter.limit("60/minute") -async def getSubscription( +async def get_subscription( request: Request, subscriptionId: str = Path(..., description="ID of the subscription"), currentUser: User = Depends(getCurrentUser) @@ -115,7 +115,7 @@ async def getSubscription( @router.put("/subscriptions/{subscriptionId}", response_model=MessagingSubscription) @limiter.limit("60/minute") -async def updateSubscription( +async def update_subscription( request: Request, subscriptionId: str = Path(..., description="ID of the subscription to update"), subscriptionData: MessagingSubscription = Body(...), @@ -145,7 +145,7 @@ async def updateSubscription( @router.delete("/subscriptions/{subscriptionId}", response_model=Dict[str, Any]) @limiter.limit("60/minute") -async def deleteSubscription( +async def delete_subscription( request: Request, subscriptionId: str = Path(..., description="ID of the subscription to delete"), currentUser: User = Depends(getCurrentUser) @@ -174,7 +174,7 @@ async def deleteSubscription( @router.get("/subscriptions/{subscriptionId}/registrations", response_model=PaginatedResponse[MessagingSubscriptionRegistration]) @limiter.limit("60/minute") -async def getSubscriptionRegistrations( +async def get_subscription_registrations( request: Request, subscriptionId: str = Path(..., description="ID of the subscription"), pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), @@ -219,7 +219,7 @@ async def getSubscriptionRegistrations( @router.post("/subscriptions/{subscriptionId}/subscribe", response_model=MessagingSubscriptionRegistration) @limiter.limit("60/minute") -async def subscribeUser( +async def subscribe_user( request: Request, subscriptionId: str = Path(..., description="ID of the subscription"), channel: MessagingChannel = Body(..., embed=True), @@ -241,7 +241,7 @@ async def subscribeUser( @router.delete("/subscriptions/{subscriptionId}/unsubscribe", response_model=Dict[str, Any]) @limiter.limit("60/minute") -async def unsubscribeUser( +async def unsubscribe_user( request: Request, subscriptionId: str = Path(..., description="ID of the subscription"), channel: MessagingChannel = Body(..., embed=True), @@ -267,7 +267,7 @@ async def unsubscribeUser( @router.get("/registrations", response_model=PaginatedResponse[MessagingSubscriptionRegistration]) @limiter.limit("60/minute") -async def getMyRegistrations( +async def get_my_registrations( request: Request, pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), currentUser: User = Depends(getCurrentUser) @@ -311,7 +311,7 @@ async def getMyRegistrations( @router.put("/registrations/{registrationId}", response_model=MessagingSubscriptionRegistration) @limiter.limit("60/minute") -async def updateRegistration( +async def update_registration( request: Request, registrationId: str = Path(..., description="ID of the registration to update"), registrationData: MessagingSubscriptionRegistration = Body(...), @@ -341,7 +341,7 @@ async def updateRegistration( @router.delete("/registrations/{registrationId}", response_model=Dict[str, Any]) @limiter.limit("60/minute") -async def deleteRegistration( +async def delete_registration( request: Request, registrationId: str = Path(..., description="ID of the registration to delete"), currentUser: User = Depends(getCurrentUser) @@ -376,7 +376,7 @@ def _getTriggerKey(request: Request) -> str: @router.post("/trigger/{subscriptionId}", response_model=MessagingSubscriptionExecutionResult) @limiter.limit("60/minute", key_func=_getTriggerKey) -async def triggerSubscription( +async def trigger_subscription( request: Request, subscriptionId: str = Path(..., description="ID of the subscription to trigger"), eventParameters: Dict[str, Any] = Body(...), @@ -440,7 +440,7 @@ def _hasTriggerPermission(context: RequestContext) -> bool: @router.get("/deliveries", response_model=PaginatedResponse[MessagingDelivery]) @limiter.limit("60/minute") -async def getDeliveries( +async def get_deliveries( request: Request, subscriptionId: Optional[str] = Query(None, description="Filter by subscription ID"), pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"), @@ -486,7 +486,7 @@ async def getDeliveries( @router.get("/deliveries/{deliveryId}", response_model=MessagingDelivery) @limiter.limit("60/minute") -async def getDelivery( +async def get_delivery( request: Request, deliveryId: str = Path(..., description="ID of the delivery"), currentUser: User = Depends(getCurrentUser) diff --git a/modules/routes/routeSecurityLocal.py b/modules/routes/routeSecurityLocal.py index 4c61cbc1..8ab211cf 100644 --- a/modules/routes/routeSecurityLocal.py +++ b/modules/routes/routeSecurityLocal.py @@ -106,18 +106,9 @@ async def login( # Get gateway interface with root privileges for authentication rootInterface = getRootInterface() - # Get default mandate ID - defaultMandateId = rootInterface.getInitialId(Mandate) - if not defaultMandateId: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="No default mandate found" - ) - - # Set the mandate ID on the interface - rootInterface.mandateId = defaultMandateId - # Authenticate user + # Note: authenticateLocalUser uses _getUserForAuthentication which bypasses RBAC + # This is correct because users are mandate-independent (Multi-Tenant Design) user = rootInterface.authenticateLocalUser( username=formData.username, password=formData.password @@ -265,16 +256,9 @@ async def register_user( # Get gateway interface with root privileges since this is a public endpoint appInterface = getRootInterface() - # Get default mandate ID - defaultMandateId = appInterface.getInitialId(Mandate) - if not defaultMandateId: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="No default mandate found" - ) - - # Set the mandate ID on the interface - appInterface.mandateId = defaultMandateId + # Note: User registration does NOT require mandateId context + # Users are mandate-independent (Multi-Tenant Design) + # Mandate assignment happens via createUserMandate() after registration # Frontend URL is required - no fallback baseUrl = frontendUrl.rstrip("/") @@ -548,7 +532,7 @@ async def check_username_availability( @router.post("/password-reset-request") @limiter.limit("5/minute") -async def passwordResetRequest( +async def password_reset_request( request: Request, username: str = Body(..., embed=True), frontendUrl: str = Body(..., embed=True) @@ -628,7 +612,7 @@ Falls Sie diese Anforderung nicht gestellt haben, können Sie diese E-Mail ignor @router.post("/password-reset") @limiter.limit("10/minute") -async def passwordReset( +async def password_reset( request: Request, token: str = Body(..., embed=True), password: str = Body(..., embed=True) diff --git a/modules/routes/routeVoiceGoogle.py b/modules/routes/routeVoiceGoogle.py index 401bfc0b..8e72207c 100644 --- a/modules/routes/routeVoiceGoogle.py +++ b/modules/routes/routeVoiceGoogle.py @@ -39,7 +39,7 @@ class ConnectionManager: del activeConnections[connectionId] logger.info(f"WebSocket disconnected: {connectionId}") - async def sendPersonalMessage(self, message: dict, websocket: WebSocket): + async def send_personal_message(self, message: dict, websocket: WebSocket): try: await websocket.send_text(json.dumps(message)) except Exception as e: diff --git a/tests/functional/test03_ai_operations.py b/tests/functional/test03_ai_operations.py index a80be79c..d8b5c078 100644 --- a/tests/functional/test03_ai_operations.py +++ b/tests/functional/test03_ai_operations.py @@ -28,8 +28,11 @@ class MethodAiOperationsTester: def __init__(self): # Use root user for testing (has full access to everything) from modules.interfaces.interfaceDbApp import getRootInterface + from modules.datamodels.datamodelUam import Mandate rootInterface = getRootInterface() self.testUser = rootInterface.currentUser + # Get initial mandate ID for testing (User has no mandateId - use initial mandate) + self.testMandateId = rootInterface.getInitialId(Mandate) self.services = None self.methodAi = None @@ -119,7 +122,7 @@ class MethodAiOperationsTester: currentAction=0, totalTasks=0, totalActions=0, - mandateId=self.testUser.mandateId, + mandateId=self.testMandateId, messageIds=[], workflowMode=WorkflowModeEnum.WORKFLOW_DYNAMIC, maxSteps=5 @@ -277,7 +280,7 @@ class MethodAiOperationsTester: currentAction=0, totalTasks=0, totalActions=0, - mandateId=self.testUser.mandateId, + mandateId=self.testMandateId, messageIds=[], workflowMode=WorkflowModeEnum.WORKFLOW_DYNAMIC, maxSteps=5 diff --git a/tests/functional/test04_ai_behavior.py b/tests/functional/test04_ai_behavior.py index 657946a9..7b5d6d73 100644 --- a/tests/functional/test04_ai_behavior.py +++ b/tests/functional/test04_ai_behavior.py @@ -28,8 +28,11 @@ class AIBehaviorTester: def __init__(self): # Use root user for testing (has full access to everything) from modules.interfaces.interfaceDbApp import getRootInterface + from modules.datamodels.datamodelUam import Mandate rootInterface = getRootInterface() self.testUser = rootInterface.currentUser + # Get initial mandate ID for testing (User has no mandateId - use initial mandate) + self.testMandateId = rootInterface.getInitialId(Mandate) # Initialize services using the existing system self.services = getServices(self.testUser, None) # Test user, no workflow @@ -60,7 +63,7 @@ class AIBehaviorTester: currentAction=0, totalTasks=0, totalActions=0, - mandateId=self.testUser.mandateId, + mandateId=self.testMandateId, messageIds=[], workflowMode=WorkflowModeEnum.WORKFLOW_DYNAMIC, maxSteps=5 diff --git a/tests/functional/test05_workflow_with_documents.py b/tests/functional/test05_workflow_with_documents.py index fac1ab41..960c7d8d 100644 --- a/tests/functional/test05_workflow_with_documents.py +++ b/tests/functional/test05_workflow_with_documents.py @@ -30,8 +30,11 @@ class WorkflowWithDocumentsTester: def __init__(self): # Use root user for testing (has full access to everything) from modules.interfaces.interfaceDbApp import getRootInterface + from modules.datamodels.datamodelUam import Mandate rootInterface = getRootInterface() self.testUser = rootInterface.currentUser + # Get initial mandate ID for testing (User has no mandateId - use initial mandate) + self.testMandateId = rootInterface.getInitialId(Mandate) # Initialize services using the existing system self.services = getServices(self.testUser, None) # Test user, no workflow @@ -45,7 +48,7 @@ class WorkflowWithDocumentsTester: logging.getLogger().setLevel(logging.INFO) print(f"Initialized test with user: {self.testUser.id}") - print(f"Mandate ID: {self.testUser.mandateId}") + print(f"Test Mandate ID: {self.testMandateId}") def createCsvTemplate(self) -> str: """Create a CSV template file for prime numbers.""" diff --git a/tests/functional/test06_workflow_prompt_variations.py b/tests/functional/test06_workflow_prompt_variations.py index 4b39454a..121b22a1 100644 --- a/tests/functional/test06_workflow_prompt_variations.py +++ b/tests/functional/test06_workflow_prompt_variations.py @@ -32,8 +32,11 @@ class WorkflowPromptVariationsTester: def __init__(self): # Use root user for testing (has full access to everything) from modules.interfaces.interfaceDbApp import getRootInterface + from modules.datamodels.datamodelUam import Mandate rootInterface = getRootInterface() self.testUser = rootInterface.currentUser + # Get initial mandate ID for testing (User has no mandateId - use initial mandate) + self.testMandateId = rootInterface.getInitialId(Mandate) # Initialize services using the existing system self.services = getServices(self.testUser, None) # Test user, no workflow @@ -46,7 +49,7 @@ class WorkflowPromptVariationsTester: logging.getLogger().setLevel(logging.INFO) print(f"Initialized test with user: {self.testUser.id}") - print(f"Mandate ID: {self.testUser.mandateId}") + print(f"Test Mandate ID: {self.testMandateId}") def _createFile(self, fileName: str, mimeType: str, content: str) -> str: """Helper method to create a file and return its ID.""" diff --git a/tests/functional/test09_document_generation_formats.py b/tests/functional/test09_document_generation_formats.py index a6f99236..e646bc99 100644 --- a/tests/functional/test09_document_generation_formats.py +++ b/tests/functional/test09_document_generation_formats.py @@ -31,8 +31,11 @@ class DocumentGenerationFormatsTester: def __init__(self): # Use root user for testing (has full access to everything) from modules.interfaces.interfaceDbApp import getRootInterface + from modules.datamodels.datamodelUam import Mandate rootInterface = getRootInterface() self.testUser = rootInterface.currentUser + # Get initial mandate ID for testing (User has no mandateId - use initial mandate) + self.testMandateId = rootInterface.getInitialId(Mandate) # Initialize services using the existing system self.services = getServices(self.testUser, None) # Test user, no workflow @@ -52,7 +55,7 @@ class DocumentGenerationFormatsTester: logging.getLogger().setLevel(logging.INFO) print(f"Initialized test with user: {self.testUser.id}") - print(f"Mandate ID: {self.testUser.mandateId}") + print(f"Test Mandate ID: {self.testMandateId}") print(f"Debug logging enabled: {APP_CONFIG.get('APP_DEBUG_CHAT_WORKFLOW_ENABLED', False)}") # Upload PDF file for testing diff --git a/tests/functional/test10_document_generation_formats.py b/tests/functional/test10_document_generation_formats.py index e1990910..c9542efe 100644 --- a/tests/functional/test10_document_generation_formats.py +++ b/tests/functional/test10_document_generation_formats.py @@ -31,8 +31,11 @@ class DocumentGenerationFormatsTester10: def __init__(self): # Use root user for testing (has full access to everything) from modules.interfaces.interfaceDbApp import getRootInterface + from modules.datamodels.datamodelUam import Mandate rootInterface = getRootInterface() self.testUser = rootInterface.currentUser + # Get initial mandate ID for testing (User has no mandateId - use initial mandate) + self.testMandateId = rootInterface.getInitialId(Mandate) # Initialize services using the existing system self.services = getServices(self.testUser, None) # Test user, no workflow @@ -52,7 +55,7 @@ class DocumentGenerationFormatsTester10: logging.getLogger().setLevel(logging.INFO) print(f"Initialized test with user: {self.testUser.id}") - print(f"Mandate ID: {self.testUser.mandateId}") + print(f"Test Mandate ID: {self.testMandateId}") print(f"Debug logging enabled: {APP_CONFIG.get('APP_DEBUG_CHAT_WORKFLOW_ENABLED', False)}") # Upload PDF file for testing diff --git a/tests/functional/test11_code_generation_formats.py b/tests/functional/test11_code_generation_formats.py index 43c294e4..76f26a61 100644 --- a/tests/functional/test11_code_generation_formats.py +++ b/tests/functional/test11_code_generation_formats.py @@ -33,8 +33,11 @@ class CodeGenerationFormatsTester11: def __init__(self): # Use root user for testing (has full access to everything) from modules.interfaces.interfaceDbApp import getRootInterface + from modules.datamodels.datamodelUam import Mandate rootInterface = getRootInterface() self.testUser = rootInterface.currentUser + # Get initial mandate ID for testing (User has no mandateId - use initial mandate) + self.testMandateId = rootInterface.getInitialId(Mandate) # Initialize services using the existing system self.services = getServices(self.testUser, None) # Test user, no workflow @@ -53,7 +56,7 @@ class CodeGenerationFormatsTester11: logging.getLogger().setLevel(logging.INFO) print(f"Initialized test with user: {self.testUser.id}") - print(f"Mandate ID: {self.testUser.mandateId}") + print(f"Test Mandate ID: {self.testMandateId}") print(f"Debug logging enabled: {APP_CONFIG.get('APP_DEBUG_CHAT_WORKFLOW_ENABLED', False)}") def createTestPrompt(self, format: str) -> str: