tool fixes
This commit is contained in:
parent
9ef0d43091
commit
3934cdd3ee
15 changed files with 691 additions and 238 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -1192,24 +1192,79 @@ async def deleteDocumentRoute(
|
|||
|
||||
|
||||
def _extractText(content: bytes, mimeType: str, fileName: str) -> Optional[str]:
|
||||
"""Extract text from uploaded file content."""
|
||||
try:
|
||||
if mimeType == "text/plain" or fileName.endswith(".txt"):
|
||||
return content.decode("utf-8", errors="replace")
|
||||
if mimeType == "text/markdown" or fileName.endswith(".md"):
|
||||
return content.decode("utf-8", errors="replace")
|
||||
if "pdf" in mimeType or fileName.endswith(".pdf"):
|
||||
try:
|
||||
"""Extract text from uploaded file content (TXT, MD, HTML, PDF, DOCX, XLSX, PPTX)."""
|
||||
import io
|
||||
|
||||
lowerName = fileName.lower()
|
||||
try:
|
||||
if mimeType in ("text/plain",) or lowerName.endswith(".txt"):
|
||||
return content.decode("utf-8", errors="replace")
|
||||
|
||||
if mimeType in ("text/markdown",) or lowerName.endswith(".md"):
|
||||
return content.decode("utf-8", errors="replace")
|
||||
|
||||
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:
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
65
modules/features/workspace/datamodelFeatureWorkspace.py
Normal file
65
modules/features/workspace/datamodelFeatureWorkspace.py
Normal file
|
|
@ -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"},
|
||||
},
|
||||
)
|
||||
248
modules/features/workspace/interfaceFeatureWorkspace.py
Normal file
248
modules/features/workspace/interfaceFeatureWorkspace.py
Normal file
|
|
@ -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]
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"] = {}
|
||||
|
|
|
|||
|
|
@ -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'<p style="margin:4px 0;font-size:14px;">'
|
||||
f'<a href="{htmlmod.escape(hostedUrl)}" style="color:#3b82f6;text-decoration:underline;">'
|
||||
f'Letzte Stripe-Rechnung anzeigen</a></p>'
|
||||
)
|
||||
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
|
||||
# ============================================================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue