fixes lang uand issues

This commit is contained in:
ValueOn AG 2026-04-12 14:04:49 +02:00
parent 1b51ee3e1c
commit e43b0741ed
14 changed files with 275 additions and 166 deletions

View file

@ -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:

View file

@ -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},

View file

@ -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,

View file

@ -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"):

View file

@ -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

View file

@ -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
})

View file

@ -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,

View file

@ -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,

View file

@ -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"),

View file

@ -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}

View file

@ -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

View file

@ -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,

View file

@ -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.

View file

@ -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,