diff --git a/modules/features/teamsbot/routeFeatureTeamsbot.py b/modules/features/teamsbot/routeFeatureTeamsbot.py index 8619505f..f426ae1b 100644 --- a/modules/features/teamsbot/routeFeatureTeamsbot.py +++ b/modules/features/teamsbot/routeFeatureTeamsbot.py @@ -490,7 +490,7 @@ def _getEffectiveConfig(instanceId: str, userId: str, interface) -> TeamsbotConf overrides[field] = value if overrides: - return baseConfig.model_copy(update=overrides) + return TeamsbotConfig.model_validate({**baseConfig.model_dump(), **overrides}) return baseConfig @@ -1228,7 +1228,7 @@ async def botWebsocket( if value is not None: overrides[field] = value if overrides: - config = config.model_copy(update=overrides) + config = TeamsbotConfig.model_validate({**config.model_dump(), **overrides}) logger.info(f"Browser Bot WebSocket: Applied user settings overrides: {list(overrides.keys())}") service = TeamsbotService(originalUser, mandateId, instanceId, config) diff --git a/modules/features/teamsbot/service.py b/modules/features/teamsbot/service.py index b00b6ccb..40c8962c 100644 --- a/modules/features/teamsbot/service.py +++ b/modules/features/teamsbot/service.py @@ -400,38 +400,30 @@ class TeamsbotService: if len(audioBytes) < 1000: return - # Use the existing Google Cloud Speech connector for STT - speechConnector = voiceInterface.getSpeechConnector() if voiceInterface else None - if not speechConnector or not hasattr(speechConnector, 'speech_client'): - logger.warning(f"[AudioChunk] No speech client available for session {sessionId}") + if not voiceInterface: + logger.warning(f"[AudioChunk] No voice interface available for session {sessionId}") return - from google.cloud import speech - config = speech.RecognitionConfig( - encoding=speech.RecognitionConfig.AudioEncoding.LINEAR16, - sample_rate_hertz=sampleRate, - language_code=self.config.language or "de-DE", - enable_automatic_punctuation=True, + sttResult = await voiceInterface.speechToText( + audioContent=audioBytes, + language=self.config.language or "de-DE", + sampleRate=sampleRate, ) - audio = speech.RecognitionAudio(content=audioBytes) - response = speechConnector.speech_client.recognize(config=config, audio=audio) - - for result in response.results: - if result.alternatives: - text = result.alternatives[0].transcript.strip() - if text: - logger.info(f"[AudioChunk] STT result: {text[:80]}...") - await self._processTranscript( - sessionId=sessionId, - speaker="Meeting Audio", - text=text, - isFinal=True, - interface=interface, - voiceInterface=voiceInterface, - websocket=websocket, - source="audioCapture", - ) + if sttResult and sttResult.get("success") and sttResult.get("text"): + text = sttResult["text"].strip() + if text: + logger.info(f"[AudioChunk] STT result: {text[:80]}...") + await self._processTranscript( + sessionId=sessionId, + speaker="Meeting Audio", + text=text, + isFinal=True, + interface=interface, + voiceInterface=voiceInterface, + websocket=websocket, + source="audioCapture", + ) except Exception as e: logger.error(f"[AudioChunk] STT error for session {sessionId}: {type(e).__name__}: {e}") diff --git a/modules/features/trustee/datamodelFeatureTrustee.py b/modules/features/trustee/datamodelFeatureTrustee.py index 1402f91e..e1282e3b 100644 --- a/modules/features/trustee/datamodelFeatureTrustee.py +++ b/modules/features/trustee/datamodelFeatureTrustee.py @@ -1,6 +1,6 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. -"""Trustee models: TrusteeOrganisation, TrusteeRole, TrusteeAccess, TrusteeContract, TrusteeDocument, TrusteePosition, TrusteePositionDocument.""" +"""Trustee models: TrusteeOrganisation, TrusteeRole, TrusteeAccess, TrusteeContract, TrusteeDocument, TrusteePosition.""" from typing import Optional from pydantic import BaseModel, Field @@ -384,9 +384,8 @@ registerModelLabels( class TrusteePosition(BaseModel): """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 + Each position references exactly one source document via documentId (1:N relationship). + One document (e.g. bank statement) can generate many positions. """ id: str = Field( default_factory=lambda: str(uuid.uuid4()), @@ -397,6 +396,16 @@ class TrusteePosition(BaseModel): "frontend_required": False } ) + documentId: Optional[str] = Field( + default=None, + description="Reference to TrusteeDocument.id (source document that generated this position)", + json_schema_extra={ + "frontend_type": "select", + "frontend_readonly": False, + "frontend_required": False, + "frontend_options": "/api/trustee/{instanceId}/documents/options" + } + ) valuta: Optional[str] = Field( default=None, description="Value date (ISO format: YYYY-MM-DD)", @@ -538,6 +547,7 @@ registerModelLabels( {"en": "Position", "fr": "Position", "de": "Position"}, { "id": {"en": "ID", "fr": "ID", "de": "ID"}, + "documentId": {"en": "Document", "fr": "Document", "de": "Dokument"}, "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"}, @@ -555,71 +565,3 @@ registerModelLabels( ) -class TrusteePositionDocument(BaseModel): - """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", - json_schema_extra={ - "frontend_type": "text", - "frontend_readonly": True, - "frontend_required": False - } - ) - 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" - } - ) - positionId: str = Field( - description="Reference to TrusteePosition.id", - json_schema_extra={ - "frontend_type": "select", - "frontend_readonly": False, - "frontend_required": True, - "frontend_options": "/api/trustee/{instanceId}/positions/options" - } - ) - mandateId: Optional[str] = Field( - default=None, - description="Mandate ID (auto-set from context)", - json_schema_extra={ - "frontend_type": "text", - "frontend_readonly": True, - "frontend_required": False, - "frontend_hidden": True - } - ) - featureInstanceId: Optional[str] = Field( - default=None, - 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_hidden": True - } - ) - # System attributes are automatically set by DatabaseConnector - - -registerModelLabels( - "TrusteePositionDocument", - {"en": "Position-Document Link", "fr": "Lien Position-Document", "de": "Position-Dokument-Verknüpfung"}, - { - "id": {"en": "ID", "fr": "ID", "de": "ID"}, - "documentId": {"en": "Document", "fr": "Document", "de": "Dokument"}, - "positionId": {"en": "Position", "fr": "Position", "de": "Position"}, - "mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"}, - "featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"}, - }, -) diff --git a/modules/features/trustee/interfaceFeatureTrustee.py b/modules/features/trustee/interfaceFeatureTrustee.py index 7b3e4a6b..9ead992d 100644 --- a/modules/features/trustee/interfaceFeatureTrustee.py +++ b/modules/features/trustee/interfaceFeatureTrustee.py @@ -23,7 +23,6 @@ from .datamodelFeatureTrustee import ( TrusteeContract, TrusteeDocument, TrusteePosition, - TrusteePositionDocument, ) from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResult @@ -1107,10 +1106,8 @@ class TrusteeObjects: def deleteDocument(self, documentId: str) -> bool: """Delete a document. - All position-document cross-table entries (TrusteePositionDocument) referencing - this document are deleted first, then the document. + Positions referencing this document will have their documentId set to None. """ - # Get existing document to check creator existingRecords = self.db.getRecordset(TrusteeDocument, recordFilter={"id": documentId}) existing = existingRecords[0] if existingRecords else None @@ -1120,12 +1117,17 @@ class TrusteeObjects: createdBy = existing.get("_createdBy") - # 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 - self._deletePositionDocumentLinksForDocument(documentId) + # Clear documentId on positions that reference this document + positions = self.db.getRecordset(TrusteePosition, recordFilter={"documentId": documentId}) + for pos in positions: + posId = pos.get("id") + if posId: + self.db.recordModify(TrusteePosition, posId, {"documentId": None}) + return self.db.recordDelete(TrusteeDocument, documentId) # ===== Position CRUD ===== @@ -1276,12 +1278,7 @@ 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. - - All position-document cross-table entries (TrusteePositionDocument) referencing - this position are deleted first, then the position. - """ - # Get existing position to check creator + """Delete a position.""" existingRecords = self.db.getRecordset(TrusteePosition, recordFilter={"id": positionId}) existing = existingRecords[0] if existingRecords else None @@ -1291,177 +1288,27 @@ class TrusteeObjects: createdBy = existing.get("_createdBy") - # 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 - self._deletePositionDocumentLinksForPosition(positionId) return self.db.recordDelete(TrusteePosition, positionId) - # ===== Position-Document Link CRUD ===== + # ===== Position-Document Queries ===== - def createPositionDocument(self, data: Dict[str, Any]) -> Optional[TrusteePositionDocument]: - """Create a new position-document link. - - 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 - data["featureInstanceId"] = self.featureInstanceId - - import uuid - linkId = data.get("id") or str(uuid.uuid4()) - data["id"] = linkId - - createdRecord = self.db.recordCreate(TrusteePositionDocument, data) - if createdRecord and createdRecord.get("id"): - return TrusteePositionDocument(**{k: v for k, v in createdRecord.items() if not k.startswith("_")}) - return None - - def getPositionDocument(self, linkId: str) -> Optional[TrusteePositionDocument]: - """Get a single position-document link by ID.""" - records = self.db.getRecordset(TrusteePositionDocument, recordFilter={"id": linkId}) - if not records: - return None - return TrusteePositionDocument(**{k: v for k, v in records[0].items() if not k.startswith("_")}) - - def updatePositionDocument(self, linkId: str, data: Dict[str, Any]) -> Optional[TrusteePositionDocument]: - """Update a position-document link.""" - # Check permission - if not self.checkCombinedPermission(TrusteePositionDocument, "update"): - logger.warning(f"User {self.userId} lacks permission to update position-document link") - return None - - # Verify link exists and belongs to this instance - existing = self.db.getRecordset(TrusteePositionDocument, recordFilter={"id": linkId}) - if not existing: - logger.warning(f"Position-document link {linkId} not found") - return None - - existingRecord = existing[0] - if existingRecord.get("featureInstanceId") != self.featureInstanceId: - logger.warning(f"Link {linkId} belongs to different instance") - return None - - # Prevent changing context fields - data.pop("id", None) - data.pop("mandateId", None) - data.pop("featureInstanceId", None) - - updatedRecord = self.db.recordModify(TrusteePositionDocument, linkId, data) - if updatedRecord and updatedRecord.get("id"): - return TrusteePositionDocument(**{k: v for k, v in updatedRecord.items() if not k.startswith("_")}) - return None - - def getAllPositionDocuments(self, params: Optional[PaginationParams] = None) -> PaginatedResult: - """Get all position-document links with RBAC filtering + feature-level access filtering.""" - # Step 1: System RBAC filtering with per-row permissions + def getPositionsByDocument(self, documentId: str) -> List[TrusteePosition]: + """Get all positions that reference a specific document (1:N via documentId FK).""" records = getRecordsetWithRBAC( connector=self.db, - modelClass=TrusteePositionDocument, - currentUser=self.currentUser, - recordFilter=None, - orderBy="id", - mandateId=self.mandateId, - featureInstanceId=self.featureInstanceId, - enrichPermissions=True, - featureCode=self.FEATURE_CODE - ) - - totalItems = len(records) - if params: - pageSize = params.pageSize or 20 - page = params.page or 1 - startIdx = (page - 1) * pageSize - endIdx = startIdx + pageSize - items = records[startIdx:endIdx] - totalPages = math.ceil(totalItems / pageSize) if pageSize > 0 else 1 - else: - items = records - totalPages = 1 - page = 1 - pageSize = totalItems - - return PaginatedResult( - items=items, - totalItems=totalItems, - totalPages=totalPages - ) - - def getDocumentsForPosition(self, positionId: str) -> List[TrusteePositionDocument]: - """Get all documents linked to a position.""" - # Step 1: System RBAC filtering - links = getRecordsetWithRBAC( - connector=self.db, - modelClass=TrusteePositionDocument, - currentUser=self.currentUser, - recordFilter={"positionId": positionId}, - orderBy="id", - mandateId=self.mandateId, - featureInstanceId=self.featureInstanceId, - featureCode=self.FEATURE_CODE - ) - return [TrusteePositionDocument(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in links] - - def getPositionsForDocument(self, documentId: str) -> List[TrusteePositionDocument]: - """Get all positions linked to a document.""" - # Step 1: System RBAC filtering - links = getRecordsetWithRBAC( - connector=self.db, - modelClass=TrusteePositionDocument, + modelClass=TrusteePosition, currentUser=self.currentUser, recordFilter={"documentId": documentId}, - orderBy="id", + orderBy="valuta", mandateId=self.mandateId, featureInstanceId=self.featureInstanceId, featureCode=self.FEATURE_CODE ) - return [TrusteePositionDocument(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in links] - - def deletePositionDocument(self, linkId: str) -> bool: - """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 - - if not existing: - logger.warning(f"Position-document link {linkId} not found") - return False - - createdBy = existing.get("_createdBy") - - # 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) - - def _deletePositionDocumentLinksForDocument(self, documentId: str) -> None: - """Delete all position-document cross-table entries referencing this document.""" - links = self.db.getRecordset(TrusteePositionDocument, recordFilter={"documentId": documentId}) - for link in links: - linkId = link.get("id") - if linkId: - self.db.recordDelete(TrusteePositionDocument, linkId) - - def _deletePositionDocumentLinksForPosition(self, positionId: str) -> None: - """Delete all position-document cross-table entries referencing this position.""" - links = self.db.getRecordset(TrusteePositionDocument, recordFilter={"positionId": positionId}) - for link in links: - linkId = link.get("id") - if linkId: - self.db.recordDelete(TrusteePositionDocument, linkId) + return [TrusteePosition(**{k: v for k, v in r.items() if not k.startswith("_")}) for r in records] # ===== Trustee-specific Access Check ===== @@ -1583,7 +1430,7 @@ class TrusteeObjects: # operate role: CRUD for contracts, documents, positions if "operate" in roles: - if modelClass in (TrusteeContract, TrusteeDocument, TrusteePosition, TrusteePositionDocument): + if modelClass in (TrusteeContract, TrusteeDocument, TrusteePosition): return True # operate can read organisations if modelClass == TrusteeOrganisation and operation == "read": @@ -1591,7 +1438,7 @@ class TrusteeObjects: # userreport role: CRUD own records for documents/positions if "userreport" in roles: - if modelClass in (TrusteeDocument, TrusteePosition, TrusteePositionDocument): + if modelClass in (TrusteeDocument, TrusteePosition): # For create, always allowed if operation == "create": return True diff --git a/modules/features/trustee/mainTrustee.py b/modules/features/trustee/mainTrustee.py index 8d7e1243..bfd443ef 100644 --- a/modules/features/trustee/mainTrustee.py +++ b/modules/features/trustee/mainTrustee.py @@ -33,11 +33,6 @@ UI_OBJECTS = [ "label": {"en": "Documents", "de": "Dokumente", "fr": "Documents"}, "meta": {"area": "documents"} }, - { - "objectKey": "ui.feature.trustee.position-documents", - "label": {"en": "Position Documents", "de": "Positions-Dokumente", "fr": "Documents de position"}, - "meta": {"area": "position-documents"} - }, { "objectKey": "ui.feature.trustee.expense-import", "label": {"en": "Expense Import", "de": "Spesen Import", "fr": "Import de dépenses"}, @@ -63,11 +58,6 @@ DATA_OBJECTS = [ "label": {"en": "Document", "de": "Dokument", "fr": "Document"}, "meta": {"table": "TrusteeDocument", "fields": ["id", "filename", "mimeType", "fileSize", "uploadDate"]} }, - { - "objectKey": "data.feature.trustee.TrusteePositionDocument", - "label": {"en": "Position-Document Assignment", "de": "Position-Dokument Zuordnung", "fr": "Assignation Position-Document"}, - "meta": {"table": "TrusteePositionDocument", "fields": ["id", "positionId", "documentId"]} - }, { "objectKey": "data.feature.trustee.*", "label": {"en": "All Trustee Data", "de": "Alle Treuhand-Daten", "fr": "Toutes les données fiduciaires"}, @@ -148,7 +138,6 @@ TEMPLATE_ROLES = [ {"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.trustee.positions", "view": True}, {"context": "UI", "item": "ui.feature.trustee.documents", "view": True}, - {"context": "UI", "item": "ui.feature.trustee.position-documents", "view": True}, # Group-level DATA access {"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"}, ] @@ -165,12 +154,10 @@ TEMPLATE_ROLES = [ {"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.trustee.positions", "view": True}, {"context": "UI", "item": "ui.feature.trustee.documents", "view": True}, - {"context": "UI", "item": "ui.feature.trustee.position-documents", "view": True}, {"context": "UI", "item": "ui.feature.trustee.expense-import", "view": True}, - # Own records only (MY level) - explizite Regeln pro Tabelle + # Own records only (MY level) {"context": "DATA", "item": "data.feature.trustee.TrusteePosition", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"}, {"context": "DATA", "item": "data.feature.trustee.TrusteeDocument", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"}, - {"context": "DATA", "item": "data.feature.trustee.TrusteePositionDocument", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"}, ] }, ]