From 3934cdd3ee5e5f9e1186f6c9679a122c276e5bbd Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Mon, 23 Mar 2026 00:05:29 +0100
Subject: [PATCH] tool fixes
---
.../connectors/providerMsft/connectorMsft.py | 49 +++-
modules/datamodels/datamodelVoice.py | 45 +---
.../commcoach/routeFeatureCommcoach.py | 73 +++++-
.../features/commcoach/serviceCommcoach.py | 3 +-
.../mainServiceNeutralization.py | 18 +-
.../workspace/datamodelFeatureWorkspace.py | 65 +++++
.../workspace/interfaceFeatureWorkspace.py | 248 ++++++++++++++++++
.../workspace/routeFeatureWorkspace.py | 95 ++++++-
modules/interfaces/interfaceDbKnowledge.py | 2 +
modules/interfaces/interfaceDbManagement.py | 153 -----------
modules/serviceCenter/registry.py | 2 +-
.../services/serviceAgent/mainServiceAgent.py | 59 ++++-
.../serviceBilling/mainServiceBilling.py | 2 +-
.../mainServiceSubscription.py | 54 +++-
.../serviceSubscription/stripeBootstrap.py | 61 ++++-
15 files changed, 691 insertions(+), 238 deletions(-)
create mode 100644 modules/features/workspace/datamodelFeatureWorkspace.py
create mode 100644 modules/features/workspace/interfaceFeatureWorkspace.py
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