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,