From e43b0741ede73c401cc6122794aa4f4332ffafdc Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Sun, 12 Apr 2026 14:04:49 +0200
Subject: [PATCH] fixes lang uand issues
---
modules/connectors/connectorVoiceGoogle.py | 25 ++--
modules/datamodels/datamodelFiles.py | 8 +-
modules/datamodels/datamodelSubscription.py | 34 ++---
modules/interfaces/interfaceDbBilling.py | 6 +-
modules/interfaces/interfaceDbManagement.py | 125 ++++++++----------
modules/routes/routeAdminFeatures.py | 9 +-
modules/routes/routeAdminRbacRules.py | 12 +-
modules/routes/routeBilling.py | 16 ++-
modules/routes/routeDataFiles.py | 2 +-
modules/routes/routeI18n.py | 17 ++-
modules/routes/routeSubscription.py | 69 +++++++++-
modules/routes/routeSystem.py | 20 ++-
.../services/serviceExtraction/subRegistry.py | 34 +++++
modules/system/mainSystem.py | 64 ++++-----
14 files changed, 275 insertions(+), 166 deletions(-)
diff --git a/modules/connectors/connectorVoiceGoogle.py b/modules/connectors/connectorVoiceGoogle.py
index 0dbb46a5..aebede8a 100644
--- a/modules/connectors/connectorVoiceGoogle.py
+++ b/modules/connectors/connectorVoiceGoogle.py
@@ -18,9 +18,13 @@ from modules.shared.configuration import APP_CONFIG
logger = logging.getLogger(__name__)
-# Gemini-TTS speaker IDs from voices.list use short names (e.g. "Kore") and require model_name + prompt.
+# Gemini-TTS speaker IDs from voices.list use short names (e.g. "Kore") and require
+# SynthesisInput.prompt + VoiceSelectionParams.model_name (google-cloud-texttospeech >= 2.24.0).
_GEMINI_TTS_DEFAULT_MODEL = "gemini-2.5-flash-tts"
_GEMINI_TTS_NEUTRAL_PROMPT = "Say the following"
+_GEMINI_TTS_MIN_CLIENT_HINT = (
+ "Gemini-TTS requires google-cloud-texttospeech>=2.24.0 (SynthesisInput.prompt, VoiceSelectionParams.model_name)."
+)
class ConnectorGoogleSpeech:
@@ -940,7 +944,9 @@ class ConnectorGoogleSpeech:
logger.info(f"Using TTS voice: {selectedVoice} for language: {languageCode}")
- if self._isGeminiTtsSpeakerVoiceName(selectedVoice):
+ isGeminiVoice = self._isGeminiTtsSpeakerVoiceName(selectedVoice)
+
+ if isGeminiVoice:
synthesisInput = texttospeech.SynthesisInput(
text=text,
prompt=_GEMINI_TTS_NEUTRAL_PROMPT,
@@ -958,19 +964,17 @@ class ConnectorGoogleSpeech:
name=selectedVoice,
ssml_gender=texttospeech.SsmlVoiceGender.NEUTRAL,
)
-
- # Select the type of audio file to return
+
audioConfig = texttospeech.AudioConfig(
audio_encoding=texttospeech.AudioEncoding.MP3
)
-
- # Perform the text-to-speech request
+
response = self.tts_client.synthesize_speech(
input=synthesisInput,
voice=voice,
audio_config=audioConfig
)
-
+
# Return the audio content
return {
"success": True,
@@ -982,9 +986,14 @@ class ConnectorGoogleSpeech:
except Exception as e:
logger.error(f"Text-to-Speech error: {e}")
+ detail = str(e)
+ extra = ""
+ low = detail.lower()
+ if "prompt" in low or "model_name" in low or "unknown field" in low:
+ extra = f" {_GEMINI_TTS_MIN_CLIENT_HINT}"
return {
"success": False,
- "error": f"Text-to-Speech failed: {str(e)}"
+ "error": f"Text-to-Speech failed: {detail}{extra}",
}
def _getDefaultVoice(self, languageCode: str) -> str:
diff --git a/modules/datamodels/datamodelFiles.py b/modules/datamodels/datamodelFiles.py
index 8a423fe5..0bf79bca 100644
--- a/modules/datamodels/datamodelFiles.py
+++ b/modules/datamodels/datamodelFiles.py
@@ -18,6 +18,10 @@ class FileItem(PowerOnModel):
description="Primary key",
json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
)
+ fileName: str = Field(
+ description="Name of the file",
+ json_schema_extra={"label": "Dateiname", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True},
+ )
mandateId: Optional[str] = Field(
default="",
description="ID of the mandate this file belongs to",
@@ -28,10 +32,6 @@ class FileItem(PowerOnModel):
description="ID of the feature instance this file belongs to",
json_schema_extra={"label": "Feature-Instanz", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "frontend_fk_source": "/api/features/instances", "frontend_fk_display_field": "label"},
)
- fileName: str = Field(
- description="Name of the file",
- json_schema_extra={"label": "Dateiname", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True},
- )
mimeType: str = Field(
description="MIME type of the file",
json_schema_extra={"label": "MIME-Typ", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False},
diff --git a/modules/datamodels/datamodelSubscription.py b/modules/datamodels/datamodelSubscription.py
index 73eca60f..5a377244 100644
--- a/modules/datamodels/datamodelSubscription.py
+++ b/modules/datamodels/datamodelSubscription.py
@@ -11,7 +11,7 @@ from enum import Enum
from datetime import datetime, timezone
from pydantic import BaseModel, Field
from modules.datamodels.datamodelBase import PowerOnModel
-from modules.shared.i18nRegistry import i18nModel
+from modules.shared.i18nRegistry import i18nModel, t
import uuid
@@ -293,8 +293,8 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
"ROOT": SubscriptionPlan(
planKey="ROOT",
selectableByUser=False,
- title="Root (System)",
- description="Interner Systemplan — keine Verrechnung.",
+ title=t("Root (System)"),
+ description=t("Interner Systemplan — keine Verrechnung."),
billingPeriod=BillingPeriodEnum.NONE,
autoRenew=False,
maxUsers=None,
@@ -307,8 +307,8 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
"TRIAL_14D": SubscriptionPlan(
planKey="TRIAL_14D",
selectableByUser=False,
- title="Gratis-Testphase (14 Tage)",
- description="14 Tage kostenlos testen — 1 User, 2 Module inklusive, CHF 25 AI-Budget.",
+ title=t("Gratis-Testphase (14 Tage)"),
+ description=t("14 Tage kostenlos testen — 1 User, 2 Module inklusive, CHF 25 AI-Budget."),
billingPeriod=BillingPeriodEnum.NONE,
autoRenew=False,
maxUsers=1,
@@ -323,8 +323,8 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
"STARTER_MONTHLY": SubscriptionPlan(
planKey="STARTER_MONTHLY",
selectableByUser=True,
- title="Starter (Monatlich)",
- description="CHF 69 pro User/Monat. 2 Module inklusive, CHF 25 AI-Budget pro User.",
+ title=t("Starter (Monatlich)"),
+ description=t("CHF 69 pro User/Monat. 2 Module inklusive, CHF 25 AI-Budget pro User."),
billingPeriod=BillingPeriodEnum.MONTHLY,
pricePerUserCHF=69.0,
pricePerFeatureInstanceCHF=39.0,
@@ -337,8 +337,8 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
"STARTER_YEARLY": SubscriptionPlan(
planKey="STARTER_YEARLY",
selectableByUser=True,
- title="Starter (Jaehrlich)",
- description="CHF 690 pro User/Jahr (-17%). 2 Module inklusive, CHF 25 AI-Budget pro User/Monat.",
+ title=t("Starter (Jaehrlich)"),
+ description=t("CHF 690 pro User/Jahr (-17%). 2 Module inklusive, CHF 25 AI-Budget pro User/Monat."),
billingPeriod=BillingPeriodEnum.YEARLY,
pricePerUserCHF=690.0,
pricePerFeatureInstanceCHF=39.0,
@@ -351,8 +351,8 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
"PROFESSIONAL_MONTHLY": SubscriptionPlan(
planKey="PROFESSIONAL_MONTHLY",
selectableByUser=True,
- title="Professional (Monatlich)",
- description="CHF 99 pro User/Monat. 5 Module inklusive, CHF 50 AI-Budget pro User.",
+ title=t("Professional (Monatlich)"),
+ description=t("CHF 99 pro User/Monat. 5 Module inklusive, CHF 50 AI-Budget pro User."),
billingPeriod=BillingPeriodEnum.MONTHLY,
pricePerUserCHF=99.0,
pricePerFeatureInstanceCHF=29.0,
@@ -365,8 +365,8 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
"PROFESSIONAL_YEARLY": SubscriptionPlan(
planKey="PROFESSIONAL_YEARLY",
selectableByUser=True,
- title="Professional (Jaehrlich)",
- description="CHF 990 pro User/Jahr (-17%). 5 Module inklusive, CHF 50 AI-Budget pro User/Monat.",
+ title=t("Professional (Jaehrlich)"),
+ description=t("CHF 990 pro User/Jahr (-17%). 5 Module inklusive, CHF 50 AI-Budget pro User/Monat."),
billingPeriod=BillingPeriodEnum.YEARLY,
pricePerUserCHF=990.0,
pricePerFeatureInstanceCHF=29.0,
@@ -379,8 +379,8 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
"MAX_MONTHLY": SubscriptionPlan(
planKey="MAX_MONTHLY",
selectableByUser=True,
- title="Max (Monatlich)",
- description="CHF 145 pro User/Monat. 15 Module inklusive, CHF 100 AI-Budget pro User.",
+ title=t("Max (Monatlich)"),
+ description=t("CHF 145 pro User/Monat. 15 Module inklusive, CHF 100 AI-Budget pro User."),
billingPeriod=BillingPeriodEnum.MONTHLY,
pricePerUserCHF=145.0,
pricePerFeatureInstanceCHF=19.0,
@@ -393,8 +393,8 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = {
"MAX_YEARLY": SubscriptionPlan(
planKey="MAX_YEARLY",
selectableByUser=True,
- title="Max (Jaehrlich)",
- description="CHF 1450 pro User/Jahr (-17%). 15 Module inklusive, CHF 100 AI-Budget pro User/Monat.",
+ title=t("Max (Jaehrlich)"),
+ description=t("CHF 1450 pro User/Jahr (-17%). 15 Module inklusive, CHF 100 AI-Budget pro User/Monat."),
billingPeriod=BillingPeriodEnum.YEARLY,
pricePerUserCHF=1450.0,
pricePerFeatureInstanceCHF=19.0,
diff --git a/modules/interfaces/interfaceDbBilling.py b/modules/interfaces/interfaceDbBilling.py
index 28c9848f..342c98c0 100644
--- a/modules/interfaces/interfaceDbBilling.py
+++ b/modules/interfaces/interfaceDbBilling.py
@@ -1541,7 +1541,7 @@ class BillingObjects:
return PaginatedResult(items=[], totalItems=0, totalPages=0)
recordFilter: Dict[str, Any] = {"accountId": accountIds}
- if scope == "personal" and userId:
+ if userId:
recordFilter["createdByUserId"] = userId
result = self.db.getRecordsetPaginated(
@@ -1622,7 +1622,7 @@ class BillingObjects:
conditions = ['"accountId" = ANY(%s)', '"transactionType" = %s']
values: list = [accountIds, "DEBIT"]
- if scope == "personal" and userId:
+ if userId:
conditions.append('"createdByUserId" = %s')
values.append(userId)
@@ -1790,7 +1790,7 @@ class BillingObjects:
return []
recordFilter: Dict[str, Any] = {"accountId": accountIds}
- if scope == "personal" and userId:
+ if userId:
recordFilter["createdByUserId"] = userId
if column in ("mandateName", "userName"):
diff --git a/modules/interfaces/interfaceDbManagement.py b/modules/interfaces/interfaceDbManagement.py
index 9f54fc44..98b86b3b 100644
--- a/modules/interfaces/interfaceDbManagement.py
+++ b/modules/interfaces/interfaceDbManagement.py
@@ -869,77 +869,66 @@ class ComponentObjects:
sysCreatedAt=file.get("sysCreatedAt"),
)
+ # Class-level cache — built once from the ExtractorRegistry
+ _extensionToMime: Optional[Dict[str, str]] = None
+ _textMimeTypes: Optional[set] = None
+
+ @classmethod
+ def _ensureMimeMaps(cls):
+ """Lazily build extension→MIME and text-MIME-set from the ExtractorRegistry."""
+ if cls._extensionToMime is not None:
+ return
+ try:
+ from modules.serviceCenter.services.serviceExtraction.subRegistry import ExtractorRegistry
+ registry = ExtractorRegistry()
+ cls._extensionToMime = registry.getExtensionToMimeMap()
+
+ # Collect all MIME types declared by the TextExtractor (and other text-ish extractors)
+ textMimes: set = set()
+ seen: set = set()
+ for ext in registry._map.values():
+ eid = id(ext)
+ if eid in seen:
+ continue
+ seen.add(eid)
+ mimes = ext.getSupportedMimeTypes()
+ if any(m.startswith("text/") for m in mimes):
+ textMimes.update(mimes)
+ # Always include common text types
+ textMimes.update({
+ "application/json", "application/xml", "application/javascript",
+ "application/sql", "application/x-yaml", "application/x-toml",
+ })
+ cls._textMimeTypes = textMimes
+ except Exception:
+ cls._extensionToMime = {}
+ cls._textMimeTypes = set()
+
def getMimeType(self, fileName: str) -> str:
- """Determines the MIME type based on the file extension."""
- ext = os.path.splitext(fileName)[1].lower()[1:]
- extensionToMime = {
- "pdf": "application/pdf",
- "docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
- "doc": "application/msword",
- "xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
- "xls": "application/vnd.ms-excel",
- "pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
- "ppt": "application/vnd.ms-powerpoint",
- "csv": "text/csv",
- "txt": "text/plain",
- "json": "application/json",
- "xml": "application/xml",
- "html": "text/html",
- "htm": "text/html",
- "jpg": "image/jpeg",
- "jpeg": "image/jpeg",
- "png": "image/png",
- "gif": "image/gif",
- "webp": "image/webp",
- "svg": "image/svg+xml",
- "py": "text/x-python",
- "js": "application/javascript",
- "css": "text/css",
- "eml": "message/rfc822",
- "msg": "application/vnd.ms-outlook",
- }
- return extensionToMime.get(ext.lower(), "application/octet-stream")
+ """Determines the MIME type based on the file extension.
+
+ Resolution order:
+ 1. ExtractorRegistry (derived from all registered extractors)
+ 2. Python stdlib mimetypes.guess_type
+ 3. Fallback: application/octet-stream
+ """
+ self._ensureMimeMaps()
+ ext = os.path.splitext(fileName)[1].lower().lstrip('.')
+ if ext and ext in self._extensionToMime:
+ return self._extensionToMime[ext]
+ guessed, _ = mimetypes.guess_type(fileName, strict=False)
+ return guessed or "application/octet-stream"
def isTextMimeType(self, mimeType: str) -> bool:
- """Determines if a MIME type represents a text-based format."""
- textMimeTypes = {
- 'text/plain',
- 'text/html',
- 'text/css',
- 'text/javascript',
- 'text/x-python',
- 'text/csv',
- 'text/xml',
- 'application/json',
- 'application/xml',
- 'application/javascript',
- 'application/x-python',
- 'application/x-httpd-php',
- 'application/x-sh',
- 'application/x-shellscript',
- 'application/x-yaml',
- 'application/x-toml',
- 'application/x-markdown',
- 'application/x-latex',
- 'application/x-tex',
- 'application/x-rst',
- 'application/x-asciidoc',
- 'application/x-markdown',
- 'application/x-httpd-php',
- 'application/x-httpd-php-source',
- 'application/x-httpd-php3',
- 'application/x-httpd-php4',
- 'application/x-httpd-php5',
- 'application/x-httpd-php7',
- 'application/x-httpd-php8',
- 'application/x-httpd-php-source',
- 'application/x-httpd-php3-source',
- 'application/x-httpd-php4-source',
- 'application/x-httpd-php5-source',
- 'application/x-httpd-php7-source',
- 'application/x-httpd-php8-source'
- }
- return mimeType.lower() in textMimeTypes
+ """Determines if a MIME type represents a text-based format.
+
+ Derived from the MIME types declared by text-oriented extractors.
+ """
+ self._ensureMimeMaps()
+ lower = mimeType.lower()
+ if lower.startswith("text/"):
+ return True
+ return lower in self._textMimeTypes
# File methods - metadata-based operations
diff --git a/modules/routes/routeAdminFeatures.py b/modules/routes/routeAdminFeatures.py
index 519efe11..e052a9a2 100644
--- a/modules/routes/routeAdminFeatures.py
+++ b/modules/routes/routeAdminFeatures.py
@@ -869,7 +869,12 @@ def _buildTemplateRolesList(featureCode: Optional[str] = None) -> List[Dict[str,
rootInterface = getRootInterface()
featureInterface = getFeatureInterface(rootInterface.db)
roles = featureInterface.getTemplateRoles(featureCode)
- return [r.model_dump() for r in roles]
+ result = []
+ for r in roles:
+ d = r.model_dump()
+ d["description"] = resolveText(r.description)
+ result.append(d)
+ return result
@router.get("/templates/roles")
@@ -1546,7 +1551,7 @@ def get_feature_instance_available_roles(
result.append({
"id": role.id,
"roleLabel": role.roleLabel,
- "description": role.description or {},
+ "description": resolveText(role.description),
"featureCode": role.featureCode,
"isSystemRole": role.isSystemRole
})
diff --git a/modules/routes/routeAdminRbacRules.py b/modules/routes/routeAdminRbacRules.py
index 9b756152..d78ebeaa 100644
--- a/modules/routes/routeAdminRbacRules.py
+++ b/modules/routes/routeAdminRbacRules.py
@@ -911,13 +911,13 @@ def list_roles(
result.append({
"id": role.id,
"roleLabel": role.roleLabel,
- "description": resolveText(role.description),
+ "description": role.description.model_dump() if hasattr(role.description, 'model_dump') else role.description,
"mandateId": role.mandateId,
"featureInstanceId": role.featureInstanceId,
"featureCode": role.featureCode,
"userCount": roleCounts.get(str(role.id), 0),
"isSystemRole": role.isSystemRole,
- "scopeType": scopeType # Computed field for frontend display
+ "scopeType": scopeType
})
# MandateAdmin: filter to only roles in admin's mandates
@@ -1040,7 +1040,7 @@ def get_roles_filter_values(
result.append({
"id": role.id,
"roleLabel": role.roleLabel,
- "description": resolveText(role.description),
+ "description": role.description.model_dump() if hasattr(role.description, 'model_dump') else role.description,
"mandateId": role.mandateId,
"featureInstanceId": role.featureInstanceId,
"featureCode": role.featureCode,
@@ -1095,7 +1095,7 @@ def create_role(
return {
"id": createdRole.id,
"roleLabel": createdRole.roleLabel,
- "description": createdRole.description,
+ "description": createdRole.description.model_dump() if hasattr(createdRole.description, 'model_dump') else createdRole.description,
"mandateId": createdRole.mandateId,
"featureInstanceId": createdRole.featureInstanceId,
"featureCode": createdRole.featureCode,
@@ -1157,7 +1157,7 @@ def get_role(
return {
"id": role.id,
"roleLabel": role.roleLabel,
- "description": resolveText(role.description),
+ "description": role.description.model_dump() if hasattr(role.description, 'model_dump') else role.description,
"mandateId": role.mandateId,
"featureInstanceId": role.featureInstanceId,
"featureCode": role.featureCode,
@@ -1220,7 +1220,7 @@ def update_role(
return {
"id": updatedRole.id,
"roleLabel": updatedRole.roleLabel,
- "description": updatedRole.description,
+ "description": updatedRole.description.model_dump() if hasattr(updatedRole.description, 'model_dump') else updatedRole.description,
"mandateId": updatedRole.mandateId,
"featureInstanceId": updatedRole.featureInstanceId,
"featureCode": updatedRole.featureCode,
diff --git a/modules/routes/routeBilling.py b/modules/routes/routeBilling.py
index 944131d6..c99ffc2a 100644
--- a/modules/routes/routeBilling.py
+++ b/modules/routes/routeBilling.py
@@ -1633,6 +1633,7 @@ def getUserViewStatistics(
month: Optional[int] = Query(None, description="Month (1-12, required for period='day')"),
scope: str = Query(default="all", description="Scope: 'personal' (own costs only), 'mandate' (filter by mandateId), 'all' (RBAC-filtered)"),
mandateId: Optional[str] = Query(None, description="Mandate ID filter (used with scope='mandate')"),
+ onlyMine: Optional[bool] = Query(None, description="Additional filter: restrict to current user's transactions within the selected scope"),
ctx: RequestContext = Depends(getRequestContext)
) -> ViewStatisticsResponse:
"""
@@ -1643,6 +1644,9 @@ def getUserViewStatistics(
- mandate: transactions for a specific mandate (requires mandateId parameter)
- all: RBAC-filtered (SysAdmin sees everything, admin sees mandate, user sees own)
+ onlyMine: additional filter that restricts results to the current user's
+ transactions while keeping the scope-based mandate selection.
+
- period='month': returns monthly time series for the given year
- period='day': returns daily time series for the given month/year
"""
@@ -1668,7 +1672,7 @@ def getUserViewStatistics(
if scope == "mandate" and mandateId:
loadMandateIds = [mandateId]
- personalUserId = str(ctx.user.id) if scope == "personal" else None
+ personalUserId = str(ctx.user.id) if (scope == "personal" or onlyMine) else None
if period == "day":
startDate = date(year, month, 1)
@@ -1745,6 +1749,7 @@ def getUserViewTransactions(
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
scope: str = Query(default="all", description="Scope: 'personal' (own costs only), 'mandate' (filter by mandateId), 'all' (RBAC-filtered)"),
mandateId: Optional[str] = Query(None, description="Mandate ID filter (used with scope='mandate')"),
+ onlyMine: Optional[bool] = Query(None, description="Additional filter: restrict to current user's transactions within the selected scope"),
ctx: RequestContext = Depends(getRequestContext)
) -> PaginatedResponse[UserTransactionResponse]:
"""
@@ -1755,10 +1760,14 @@ def getUserViewTransactions(
- mandate: transactions for a specific mandate (requires mandateId parameter)
- all: RBAC-filtered (SysAdmin sees everything, admin sees mandate, user sees own)
+ onlyMine: additional filter that restricts results to the current user's
+ transactions while keeping the scope-based mandate selection.
+
Query Parameters:
- pagination: JSON-encoded PaginationParams object, or None for no pagination
- scope: 'personal', 'mandate', or 'all'
- mandateId: required when scope='mandate'
+ - onlyMine: true to restrict to current user's data within the scope
"""
try:
billingInterface = getBillingInterface(ctx.user, ctx.mandateId)
@@ -1783,7 +1792,7 @@ def getUserViewTransactions(
loadMandateIds = [mandateId]
effectiveScope = scope
- personalUserId = str(ctx.user.id) if scope == "personal" else None
+ personalUserId = str(ctx.user.id) if (scope == "personal" or onlyMine) else None
if not paginationParams:
paginationParams = PaginationParams(page=1, pageSize=50)
@@ -1844,6 +1853,7 @@ def getUserViewTransactionsFilterValues(
pagination: Optional[str] = Query(None, description="JSON-encoded current filters"),
scope: str = Query(default="all", description="Scope: 'personal', 'mandate', 'all'"),
mandateId: Optional[str] = Query(None, description="Mandate ID filter (used with scope='mandate')"),
+ onlyMine: Optional[bool] = Query(None, description="Additional filter: restrict to current user's data within the selected scope"),
ctx: RequestContext = Depends(getRequestContext)
):
"""Return distinct filter values for a column in user transactions (SQL DISTINCT)."""
@@ -1876,7 +1886,7 @@ def getUserViewTransactionsFilterValues(
except (json.JSONDecodeError, ValueError):
pass
- personalUserId = str(ctx.user.id) if scope == "personal" else None
+ personalUserId = str(ctx.user.id) if (scope == "personal" or onlyMine) else None
return billingInterface.getTransactionDistinctValues(
mandateIds=loadMandateIds,
diff --git a/modules/routes/routeDataFiles.py b/modules/routes/routeDataFiles.py
index 907f0a20..efac6430 100644
--- a/modules/routes/routeDataFiles.py
+++ b/modules/routes/routeDataFiles.py
@@ -173,7 +173,7 @@ router = APIRouter(
)
@router.get("/list", response_model=PaginatedResponse[FileItem])
-@limiter.limit("30/minute")
+@limiter.limit("120/minute")
def get_files(
request: Request,
pagination: Optional[str] = Query(None, description="JSON-encoded PaginationParams object"),
diff --git a/modules/routes/routeI18n.py b/modules/routes/routeI18n.py
index 41e9645c..23a6ad46 100644
--- a/modules/routes/routeI18n.py
+++ b/modules/routes/routeI18n.py
@@ -1063,10 +1063,15 @@ async def import_language_sets(
_TRANSLATE_FIELD_MAX_LEN = 2000
+class _TargetLang(BaseModel):
+ code: str = Field(..., min_length=2, max_length=10)
+ label: str = Field(default="")
+
+
class TranslateFieldRequest(BaseModel):
sourceText: str = Field(..., min_length=1, max_length=_TRANSLATE_FIELD_MAX_LEN)
sourceLang: str = Field(default="de", min_length=2, max_length=5)
- targetLangs: List[str] = Field(..., min_length=1)
+ targetLangs: List[_TargetLang] = Field(..., min_length=1)
@router.post("/translate-field")
@@ -1076,7 +1081,7 @@ async def translateField(
currentUser: User = Depends(getCurrentUser),
):
"""Translate a single text into one or more target languages (for TextMultilingual fields)."""
- targets = [c for c in body.targetLangs if c != body.sourceLang]
+ targets = [t for t in body.targetLangs if t.code != body.sourceLang]
if not targets:
return {"translations": {}}
@@ -1084,12 +1089,12 @@ async def translateField(
billingCb = _makeBillingCallback(currentUser, mandateId)
results: Dict[str, str] = {}
- for targetCode in targets:
- targetLabel = _ISO_LABELS.get(targetCode, targetCode)
+ for target in targets:
+ targetLabel = target.label or _ISO_LABELS.get(target.code, target.code)
keysToTranslate = {body.sourceText: "TextMultilingual field"}
- translated = await _translateBatch(keysToTranslate, targetLabel, targetCode, billingCb)
+ translated = await _translateBatch(keysToTranslate, targetLabel, target.code, billingCb)
val = translated.get(body.sourceText, "")
if val:
- results[targetCode] = val
+ results[target.code] = val
return {"translations": results}
diff --git a/modules/routes/routeSubscription.py b/modules/routes/routeSubscription.py
index d2881741..9d8fbfd3 100644
--- a/modules/routes/routeSubscription.py
+++ b/modules/routes/routeSubscription.py
@@ -23,12 +23,22 @@ from pydantic import BaseModel, Field
from modules.auth import limiter, getRequestContext, RequestContext
from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict
from modules.routes.routeDataUsers import _applyFiltersAndSort, _extractDistinctValues
-from modules.shared.i18nRegistry import apiRouteContext
+from modules.shared.i18nRegistry import apiRouteContext, resolveText
routeApiMsg = apiRouteContext("routeSubscription")
logger = logging.getLogger(__name__)
+def _planToDict(plan) -> Optional[Dict[str, Any]]:
+ """Serialize a SubscriptionPlan with resolved i18n title/description."""
+ if not plan:
+ return None
+ d = plan.model_dump()
+ d["title"] = resolveText(plan.title)
+ d["description"] = resolveText(plan.description)
+ return d
+
+
def _resolveMandateId(context: RequestContext) -> str:
if context.mandateId:
return str(context.mandateId)
@@ -78,11 +88,56 @@ class ForceCancelRequest(BaseModel):
class VerifyCheckoutRequest(BaseModel):
sessionId: str = Field(..., description="Stripe Checkout Session ID to verify")
+class SubscriptionUsage(BaseModel):
+ activeUsers: int = 0
+ activeInstances: int = 0
+ usedStorageMB: float = 0
+ maxStorageMB: Optional[float] = None
+ storagePercent: Optional[float] = None
+
class SubscriptionStatusResponse(BaseModel):
active: bool
subscription: Optional[Dict[str, Any]] = None
plan: Optional[Dict[str, Any]] = None
scheduled: Optional[Dict[str, Any]] = None
+ usage: Optional[SubscriptionUsage] = None
+
+
+def _computeUsage(mandateId: str, plan) -> SubscriptionUsage:
+ """Compute current usage metrics for a mandate's subscription."""
+ try:
+ from modules.interfaces.interfaceDbApp import getRootInterface
+ from modules.datamodels.datamodelMembership import UserMandate
+ from modules.datamodels.datamodelFeatures import FeatureInstance
+ from modules.interfaces.interfaceDbKnowledge import aggregateMandateRagTotalBytes
+
+ rootIf = getRootInterface()
+
+ allUM = rootIf.db.getRecordset(UserMandate, recordFilter={"mandateId": mandateId})
+ activeUsers = len(allUM) if allUM else 0
+
+ allFI = rootIf.db.getRecordset(FeatureInstance, recordFilter={"mandateId": mandateId})
+ activeInstances = sum(
+ 1 for fi in (allFI or [])
+ if (fi.get("enabled") if isinstance(fi, dict) else getattr(fi, "enabled", False))
+ )
+
+ ragBytes = aggregateMandateRagTotalBytes(mandateId)
+ usedMB = round(ragBytes / (1024 * 1024), 2)
+
+ maxMB = plan.maxDataVolumeMB if plan else None
+ storagePercent = round((usedMB / maxMB) * 100, 1) if maxMB else None
+
+ return SubscriptionUsage(
+ activeUsers=activeUsers,
+ activeInstances=activeInstances,
+ usedStorageMB=usedMB,
+ maxStorageMB=maxMB,
+ storagePercent=storagePercent,
+ )
+ except Exception as e:
+ logger.warning("Failed to compute subscription usage: %s", e)
+ return SubscriptionUsage()
# =============================================================================
@@ -110,7 +165,7 @@ def getPlans(request: Request, context: RequestContext = Depends(getRequestConte
mandateId = _resolveMandateId(context)
subService = getSubscriptionService(context.user, mandateId)
plans = subService.getSelectablePlans()
- return [p.model_dump() for p in plans]
+ return [_planToDict(p) for p in plans]
except Exception as e:
logger.error("Error fetching plans: %s", e)
raise HTTPException(status_code=500, detail=str(e))
@@ -142,17 +197,21 @@ def getStatus(request: Request, context: RequestContext = Depends(getRequestCont
return SubscriptionStatusResponse(
active=False,
subscription=sub,
- plan=plan.model_dump() if plan else None,
+ plan=_planToDict(plan),
scheduled=scheduled,
)
return SubscriptionStatusResponse(active=False, scheduled=scheduled)
plan = subService.getPlan(operative.get("planKey", ""))
+
+ usage = _computeUsage(mandateId, plan)
+
return SubscriptionStatusResponse(
active=True,
subscription=operative,
- plan=plan.model_dump() if plan else None,
+ plan=_planToDict(plan),
scheduled=scheduled,
+ usage=usage,
)
except Exception as e:
logger.error("Error fetching status: %s", e)
@@ -394,7 +453,7 @@ def _buildEnrichedSubscriptions() -> List[Dict[str, Any]]:
plan = BUILTIN_PLANS.get(planKey)
sub["mandateName"] = mandateNames.get(mid, mid[:8])
- sub["planTitle"] = (plan.title or planKey) if plan else planKey
+ sub["planTitle"] = resolveText(plan.title) if plan else planKey
if sub.get("status") in operativeValues:
userPrice = sub.get("snapshotPricePerUserCHF", 0) or 0
diff --git a/modules/routes/routeSystem.py b/modules/routes/routeSystem.py
index a6d535c1..fb921293 100644
--- a/modules/routes/routeSystem.py
+++ b/modules/routes/routeSystem.py
@@ -18,6 +18,7 @@ from slowapi.util import get_remote_address
from modules.auth.authentication import getRequestContext, RequestContext
from modules.system.mainSystem import NAVIGATION_SECTIONS, _objectKeyToUiComponent
+from modules.shared.i18nRegistry import resolveText
from modules.interfaces.interfaceDbApp import getRootInterface
from modules.interfaces.interfaceFeatures import getFeatureInterface
from modules.datamodels.datamodelRbac import AccessRule, AccessRuleContext
@@ -386,8 +387,8 @@ def _buildStaticBlocks(
"""
Build static navigation blocks from NAVIGATION_SECTIONS.
- Labels/titles are plain German strings (i18n base keys).
- The frontend translates them via t().
+ Keys are registered at import time via t() in mainSystem.py.
+ At request time, resolveText() translates them to the current language.
"""
blocks = []
@@ -407,7 +408,7 @@ def _buildStaticBlocks(
if subItems:
filteredSubgroups.append({
"id": subgroup["id"],
- "title": subgroup["title"],
+ "title": resolveText(subgroup["title"]),
"order": subgroup.get("order", 50),
"items": subItems,
})
@@ -424,7 +425,7 @@ def _buildStaticBlocks(
blocks.append({
"type": "static",
"id": section["id"],
- "title": section["title"],
+ "title": resolveText(section["title"]),
"order": section.get("order", 50),
"items": topLevelItems,
"subgroups": filteredSubgroups,
@@ -438,7 +439,7 @@ def _buildStaticBlocks(
blocks.append({
"type": "static",
"id": section["id"],
- "title": section["title"],
+ "title": resolveText(section["title"]),
"order": section.get("order", 50),
"items": filteredItems,
})
@@ -447,18 +448,13 @@ def _buildStaticBlocks(
def _formatBlockItem(item: Dict[str, Any]) -> Dict[str, Any]:
- """
- Format a navigation item for the API response.
-
- Labels are plain German strings (i18n base keys).
- The frontend translates them via t().
- """
+ """Format a navigation item for the API response."""
objectKey = item["objectKey"]
uiComponent = _objectKeyToUiComponent(objectKey)
return {
"uiComponent": uiComponent,
- "uiLabel": item["label"],
+ "uiLabel": resolveText(item["label"]),
"uiPath": item["path"],
"order": item.get("order", 50),
"objectKey": objectKey,
diff --git a/modules/serviceCenter/services/serviceExtraction/subRegistry.py b/modules/serviceCenter/services/serviceExtraction/subRegistry.py
index cd14b0d7..826eef9d 100644
--- a/modules/serviceCenter/services/serviceExtraction/subRegistry.py
+++ b/modules/serviceCenter/services/serviceExtraction/subRegistry.py
@@ -139,6 +139,40 @@ class ExtractorRegistry:
return self._map[ext]
return self._fallback
+ def getExtensionToMimeMap(self) -> Dict[str, str]:
+ """Build a map from file extension (without dot) to primary MIME type.
+
+ Iterates all registered extractors and pairs each declared extension
+ with the first MIME type declared by the same extractor. Specialized
+ extractors (Pdf, Docx, …) are processed first so their mapping wins
+ over broad extractors like TextExtractor.
+ """
+ extMap: Dict[str, str] = {}
+ seen: set = set()
+
+ # Collect unique extractor instances (same instance registered under many keys)
+ extractors: list[Extractor] = []
+ for ext in self._map.values():
+ eid = id(ext)
+ if eid not in seen:
+ seen.add(eid)
+ extractors.append(ext)
+
+ # Specialized (few extensions) first so they win over broad ones
+ extractors.sort(key=lambda e: len(e.getSupportedExtensions()))
+
+ for ext in extractors:
+ extensions = ext.getSupportedExtensions()
+ mimeTypes = ext.getSupportedMimeTypes()
+ if not extensions or not mimeTypes:
+ continue
+ primaryMime = mimeTypes[0]
+ for rawExt in extensions:
+ key = rawExt.lstrip('.').lower()
+ if key not in extMap:
+ extMap[key] = primaryMime
+ return extMap
+
def getAllSupportedFormats(self) -> Dict[str, Dict[str, list[str]]]:
"""
Get all supported formats from all registered extractors.
diff --git a/modules/system/mainSystem.py b/modules/system/mainSystem.py
index e3cfe2b0..ee3e0f84 100644
--- a/modules/system/mainSystem.py
+++ b/modules/system/mainSystem.py
@@ -11,6 +11,8 @@ Also defines the navigation structure for the frontend.
import logging
from typing import Dict, List, Any, Optional
+from modules.shared.i18nRegistry import t
+
logger = logging.getLogger(__name__)
# System metadata
@@ -38,13 +40,13 @@ NAVIGATION_SECTIONS = [
# ─── Meine Sicht (with top-level item + subgroups) ───
{
"id": "system",
- "title": "Meine Sicht",
+ "title": t("Meine Sicht"),
"order": 10,
"items": [
{
"id": "home",
"objectKey": "ui.system.home",
- "label": "Übersicht",
+ "label": t("Übersicht"),
"icon": "FaHome",
"path": "/",
"order": 10,
@@ -55,13 +57,13 @@ NAVIGATION_SECTIONS = [
# ── Basisdaten ──
{
"id": "system-basedata",
- "title": "Basisdaten",
+ "title": t("Basisdaten"),
"order": 20,
"items": [
{
"id": "connections",
"objectKey": "ui.system.connections",
- "label": "Verbindungen",
+ "label": t("Verbindungen"),
"icon": "FaLink",
"path": "/basedata/connections",
"order": 10,
@@ -69,7 +71,7 @@ NAVIGATION_SECTIONS = [
{
"id": "files",
"objectKey": "ui.system.files",
- "label": "Dateien",
+ "label": t("Dateien"),
"icon": "FaRegFileAlt",
"path": "/basedata/files",
"order": 20,
@@ -77,7 +79,7 @@ NAVIGATION_SECTIONS = [
{
"id": "prompts",
"objectKey": "ui.system.prompts",
- "label": "Prompts",
+ "label": t("Prompts"),
"icon": "FaLightbulb",
"path": "/basedata/prompts",
"order": 30,
@@ -87,13 +89,13 @@ NAVIGATION_SECTIONS = [
# ── Nutzung ──
{
"id": "system-usage",
- "title": "Nutzung",
+ "title": t("Nutzung"),
"order": 30,
"items": [
{
"id": "billing-admin",
"objectKey": "ui.system.billingAdmin",
- "label": "Abrechnung",
+ "label": t("Abrechnung"),
"icon": "FaMoneyBillAlt",
"path": "/billing/admin",
"order": 10,
@@ -101,7 +103,7 @@ NAVIGATION_SECTIONS = [
{
"id": "statistics",
"objectKey": "ui.system.statistics",
- "label": "Statistiken",
+ "label": t("Statistiken"),
"icon": "FaChartBar",
"path": "/billing/transactions",
"order": 20,
@@ -109,7 +111,7 @@ NAVIGATION_SECTIONS = [
{
"id": "automations",
"objectKey": "ui.system.automations",
- "label": "Automations",
+ "label": t("Automations"),
"icon": "FaRobot",
"path": "/automations",
"order": 30,
@@ -117,7 +119,7 @@ NAVIGATION_SECTIONS = [
{
"id": "store",
"objectKey": "ui.system.store",
- "label": "Store",
+ "label": t("Store"),
"icon": "FaStore",
"path": "/store",
"order": 40,
@@ -126,7 +128,7 @@ NAVIGATION_SECTIONS = [
{
"id": "settings",
"objectKey": "ui.system.settings",
- "label": "Einstellungen",
+ "label": t("Einstellungen"),
"icon": "FaCog",
"path": "/settings",
"order": 50,
@@ -139,19 +141,19 @@ NAVIGATION_SECTIONS = [
# ─── Administration (with subgroups) ───
{
"id": "admin",
- "title": "Administration",
+ "title": t("Administration"),
"order": 200,
"subgroups": [
# ── Wizards ──
{
"id": "admin-wizards",
- "title": "Wizards",
+ "title": t("Wizards"),
"order": 10,
"items": [
{
"id": "admin-mandate-wizard",
"objectKey": "ui.admin.mandateWizard",
- "label": "Mandanten-Wizard",
+ "label": t("Mandanten-Wizard"),
"icon": "FaMagic",
"path": "/admin/mandate-wizard",
"order": 10,
@@ -160,7 +162,7 @@ NAVIGATION_SECTIONS = [
{
"id": "admin-invitation-wizard",
"objectKey": "ui.admin.invitationWizard",
- "label": "Einladungs-Wizard",
+ "label": t("Einladungs-Wizard"),
"icon": "FaEnvelopeOpenText",
"path": "/admin/invitation-wizard",
"order": 20,
@@ -171,13 +173,13 @@ NAVIGATION_SECTIONS = [
# ── Users ──
{
"id": "admin-users-group",
- "title": "Benutzer",
+ "title": t("Benutzer"),
"order": 20,
"items": [
{
"id": "admin-users",
"objectKey": "ui.admin.users",
- "label": "Benutzer",
+ "label": t("Benutzer"),
"icon": "FaUsers",
"path": "/admin/users",
"order": 10,
@@ -186,7 +188,7 @@ NAVIGATION_SECTIONS = [
{
"id": "admin-invitations",
"objectKey": "ui.admin.invitations",
- "label": "Benutzer-Einladungen",
+ "label": t("Benutzer-Einladungen"),
"icon": "FaEnvelopeOpenText",
"path": "/admin/invitations",
"order": 20,
@@ -195,7 +197,7 @@ NAVIGATION_SECTIONS = [
{
"id": "admin-user-access-overview",
"objectKey": "ui.admin.userAccessOverview",
- "label": "Benutzer-Zugriffsübersicht",
+ "label": t("Benutzer-Zugriffsübersicht"),
"icon": "FaClipboardList",
"path": "/admin/user-access-overview",
"order": 30,
@@ -204,7 +206,7 @@ NAVIGATION_SECTIONS = [
{
"id": "admin-subscriptions",
"objectKey": "ui.admin.subscriptions",
- "label": "Abonnements",
+ "label": t("Abonnements"),
"icon": "FaFileContract",
"path": "/admin/subscriptions",
"order": 40,
@@ -215,13 +217,13 @@ NAVIGATION_SECTIONS = [
# ── System ──
{
"id": "admin-system-group",
- "title": "System",
+ "title": t("System"),
"order": 30,
"items": [
{
"id": "admin-roles",
"objectKey": "ui.admin.roles",
- "label": "Rollen",
+ "label": t("Rollen"),
"icon": "FaUserTag",
"path": "/admin/mandate-roles",
"order": 10,
@@ -230,7 +232,7 @@ NAVIGATION_SECTIONS = [
{
"id": "admin-mandate-role-permissions",
"objectKey": "ui.admin.mandateRolePermissions",
- "label": "Rollen-Berechtigungen",
+ "label": t("Rollen-Berechtigungen"),
"icon": "FaKey",
"path": "/admin/mandate-role-permissions",
"order": 20,
@@ -239,7 +241,7 @@ NAVIGATION_SECTIONS = [
{
"id": "admin-mandates",
"objectKey": "ui.admin.mandates",
- "label": "Mandanten",
+ "label": t("Mandanten"),
"icon": "FaBuilding",
"path": "/admin/mandates",
"order": 30,
@@ -248,7 +250,7 @@ NAVIGATION_SECTIONS = [
{
"id": "admin-user-mandates",
"objectKey": "ui.admin.userMandates",
- "label": "Mandanten-Mitglieder",
+ "label": t("Mandanten-Mitglieder"),
"icon": "FaUserFriends",
"path": "/admin/user-mandates",
"order": 40,
@@ -257,7 +259,7 @@ NAVIGATION_SECTIONS = [
{
"id": "admin-access",
"objectKey": "ui.admin.access",
- "label": "Zugriffsverwaltung",
+ "label": t("Zugriffsverwaltung"),
"icon": "FaBuilding",
"path": "/admin/access",
"order": 50,
@@ -266,7 +268,7 @@ NAVIGATION_SECTIONS = [
{
"id": "admin-feature-instances",
"objectKey": "ui.admin.featureInstances",
- "label": "Feature-Instanzen",
+ "label": t("Feature-Instanzen"),
"icon": "FaCubes",
"path": "/admin/feature-instances",
"order": 60,
@@ -275,7 +277,7 @@ NAVIGATION_SECTIONS = [
{
"id": "admin-feature-roles",
"objectKey": "ui.admin.featureRoles",
- "label": "Features Rollen-Vorlagen",
+ "label": t("Features Rollen-Vorlagen"),
"icon": "FaShieldAlt",
"path": "/admin/feature-roles",
"order": 70,
@@ -285,7 +287,7 @@ NAVIGATION_SECTIONS = [
{
"id": "admin-logs",
"objectKey": "ui.admin.logs",
- "label": "Logs",
+ "label": t("Logs"),
"icon": "FaFileAlt",
"path": "/admin/logs",
"order": 90,
@@ -295,7 +297,7 @@ NAVIGATION_SECTIONS = [
{
"id": "admin-languages",
"objectKey": "ui.admin.languages",
- "label": "UI-Sprachen",
+ "label": t("UI-Sprachen"),
"icon": "FaGlobe",
"path": "/admin/languages",
"order": 95,