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", [])
|
for m in result.get("value", [])
|
||||||
]
|
]
|
||||||
|
|
||||||
async def sendMail(
|
def _buildMessage(
|
||||||
self, to: List[str], subject: str, body: str,
|
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]:
|
) -> Dict[str, Any]:
|
||||||
"""Send an email via Microsoft Graph."""
|
"""Build a Graph API message object.
|
||||||
import json
|
|
||||||
|
attachments: list of {"name": str, "contentBytes": str (base64), "contentType": str}
|
||||||
|
"""
|
||||||
message: Dict[str, Any] = {
|
message: Dict[str, Any] = {
|
||||||
"subject": subject,
|
"subject": subject,
|
||||||
"body": {"contentType": "Text", "content": body},
|
"body": {"contentType": bodyType, "content": body},
|
||||||
"toRecipients": [{"emailAddress": {"address": addr}} for addr in to],
|
"toRecipients": [{"emailAddress": {"address": addr}} for addr in to],
|
||||||
}
|
}
|
||||||
if cc:
|
if cc:
|
||||||
message["ccRecipients"] = [{"emailAddress": {"address": addr}} for addr in 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")
|
payload = json.dumps({"message": message, "saveToSentItems": True}).encode("utf-8")
|
||||||
result = await self._graphPost("me/sendMail", payload)
|
result = await self._graphPost("me/sendMail", payload)
|
||||||
if "error" in result:
|
if "error" in result:
|
||||||
return result
|
return result
|
||||||
return {"success": True}
|
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)
|
# Teams Adapter (Stub)
|
||||||
|
|
|
||||||
|
|
@ -1,46 +1,7 @@
|
||||||
# Copyright (c) 2025 Patrick Motsch
|
# Copyright (c) 2025 Patrick Motsch
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
"""Voice settings datamodel."""
|
"""Voice settings datamodel — re-exported from workspace feature for backward compatibility."""
|
||||||
|
|
||||||
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"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
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]:
|
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)."""
|
||||||
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:
|
|
||||||
import io
|
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
|
from PyPDF2 import PdfReader
|
||||||
reader = PdfReader(io.BytesIO(content))
|
reader = PdfReader(io.BytesIO(content))
|
||||||
text = ""
|
return "".join(page.extract_text() or "" for page in reader.pages)
|
||||||
for page in reader.pages:
|
|
||||||
text += page.extract_text() or ""
|
|
||||||
return text
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
logger.warning("PyPDF2 not installed, cannot extract PDF text")
|
logger.warning("PyPDF2 not installed, cannot extract PDF text")
|
||||||
return None
|
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:
|
except Exception as e:
|
||||||
logger.warning(f"Text extraction failed for {fileName}: {e}")
|
logger.warning(f"Text extraction failed for {fileName}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -331,7 +331,8 @@ def _getDocumentSummaries(contextId: str, userId: str, interface) -> Optional[Li
|
||||||
elif doc.get("extractedText"):
|
elif doc.get("extractedText"):
|
||||||
summaries.append(f"[{doc.get('fileName', 'Dokument')}] {doc['extractedText'][:200]}...")
|
summaries.append(f"[{doc.get('fileName', 'Dokument')}] {doc['extractedText'][:200]}...")
|
||||||
return summaries if summaries else None
|
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
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,19 +39,21 @@ EXTRACTABLE_BINARY_MIME_TYPES = frozenset({
|
||||||
class NeutralizationService:
|
class NeutralizationService:
|
||||||
"""Service for handling data neutralization operations"""
|
"""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
|
"""Initialize the service with user context and anonymization processors
|
||||||
|
|
||||||
Args:
|
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)
|
NamesToParse: List of names to parse and replace (case-insensitive)
|
||||||
"""
|
"""
|
||||||
self.services = serviceCenter
|
self.services = serviceCenter
|
||||||
self.interfaceDbComponent = serviceCenter.interfaceDbComponent
|
self._getService = getServiceFn
|
||||||
|
self.interfaceDbComponent = getattr(serviceCenter, "interfaceDbComponent", None)
|
||||||
|
|
||||||
# Create feature-specific interface for neutralizer DB operations
|
# Create feature-specific interface for neutralizer DB operations
|
||||||
self.interfaceNeutralizer: InterfaceFeatureNeutralizer = None
|
self.interfaceNeutralizer: InterfaceFeatureNeutralizer = None
|
||||||
if serviceCenter and serviceCenter.interfaceDbApp:
|
if serviceCenter and getattr(serviceCenter, "interfaceDbApp", None):
|
||||||
dbApp = serviceCenter.interfaceDbApp
|
dbApp = serviceCenter.interfaceDbApp
|
||||||
self.interfaceNeutralizer = getNeutralizerInterface(
|
self.interfaceNeutralizer = getNeutralizerInterface(
|
||||||
currentUser=dbApp.currentUser,
|
currentUser=dbApp.currentUser,
|
||||||
|
|
@ -59,10 +61,10 @@ class NeutralizationService:
|
||||||
featureInstanceId=getattr(serviceCenter, 'featureInstanceId', None) or getattr(dbApp, 'featureInstanceId', None)
|
featureInstanceId=getattr(serviceCenter, 'featureInstanceId', None) or getattr(dbApp, 'featureInstanceId', None)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Initialize anonymization processors
|
namesList = NamesToParse if isinstance(NamesToParse, list) else []
|
||||||
self.NamesToParse = NamesToParse or []
|
self.NamesToParse = namesList
|
||||||
self.textProcessor = TextProcessor(NamesToParse)
|
self.textProcessor = TextProcessor(namesList)
|
||||||
self.listProcessor = ListProcessor(NamesToParse)
|
self.listProcessor = ListProcessor(namesList)
|
||||||
self.binaryProcessor = BinaryProcessor()
|
self.binaryProcessor = BinaryProcessor()
|
||||||
self.commonUtils = CommonUtils()
|
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,
|
SubscriptionInactiveException,
|
||||||
)
|
)
|
||||||
from modules.interfaces import interfaceDbChat, interfaceDbManagement
|
from modules.interfaces import interfaceDbChat, interfaceDbManagement
|
||||||
|
from modules.features.workspace import interfaceFeatureWorkspace
|
||||||
from modules.interfaces.interfaceAiObjects import AiObjects
|
from modules.interfaces.interfaceAiObjects import AiObjects
|
||||||
from modules.serviceCenter.core.serviceStreaming import get_event_manager
|
from modules.serviceCenter.core.serviceStreaming import get_event_manager
|
||||||
from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentEventTypeEnum, PendingFileEdit
|
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 = {
|
_SOURCE_TYPE_TO_SERVICE = {
|
||||||
"sharepointFolder": "sharepoint",
|
"sharepointFolder": "sharepoint",
|
||||||
"onedriveFolder": "onedrive",
|
"onedriveFolder": "onedrive",
|
||||||
|
|
@ -701,7 +710,17 @@ async def _runWorkspaceAgent(
|
||||||
_toolSet = _cfg.get("toolSet", "core")
|
_toolSet = _cfg.get("toolSet", "core")
|
||||||
_agentCfg = _cfg.get("agentConfig")
|
_agentCfg = _cfg.get("agentConfig")
|
||||||
from modules.serviceCenter.services.serviceAgent.datamodelAgent import 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(
|
async for event in agentService.runAgent(
|
||||||
prompt=enrichedPrompt,
|
prompt=enrichedPrompt,
|
||||||
|
|
@ -1575,13 +1594,13 @@ async def getVoiceSettings(
|
||||||
):
|
):
|
||||||
"""Load voice settings for the current user and instance."""
|
"""Load voice settings for the current user and instance."""
|
||||||
_validateInstanceAccess(instanceId, context)
|
_validateInstanceAccess(instanceId, context)
|
||||||
dbMgmt = _getDbManagement(context, instanceId)
|
wsInterface = _getWorkspaceInterface(context, instanceId)
|
||||||
userId = str(context.user.id)
|
userId = str(context.user.id)
|
||||||
try:
|
try:
|
||||||
vs = dbMgmt.getVoiceSettings(userId)
|
vs = wsInterface.getVoiceSettings(userId)
|
||||||
if not vs:
|
if not vs:
|
||||||
logger.info(f"GET voice settings: not found for user={userId}, creating defaults")
|
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 {}
|
result = vs.model_dump() if vs else {}
|
||||||
mapKeys = list(result.get("ttsVoiceMap", {}).keys()) if result else []
|
mapKeys = list(result.get("ttsVoiceMap", {}).keys()) if result else []
|
||||||
logger.info(f"GET voice settings for user={userId}: ttsVoiceMap languages={mapKeys}")
|
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."""
|
"""Update voice settings for the current user and instance."""
|
||||||
_validateInstanceAccess(instanceId, context)
|
_validateInstanceAccess(instanceId, context)
|
||||||
dbMgmt = _getDbManagement(context, instanceId)
|
wsInterface = _getWorkspaceInterface(context, instanceId)
|
||||||
userId = str(context.user.id)
|
userId = str(context.user.id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info(f"PUT voice settings for user={userId}, instance={instanceId}, body keys={list(body.keys())}")
|
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:
|
if not vs:
|
||||||
logger.info(f"No existing voice settings, creating new for user={userId}")
|
logger.info(f"No existing voice settings, creating new for user={userId}")
|
||||||
createData = {
|
createData = {
|
||||||
|
|
@ -1615,13 +1634,13 @@ async def updateVoiceSettings(
|
||||||
"featureInstanceId": instanceId,
|
"featureInstanceId": instanceId,
|
||||||
}
|
}
|
||||||
createData.update(body)
|
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())}")
|
logger.info(f"Created voice settings for user={userId}, ttsVoiceMap keys={list((created or {}).get('ttsVoiceMap', {}).keys())}")
|
||||||
return JSONResponse(created)
|
return JSONResponse(created)
|
||||||
|
|
||||||
updateData = {k: v for k, v in body.items() if k not in ("id", "userId", "mandateId", "featureInstanceId", "creationDate")}
|
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())}")
|
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())}")
|
logger.info(f"Updated voice settings for user={userId}, ttsVoiceMap keys={list((updated or {}).get('ttsVoiceMap', {}).keys())}")
|
||||||
return JSONResponse(updated)
|
return JSONResponse(updated)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -1827,3 +1846,63 @@ async def rejectAllEdits(
|
||||||
|
|
||||||
logger.info(f"Rejected {len(rejected)} edits for instance {instanceId}")
|
logger.info(f"Rejected {len(rejected)} edits for instance {instanceId}")
|
||||||
return JSONResponse({"rejected": rejected})
|
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
|
import logging
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
from typing import Dict, Any, List, Optional
|
from typing import Dict, Any, List, Optional
|
||||||
|
|
||||||
from modules.connectors.connectorDbPostgre import _get_cached_connector
|
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.datamodelFiles import FilePreview, FileItem, FileData
|
||||||
from modules.datamodels.datamodelFileFolder import FileFolder
|
from modules.datamodels.datamodelFileFolder import FileFolder
|
||||||
from modules.datamodels.datamodelUtils import Prompt
|
from modules.datamodels.datamodelUtils import Prompt
|
||||||
from modules.datamodels.datamodelVoice import VoiceSettings
|
|
||||||
from modules.datamodels.datamodelMessaging import (
|
from modules.datamodels.datamodelMessaging import (
|
||||||
MessagingSubscription,
|
MessagingSubscription,
|
||||||
MessagingSubscriptionRegistration,
|
MessagingSubscriptionRegistration,
|
||||||
|
|
@ -1723,158 +1722,6 @@ class ComponentObjects:
|
||||||
logger.error(f"Error in saveUploadedFile for {fileName}: {str(e)}", exc_info=True)
|
logger.error(f"Error in saveUploadedFile for {fileName}: {str(e)}", exc_info=True)
|
||||||
raise FileStorageError(f"Error saving file: {str(e)}")
|
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
|
# Messaging Subscription methods
|
||||||
|
|
||||||
def getAllSubscriptions(self, pagination: Optional[PaginationParams] = None) -> Union[List[MessagingSubscription], PaginatedResult]:
|
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"},
|
"label": {"en": "Web Research", "de": "Web-Recherche", "fr": "Recherche Web"},
|
||||||
},
|
},
|
||||||
"neutralization": {
|
"neutralization": {
|
||||||
"module": "modules.serviceCenter.services.serviceNeutralization.mainServiceNeutralization",
|
"module": "modules.features.neutralization.serviceNeutralization.mainServiceNeutralization",
|
||||||
"class": "NeutralizationService",
|
"class": "NeutralizationService",
|
||||||
"dependencies": ["extraction", "generation"],
|
"dependencies": ["extraction", "generation"],
|
||||||
"objectKey": "service.neutralization",
|
"objectKey": "service.neutralization",
|
||||||
|
|
|
||||||
|
|
@ -1432,21 +1432,55 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
||||||
return ToolResult(toolCallId="", toolName="uploadToExternal", success=False, error=str(e))
|
return ToolResult(toolCallId="", toolName="uploadToExternal", success=False, error=str(e))
|
||||||
|
|
||||||
async def _sendMail(args: Dict[str, Any], context: Dict[str, Any]):
|
async def _sendMail(args: Dict[str, Any], context: Dict[str, Any]):
|
||||||
|
import base64 as _b64
|
||||||
|
|
||||||
connectionId = args.get("connectionId", "")
|
connectionId = args.get("connectionId", "")
|
||||||
to = args.get("to", [])
|
to = args.get("to", [])
|
||||||
subject = args.get("subject", "")
|
subject = args.get("subject", "")
|
||||||
body = args.get("body", "")
|
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:
|
if not connectionId or not to or not subject:
|
||||||
return ToolResult(toolCallId="", toolName="sendMail", success=False, error="connectionId, to, and subject are required")
|
return ToolResult(toolCallId="", toolName="sendMail", success=False, error="connectionId, to, and subject are required")
|
||||||
try:
|
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
|
from modules.connectors.connectorResolver import ConnectorResolver
|
||||||
resolver = ConnectorResolver(
|
resolver = ConnectorResolver(
|
||||||
services.getService("security"),
|
services.getService("security"),
|
||||||
_buildResolverDb(),
|
_buildResolverDb(),
|
||||||
)
|
)
|
||||||
adapter = await resolver.resolveService(connectionId, "outlook")
|
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"):
|
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=True, data=str(result))
|
||||||
return ToolResult(toolCallId="", toolName="sendMail", success=False, error="Mail not supported by this adapter")
|
return ToolResult(toolCallId="", toolName="sendMail", success=False, error="Mail not supported by this adapter")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -1484,15 +1518,26 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
||||||
|
|
||||||
registry.register(
|
registry.register(
|
||||||
"sendMail", _sendMail,
|
"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={
|
parameters={
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"connectionId": {"type": "string", "description": "UserConnection ID"},
|
"connectionId": {"type": "string", "description": "UserConnection ID"},
|
||||||
"to": {"type": "array", "items": {"type": "string"}, "description": "Recipient email addresses"},
|
"to": {"type": "array", "items": {"type": "string"}, "description": "Recipient email addresses"},
|
||||||
"subject": {"type": "string", "description": "Email subject"},
|
"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"},
|
"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"],
|
"required": ["connectionId", "to", "subject", "body"],
|
||||||
},
|
},
|
||||||
|
|
@ -2471,16 +2516,16 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
||||||
|
|
||||||
if not voiceName:
|
if not voiceName:
|
||||||
try:
|
try:
|
||||||
from modules.interfaces import interfaceDbManagement
|
from modules.features.workspace import interfaceFeatureWorkspace
|
||||||
featureInstanceId = context.get("featureInstanceId", "")
|
featureInstanceId = context.get("featureInstanceId", "")
|
||||||
userId = context.get("userId", "")
|
userId = context.get("userId", "")
|
||||||
if userId:
|
if userId:
|
||||||
dbMgmt = interfaceDbManagement.getInterface(
|
wsIf = interfaceFeatureWorkspace.getInterface(
|
||||||
services.user,
|
services.user,
|
||||||
mandateId=mandateId or None,
|
mandateId=mandateId or None,
|
||||||
featureInstanceId=featureInstanceId 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:
|
if vs:
|
||||||
voiceMap = {}
|
voiceMap = {}
|
||||||
if hasattr(vs, "ttsVoiceMap") and vs.ttsVoiceMap:
|
if hasattr(vs, "ttsVoiceMap") and vs.ttsVoiceMap:
|
||||||
|
|
@ -2914,6 +2959,8 @@ def _registerCoreTools(registry: ToolRegistry, services):
|
||||||
neutralizationService = services.getService("neutralization")
|
neutralizationService = services.getService("neutralization")
|
||||||
if not neutralizationService:
|
if not neutralizationService:
|
||||||
return ToolResult(toolCallId="", toolName="neutralizeData", success=False, error="Neutralization service not available")
|
return ToolResult(toolCallId="", toolName="neutralizeData", success=False, error="Neutralization service not available")
|
||||||
|
if not neutralizationService.interfaceDbComponent:
|
||||||
|
neutralizationService.interfaceDbComponent = services.chat.interfaceDbComponent
|
||||||
if text:
|
if text:
|
||||||
result = neutralizationService.processText(text)
|
result = neutralizationService.processText(text)
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ from modules.interfaces.interfaceDbBilling import getInterface as getBillingInte
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Markup percentage for internal pricing (+50% für Infrastruktur und Platform Service + 50% für Währungsrisiko ==> Faktor 2.0)
|
# 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
|
# Singleton cache
|
||||||
_billingServices: Dict[str, "BillingService"] = {}
|
_billingServices: Dict[str, "BillingService"] = {}
|
||||||
|
|
|
||||||
|
|
@ -374,11 +374,13 @@ class SubscriptionService:
|
||||||
raise ValueError("Subscription is already cancelled (non-recurring)")
|
raise ValueError("Subscription is already cancelled (non-recurring)")
|
||||||
|
|
||||||
stripeSubId = sub.get("stripeSubscriptionId")
|
stripeSubId = sub.get("stripeSubscriptionId")
|
||||||
|
pUrl = ""
|
||||||
if stripeSubId:
|
if stripeSubId:
|
||||||
try:
|
try:
|
||||||
from modules.shared.stripeClient import getStripeClient
|
from modules.shared.stripeClient import getStripeClient
|
||||||
stripe = 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:
|
except Exception as e:
|
||||||
logger.error("Failed to set cancel_at_period_end for %s: %s", stripeSubId, e)
|
logger.error("Failed to set cancel_at_period_end for %s: %s", stripeSubId, e)
|
||||||
|
|
||||||
|
|
@ -386,7 +388,7 @@ class SubscriptionService:
|
||||||
self.invalidateCache(mandateId)
|
self.invalidateCache(mandateId)
|
||||||
|
|
||||||
plan = _getPlan(sub.get("planKey", ""))
|
plan = _getPlan(sub.get("planKey", ""))
|
||||||
_notifySubscriptionChange(mandateId, "cancelled", plan)
|
_notifySubscriptionChange(mandateId, "cancelled", plan, subscriptionRecord=sub, platformUrl=pUrl)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
@ -435,16 +437,23 @@ class SubscriptionService:
|
||||||
raise ValueError(f"Subscription {subscriptionId} not found")
|
raise ValueError(f"Subscription {subscriptionId} not found")
|
||||||
|
|
||||||
stripeSubId = sub.get("stripeSubscriptionId")
|
stripeSubId = sub.get("stripeSubscriptionId")
|
||||||
|
pUrl = ""
|
||||||
if stripeSubId:
|
if stripeSubId:
|
||||||
try:
|
try:
|
||||||
from modules.shared.stripeClient import getStripeClient
|
from modules.shared.stripeClient import getStripeClient
|
||||||
stripe = getStripeClient()
|
stripe = getStripeClient()
|
||||||
|
stripeSub = stripe.Subscription.retrieve(stripeSubId)
|
||||||
|
pUrl = (stripeSub.get("metadata") or {}).get("platformUrl", "")
|
||||||
stripe.Subscription.cancel(stripeSubId)
|
stripe.Subscription.cancel(stripeSubId)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error("Failed to cancel Stripe sub %s: %s", stripeSubId, e)
|
logger.error("Failed to cancel Stripe sub %s: %s", stripeSubId, e)
|
||||||
|
|
||||||
result = self._interface.forceExpire(subscriptionId)
|
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
|
return result
|
||||||
|
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|
@ -496,6 +505,8 @@ def _notifySubscriptionChange(
|
||||||
|
|
||||||
if event == "activated" and plan and subscriptionRecord:
|
if event == "activated" and plan and subscriptionRecord:
|
||||||
rawHtmlBlock = _buildInvoiceSummaryHtml(plan, subscriptionRecord, mandateId, platformUrl)
|
rawHtmlBlock = _buildInvoiceSummaryHtml(plan, subscriptionRecord, mandateId, platformUrl)
|
||||||
|
elif event in ("cancelled", "force_cancelled") and subscriptionRecord:
|
||||||
|
rawHtmlBlock = _buildCancelSummaryHtml(subscriptionRecord, platformUrl)
|
||||||
|
|
||||||
templates: Dict[str, Dict[str, Any]] = {
|
templates: Dict[str, Dict[str, Any]] = {
|
||||||
"activated": {
|
"activated": {
|
||||||
|
|
@ -520,6 +531,17 @@ def _notifySubscriptionChange(
|
||||||
] if p
|
] 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": {
|
"trial_expired": {
|
||||||
"subject": "[PowerOn] Testphase abgelaufen",
|
"subject": "[PowerOn] Testphase abgelaufen",
|
||||||
"headline": "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
|
# Exception Classes
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,43 @@ def _findExistingStripePrice(stripe, productId: str, unitAmount: int, interval:
|
||||||
return None
|
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:
|
def _createStripePrice(stripe, productId: str, unitAmountCHF: float, interval: str, nickname: str) -> str:
|
||||||
price = stripe.Price.create(
|
price = stripe.Price.create(
|
||||||
product=productId,
|
product=productId,
|
||||||
|
|
@ -146,7 +183,29 @@ def bootstrapStripePrices() -> None:
|
||||||
hasAllPrices = mapping.stripePriceIdUsers and mapping.stripePriceIdInstances
|
hasAllPrices = mapping.stripePriceIdUsers and mapping.stripePriceIdInstances
|
||||||
hasAllProducts = mapping.stripeProductIdUsers and mapping.stripeProductIdInstances
|
hasAllProducts = mapping.stripeProductIdUsers and mapping.stripeProductIdInstances
|
||||||
if hasAllPrices and hasAllProducts:
|
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
|
continue
|
||||||
|
|
||||||
productIdUsers = None
|
productIdUsers = None
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue