diff --git a/modules/connectors/providerMsft/connectorMsft.py b/modules/connectors/providerMsft/connectorMsft.py index 3654bad9..a51fa231 100644 --- a/modules/connectors/providerMsft/connectorMsft.py +++ b/modules/connectors/providerMsft/connectorMsft.py @@ -299,26 +299,65 @@ class OutlookAdapter(_GraphApiMixin, ServiceAdapter): for m in result.get("value", []) ] - async def sendMail( + def _buildMessage( self, to: List[str], subject: str, body: str, - cc: Optional[List[str]] = None, attachments: Optional[List[Dict]] = None + bodyType: str = "Text", + cc: Optional[List[str]] = None, + attachments: Optional[List[Dict]] = None, ) -> Dict[str, Any]: - """Send an email via Microsoft Graph.""" - import json + """Build a Graph API message object. + + attachments: list of {"name": str, "contentBytes": str (base64), "contentType": str} + """ message: Dict[str, Any] = { "subject": subject, - "body": {"contentType": "Text", "content": body}, + "body": {"contentType": bodyType, "content": body}, "toRecipients": [{"emailAddress": {"address": addr}} for addr in to], } if cc: message["ccRecipients"] = [{"emailAddress": {"address": addr}} for addr in cc] + if attachments: + message["attachments"] = [ + { + "@odata.type": "#microsoft.graph.fileAttachment", + "name": att["name"], + "contentBytes": att["contentBytes"], + "contentType": att.get("contentType", "application/octet-stream"), + } + for att in attachments + ] + return message + async def sendMail( + self, to: List[str], subject: str, body: str, + bodyType: str = "Text", + cc: Optional[List[str]] = None, + attachments: Optional[List[Dict]] = None, + ) -> Dict[str, Any]: + """Send an email via Microsoft Graph. bodyType: 'Text' or 'HTML'.""" + import json + message = self._buildMessage(to, subject, body, bodyType, cc, attachments) payload = json.dumps({"message": message, "saveToSentItems": True}).encode("utf-8") result = await self._graphPost("me/sendMail", payload) if "error" in result: return result return {"success": True} + async def createDraft( + self, to: List[str], subject: str, body: str, + bodyType: str = "Text", + cc: Optional[List[str]] = None, + attachments: Optional[List[Dict]] = None, + ) -> Dict[str, Any]: + """Create a draft email in the user's Drafts folder via Microsoft Graph.""" + import json + message = self._buildMessage(to, subject, body, bodyType, cc, attachments) + payload = json.dumps(message).encode("utf-8") + result = await self._graphPost("me/messages", payload) + if "error" in result: + return result + return {"success": True, "draft": True, "messageId": result.get("id", "")} + # --------------------------------------------------------------------------- # Teams Adapter (Stub) diff --git a/modules/datamodels/datamodelVoice.py b/modules/datamodels/datamodelVoice.py index 2223a3e6..565c7677 100644 --- a/modules/datamodels/datamodelVoice.py +++ b/modules/datamodels/datamodelVoice.py @@ -1,46 +1,7 @@ # Copyright (c) 2025 Patrick Motsch # All rights reserved. -"""Voice settings datamodel.""" - -from typing import Dict, Any, Optional -from pydantic import BaseModel, Field -from modules.shared.attributeUtils import registerModelLabels -from modules.shared.timeUtils import getUtcTimestamp -import uuid - - -class VoiceSettings(BaseModel): - id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) - userId: str = Field(description="ID of the user these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) - mandateId: str = Field(description="ID of the mandate these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) - featureInstanceId: str = Field(description="ID of the feature instance these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) - sttLanguage: str = Field(default="de-DE", description="Speech-to-Text language", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True}) - ttsLanguage: str = Field(default="de-DE", description="Text-to-Speech language", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True}) - ttsVoice: str = Field(default="de-DE-KatjaNeural", description="Text-to-Speech voice", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True}) - ttsVoiceMap: Dict[str, Any] = Field(default_factory=dict, description="Per-language voice mapping, e.g. {'de-DE': {'voiceName': 'de-DE-Wavenet-A'}, 'en-US': {'voiceName': 'en-US-Wavenet-C'}}", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False}) - translationEnabled: bool = Field(default=True, description="Whether translation is enabled", json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}) - targetLanguage: str = Field(default="en-US", description="Target language for translation", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False}) - creationDate: float = Field(default_factory=getUtcTimestamp, description="Date when the settings were created (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}) - lastModified: float = Field(default_factory=getUtcTimestamp, description="Date when the settings were last modified (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}) - - -registerModelLabels( - "VoiceSettings", - {"en": "Voice Settings", "fr": "Paramètres vocaux"}, - { - "id": {"en": "ID", "fr": "ID"}, - "userId": {"en": "User ID", "fr": "ID utilisateur"}, - "mandateId": {"en": "Mandate ID", "fr": "ID du mandat"}, - "featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"}, - "sttLanguage": {"en": "STT Language", "fr": "Langue STT"}, - "ttsLanguage": {"en": "TTS Language", "fr": "Langue TTS"}, - "ttsVoice": {"en": "TTS Voice", "fr": "Voix TTS"}, - "ttsVoiceMap": {"en": "TTS Voice Map", "fr": "Carte des voix TTS"}, - "translationEnabled": {"en": "Translation Enabled", "fr": "Traduction activée"}, - "targetLanguage": {"en": "Target Language", "fr": "Langue cible"}, - "creationDate": {"en": "Creation Date", "fr": "Date de création"}, - "lastModified": {"en": "Last Modified", "fr": "Dernière modification"}, - }, -) +"""Voice settings datamodel — re-exported from workspace feature for backward compatibility.""" +from modules.features.workspace.datamodelFeatureWorkspace import VoiceSettings +__all__ = ["VoiceSettings"] diff --git a/modules/features/commcoach/routeFeatureCommcoach.py b/modules/features/commcoach/routeFeatureCommcoach.py index 81585a19..9074d2ba 100644 --- a/modules/features/commcoach/routeFeatureCommcoach.py +++ b/modules/features/commcoach/routeFeatureCommcoach.py @@ -1192,24 +1192,79 @@ async def deleteDocumentRoute( def _extractText(content: bytes, mimeType: str, fileName: str) -> Optional[str]: - """Extract text from uploaded file content.""" + """Extract text from uploaded file content (TXT, MD, HTML, PDF, DOCX, XLSX, PPTX).""" + import io + + lowerName = fileName.lower() try: - if mimeType == "text/plain" or fileName.endswith(".txt"): + if mimeType in ("text/plain",) or lowerName.endswith(".txt"): return content.decode("utf-8", errors="replace") - if mimeType == "text/markdown" or fileName.endswith(".md"): + + if mimeType in ("text/markdown",) or lowerName.endswith(".md"): return content.decode("utf-8", errors="replace") - if "pdf" in mimeType or fileName.endswith(".pdf"): + + if mimeType in ("text/html",) or lowerName.endswith((".html", ".htm")): + from html.parser import HTMLParser + class _Strip(HTMLParser): + def __init__(self): + super().__init__() + self._parts: list[str] = [] + def handle_data(self, d): + self._parts.append(d) + def result(self): + return " ".join(self._parts) + parser = _Strip() + parser.feed(content.decode("utf-8", errors="replace")) + return parser.result() + + if "pdf" in mimeType or lowerName.endswith(".pdf"): try: - import io from PyPDF2 import PdfReader reader = PdfReader(io.BytesIO(content)) - text = "" - for page in reader.pages: - text += page.extract_text() or "" - return text + return "".join(page.extract_text() or "" for page in reader.pages) except ImportError: logger.warning("PyPDF2 not installed, cannot extract PDF text") return None + + if "wordprocessingml" in mimeType or lowerName.endswith(".docx"): + try: + from docx import Document + doc = Document(io.BytesIO(content)) + return "\n".join(p.text for p in doc.paragraphs if p.text) + except ImportError: + logger.warning("python-docx not installed, cannot extract DOCX text") + return None + + if "spreadsheetml" in mimeType or lowerName.endswith(".xlsx"): + try: + from openpyxl import load_workbook + wb = load_workbook(io.BytesIO(content), read_only=True, data_only=True) + parts: list[str] = [] + for ws in wb.worksheets: + for row in ws.iter_rows(values_only=True): + cells = [str(c) for c in row if c is not None] + if cells: + parts.append("\t".join(cells)) + return "\n".join(parts) + except ImportError: + logger.warning("openpyxl not installed, cannot extract XLSX text") + return None + + if "presentationml" in mimeType or lowerName.endswith(".pptx"): + try: + from pptx import Presentation + prs = Presentation(io.BytesIO(content)) + parts = [] + for slide in prs.slides: + for shape in slide.shapes: + if shape.has_text_frame: + parts.append(shape.text_frame.text) + return "\n".join(parts) + except ImportError: + logger.warning("python-pptx not installed, cannot extract PPTX text") + return None + + logger.info(f"No text extractor for {fileName} (mime={mimeType})") except Exception as e: logger.warning(f"Text extraction failed for {fileName}: {e}") return None diff --git a/modules/features/commcoach/serviceCommcoach.py b/modules/features/commcoach/serviceCommcoach.py index be47a917..bf5ec281 100644 --- a/modules/features/commcoach/serviceCommcoach.py +++ b/modules/features/commcoach/serviceCommcoach.py @@ -331,7 +331,8 @@ def _getDocumentSummaries(contextId: str, userId: str, interface) -> Optional[Li elif doc.get("extractedText"): summaries.append(f"[{doc.get('fileName', 'Dokument')}] {doc['extractedText'][:200]}...") return summaries if summaries else None - except Exception: + except Exception as e: + logger.warning(f"Failed to load document summaries for context {contextId}: {e}") return None diff --git a/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py b/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py index cf8f0f53..c803b375 100644 --- a/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py +++ b/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py @@ -39,19 +39,21 @@ EXTRACTABLE_BINARY_MIME_TYPES = frozenset({ class NeutralizationService: """Service for handling data neutralization operations""" - def __init__(self, serviceCenter=None, NamesToParse: List[str] = None): + def __init__(self, serviceCenter=None, getServiceFn=None, NamesToParse: List[str] = None): """Initialize the service with user context and anonymization processors Args: - serviceCenter: Service center instance for accessing other services + serviceCenter: Service center context or legacy service center instance + getServiceFn: Service resolver function (injected by ServiceCenter resolver) NamesToParse: List of names to parse and replace (case-insensitive) """ self.services = serviceCenter - self.interfaceDbComponent = serviceCenter.interfaceDbComponent + self._getService = getServiceFn + self.interfaceDbComponent = getattr(serviceCenter, "interfaceDbComponent", None) # Create feature-specific interface for neutralizer DB operations self.interfaceNeutralizer: InterfaceFeatureNeutralizer = None - if serviceCenter and serviceCenter.interfaceDbApp: + if serviceCenter and getattr(serviceCenter, "interfaceDbApp", None): dbApp = serviceCenter.interfaceDbApp self.interfaceNeutralizer = getNeutralizerInterface( currentUser=dbApp.currentUser, @@ -59,10 +61,10 @@ class NeutralizationService: featureInstanceId=getattr(serviceCenter, 'featureInstanceId', None) or getattr(dbApp, 'featureInstanceId', None) ) - # Initialize anonymization processors - self.NamesToParse = NamesToParse or [] - self.textProcessor = TextProcessor(NamesToParse) - self.listProcessor = ListProcessor(NamesToParse) + namesList = NamesToParse if isinstance(NamesToParse, list) else [] + self.NamesToParse = namesList + self.textProcessor = TextProcessor(namesList) + self.listProcessor = ListProcessor(namesList) self.binaryProcessor = BinaryProcessor() self.commonUtils = CommonUtils() diff --git a/modules/features/workspace/datamodelFeatureWorkspace.py b/modules/features/workspace/datamodelFeatureWorkspace.py new file mode 100644 index 00000000..80da5915 --- /dev/null +++ b/modules/features/workspace/datamodelFeatureWorkspace.py @@ -0,0 +1,65 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +"""Workspace feature data models — VoiceSettings and WorkspaceUserSettings.""" + +from typing import Dict, Any, Optional +from pydantic import BaseModel, Field +from modules.shared.attributeUtils import registerModelLabels +from modules.shared.timeUtils import getUtcTimestamp +import uuid + + +class VoiceSettings(BaseModel): + id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) + userId: str = Field(description="ID of the user these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) + mandateId: str = Field(description="ID of the mandate these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) + featureInstanceId: str = Field(description="ID of the feature instance these settings belong to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) + sttLanguage: str = Field(default="de-DE", description="Speech-to-Text language", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True}) + ttsLanguage: str = Field(default="de-DE", description="Text-to-Speech language", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True}) + ttsVoice: str = Field(default="de-DE-KatjaNeural", description="Text-to-Speech voice", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True}) + ttsVoiceMap: Dict[str, Any] = Field(default_factory=dict, description="Per-language voice mapping, e.g. {'de-DE': {'voiceName': 'de-DE-Wavenet-A'}, 'en-US': {'voiceName': 'en-US-Wavenet-C'}}", json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False}) + translationEnabled: bool = Field(default=True, description="Whether translation is enabled", json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}) + targetLanguage: str = Field(default="en-US", description="Target language for translation", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False}) + creationDate: float = Field(default_factory=getUtcTimestamp, description="Date when the settings were created (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}) + lastModified: float = Field(default_factory=getUtcTimestamp, description="Date when the settings were last modified (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}) + + +class WorkspaceUserSettings(BaseModel): + """Per-user workspace settings. None values mean 'use instance default'.""" + id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) + userId: str = Field(description="User ID", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) + mandateId: str = Field(description="Mandate ID", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) + featureInstanceId: str = Field(description="Feature Instance ID", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) + maxAgentRounds: Optional[int] = Field(default=None, description="Max agent rounds override (None = instance default)", json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": False}) + + +registerModelLabels( + "VoiceSettings", + {"en": "Voice Settings", "fr": "Paramètres vocaux"}, + { + "id": {"en": "ID", "fr": "ID"}, + "userId": {"en": "User ID", "fr": "ID utilisateur"}, + "mandateId": {"en": "Mandate ID", "fr": "ID du mandat"}, + "featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"}, + "sttLanguage": {"en": "STT Language", "fr": "Langue STT"}, + "ttsLanguage": {"en": "TTS Language", "fr": "Langue TTS"}, + "ttsVoice": {"en": "TTS Voice", "fr": "Voix TTS"}, + "ttsVoiceMap": {"en": "TTS Voice Map", "fr": "Carte des voix TTS"}, + "translationEnabled": {"en": "Translation Enabled", "fr": "Traduction activée"}, + "targetLanguage": {"en": "Target Language", "fr": "Langue cible"}, + "creationDate": {"en": "Creation Date", "fr": "Date de création"}, + "lastModified": {"en": "Last Modified", "fr": "Dernière modification"}, + }, +) + +registerModelLabels( + "WorkspaceUserSettings", + {"en": "Workspace User Settings", "de": "Workspace Benutzereinstellungen"}, + { + "id": {"en": "ID", "de": "ID"}, + "userId": {"en": "User ID", "de": "Benutzer-ID"}, + "mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"}, + "featureInstanceId": {"en": "Feature Instance ID", "de": "Feature-Instanz-ID"}, + "maxAgentRounds": {"en": "Max Agent Rounds", "de": "Max. Agenten-Runden"}, + }, +) diff --git a/modules/features/workspace/interfaceFeatureWorkspace.py b/modules/features/workspace/interfaceFeatureWorkspace.py new file mode 100644 index 00000000..bd1a03c4 --- /dev/null +++ b/modules/features/workspace/interfaceFeatureWorkspace.py @@ -0,0 +1,248 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Interface for Workspace feature — manages VoiceSettings and WorkspaceUserSettings. +Uses a dedicated poweron_workspace database. +""" + +import logging +from typing import Dict, Any, Optional + +from modules.connectors.connectorDbPostgre import DatabaseConnector +from modules.datamodels.datamodelUam import User +from modules.features.workspace.datamodelFeatureWorkspace import VoiceSettings, WorkspaceUserSettings +from modules.interfaces.interfaceRbac import getRecordsetWithRBAC +from modules.security.rbac import RbacClass +from modules.shared.configuration import APP_CONFIG +from modules.shared.timeUtils import getUtcTimestamp + +logger = logging.getLogger(__name__) + +_workspaceInterfaces: Dict[str, "WorkspaceObjects"] = {} + + +class WorkspaceObjects: + """Interface for Workspace-specific database operations (voice + general settings).""" + + def __init__(self, currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None): + self.currentUser = currentUser + self.mandateId = mandateId + self.featureInstanceId = featureInstanceId + self.userId = currentUser.id if currentUser else None + + self._initializeDatabase() + + from modules.security.rootAccess import getRootDbAppConnector + dbApp = getRootDbAppConnector() + self.rbac = RbacClass(self.db, dbApp=dbApp) + + self.db.updateContext(self.userId) + + def _initializeDatabase(self): + dbHost = APP_CONFIG.get("DB_HOST", "_no_config_default_data") + dbDatabase = "poweron_workspace" + dbUser = APP_CONFIG.get("DB_USER") + dbPassword = APP_CONFIG.get("DB_PASSWORD_SECRET") + dbPort = int(APP_CONFIG.get("DB_PORT", 5432)) + + self.db = DatabaseConnector( + dbHost=dbHost, + dbDatabase=dbDatabase, + dbUser=dbUser, + dbPassword=dbPassword, + dbPort=dbPort, + userId=self.userId, + ) + logger.debug(f"Workspace database initialized for user {self.userId}") + + def setUserContext(self, currentUser: User, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None): + self.currentUser = currentUser + self.userId = currentUser.id if currentUser else None + self.mandateId = mandateId + self.featureInstanceId = featureInstanceId + self.db.updateContext(self.userId) + + # ========================================================================= + # VoiceSettings CRUD + # ========================================================================= + + def getVoiceSettings(self, userId: Optional[str] = None) -> Optional[VoiceSettings]: + try: + targetUserId = userId or self.userId + if not targetUserId: + logger.error("No user ID provided for voice settings") + return None + + recordFilter: Dict[str, Any] = {"userId": targetUserId} + if self.featureInstanceId: + recordFilter["featureInstanceId"] = self.featureInstanceId + + filteredSettings = getRecordsetWithRBAC( + self.db, VoiceSettings, self.currentUser, + recordFilter=recordFilter, mandateId=self.mandateId, + ) + + if not filteredSettings: + return None + + settingsData = filteredSettings[0] + if not settingsData.get("creationDate"): + settingsData["creationDate"] = getUtcTimestamp() + if not settingsData.get("lastModified"): + settingsData["lastModified"] = getUtcTimestamp() + + return VoiceSettings(**settingsData) + + except Exception as e: + logger.error(f"Error getting voice settings: {e}") + return None + + def createVoiceSettings(self, settingsData: Dict[str, Any]) -> Dict[str, Any]: + try: + if "userId" not in settingsData: + settingsData["userId"] = self.userId + if "mandateId" not in settingsData: + settingsData["mandateId"] = self.mandateId + if "featureInstanceId" not in settingsData: + settingsData["featureInstanceId"] = self.featureInstanceId + + existing = self.getVoiceSettings(settingsData["userId"]) + if existing: + raise ValueError(f"Voice settings already exist for user {settingsData['userId']}") + + createdRecord = self.db.recordCreate(VoiceSettings, settingsData) + if not createdRecord or not createdRecord.get("id"): + raise ValueError("Failed to create voice settings record") + + logger.info(f"Created voice settings for user {settingsData['userId']}") + return createdRecord + + except Exception as e: + logger.error(f"Error creating voice settings: {e}") + raise + + def updateVoiceSettings(self, userId: str, updateData: Dict[str, Any]) -> Dict[str, Any]: + try: + existing = self.getVoiceSettings(userId) + if not existing: + raise ValueError(f"Voice settings not found for user {userId}") + + updateData["lastModified"] = getUtcTimestamp() + success = self.db.recordModify(VoiceSettings, existing.id, updateData) + if not success: + raise ValueError("Failed to update voice settings record") + + updated = self.getVoiceSettings(userId) + if not updated: + raise ValueError("Failed to retrieve updated voice settings") + + logger.info(f"Updated voice settings for user {userId}") + return updated.model_dump() + + except Exception as e: + logger.error(f"Error updating voice settings: {e}") + raise + + def deleteVoiceSettings(self, userId: str) -> bool: + try: + existing = self.getVoiceSettings(userId) + if not existing: + return False + success = self.db.recordDelete(VoiceSettings, existing.id) + if success: + logger.info(f"Deleted voice settings for user {userId}") + return success + except Exception as e: + logger.error(f"Error deleting voice settings: {e}") + return False + + def getOrCreateVoiceSettings(self, userId: Optional[str] = None) -> VoiceSettings: + targetUserId = userId or self.userId + if not targetUserId: + raise ValueError("No user ID provided for voice settings") + + existing = self.getVoiceSettings(targetUserId) + if existing: + return existing + + defaultSettings = { + "userId": targetUserId, + "mandateId": self.mandateId, + "featureInstanceId": self.featureInstanceId, + "sttLanguage": "de-DE", + "ttsLanguage": "de-DE", + "ttsVoice": "de-DE-KatjaNeural", + "translationEnabled": True, + "targetLanguage": "en-US", + } + createdRecord = self.createVoiceSettings(defaultSettings) + return VoiceSettings(**createdRecord) + + # ========================================================================= + # WorkspaceUserSettings CRUD + # ========================================================================= + + def getWorkspaceUserSettings(self, userId: Optional[str] = None) -> Optional[WorkspaceUserSettings]: + targetUserId = userId or self.userId + if not targetUserId: + return None + + try: + recordFilter: Dict[str, Any] = {"userId": targetUserId} + if self.featureInstanceId: + recordFilter["featureInstanceId"] = self.featureInstanceId + + records = getRecordsetWithRBAC( + self.db, WorkspaceUserSettings, self.currentUser, + recordFilter=recordFilter, mandateId=self.mandateId, + ) + if not records: + return None + return WorkspaceUserSettings(**records[0]) + + except Exception as e: + logger.error(f"Error getting workspace user settings: {e}") + return None + + def saveWorkspaceUserSettings(self, data: Dict[str, Any]) -> WorkspaceUserSettings: + """Upsert: create or update workspace user settings for the current user.""" + targetUserId = data.get("userId", self.userId) + if not targetUserId: + raise ValueError("No user ID provided") + + existing = self.getWorkspaceUserSettings(targetUserId) + if existing: + updateData = {k: v for k, v in data.items() if k not in ("id", "userId", "mandateId", "featureInstanceId")} + self.db.recordModify(WorkspaceUserSettings, existing.id, updateData) + updated = self.getWorkspaceUserSettings(targetUserId) + if not updated: + raise ValueError("Failed to retrieve updated workspace user settings") + return updated + + createData = { + "userId": targetUserId, + "mandateId": data.get("mandateId", self.mandateId), + "featureInstanceId": data.get("featureInstanceId", self.featureInstanceId), + } + createData.update({k: v for k, v in data.items() if k not in ("id", "userId", "mandateId", "featureInstanceId")}) + createdRecord = self.db.recordCreate(WorkspaceUserSettings, createData) + if not createdRecord or not createdRecord.get("id"): + raise ValueError("Failed to create workspace user settings") + return WorkspaceUserSettings(**createdRecord) + + +def getInterface(currentUser: Optional[User] = None, mandateId: Optional[str] = None, featureInstanceId: Optional[str] = None) -> WorkspaceObjects: + if not currentUser: + raise ValueError("Invalid user context: user is required") + + effectiveMandateId = str(mandateId) if mandateId else None + effectiveFeatureInstanceId = str(featureInstanceId) if featureInstanceId else None + + contextKey = f"workspace_{effectiveMandateId}_{effectiveFeatureInstanceId}_{currentUser.id}" + + if contextKey not in _workspaceInterfaces: + _workspaceInterfaces[contextKey] = WorkspaceObjects(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId) + else: + _workspaceInterfaces[contextKey].setUserContext(currentUser, mandateId=effectiveMandateId, featureInstanceId=effectiveFeatureInstanceId) + + return _workspaceInterfaces[contextKey] diff --git a/modules/features/workspace/routeFeatureWorkspace.py b/modules/features/workspace/routeFeatureWorkspace.py index 6dc7774e..dd8481ff 100644 --- a/modules/features/workspace/routeFeatureWorkspace.py +++ b/modules/features/workspace/routeFeatureWorkspace.py @@ -23,6 +23,7 @@ from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription SubscriptionInactiveException, ) from modules.interfaces import interfaceDbChat, interfaceDbManagement +from modules.features.workspace import interfaceFeatureWorkspace from modules.interfaces.interfaceAiObjects import AiObjects from modules.serviceCenter.core.serviceStreaming import get_event_manager from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentEventTypeEnum, PendingFileEdit @@ -145,6 +146,14 @@ def _getDbManagement(context: RequestContext, featureInstanceId: str = None): ) +def _getWorkspaceInterface(context: RequestContext, featureInstanceId: str = None): + return interfaceFeatureWorkspace.getInterface( + context.user, + mandateId=str(context.mandateId) if context.mandateId else None, + featureInstanceId=featureInstanceId, + ) + + _SOURCE_TYPE_TO_SERVICE = { "sharepointFolder": "sharepoint", "onedriveFolder": "onedrive", @@ -701,7 +710,17 @@ async def _runWorkspaceAgent( _toolSet = _cfg.get("toolSet", "core") _agentCfg = _cfg.get("agentConfig") from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentConfig - agentConfig = AgentConfig(**_agentCfg) if isinstance(_agentCfg, dict) else None + + agentCfgDict = dict(_agentCfg) if isinstance(_agentCfg, dict) else {} + try: + wsIf = interfaceFeatureWorkspace.getInterface(user, mandateId=mandateId, featureInstanceId=instanceId) + userSettings = wsIf.getWorkspaceUserSettings(user.id if user else None) + if userSettings and userSettings.maxAgentRounds is not None: + agentCfgDict["maxRounds"] = userSettings.maxAgentRounds + except Exception as e: + logger.debug(f"Could not load workspace user settings for agent config: {e}") + + agentConfig = AgentConfig(**agentCfgDict) if agentCfgDict else None async for event in agentService.runAgent( prompt=enrichedPrompt, @@ -1575,13 +1594,13 @@ async def getVoiceSettings( ): """Load voice settings for the current user and instance.""" _validateInstanceAccess(instanceId, context) - dbMgmt = _getDbManagement(context, instanceId) + wsInterface = _getWorkspaceInterface(context, instanceId) userId = str(context.user.id) try: - vs = dbMgmt.getVoiceSettings(userId) + vs = wsInterface.getVoiceSettings(userId) if not vs: logger.info(f"GET voice settings: not found for user={userId}, creating defaults") - vs = dbMgmt.getOrCreateVoiceSettings(userId) + vs = wsInterface.getOrCreateVoiceSettings(userId) result = vs.model_dump() if vs else {} mapKeys = list(result.get("ttsVoiceMap", {}).keys()) if result else [] logger.info(f"GET voice settings for user={userId}: ttsVoiceMap languages={mapKeys}") @@ -1601,12 +1620,12 @@ async def updateVoiceSettings( ): """Update voice settings for the current user and instance.""" _validateInstanceAccess(instanceId, context) - dbMgmt = _getDbManagement(context, instanceId) + wsInterface = _getWorkspaceInterface(context, instanceId) userId = str(context.user.id) try: logger.info(f"PUT voice settings for user={userId}, instance={instanceId}, body keys={list(body.keys())}") - vs = dbMgmt.getVoiceSettings(userId) + vs = wsInterface.getVoiceSettings(userId) if not vs: logger.info(f"No existing voice settings, creating new for user={userId}") createData = { @@ -1615,13 +1634,13 @@ async def updateVoiceSettings( "featureInstanceId": instanceId, } createData.update(body) - created = dbMgmt.createVoiceSettings(createData) + created = wsInterface.createVoiceSettings(createData) logger.info(f"Created voice settings for user={userId}, ttsVoiceMap keys={list((created or {}).get('ttsVoiceMap', {}).keys())}") return JSONResponse(created) updateData = {k: v for k, v in body.items() if k not in ("id", "userId", "mandateId", "featureInstanceId", "creationDate")} logger.info(f"Updating voice settings for user={userId}, update keys={list(updateData.keys())}") - updated = dbMgmt.updateVoiceSettings(userId, updateData) + updated = wsInterface.updateVoiceSettings(userId, updateData) logger.info(f"Updated voice settings for user={userId}, ttsVoiceMap keys={list((updated or {}).get('ttsVoiceMap', {}).keys())}") return JSONResponse(updated) except Exception as e: @@ -1827,3 +1846,63 @@ async def rejectAllEdits( logger.info(f"Rejected {len(rejected)} edits for instance {instanceId}") return JSONResponse({"rejected": rejected}) + + +# ========================================================================= +# General Settings Endpoints (per-user workspace settings) +# ========================================================================= + +@router.get("/{instanceId}/settings/general") +@limiter.limit("120/minute") +async def getGeneralSettings( + request: Request, + instanceId: str = Path(...), + context: RequestContext = Depends(getRequestContext), +): + """Load general workspace settings for the current user, with effective values.""" + _mandateId, instanceConfig = _validateInstanceAccess(instanceId, context) + wsInterface = _getWorkspaceInterface(context, instanceId) + userId = str(context.user.id) + + userSettings = wsInterface.getWorkspaceUserSettings(userId) + + agentCfg = (instanceConfig or {}).get("agentConfig", {}) + instanceDefault = agentCfg.get("maxRounds", 25) if isinstance(agentCfg, dict) else 25 + + userOverride = userSettings.maxAgentRounds if userSettings else None + effective = userOverride if userOverride is not None else instanceDefault + + return JSONResponse({ + "maxAgentRounds": { + "effective": effective, + "userOverride": userOverride, + "instanceDefault": instanceDefault, + }, + }) + + +@router.put("/{instanceId}/settings/general") +@limiter.limit("120/minute") +async def updateGeneralSettings( + request: Request, + instanceId: str = Path(...), + body: dict = Body(...), + context: RequestContext = Depends(getRequestContext), +): + """Update general workspace settings for the current user.""" + _validateInstanceAccess(instanceId, context) + wsInterface = _getWorkspaceInterface(context, instanceId) + userId = str(context.user.id) + + data = { + "userId": userId, + "mandateId": str(context.mandateId) if context.mandateId else "", + "featureInstanceId": instanceId, + } + if "maxAgentRounds" in body: + val = body["maxAgentRounds"] + data["maxAgentRounds"] = int(val) if val is not None else None + + wsInterface.saveWorkspaceUserSettings(data) + + return await getGeneralSettings(request, instanceId, context) diff --git a/modules/interfaces/interfaceDbKnowledge.py b/modules/interfaces/interfaceDbKnowledge.py index c8a597df..ae822db8 100644 --- a/modules/interfaces/interfaceDbKnowledge.py +++ b/modules/interfaces/interfaceDbKnowledge.py @@ -7,6 +7,8 @@ and semantic search via pgvector. """ import logging +from collections import defaultdict +from datetime import datetime, timezone, timedelta from typing import Dict, Any, List, Optional from modules.connectors.connectorDbPostgre import _get_cached_connector diff --git a/modules/interfaces/interfaceDbManagement.py b/modules/interfaces/interfaceDbManagement.py index ae0b14b0..64883b95 100644 --- a/modules/interfaces/interfaceDbManagement.py +++ b/modules/interfaces/interfaceDbManagement.py @@ -21,7 +21,6 @@ from modules.datamodels.datamodelUam import AccessLevel from modules.datamodels.datamodelFiles import FilePreview, FileItem, FileData from modules.datamodels.datamodelFileFolder import FileFolder from modules.datamodels.datamodelUtils import Prompt -from modules.datamodels.datamodelVoice import VoiceSettings from modules.datamodels.datamodelMessaging import ( MessagingSubscription, MessagingSubscriptionRegistration, @@ -1723,158 +1722,6 @@ class ComponentObjects: logger.error(f"Error in saveUploadedFile for {fileName}: {str(e)}", exc_info=True) raise FileStorageError(f"Error saving file: {str(e)}") - # VoiceSettings methods - - def getVoiceSettings(self, userId: Optional[str] = None) -> Optional[VoiceSettings]: - """Returns voice settings for a user if user has access.""" - try: - targetUserId = userId or self.userId - if not targetUserId: - logger.error("No user ID provided for voice settings") - return None - - recordFilter: Dict[str, Any] = {"userId": targetUserId} - if self.featureInstanceId: - recordFilter["featureInstanceId"] = self.featureInstanceId - - # Get voice settings for the user (scoped to current feature instance if available), filtered by RBAC - filteredSettings = getRecordsetWithRBAC(self.db, - VoiceSettings, - self.currentUser, - recordFilter=recordFilter, - mandateId=self.mandateId - ) - - if not filteredSettings: - logger.warning(f"No access to voice settings for user {targetUserId}") - return None - - # Ensure timestamps are set for validation - settings_data = filteredSettings[0] - if not settings_data.get("creationDate"): - settings_data["creationDate"] = getUtcTimestamp() - if not settings_data.get("lastModified"): - settings_data["lastModified"] = getUtcTimestamp() - - return VoiceSettings(**settings_data) - - except Exception as e: - logger.error(f"Error getting voice settings: {str(e)}") - return None - - def createVoiceSettings(self, settingsData: Dict[str, Any]) -> Dict[str, Any]: - """Creates voice settings for a user if user has permission.""" - try: - if not self.checkRbacPermission(VoiceSettings, "update"): - raise PermissionError("No permission to create voice settings") - - # Ensure userId is set - if "userId" not in settingsData: - settingsData["userId"] = self.userId - - # Ensure mandateId and featureInstanceId are set from context - if "mandateId" not in settingsData: - settingsData["mandateId"] = self.mandateId - if "featureInstanceId" not in settingsData: - settingsData["featureInstanceId"] = self.featureInstanceId - - # Check if settings already exist for this user - existingSettings = self.getVoiceSettings(settingsData["userId"]) - if existingSettings: - raise ValueError(f"Voice settings already exist for user {settingsData['userId']}") - - # Create voice settings record - createdRecord = self.db.recordCreate(VoiceSettings, settingsData) - if not createdRecord or not createdRecord.get("id"): - raise ValueError("Failed to create voice settings record") - - logger.info(f"Created voice settings for user {settingsData['userId']}") - return createdRecord - - except Exception as e: - logger.error(f"Error creating voice settings: {str(e)}") - raise - - def updateVoiceSettings(self, userId: str, updateData: Dict[str, Any]) -> Dict[str, Any]: - """Updates voice settings for a user if user has access.""" - try: - # Get existing settings - existingSettings = self.getVoiceSettings(userId) - if not existingSettings: - raise ValueError(f"Voice settings not found for user {userId}") - - # Update lastModified timestamp - updateData["lastModified"] = getUtcTimestamp() - - # Update voice settings record - success = self.db.recordModify(VoiceSettings, existingSettings.id, updateData) - if not success: - raise ValueError("Failed to update voice settings record") - - # Get updated settings - updatedSettings = self.getVoiceSettings(userId) - if not updatedSettings: - raise ValueError("Failed to retrieve updated voice settings") - - logger.info(f"Updated voice settings for user {userId}") - return updatedSettings.model_dump() - - except Exception as e: - logger.error(f"Error updating voice settings: {str(e)}") - raise - - def deleteVoiceSettings(self, userId: str) -> bool: - """Deletes voice settings for a user if user has access.""" - try: - # Get existing settings - existingSettings = self.getVoiceSettings(userId) - if not existingSettings: - logger.warning(f"Voice settings not found for user {userId}") - return False - - # Delete voice settings - success = self.db.recordDelete(VoiceSettings, existingSettings.id) - if success: - logger.info(f"Deleted voice settings for user {userId}") - else: - logger.error(f"Failed to delete voice settings for user {userId}") - - return success - - except Exception as e: - logger.error(f"Error deleting voice settings: {str(e)}") - return False - - def getOrCreateVoiceSettings(self, userId: Optional[str] = None) -> VoiceSettings: - """Gets existing voice settings or creates default ones for a user.""" - try: - targetUserId = userId or self.userId - if not targetUserId: - raise ValueError("No user ID provided for voice settings") - - # Try to get existing settings - existingSettings = self.getVoiceSettings(targetUserId) - if existingSettings: - return existingSettings - - # Create default settings - defaultSettings = { - "userId": targetUserId, - "mandateId": self.mandateId, - "sttLanguage": "de-DE", - "ttsLanguage": "de-DE", - "ttsVoice": "de-DE-KatjaNeural", - "translationEnabled": True, - "targetLanguage": "en-US" - } - - createdRecord = self.createVoiceSettings(defaultSettings) - return VoiceSettings(**createdRecord) - - except Exception as e: - logger.error(f"Error getting or creating voice settings: {str(e)}") - raise - # Messaging Subscription methods def getAllSubscriptions(self, pagination: Optional[PaginationParams] = None) -> Union[List[MessagingSubscription], PaginatedResult]: diff --git a/modules/serviceCenter/registry.py b/modules/serviceCenter/registry.py index be0accba..cdf57304 100644 --- a/modules/serviceCenter/registry.py +++ b/modules/serviceCenter/registry.py @@ -99,7 +99,7 @@ IMPORTABLE_SERVICES: Dict[str, Dict[str, Any]] = { "label": {"en": "Web Research", "de": "Web-Recherche", "fr": "Recherche Web"}, }, "neutralization": { - "module": "modules.serviceCenter.services.serviceNeutralization.mainServiceNeutralization", + "module": "modules.features.neutralization.serviceNeutralization.mainServiceNeutralization", "class": "NeutralizationService", "dependencies": ["extraction", "generation"], "objectKey": "service.neutralization", diff --git a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py index 03b8598e..78c69ff3 100644 --- a/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py +++ b/modules/serviceCenter/services/serviceAgent/mainServiceAgent.py @@ -1432,21 +1432,55 @@ def _registerCoreTools(registry: ToolRegistry, services): return ToolResult(toolCallId="", toolName="uploadToExternal", success=False, error=str(e)) async def _sendMail(args: Dict[str, Any], context: Dict[str, Any]): + import base64 as _b64 + connectionId = args.get("connectionId", "") to = args.get("to", []) subject = args.get("subject", "") body = args.get("body", "") + bodyType = "HTML" if args.get("bodyType", "text").lower() == "html" else "Text" + draft = args.get("draft", False) + attachmentFileIds = args.get("attachmentFileIds") or [] + if not connectionId or not to or not subject: return ToolResult(toolCallId="", toolName="sendMail", success=False, error="connectionId, to, and subject are required") try: + graphAttachments: List[Dict[str, Any]] = [] + if attachmentFileIds: + chatService = services.chat + dbMgmt = chatService.interfaceDbComponent + for fid in attachmentFileIds: + fileRow = dbMgmt.getFile(fid) + if not fileRow: + return ToolResult(toolCallId="", toolName="sendMail", success=False, error=f"Attachment file not found: {fid}") + rawBytes = dbMgmt.getFileData(fid) + if not rawBytes: + return ToolResult(toolCallId="", toolName="sendMail", success=False, error=f"Attachment file has no data: {fid}") + graphAttachments.append({ + "name": fileRow.fileName, + "contentBytes": _b64.b64encode(rawBytes).decode("ascii"), + "contentType": getattr(fileRow, "mimeType", "application/octet-stream"), + }) + from modules.connectors.connectorResolver import ConnectorResolver resolver = ConnectorResolver( services.getService("security"), _buildResolverDb(), ) adapter = await resolver.resolveService(connectionId, "outlook") + + if draft and hasattr(adapter, "createDraft"): + result = await adapter.createDraft( + to=to, subject=subject, body=body, bodyType=bodyType, + cc=args.get("cc"), attachments=graphAttachments or None, + ) + return ToolResult(toolCallId="", toolName="sendMail", success=True, data=str(result)) + if hasattr(adapter, "sendMail"): - result = await adapter.sendMail(to=to, subject=subject, body=body, cc=args.get("cc")) + result = await adapter.sendMail( + to=to, subject=subject, body=body, bodyType=bodyType, + cc=args.get("cc"), attachments=graphAttachments or None, + ) return ToolResult(toolCallId="", toolName="sendMail", success=True, data=str(result)) return ToolResult(toolCallId="", toolName="sendMail", success=False, error="Mail not supported by this adapter") except Exception as e: @@ -1484,15 +1518,26 @@ def _registerCoreTools(registry: ToolRegistry, services): registry.register( "sendMail", _sendMail, - description="Send an email via a connected mail service (Outlook, Gmail). Use listConnections to find the connectionId.", + description=( + "Send or draft an email via a connected mail service (Outlook). " + "Supports HTML body and file attachments from the workspace. " + "Set draft=true to save as draft without sending. " + "Use listConnections to find the connectionId." + ), parameters={ "type": "object", "properties": { "connectionId": {"type": "string", "description": "UserConnection ID"}, "to": {"type": "array", "items": {"type": "string"}, "description": "Recipient email addresses"}, "subject": {"type": "string", "description": "Email subject"}, - "body": {"type": "string", "description": "Email body text"}, + "body": {"type": "string", "description": "Email body — plain text or HTML markup"}, + "bodyType": {"type": "string", "enum": ["text", "html"], "description": "Body format: 'text' (default) or 'html'"}, "cc": {"type": "array", "items": {"type": "string"}, "description": "CC addresses"}, + "attachmentFileIds": { + "type": "array", "items": {"type": "string"}, + "description": "File IDs from the workspace to attach (use listFiles to find IDs)", + }, + "draft": {"type": "boolean", "description": "If true, save as draft in Drafts folder instead of sending"}, }, "required": ["connectionId", "to", "subject", "body"], }, @@ -2471,16 +2516,16 @@ def _registerCoreTools(registry: ToolRegistry, services): if not voiceName: try: - from modules.interfaces import interfaceDbManagement + from modules.features.workspace import interfaceFeatureWorkspace featureInstanceId = context.get("featureInstanceId", "") userId = context.get("userId", "") if userId: - dbMgmt = interfaceDbManagement.getInterface( + wsIf = interfaceFeatureWorkspace.getInterface( services.user, mandateId=mandateId or None, featureInstanceId=featureInstanceId or None, ) - vs = dbMgmt.getVoiceSettings(userId) if dbMgmt and hasattr(dbMgmt, "getVoiceSettings") else None + vs = wsIf.getVoiceSettings(userId) if wsIf else None if vs: voiceMap = {} if hasattr(vs, "ttsVoiceMap") and vs.ttsVoiceMap: @@ -2914,6 +2959,8 @@ def _registerCoreTools(registry: ToolRegistry, services): neutralizationService = services.getService("neutralization") if not neutralizationService: return ToolResult(toolCallId="", toolName="neutralizeData", success=False, error="Neutralization service not available") + if not neutralizationService.interfaceDbComponent: + neutralizationService.interfaceDbComponent = services.chat.interfaceDbComponent if text: result = neutralizationService.processText(text) else: diff --git a/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py b/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py index 969ec6b8..790612ed 100644 --- a/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py +++ b/modules/serviceCenter/services/serviceBilling/mainServiceBilling.py @@ -29,7 +29,7 @@ from modules.interfaces.interfaceDbBilling import getInterface as getBillingInte logger = logging.getLogger(__name__) # Markup percentage for internal pricing (+50% für Infrastruktur und Platform Service + 50% für Währungsrisiko ==> Faktor 2.0) -BILLING_MARKUP_PERCENT = 100 +BILLING_MARKUP_PERCENT = 400 # Singleton cache _billingServices: Dict[str, "BillingService"] = {} diff --git a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py index b9a1481c..944da4f7 100644 --- a/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py +++ b/modules/serviceCenter/services/serviceSubscription/mainServiceSubscription.py @@ -374,11 +374,13 @@ class SubscriptionService: raise ValueError("Subscription is already cancelled (non-recurring)") stripeSubId = sub.get("stripeSubscriptionId") + pUrl = "" if stripeSubId: try: from modules.shared.stripeClient import getStripeClient stripe = getStripeClient() - stripe.Subscription.modify(stripeSubId, cancel_at_period_end=True) + stripeSub = stripe.Subscription.modify(stripeSubId, cancel_at_period_end=True) + pUrl = (stripeSub.get("metadata") or {}).get("platformUrl", "") except Exception as e: logger.error("Failed to set cancel_at_period_end for %s: %s", stripeSubId, e) @@ -386,7 +388,7 @@ class SubscriptionService: self.invalidateCache(mandateId) plan = _getPlan(sub.get("planKey", "")) - _notifySubscriptionChange(mandateId, "cancelled", plan) + _notifySubscriptionChange(mandateId, "cancelled", plan, subscriptionRecord=sub, platformUrl=pUrl) return result # ========================================================================= @@ -435,16 +437,23 @@ class SubscriptionService: raise ValueError(f"Subscription {subscriptionId} not found") stripeSubId = sub.get("stripeSubscriptionId") + pUrl = "" if stripeSubId: try: from modules.shared.stripeClient import getStripeClient stripe = getStripeClient() + stripeSub = stripe.Subscription.retrieve(stripeSubId) + pUrl = (stripeSub.get("metadata") or {}).get("platformUrl", "") stripe.Subscription.cancel(stripeSubId) except Exception as e: logger.error("Failed to cancel Stripe sub %s: %s", stripeSubId, e) result = self._interface.forceExpire(subscriptionId) - self.invalidateCache(sub["mandateId"]) + mandateId = sub["mandateId"] + self.invalidateCache(mandateId) + + plan = _getPlan(sub.get("planKey", "")) + _notifySubscriptionChange(mandateId, "force_cancelled", plan, subscriptionRecord=sub, platformUrl=pUrl) return result # ========================================================================= @@ -496,6 +505,8 @@ def _notifySubscriptionChange( if event == "activated" and plan and subscriptionRecord: rawHtmlBlock = _buildInvoiceSummaryHtml(plan, subscriptionRecord, mandateId, platformUrl) + elif event in ("cancelled", "force_cancelled") and subscriptionRecord: + rawHtmlBlock = _buildCancelSummaryHtml(subscriptionRecord, platformUrl) templates: Dict[str, Dict[str, Any]] = { "activated": { @@ -520,6 +531,17 @@ def _notifySubscriptionChange( ] if p ], }, + "force_cancelled": { + "subject": f"[PowerOn] Abonnement sofort beendet — {planLabel}", + "headline": "Abonnement sofort beendet", + "paragraphs": [ + p for p in [ + f"Das Abonnement «{planLabel}» wurde durch den Plattform-Administrator sofort beendet.", + platformHint, + "Der Zugang wurde per sofort deaktiviert. Bei Fragen wenden Sie sich an den Plattform-Support.", + ] if p + ], + }, "trial_expired": { "subject": "[PowerOn] Testphase abgelaufen", "headline": "Testphase abgelaufen", @@ -633,6 +655,32 @@ def _buildInvoiceSummaryHtml( ) +def _buildCancelSummaryHtml(subRecord: Dict[str, Any], platformUrl: str = "") -> str: + """Build an HTML block with billing link and Stripe invoice link for cancel emails.""" + import html as htmlmod + + parts: list[str] = [] + + stripeSubId = subRecord.get("stripeSubscriptionId") + if stripeSubId: + try: + from modules.shared.stripeClient import getStripeClient + stripe = getStripeClient() + invoices = stripe.Invoice.list(subscription=stripeSubId, limit=1) + if invoices.data: + hostedUrl = invoices.data[0].get("hosted_invoice_url", "") + if hostedUrl: + parts.append( + f'

' + f'' + f'Letzte Stripe-Rechnung anzeigen

' + ) + except Exception as e: + logger.warning("Could not fetch Stripe invoice URL for sub %s: %s", stripeSubId, e) + + return "\n".join(parts) if parts else "" + + # ============================================================================ # Exception Classes # ============================================================================ diff --git a/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py b/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py index fd5666e0..38ac29e1 100644 --- a/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py +++ b/modules/serviceCenter/services/serviceSubscription/stripeBootstrap.py @@ -105,6 +105,43 @@ def _findExistingStripePrice(stripe, productId: str, unitAmount: int, interval: return None +def _getStripePriceAmount(stripe, priceId: str) -> Optional[int]: + """Retrieve the unit_amount (in Rappen) of an existing Stripe Price.""" + try: + price = stripe.Price.retrieve(priceId) + return price.get("unit_amount") if price else None + except Exception: + return None + + +def _reconcilePrice(stripe, productId: str, oldPriceId: str, expectedCHF: float, interval: str, nickname: str) -> str: + """If the stored Stripe Price has a different amount, create a new one and deactivate the old.""" + expectedCents = int(expectedCHF * 100) + actualCents = _getStripePriceAmount(stripe, oldPriceId) + + if actualCents == expectedCents: + return oldPriceId + + logger.warning( + "Price drift detected for %s: Stripe has %s Rappen, catalog expects %s Rappen. Rotating price.", + oldPriceId, actualCents, expectedCents, + ) + + existingMatch = _findExistingStripePrice(stripe, productId, expectedCents, interval) + if existingMatch: + newPriceId = existingMatch + else: + newPriceId = _createStripePrice(stripe, productId, expectedCHF, interval, nickname) + + try: + stripe.Price.modify(oldPriceId, active=False) + logger.info("Deactivated old Stripe Price %s", oldPriceId) + except Exception as e: + logger.warning("Could not deactivate old price %s: %s", oldPriceId, e) + + return newPriceId + + def _createStripePrice(stripe, productId: str, unitAmountCHF: float, interval: str, nickname: str) -> str: price = stripe.Price.create( product=productId, @@ -146,7 +183,29 @@ def bootstrapStripePrices() -> None: hasAllPrices = mapping.stripePriceIdUsers and mapping.stripePriceIdInstances hasAllProducts = mapping.stripeProductIdUsers and mapping.stripeProductIdInstances if hasAllPrices and hasAllProducts: - logger.debug("Stripe prices already configured for plan %s", planKey) + changed = False + reconciledUsers = _reconcilePrice( + stripe, mapping.stripeProductIdUsers, mapping.stripePriceIdUsers, + plan.pricePerUserCHF, interval, f"{planKey} — Benutzer-Lizenz", + ) + if reconciledUsers != mapping.stripePriceIdUsers: + changed = True + + reconciledInstances = _reconcilePrice( + stripe, mapping.stripeProductIdInstances, mapping.stripePriceIdInstances, + plan.pricePerFeatureInstanceCHF, interval, f"{planKey} — Feature-Instanz", + ) + if reconciledInstances != mapping.stripePriceIdInstances: + changed = True + + if changed: + db.recordModify(StripePlanPrice, mapping.id, { + "stripePriceIdUsers": reconciledUsers, + "stripePriceIdInstances": reconciledInstances, + }) + logger.info("Reconciled Stripe prices for plan %s: users=%s, instances=%s", planKey, reconciledUsers, reconciledInstances) + else: + logger.debug("Stripe prices up-to-date for plan %s", planKey) continue productIdUsers = None