diff --git a/app.py b/app.py index 4b08dbff..fa92e882 100644 --- a/app.py +++ b/app.py @@ -317,6 +317,15 @@ async def lifespan(app: FastAPI): except Exception as e: logger.error(f"Feature catalog registration failed: {e}") + # Sync gateway i18n registry to DB and load translation cache + try: + from modules.shared.i18nRegistry import _syncRegistryToDb, _loadCache + await _syncRegistryToDb() + await _loadCache() + logger.info("i18n registry sync + cache load completed") + except Exception as e: + logger.warning(f"i18n registry sync failed (non-critical): {e}") + # Pre-warm service center modules (avoids first-request import latency) try: from modules.serviceCenter import preWarm @@ -481,6 +490,16 @@ from modules.auth import ( ProactiveTokenRefreshMiddleware, ) +# i18n language detection middleware (sets per-request language from Accept-Language header) +from modules.shared.i18nRegistry import _setLanguage + +@app.middleware("http") +async def _i18nMiddleware(request: Request, call_next): + acceptLang = request.headers.get("Accept-Language", "") + lang = acceptLang[:2].lower() if len(acceptLang) >= 2 and acceptLang[:2].isalpha() else "de" + _setLanguage(lang) + return await call_next(request) + app.add_middleware(CSRFMiddleware) # Token refresh middleware (silent refresh for expired OAuth tokens) diff --git a/modules/datamodels/datamodelAudit.py b/modules/datamodels/datamodelAudit.py index 76c9ecfb..f95b213d 100644 --- a/modules/datamodels/datamodelAudit.py +++ b/modules/datamodels/datamodelAudit.py @@ -20,7 +20,7 @@ from enum import Enum import uuid from modules.shared.timeUtils import getUtcTimestamp -from modules.shared.attributeUtils import registerModelLabels +from modules.shared.i18nRegistry import i18nModel class AuditCategory(str, Enum): @@ -82,6 +82,7 @@ class AuditAction(str, Enum): CONFIG_CHANGE = "config_change" +@i18nModel("Audit-Log-Eintrag") class AuditLogEntry(BaseModel): """ Audit log entry for database storage. @@ -92,117 +93,94 @@ class AuditLogEntry(BaseModel): id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Unique identifier for the audit entry", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False} ) - + # Timestamp timestamp: float = Field( default_factory=getUtcTimestamp, description="UTC timestamp when the event occurred", - json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": True} + json_schema_extra={"label": "Zeitstempel", "frontend_type": "datetime", "frontend_readonly": True, "frontend_required": True} ) - + # Actor identification userId: str = Field( description="ID of the user who performed the action (or 'system' for system events)", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True} + json_schema_extra={"label": "Benutzer-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True} ) - + username: Optional[str] = Field( default=None, description="Username at the time of the event (for historical reference)", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"label": "Benutzername", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False} ) - + # Context mandateId: Optional[str] = Field( default=None, description="Mandate context (if applicable)", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"label": "Mandanten-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False} ) - + featureInstanceId: Optional[str] = Field( default=None, description="Feature instance context (if applicable)", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"label": "Feature-Instanz-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False} ) - + # Event classification category: str = Field( description="Event category (access, key, data, security, gdpr, permission, system)", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True} + json_schema_extra={"label": "Kategorie", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True} ) - + action: str = Field( description="Specific action performed", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True} + json_schema_extra={"label": "Aktion", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True} ) - + # Event details resourceType: Optional[str] = Field( default=None, description="Type of resource affected (e.g., 'User', 'ChatWorkflow', 'TrusteeContract')", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"label": "Ressourcentyp", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False} ) - + resourceId: Optional[str] = Field( default=None, description="ID of the affected resource", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"label": "Ressourcen-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False} ) - + details: Optional[str] = Field( default=None, description="Additional details about the event", - json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"label": "Details", "frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False} ) - + # Request metadata ipAddress: Optional[str] = Field( default=None, description="IP address of the client", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"label": "IP-Adresse", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False} ) - + userAgent: Optional[str] = Field( default=None, description="User agent string from the request", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"label": "User-Agent", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False} ) - + # Outcome success: bool = Field( default=True, description="Whether the action was successful", - json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": True} + json_schema_extra={"label": "Erfolgreich", "frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": True} ) - + errorMessage: Optional[str] = Field( default=None, description="Error message if the action failed", - json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"label": "Fehlermeldung", "frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False} ) - -# Register labels for internationalization -registerModelLabels( - "AuditLogEntry", - {"en": "Audit Log Entry", "de": "Audit-Log-Eintrag", "fr": "Entrée du journal d'audit"}, - { - "id": {"en": "ID", "de": "ID", "fr": "ID"}, - "timestamp": {"en": "Timestamp", "de": "Zeitstempel", "fr": "Horodatage"}, - "userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"}, - "username": {"en": "Username", "de": "Benutzername", "fr": "Nom d'utilisateur"}, - "mandateId": {"en": "Mandate ID", "de": "Mandanten-ID", "fr": "ID du mandat"}, - "featureInstanceId": {"en": "Feature Instance ID", "de": "Feature-Instanz-ID", "fr": "ID de l'instance"}, - "category": {"en": "Category", "de": "Kategorie", "fr": "Catégorie"}, - "action": {"en": "Action", "de": "Aktion", "fr": "Action"}, - "resourceType": {"en": "Resource Type", "de": "Ressourcentyp", "fr": "Type de ressource"}, - "resourceId": {"en": "Resource ID", "de": "Ressourcen-ID", "fr": "ID de ressource"}, - "details": {"en": "Details", "de": "Details", "fr": "Détails"}, - "ipAddress": {"en": "IP Address", "de": "IP-Adresse", "fr": "Adresse IP"}, - "userAgent": {"en": "User Agent", "de": "User-Agent", "fr": "Agent utilisateur"}, - "success": {"en": "Success", "de": "Erfolgreich", "fr": "Succès"}, - "errorMessage": {"en": "Error Message", "de": "Fehlermeldung", "fr": "Message d'erreur"}, - }, -) diff --git a/modules/datamodels/datamodelBase.py b/modules/datamodels/datamodelBase.py index 862f177b..854be75e 100644 --- a/modules/datamodels/datamodelBase.py +++ b/modules/datamodels/datamodelBase.py @@ -6,14 +6,17 @@ from typing import Optional from pydantic import BaseModel, Field -from modules.shared.attributeUtils import registerModelLabels +from modules.shared.i18nRegistry import i18nModel +@i18nModel("Basisdatensatz") class PowerOnModel(BaseModel): + """Basis-Datenmodell mit System-Audit-Feldern fuer alle DB-Tabellen.""" sysCreatedAt: Optional[float] = Field( default=None, description="Record creation timestamp (UTC, set by system)", json_schema_extra={ + "label": "Erstellt am", "frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, @@ -25,6 +28,7 @@ class PowerOnModel(BaseModel): default=None, description="User ID who created this record (set by system)", json_schema_extra={ + "label": "Erstellt von", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False, @@ -36,6 +40,7 @@ class PowerOnModel(BaseModel): default=None, description="Record last modification timestamp (UTC, set by system)", json_schema_extra={ + "label": "Geaendert am", "frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, @@ -47,6 +52,7 @@ class PowerOnModel(BaseModel): default=None, description="User ID who last modified this record (set by system)", json_schema_extra={ + "label": "Geaendert von", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False, @@ -54,15 +60,3 @@ class PowerOnModel(BaseModel): "system": True, }, ) - - -registerModelLabels( - "PowerOnModel", - {"en": "Base Record", "de": "Basisdatensatz"}, - { - "sysCreatedAt": {"en": "Created At", "de": "Erstellt am", "fr": "Cree le"}, - "sysCreatedBy": {"en": "Created By", "de": "Erstellt von", "fr": "Cree par"}, - "sysModifiedAt": {"en": "Modified At", "de": "Geaendert am", "fr": "Modifie le"}, - "sysModifiedBy": {"en": "Modified By", "de": "Geaendert von", "fr": "Modifie par"}, - }, -) diff --git a/modules/datamodels/datamodelBilling.py b/modules/datamodels/datamodelBilling.py index ccf1f4a1..fb1a1061 100644 --- a/modules/datamodels/datamodelBilling.py +++ b/modules/datamodels/datamodelBilling.py @@ -7,7 +7,7 @@ from enum import Enum from datetime import date, datetime, timezone from pydantic import BaseModel, Field from modules.datamodels.datamodelBase import PowerOnModel -from modules.shared.attributeUtils import registerModelLabels +from modules.shared.i18nRegistry import i18nModel import uuid # End-customer price for storage above plan-included volume (CHF per GB per month). @@ -38,203 +38,170 @@ class PeriodTypeEnum(str, Enum): YEAR = "YEAR" +@i18nModel("Abrechnungskonto") class BillingAccount(PowerOnModel): """Billing account for mandate or user-mandate combination.""" id: str = Field( - default_factory=lambda: str(uuid.uuid4()), description="Primary key" + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + json_schema_extra={"label": "ID"}, ) - mandateId: str = Field(..., description="Foreign key to Mandate") - userId: Optional[str] = Field(None, description="Foreign key to User (None = mandate pool account, set = user audit account)") - balance: float = Field(default=0.0, description="Current balance in CHF") - warningThreshold: float = Field(default=0.0, description="Warning threshold in CHF") - lastWarningAt: Optional[datetime] = Field(None, description="Last warning sent timestamp") - enabled: bool = Field(default=True, description="Account is active") - - -registerModelLabels( - "BillingAccount", - {"en": "Billing Account", "de": "Abrechnungskonto"}, - { - "id": {"en": "ID", "de": "ID"}, - "mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"}, - "userId": {"en": "User ID", "de": "Benutzer-ID"}, - "balance": {"en": "Balance (CHF)", "de": "Guthaben (CHF)"}, - "warningThreshold": {"en": "Warning Threshold (CHF)", "de": "Warnschwelle (CHF)"}, - "lastWarningAt": {"en": "Last Warning", "de": "Letzte Warnung"}, - "enabled": {"en": "Enabled", "de": "Aktiv"}, - }, -) + mandateId: str = Field(..., description="Foreign key to Mandate", json_schema_extra={"label": "Mandanten-ID"}) + userId: Optional[str] = Field( + None, + description="Foreign key to User (None = mandate pool account, set = user audit account)", + json_schema_extra={"label": "Benutzer-ID"}, + ) + balance: float = Field(default=0.0, description="Current balance in CHF", json_schema_extra={"label": "Guthaben (CHF)"}) + warningThreshold: float = Field( + default=0.0, + description="Warning threshold in CHF", + json_schema_extra={"label": "Warnschwelle (CHF)"}, + ) + lastWarningAt: Optional[datetime] = Field( + None, + description="Last warning sent timestamp", + json_schema_extra={"label": "Letzte Warnung"}, + ) + enabled: bool = Field(default=True, description="Account is active", json_schema_extra={"label": "Aktiv"}) +@i18nModel("Transaktion") class BillingTransaction(PowerOnModel): """Single billing transaction (credit, debit, adjustment).""" id: str = Field( - default_factory=lambda: str(uuid.uuid4()), description="Primary key" + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + json_schema_extra={"label": "ID"}, ) - accountId: str = Field(..., description="Foreign key to BillingAccount") - transactionType: TransactionTypeEnum = Field(..., description="Transaction type") - amount: float = Field(..., description="Amount in CHF (always positive)") - description: str = Field(..., description="Transaction description") - + accountId: str = Field(..., description="Foreign key to BillingAccount", json_schema_extra={"label": "Konto-ID"}) + transactionType: TransactionTypeEnum = Field(..., description="Transaction type", json_schema_extra={"label": "Typ"}) + amount: float = Field(..., description="Amount in CHF (always positive)", json_schema_extra={"label": "Betrag (CHF)"}) + description: str = Field(..., description="Transaction description", json_schema_extra={"label": "Beschreibung"}) + # Reference to source - referenceType: Optional[ReferenceTypeEnum] = Field(None, description="Reference type") - referenceId: Optional[str] = Field(None, description="Reference ID") - + referenceType: Optional[ReferenceTypeEnum] = Field(None, description="Reference type", json_schema_extra={"label": "Referenztyp"}) + referenceId: Optional[str] = Field(None, description="Reference ID", json_schema_extra={"label": "Referenz-ID"}) + # Context for workflow transactions - workflowId: Optional[str] = Field(None, description="Workflow ID (for WORKFLOW transactions)") - featureInstanceId: Optional[str] = Field(None, description="Feature instance ID") - featureCode: Optional[str] = Field(None, description="Feature code (e.g., automation)") - aicoreProvider: Optional[str] = Field(None, description="AICore provider (anthropic, openai, etc.)") - aicoreModel: Optional[str] = Field(None, description="AICore model name (e.g., claude-4-sonnet, gpt-4o)") - createdByUserId: Optional[str] = Field(None, description="User who created/caused this transaction") - + workflowId: Optional[str] = Field(None, description="Workflow ID (for WORKFLOW transactions)", json_schema_extra={"label": "Workflow-ID"}) + featureInstanceId: Optional[str] = Field(None, description="Feature instance ID", json_schema_extra={"label": "Feature-Instanz-ID"}) + featureCode: Optional[str] = Field(None, description="Feature code (e.g., automation)", json_schema_extra={"label": "Feature-Code"}) + aicoreProvider: Optional[str] = Field(None, description="AICore provider (anthropic, openai, etc.)", json_schema_extra={"label": "AI-Anbieter"}) + aicoreModel: Optional[str] = Field(None, description="AICore model name (e.g., claude-4-sonnet, gpt-4o)", json_schema_extra={"label": "AI-Modell"}) + createdByUserId: Optional[str] = Field(None, description="User who created/caused this transaction", json_schema_extra={"label": "Erstellt von Benutzer"}) + # AI call metadata (for per-call analytics) - processingTime: Optional[float] = Field(None, description="Processing time in seconds") - bytesSent: Optional[int] = Field(None, description="Bytes sent to AI model") - bytesReceived: Optional[int] = Field(None, description="Bytes received from AI model") - errorCount: Optional[int] = Field(None, description="Number of errors in this call") - - -registerModelLabels( - "BillingTransaction", - {"en": "Billing Transaction", "de": "Transaktion"}, - { - "id": {"en": "ID", "de": "ID"}, - "accountId": {"en": "Account ID", "de": "Konto-ID"}, - "transactionType": {"en": "Type", "de": "Typ"}, - "amount": {"en": "Amount (CHF)", "de": "Betrag (CHF)"}, - "description": {"en": "Description", "de": "Beschreibung"}, - "referenceType": {"en": "Reference Type", "de": "Referenztyp"}, - "referenceId": {"en": "Reference ID", "de": "Referenz-ID"}, - "workflowId": {"en": "Workflow ID", "de": "Workflow-ID"}, - "featureInstanceId": {"en": "Feature Instance ID", "de": "Feature-Instanz-ID"}, - "featureCode": {"en": "Feature Code", "de": "Feature-Code"}, - "aicoreProvider": {"en": "AI Provider", "de": "AI-Anbieter"}, - "aicoreModel": {"en": "AI Model", "de": "AI-Modell"}, - "createdByUserId": {"en": "Created By User", "de": "Erstellt von Benutzer"}, - }, -) + processingTime: Optional[float] = Field(None, description="Processing time in seconds", json_schema_extra={"label": "Verarbeitungszeit (s)"}) + bytesSent: Optional[int] = Field(None, description="Bytes sent to AI model", json_schema_extra={"label": "Gesendete Bytes"}) + bytesReceived: Optional[int] = Field(None, description="Bytes received from AI model", json_schema_extra={"label": "Empfangene Bytes"}) + errorCount: Optional[int] = Field(None, description="Number of errors in this call", json_schema_extra={"label": "Fehleranzahl"}) +@i18nModel("Abrechnungseinstellungen") class BillingSettings(BaseModel): """Billing settings per mandate. Only PREPAY_MANDATE model.""" id: str = Field( - default_factory=lambda: str(uuid.uuid4()), description="Primary key" + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + json_schema_extra={"label": "ID"}, + ) + mandateId: str = Field(..., description="Foreign key to Mandate (UNIQUE)", json_schema_extra={"label": "Mandanten-ID"}) + + warningThresholdPercent: float = Field( + default=10.0, + description="Warning threshold as percentage", + json_schema_extra={"label": "Warnschwelle (%)"}, ) - mandateId: str = Field(..., description="Foreign key to Mandate (UNIQUE)") - warningThresholdPercent: float = Field(default=10.0, description="Warning threshold as percentage") - # Stripe - stripeCustomerId: Optional[str] = Field(None, description="Stripe Customer ID (cus_xxx) — one per mandate") + stripeCustomerId: Optional[str] = Field( + None, + description="Stripe Customer ID (cus_xxx) — one per mandate", + json_schema_extra={"label": "Stripe-Kunden-ID"}, + ) # Auto-Recharge for AI budget - autoRechargeEnabled: bool = Field(default=False, description="Auto-buy AI budget when low") - rechargeAmountCHF: float = Field(default=10.0, description="Amount per auto-recharge (CHF, prepaid via Stripe)") - rechargeMaxPerMonth: int = Field(default=3, description="Max auto-recharges per month") - rechargesThisMonth: int = Field(default=0, description="Counter: auto-recharges used this month") - monthResetAt: Optional[datetime] = Field(None, description="When rechargesThisMonth was last reset") + autoRechargeEnabled: bool = Field(default=False, description="Auto-buy AI budget when low", json_schema_extra={"label": "Auto-Nachladung"}) + rechargeAmountCHF: float = Field( + default=10.0, + description="Amount per auto-recharge (CHF, prepaid via Stripe)", + json_schema_extra={"label": "Nachladebetrag (CHF)"}, + ) + rechargeMaxPerMonth: int = Field(default=3, description="Max auto-recharges per month", json_schema_extra={"label": "Max. Nachladungen/Monat"}) + rechargesThisMonth: int = Field(default=0, description="Counter: auto-recharges used this month", json_schema_extra={"label": "Nachladungen diesen Monat"}) + monthResetAt: Optional[datetime] = Field(None, description="When rechargesThisMonth was last reset", json_schema_extra={"label": "Monats-Reset"}) # Notifications notifyEmails: List[str] = Field( default_factory=list, description="Email addresses for billing alerts (pool exhausted, warnings, etc.)", + json_schema_extra={"label": "E-Mails fuer Billing-Alerts (Inhaber/Admin)"}, ) - notifyOnWarning: bool = Field(default=True, description="Send email when warning threshold is reached") + notifyOnWarning: bool = Field(default=True, description="Send email when warning threshold is reached", json_schema_extra={"label": "Bei Warnung benachrichtigen"}) # Storage overage (high-watermark within subscription period; resets on new period) storageHighWatermarkMB: float = Field( - default=0.0, description="Peak indexed data volume MB this billing period" + default=0.0, + description="Peak indexed data volume MB this billing period", + json_schema_extra={"label": "Speicher-Peak (MB)"}, ) storagePeriodStartAt: Optional[datetime] = Field( - None, description="Subscription billing period start used for storage reset" + None, + description="Subscription billing period start used for storage reset", + json_schema_extra={"label": "Speicher-Periodenbeginn"}, ) storageBilledUpToMB: float = Field( default=0.0, description="Overage MB already debited this period (above plan-included volume)", + json_schema_extra={"label": "Speicher abgerechneter Überhang (MB)"}, ) -registerModelLabels( - "BillingSettings", - {"en": "Billing Settings", "de": "Abrechnungseinstellungen"}, - { - "id": {"en": "ID", "de": "ID"}, - "mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"}, - "warningThresholdPercent": {"en": "Warning Threshold (%)", "de": "Warnschwelle (%)"}, - "stripeCustomerId": {"en": "Stripe Customer ID", "de": "Stripe-Kunden-ID"}, - "autoRechargeEnabled": {"en": "Auto-Recharge", "de": "Auto-Nachladung"}, - "rechargeAmountCHF": {"en": "Recharge Amount (CHF)", "de": "Nachladebetrag (CHF)"}, - "rechargeMaxPerMonth": {"en": "Max Recharges/Month", "de": "Max. Nachladungen/Monat"}, - "notifyEmails": { - "en": "Billing notification emails (owner / admin)", - "de": "E-Mails fuer Billing-Alerts (Inhaber/Admin)", - }, - "notifyOnWarning": {"en": "Notify on Warning", "de": "Bei Warnung benachrichtigen"}, - "storageHighWatermarkMB": {"en": "Storage peak (MB)", "de": "Speicher-Peak (MB)"}, - "storagePeriodStartAt": {"en": "Storage period start", "de": "Speicher-Periodenbeginn"}, - "storageBilledUpToMB": { - "en": "Storage billed overage (MB)", - "de": "Speicher abgerechneter Überhang (MB)", - }, - }, -) - - class StripeWebhookEvent(BaseModel): """Stores processed Stripe webhook event IDs for idempotency.""" id: str = Field( - default_factory=lambda: str(uuid.uuid4()), description="Primary key" + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", ) event_id: str = Field(..., description="Stripe event ID (evt_xxx)") processed_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), - description="When the event was processed" + description="When the event was processed", ) +@i18nModel("Nutzungsstatistik") class UsageStatistics(BaseModel): """Aggregated usage statistics for quick retrieval.""" id: str = Field( - default_factory=lambda: str(uuid.uuid4()), description="Primary key" + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + json_schema_extra={"label": "ID"}, ) - accountId: str = Field(..., description="Foreign key to BillingAccount") - periodType: PeriodTypeEnum = Field(..., description="Period type") - periodStart: date = Field(..., description="Period start date") - + accountId: str = Field(..., description="Foreign key to BillingAccount", json_schema_extra={"label": "Konto-ID"}) + periodType: PeriodTypeEnum = Field(..., description="Period type", json_schema_extra={"label": "Periodentyp"}) + periodStart: date = Field(..., description="Period start date", json_schema_extra={"label": "Periodenbeginn"}) + # Aggregated values - totalCostCHF: float = Field(default=0.0, description="Total cost in CHF") - transactionCount: int = Field(default=0, description="Number of transactions") - + totalCostCHF: float = Field(default=0.0, description="Total cost in CHF", json_schema_extra={"label": "Gesamtkosten (CHF)"}) + transactionCount: int = Field(default=0, description="Number of transactions", json_schema_extra={"label": "Anzahl Transaktionen"}) + # Breakdown by provider costByProvider: Dict[str, float] = Field( - default_factory=dict, - description="Cost breakdown by provider (e.g., {'anthropic': 12.50, 'openai': 8.30})" + default_factory=dict, + description="Cost breakdown by provider (e.g., {'anthropic': 12.50, 'openai': 8.30})", + json_schema_extra={"label": "Kosten nach Anbieter"}, ) - + # Breakdown by feature costByFeature: Dict[str, float] = Field( default_factory=dict, - description="Cost breakdown by feature (e.g., {'automation': 5.80, 'workspace': 3.20})" + description="Cost breakdown by feature (e.g., {'automation': 5.80, 'workspace': 3.20})", + json_schema_extra={"label": "Kosten nach Feature"}, ) -registerModelLabels( - "UsageStatistics", - {"en": "Usage Statistics", "de": "Nutzungsstatistik"}, - { - "id": {"en": "ID", "de": "ID"}, - "accountId": {"en": "Account ID", "de": "Konto-ID"}, - "periodType": {"en": "Period Type", "de": "Periodentyp"}, - "periodStart": {"en": "Period Start", "de": "Periodenbeginn"}, - "totalCostCHF": {"en": "Total Cost (CHF)", "de": "Gesamtkosten (CHF)"}, - "transactionCount": {"en": "Transaction Count", "de": "Anzahl Transaktionen"}, - "costByProvider": {"en": "Cost by Provider", "de": "Kosten nach Anbieter"}, - "costByFeature": {"en": "Cost by Feature", "de": "Kosten nach Feature"}, - }, -) - - # ============================================================================ # Response Models for API # ============================================================================ @@ -277,4 +244,3 @@ class BillingCheckResult(BaseModel): subscriptionUiPath: Optional[str] = None userAction: Optional[str] = None - diff --git a/modules/datamodels/datamodelChat.py b/modules/datamodels/datamodelChat.py index 7154e57e..f1dc720b 100644 --- a/modules/datamodels/datamodelChat.py +++ b/modules/datamodels/datamodelChat.py @@ -6,282 +6,119 @@ from typing import List, Dict, Any, Optional from enum import Enum from pydantic import BaseModel, Field from modules.datamodels.datamodelBase import PowerOnModel -from modules.shared.attributeUtils import registerModelLabels +from modules.shared.i18nRegistry import i18nModel from modules.shared.timeUtils import getUtcTimestamp import uuid - +@i18nModel("Chat-Protokoll") class ChatLog(PowerOnModel): """Log entries for chat workflows. User-owned, no mandate context.""" - id: str = Field( - default_factory=lambda: str(uuid.uuid4()), description="Primary key" - ) - workflowId: str = Field(description="Foreign key to workflow") - message: str = Field(description="Log message") - type: str = Field(description="Log type (info, warning, error, etc.)") - timestamp: float = Field( - default_factory=getUtcTimestamp, - description="When the log entry was created (UTC timestamp in seconds)", - ) - status: Optional[str] = Field(None, description="Status of the log entry") - progress: Optional[float] = Field( - None, description="Progress indicator (0.0 to 1.0)" - ) - performance: Optional[Dict[str, Any]] = Field( - None, description="Performance metrics" - ) - parentId: Optional[str] = Field( - None, description="Parent operation ID (operationId of parent operation) for hierarchical display" - ) - operationId: Optional[str] = Field( - None, description="Operation ID to group related log entries" - ) - roundNumber: Optional[int] = Field(None, description="Round number in workflow") - taskNumber: Optional[int] = Field(None, description="Task number within round") - actionNumber: Optional[int] = Field(None, description="Action number within task") - - -registerModelLabels( - "ChatLog", - {"en": "Chat Log", "fr": "Journal de chat"}, - { - "id": {"en": "ID", "fr": "ID"}, - "workflowId": {"en": "Workflow ID", "fr": "ID du flux de travail"}, - "message": {"en": "Message", "fr": "Message"}, - "type": {"en": "Type", "fr": "Type"}, - "timestamp": {"en": "Timestamp", "fr": "Horodatage"}, - "status": {"en": "Status", "fr": "Statut"}, - "progress": {"en": "Progress", "fr": "Progression"}, - "performance": {"en": "Performance", "fr": "Performance"}, - }, -) - + id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"label": "ID"}) + workflowId: str = Field(description="Foreign key to workflow", json_schema_extra={"label": "Workflow-ID"}) + message: str = Field(description="Log message", json_schema_extra={"label": "Nachricht"}) + type: str = Field(description="Log type (info, warning, error, etc.)", json_schema_extra={"label": "Typ"}) + timestamp: float = Field(default_factory=getUtcTimestamp, + description="When the log entry was created (UTC timestamp in seconds)", json_schema_extra={"label": "Zeitstempel"}) + status: Optional[str] = Field(None, description="Status of the log entry", json_schema_extra={"label": "Status"}) + progress: Optional[float] = Field(None, description="Progress indicator (0.0 to 1.0)", json_schema_extra={"label": "Fortschritt"}) + performance: Optional[Dict[str, Any]] = Field(None, description="Performance metrics", json_schema_extra={"label": "Leistung"}) + parentId: Optional[str] = Field(None, description="Parent operation ID (operationId of parent operation) for hierarchical display", json_schema_extra={"label": "Übergeordnete ID"}) + operationId: Optional[str] = Field(None, description="Operation ID to group related log entries", json_schema_extra={"label": "Vorgangs-ID"}) + roundNumber: Optional[int] = Field(None, description="Round number in workflow", json_schema_extra={"label": "Rundennummer"}) + taskNumber: Optional[int] = Field(None, description="Task number within round", json_schema_extra={"label": "Aufgabennummer"}) + actionNumber: Optional[int] = Field(None, description="Action number within task", json_schema_extra={"label": "Aktionsnummer"}) +@i18nModel("Chat-Dokument") class ChatDocument(PowerOnModel): """Documents attached to chat messages. User-owned, no mandate context.""" - id: str = Field( - default_factory=lambda: str(uuid.uuid4()), description="Primary key" - ) - messageId: str = Field(description="Foreign key to message") - fileId: str = Field(description="Foreign key to file") - fileName: str = Field(description="Name of the file") - fileSize: int = Field(description="Size of the file") - mimeType: str = Field(description="MIME type of the file") - roundNumber: Optional[int] = Field(None, description="Round number in workflow") - taskNumber: Optional[int] = Field(None, description="Task number within round") - actionNumber: Optional[int] = Field(None, description="Action number within task") - actionId: Optional[str] = Field( - None, description="ID of the action that created this document" - ) - - -registerModelLabels( - "ChatDocument", - {"en": "Chat Document", "fr": "Document de chat"}, - { - "id": {"en": "ID", "fr": "ID"}, - "messageId": {"en": "Message ID", "fr": "ID du message"}, - "fileId": {"en": "File ID", "fr": "ID du fichier"}, - "fileName": {"en": "File Name", "fr": "Nom du fichier"}, - "fileSize": {"en": "File Size", "fr": "Taille du fichier"}, - "mimeType": {"en": "MIME Type", "fr": "Type MIME"}, - "roundNumber": {"en": "Round Number", "fr": "Numéro de tour"}, - "taskNumber": {"en": "Task Number", "fr": "Numéro de tâche"}, - "actionNumber": {"en": "Action Number", "fr": "Numéro d'action"}, - "actionId": {"en": "Action ID", "fr": "ID de l'action"}, - }, -) - + id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"label": "ID"}) + messageId: str = Field(description="Foreign key to message", json_schema_extra={"label": "Nachrichten-ID"}) + fileId: str = Field(description="Foreign key to file", json_schema_extra={"label": "Datei-ID"}) + fileName: str = Field(description="Name of the file", json_schema_extra={"label": "Dateiname"}) + fileSize: int = Field(description="Size of the file", json_schema_extra={"label": "Dateigröße"}) + mimeType: str = Field(description="MIME type of the file", json_schema_extra={"label": "MIME-Typ"}) + roundNumber: Optional[int] = Field(None, description="Round number in workflow", json_schema_extra={"label": "Rundennummer"}) + taskNumber: Optional[int] = Field(None, description="Task number within round", json_schema_extra={"label": "Aufgabennummer"}) + actionNumber: Optional[int] = Field(None, description="Action number within task", json_schema_extra={"label": "Aktionsnummer"}) + actionId: Optional[str] = Field(None, description="ID of the action that created this document", json_schema_extra={"label": "Aktions-ID"}) +@i18nModel("Inhalts-Metadaten") class ContentMetadata(BaseModel): - size: int = Field(description="Content size in bytes") - pages: Optional[int] = Field( - None, description="Number of pages for multi-page content" - ) - error: Optional[str] = Field(None, description="Processing error if any") - width: Optional[int] = Field(None, description="Width in pixels for images/videos") - height: Optional[int] = Field( - None, description="Height in pixels for images/videos" - ) - colorMode: Optional[str] = Field(None, description="Color mode") - fps: Optional[float] = Field(None, description="Frames per second for videos") - durationSec: Optional[float] = Field( - None, description="Duration in seconds for media" - ) - mimeType: str = Field(description="MIME type of the content") - base64Encoded: bool = Field(description="Whether the data is base64 encoded") - - -registerModelLabels( - "ContentMetadata", - {"en": "Content Metadata", "fr": "Métadonnées du contenu"}, - { - "size": {"en": "Size", "fr": "Taille"}, - "pages": {"en": "Pages", "fr": "Pages"}, - "error": {"en": "Error", "fr": "Erreur"}, - "width": {"en": "Width", "fr": "Largeur"}, - "height": {"en": "Height", "fr": "Hauteur"}, - "colorMode": {"en": "Color Mode", "fr": "Mode de couleur"}, - "fps": {"en": "FPS", "fr": "IPS"}, - "durationSec": {"en": "Duration", "fr": "Durée"}, - "mimeType": {"en": "MIME Type", "fr": "Type MIME"}, - "base64Encoded": {"en": "Base64 Encoded", "fr": "Encodé en Base64"}, - }, -) - + size: int = Field(description="Content size in bytes", json_schema_extra={"label": "Größe"}) + pages: Optional[int] = Field(None, description="Number of pages for multi-page content", json_schema_extra={"label": "Seiten"}) + error: Optional[str] = Field(None, description="Processing error if any", json_schema_extra={"label": "Fehler"}) + width: Optional[int] = Field(None, description="Width in pixels for images/videos", json_schema_extra={"label": "Breite"}) + height: Optional[int] = Field(None, description="Height in pixels for images/videos", json_schema_extra={"label": "Höhe"}) + colorMode: Optional[str] = Field(None, description="Color mode", json_schema_extra={"label": "Farbmodus"}) + fps: Optional[float] = Field(None, description="Frames per second for videos", json_schema_extra={"label": "FPS"}) + durationSec: Optional[float] = Field(None, description="Duration in seconds for media", json_schema_extra={"label": "Dauer"}) + mimeType: str = Field(description="MIME type of the content", json_schema_extra={"label": "MIME-Typ"}) + base64Encoded: bool = Field(description="Whether the data is base64 encoded", json_schema_extra={"label": "Base64-kodiert"}) +@i18nModel("Inhaltselement") class ContentItem(BaseModel): - label: str = Field(description="Content label") - data: str = Field(description="Extracted text content") - metadata: ContentMetadata = Field(description="Content metadata") - - -registerModelLabels( - "ContentItem", - {"en": "Content Item", "fr": "Élément de contenu"}, - { - "label": {"en": "Label", "fr": "Étiquette"}, - "data": {"en": "Data", "fr": "Données"}, - "metadata": {"en": "Metadata", "fr": "Métadonnées"}, - }, -) - + label: str = Field(description="Content label", json_schema_extra={"label": "Bezeichnung"}) + data: str = Field(description="Extracted text content", json_schema_extra={"label": "Daten"}) + metadata: ContentMetadata = Field(description="Content metadata", json_schema_extra={"label": "Metadaten"}) +@i18nModel("Extrahierter Inhalt") class ChatContentExtracted(BaseModel): - id: str = Field(description="Reference to source ChatDocument") - contents: List[ContentItem] = Field( - default_factory=list, description="List of content items" - ) - - -registerModelLabels( - "ChatContentExtracted", - {"en": "Extracted Content", "fr": "Contenu extrait"}, - { - "id": {"en": "Object ID", "fr": "ID de l'objet"}, - "contents": {"en": "Contents", "fr": "Contenus"}, - }, -) - + id: str = Field(description="Reference to source ChatDocument", json_schema_extra={"label": "Objekt-ID"}) + contents: List[ContentItem] = Field(default_factory=list, description="List of content items", json_schema_extra={"label": "Inhalte"}) +@i18nModel("Chat-Nachricht") class ChatMessage(PowerOnModel): """Messages in chat workflows. User-owned, no mandate context.""" - id: str = Field( - default_factory=lambda: str(uuid.uuid4()), description="Primary key" - ) - workflowId: str = Field(description="Foreign key to workflow") - parentMessageId: Optional[str] = Field( - None, description="Parent message ID for threading" - ) - documents: List[ChatDocument] = Field( - default_factory=list, description="Associated documents" - ) - documentsLabel: Optional[str] = Field( - None, description="Label for the set of documents" - ) - message: Optional[str] = Field(None, description="Message content") - summary: Optional[str] = Field( - None, description="Short summary of this message for planning/history" - ) - role: str = Field(description="Role of the message sender") - status: str = Field(description="Status of the message (first, step, last)") - sequenceNr: Optional[int] = Field( - default=0, - description="Sequence number of the message (set automatically)" - ) - publishedAt: Optional[float] = Field( - default=None, - description="When the message was published (UTC timestamp in seconds)", - ) - success: Optional[bool] = Field( - None, description="Whether the message processing was successful" - ) - actionId: Optional[str] = Field( - None, description="ID of the action that produced this message" - ) - actionMethod: Optional[str] = Field( - None, description="Method of the action that produced this message" - ) - actionName: Optional[str] = Field( - None, description="Name of the action that produced this message" - ) - roundNumber: Optional[int] = Field(None, description="Round number in workflow") - taskNumber: Optional[int] = Field(None, description="Task number within round") - actionNumber: Optional[int] = Field(None, description="Action number within task") - taskProgress: Optional[str] = Field( - None, description="Task progress status: pending, running, success, fail, retry" - ) - actionProgress: Optional[str] = Field( - None, description="Action progress status: pending, running, success, fail" - ) - - -registerModelLabels( - "ChatMessage", - {"en": "Chat Message", "fr": "Message de chat"}, - { - "id": {"en": "ID", "fr": "ID"}, - "workflowId": {"en": "Workflow ID", "fr": "ID du flux de travail"}, - "parentMessageId": {"en": "Parent Message ID", "fr": "ID du message parent"}, - "documents": {"en": "Documents", "fr": "Documents"}, - "documentsLabel": {"en": "Documents Label", "fr": "Label des documents"}, - "message": {"en": "Message", "fr": "Message"}, - "summary": {"en": "Summary", "fr": "Résumé"}, - "role": {"en": "Role", "fr": "Rôle"}, - "status": {"en": "Status", "fr": "Statut"}, - "sequenceNr": {"en": "Sequence Number", "fr": "Numéro de séquence"}, - "publishedAt": {"en": "Published At", "fr": "Publié le"}, - "success": {"en": "Success", "fr": "Succès"}, - "actionId": {"en": "Action ID", "fr": "ID de l'action"}, - "actionMethod": {"en": "Action Method", "fr": "Méthode de l'action"}, - "actionName": {"en": "Action Name", "fr": "Nom de l'action"}, - "roundNumber": {"en": "Round Number", "fr": "Numéro de tour"}, - "taskNumber": {"en": "Task Number", "fr": "Numéro de tâche"}, - "actionNumber": {"en": "Action Number", "fr": "Numéro d'action"}, - "taskProgress": {"en": "Task Progress", "fr": "Progression de la tâche"}, - "actionProgress": {"en": "Action Progress", "fr": "Progression de l'action"}, - }, -) - + id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"label": "ID"}) + workflowId: str = Field(description="Foreign key to workflow", json_schema_extra={"label": "Workflow-ID"}) + parentMessageId: Optional[str] = Field(None, description="Parent message ID for threading", json_schema_extra={"label": "Übergeordnete Nachrichten-ID"}) + documents: List[ChatDocument] = Field(default_factory=list, description="Associated documents", json_schema_extra={"label": "Dokumente"}) + documentsLabel: Optional[str] = Field(None, description="Label for the set of documents", json_schema_extra={"label": "Dokumenten-Label"}) + message: Optional[str] = Field(None, description="Message content", json_schema_extra={"label": "Nachricht"}) + summary: Optional[str] = Field(None, description="Short summary of this message for planning/history", json_schema_extra={"label": "Zusammenfassung"}) + role: str = Field(description="Role of the message sender", json_schema_extra={"label": "Rolle"}) + status: str = Field(description="Status of the message (first, step, last)", json_schema_extra={"label": "Status"}) + sequenceNr: Optional[int] = Field(default=0, + description="Sequence number of the message (set automatically)", json_schema_extra={"label": "Sequenznummer"}) + publishedAt: Optional[float] = Field(default=None, + description="When the message was published (UTC timestamp in seconds)", json_schema_extra={"label": "Veröffentlicht am"}) + success: Optional[bool] = Field(None, description="Whether the message processing was successful", json_schema_extra={"label": "Erfolg"}) + actionId: Optional[str] = Field(None, description="ID of the action that produced this message", json_schema_extra={"label": "Aktions-ID"}) + actionMethod: Optional[str] = Field(None, description="Method of the action that produced this message", json_schema_extra={"label": "Aktionsmethode"}) + actionName: Optional[str] = Field(None, description="Name of the action that produced this message", json_schema_extra={"label": "Aktionsname"}) + roundNumber: Optional[int] = Field(None, description="Round number in workflow", json_schema_extra={"label": "Rundennummer"}) + taskNumber: Optional[int] = Field(None, description="Task number within round", json_schema_extra={"label": "Aufgabennummer"}) + actionNumber: Optional[int] = Field(None, description="Action number within task", json_schema_extra={"label": "Aktionsnummer"}) + taskProgress: Optional[str] = Field(None, description="Task progress status: pending, running, success, fail, retry", json_schema_extra={"label": "Aufgabenfortschritt"}) + actionProgress: Optional[str] = Field(None, description="Action progress status: pending, running, success, fail", json_schema_extra={"label": "Aktionsfortschritt"}) class WorkflowModeEnum(str, Enum): WORKFLOW_DYNAMIC = "Dynamic" WORKFLOW_AUTOMATION = "Automation" WORKFLOW_CHATBOT = "Chatbot" - -registerModelLabels( - "WorkflowModeEnum", - {"en": "Workflow Mode", "fr": "Mode de workflow"}, - { - "WORKFLOW_DYNAMIC": {"en": "Dynamic", "fr": "Dynamique"}, - "WORKFLOW_AUTOMATION": {"en": "Automation", "fr": "Automatisation"}, - "WORKFLOW_CHATBOT": {"en": "Chatbot", "fr": "Chatbot"}, - }, -) - - +@i18nModel("Chat-Workflow") class ChatWorkflow(PowerOnModel): """Chat workflow container. User-owned, no mandate context.""" - id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) - featureInstanceId: Optional[str] = Field(None, description="Feature instance ID for multi-tenancy isolation", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) + id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) + featureInstanceId: Optional[str] = Field(None, description="Feature instance ID for multi-tenancy isolation", json_schema_extra={"label": "Feature-Instanz-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) status: str = Field(default="running", description="Current status of the workflow", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ {"value": "running", "label": {"en": "Running", "fr": "En cours"}}, {"value": "completed", "label": {"en": "Completed", "fr": "Terminé"}}, {"value": "stopped", "label": {"en": "Stopped", "fr": "Arrêté"}}, {"value": "error", "label": {"en": "Error", "fr": "Erreur"}}, ]}) - name: Optional[str] = Field(None, description="Name of the workflow", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}) - currentRound: int = Field(default=0, description="Current round number", json_schema_extra={"frontend_type": "integer", "frontend_readonly": True, "frontend_required": False}) - currentTask: int = Field(default=0, description="Current task number", json_schema_extra={"frontend_type": "integer", "frontend_readonly": True, "frontend_required": False}) - currentAction: int = Field(default=0, description="Current action number", json_schema_extra={"frontend_type": "integer", "frontend_readonly": True, "frontend_required": False}) - totalTasks: int = Field(default=0, description="Total number of tasks in the workflow", json_schema_extra={"frontend_type": "integer", "frontend_readonly": True, "frontend_required": False}) - totalActions: int = Field(default=0, description="Total number of actions in the workflow", json_schema_extra={"frontend_type": "integer", "frontend_readonly": True, "frontend_required": False}) - lastActivity: float = Field(default_factory=getUtcTimestamp, description="Timestamp of last activity (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}) - startedAt: float = Field(default_factory=getUtcTimestamp, description="When the workflow started (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}) - logs: List[ChatLog] = Field(default_factory=list, description="Workflow logs", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) - messages: List[ChatMessage] = Field(default_factory=list, description="Messages in the workflow", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) - tasks: list = Field(default_factory=list, description="List of tasks in the workflow", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) + name: Optional[str] = Field(None, description="Name of the workflow", json_schema_extra={"label": "Name", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True}) + currentRound: int = Field(default=0, description="Current round number", json_schema_extra={"label": "Aktuelle Runde", "frontend_type": "integer", "frontend_readonly": True, "frontend_required": False}) + currentTask: int = Field(default=0, description="Current task number", json_schema_extra={"label": "Aktuelle Aufgabe", "frontend_type": "integer", "frontend_readonly": True, "frontend_required": False}) + currentAction: int = Field(default=0, description="Current action number", json_schema_extra={"label": "Aktuelle Aktion", "frontend_type": "integer", "frontend_readonly": True, "frontend_required": False}) + totalTasks: int = Field(default=0, description="Total number of tasks in the workflow", json_schema_extra={"label": "Aufgaben gesamt", "frontend_type": "integer", "frontend_readonly": True, "frontend_required": False}) + totalActions: int = Field(default=0, description="Total number of actions in the workflow", json_schema_extra={"label": "Aktionen gesamt", "frontend_type": "integer", "frontend_readonly": True, "frontend_required": False}) + lastActivity: float = Field(default_factory=getUtcTimestamp, description="Timestamp of last activity (UTC timestamp in seconds)", json_schema_extra={"label": "Letzte Aktivität", "frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}) + startedAt: float = Field(default_factory=getUtcTimestamp, description="When the workflow started (UTC timestamp in seconds)", json_schema_extra={"label": "Gestartet am", "frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}) + logs: List[ChatLog] = Field(default_factory=list, description="Workflow logs", json_schema_extra={"label": "Protokolle", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) + messages: List[ChatMessage] = Field(default_factory=list, description="Messages in the workflow", json_schema_extra={"label": "Nachrichten", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) + tasks: list = Field(default_factory=list, description="List of tasks in the workflow", json_schema_extra={"label": "Aufgaben", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) workflowMode: WorkflowModeEnum = Field(default=WorkflowModeEnum.WORKFLOW_DYNAMIC, description="Workflow mode selector", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ { "value": WorkflowModeEnum.WORKFLOW_DYNAMIC.value, @@ -296,8 +133,8 @@ class ChatWorkflow(PowerOnModel): "label": {"en": "Chatbot", "fr": "Chatbot"}, }, ]}) - maxSteps: int = Field(default=10, description="Maximum number of iterations in dynamic mode", json_schema_extra={"frontend_type": "integer", "frontend_readonly": False, "frontend_required": False}) - expectedFormats: Optional[List[str]] = Field(None, description="List of expected file format extensions from user request (e.g., ['xlsx', 'pdf']). Extracted during intent analysis.", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) + maxSteps: int = Field(default=10, description="Maximum number of iterations in dynamic mode", json_schema_extra={"label": "Max. Schritte", "frontend_type": "integer", "frontend_readonly": False, "frontend_required": False}) + expectedFormats: Optional[List[str]] = Field(None, description="List of expected file format extensions from user request (e.g., ['xlsx', 'pdf']). Extracted during intent analysis.", json_schema_extra={"label": "Erwartete Formate", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) # Helper methods for execution state management def getRoundIndex(self) -> int: @@ -327,80 +164,27 @@ class ChatWorkflow(PowerOnModel): """Increment action when executing new action in current task""" self.currentAction += 1 - -registerModelLabels( - "ChatWorkflow", - {"en": "Chat Workflow", "fr": "Flux de travail de chat"}, - { - "id": {"en": "ID", "fr": "ID"}, - "featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"}, - "status": {"en": "Status", "fr": "Statut"}, - "name": {"en": "Name", "fr": "Nom"}, - "currentRound": {"en": "Current Round", "fr": "Tour actuel"}, - "currentTask": {"en": "Current Task", "fr": "Tâche actuelle"}, - "currentAction": {"en": "Current Action", "fr": "Action actuelle"}, - "totalTasks": {"en": "Total Tasks", "fr": "Total des tâches"}, - "totalActions": {"en": "Total Actions", "fr": "Total des actions"}, - "lastActivity": {"en": "Last Activity", "fr": "Dernière activité"}, - "startedAt": {"en": "Started At", "fr": "Démarré le"}, - "logs": {"en": "Logs", "fr": "Journaux"}, - "messages": {"en": "Messages", "fr": "Messages"}, - "stats": {"en": "Statistics", "fr": "Statistiques"}, - "tasks": {"en": "Tasks", "fr": "Tâches"}, - "workflowMode": {"en": "Workflow Mode", "fr": "Mode de workflow"}, - "maxSteps": {"en": "Max Steps", "fr": "Étapes max"}, - "expectedFormats": {"en": "Expected Formats", "fr": "Formats attendus"}, - }, -) - - +@i18nModel("Benutzereingabe") class UserInputRequest(BaseModel): - prompt: str = Field(description="Prompt for the user") - listFileId: List[str] = Field(default_factory=list, description="List of file IDs") - userLanguage: str = Field(default="en", description="User's preferred language") - workflowId: Optional[str] = Field(None, description="Optional ID of the workflow to continue") - allowedProviders: Optional[List[str]] = Field(None, description="List of allowed AI providers (multiselect)") - - -registerModelLabels( - "UserInputRequest", - {"en": "User Input Request", "fr": "Demande de saisie utilisateur"}, - { - "prompt": {"en": "Prompt", "fr": "Invite"}, - "listFileId": {"en": "File IDs", "fr": "IDs des fichiers"}, - "userLanguage": {"en": "User Language", "fr": "Langue de l'utilisateur"}, - "preferredProvider": {"en": "Preferred Provider", "fr": "Fournisseur préféré"}, - }, -) - + prompt: str = Field(description="Prompt for the user", json_schema_extra={"label": "Eingabeaufforderung"}) + listFileId: List[str] = Field(default_factory=list, description="List of file IDs", json_schema_extra={"label": "Datei-IDs"}) + userLanguage: str = Field(default="en", description="User's preferred language", json_schema_extra={"label": "Benutzersprache"}) + workflowId: Optional[str] = Field(None, description="Optional ID of the workflow to continue", json_schema_extra={"label": "Workflow-ID"}) + allowedProviders: Optional[List[str]] = Field(None, description="List of allowed AI providers (multiselect)", json_schema_extra={"label": "Erlaubte Anbieter"}) +@i18nModel("Aktions-Dokument") class ActionDocument(BaseModel): """Clear document structure for action results""" - documentName: str = Field(description="Name of the document") - documentData: Any = Field(description="Content/data of the document") - mimeType: str = Field(description="MIME type of the document") - sourceJson: Optional[Dict[str, Any]] = Field( - None, - description="Source JSON structure (preserved when rendering to xlsx/docx/pdf)" - ) - validationMetadata: Optional[Dict[str, Any]] = Field( - None, - description="Action-specific metadata for content validation (e.g., email recipients, attachments, SharePoint paths)" - ) - - -registerModelLabels( - "ActionDocument", - {"en": "Action Document", "fr": "Document d'action"}, - { - "documentName": {"en": "Document Name", "fr": "Nom du document"}, - "documentData": {"en": "Document Data", "fr": "Données du document"}, - "mimeType": {"en": "MIME Type", "fr": "Type MIME"}, - }, -) - + documentName: str = Field(description="Name of the document", json_schema_extra={"label": "Dokumentname"}) + documentData: Any = Field(description="Content/data of the document", json_schema_extra={"label": "Dokumentdaten"}) + mimeType: str = Field(description="MIME type of the document", json_schema_extra={"label": "MIME-Typ"}) + sourceJson: Optional[Dict[str, Any]] = Field(None, + description="Source JSON structure (preserved when rendering to xlsx/docx/pdf)", json_schema_extra={"label": "Quell-JSON"}) + validationMetadata: Optional[Dict[str, Any]] = Field(None, + description="Action-specific metadata for content validation (e.g., email recipients, attachments, SharePoint paths)", json_schema_extra={"label": "Validierungs-Metadaten"}) +@i18nModel("Aktionsergebnis") class ActionResult(BaseModel): """Clean action result with documents as primary output @@ -409,15 +193,11 @@ class ActionResult(BaseModel): from the action plan. This ensures consistent document routing throughout the workflow. """ - success: bool = Field(description="Whether execution succeeded") - error: Optional[str] = Field(None, description="Error message if failed") - documents: List[ActionDocument] = Field( - default_factory=list, description="Document outputs" - ) - resultLabel: Optional[str] = Field( - None, - description="Label for document routing (set by action handler, not by action methods)", - ) + success: bool = Field(description="Whether execution succeeded", json_schema_extra={"label": "Erfolg"}) + error: Optional[str] = Field(None, description="Error message if failed", json_schema_extra={"label": "Fehler"}) + documents: List[ActionDocument] = Field(default_factory=list, description="Document outputs", json_schema_extra={"label": "Dokumente"}) + resultLabel: Optional[str] = Field(None, + description="Label for document routing (set by action handler, not by action methods)", json_schema_extra={"label": "Ergebnis-Label"}) @classmethod def isSuccess(cls, documents: List[ActionDocument] = None) -> "ActionResult": @@ -429,76 +209,32 @@ class ActionResult(BaseModel): ) -> "ActionResult": return cls(success=False, documents=documents or [], error=error) - -registerModelLabels( - "ActionResult", - {"en": "Action Result", "fr": "Résultat de l'action"}, - { - "success": {"en": "Success", "fr": "Succès"}, - "error": {"en": "Error", "fr": "Erreur"}, - "documents": {"en": "Documents", "fr": "Documents"}, - "resultLabel": {"en": "Result Label", "fr": "Étiquette du résultat"}, - }, -) - - +@i18nModel("Aktionsauswahl") class ActionSelection(BaseModel): - method: str = Field(description="Method to execute (e.g., web, document, ai)") - name: str = Field( - description="Action name within the method (e.g., search, extract)" - ) - - -registerModelLabels( - "ActionSelection", - {"en": "Action Selection", "fr": "Sélection d'action"}, - { - "method": {"en": "Method", "fr": "Méthode"}, - "name": {"en": "Action Name", "fr": "Nom de l'action"}, - }, -) - + method: str = Field(description="Method to execute (e.g., web, document, ai)", json_schema_extra={"label": "Methode"}) + name: str = Field(description="Action name within the method (e.g., search, extract)", json_schema_extra={"label": "Aktionsname"}) +@i18nModel("Aktionsparameter") class ActionParameters(BaseModel): - parameters: Dict[str, Any] = Field( - default_factory=dict, description="Parameters to execute the selected action" - ) - - -registerModelLabels( - "ActionParameters", - {"en": "Action Parameters", "fr": "Paramètres d'action"}, - { - "parameters": {"en": "Parameters", "fr": "Paramètres"}, - }, -) - + parameters: Dict[str, Any] = Field(default_factory=dict, description="Parameters to execute the selected action", json_schema_extra={"label": "Parameter"}) +@i18nModel("Beobachtungs-Vorschau") +@i18nModel("Beobachtung") +@i18nModel("Beobachtungs-Vorschau") +@i18nModel("Beobachtung") class ObservationPreview(BaseModel): - name: str = Field(description="Document name or URL label") - mime: Optional[str] = Field(default=None, description="MIME type or kind (legacy field)") - snippet: Optional[str] = Field(default=None, description="Short snippet or summary") + name: str = Field(description="Document name or URL label", json_schema_extra={"label": "Name"}) + mime: Optional[str] = Field(default=None, description="MIME type or kind (legacy field)", json_schema_extra={"label": "MIME"}) + snippet: Optional[str] = Field(default=None, description="Short snippet or summary", json_schema_extra={"label": "Ausschnitt"}) # Extended metadata fields - mimeType: Optional[str] = Field(default=None, description="MIME type") - size: Optional[str] = Field(default=None, description="File size") - created: Optional[str] = Field(default=None, description="Creation timestamp") - modified: Optional[str] = Field(default=None, description="Modification timestamp") - typeGroup: Optional[str] = Field(default=None, description="Document type group") - documentId: Optional[str] = Field(default=None, description="Document ID") - reference: Optional[str] = Field(default=None, description="Document reference") - contentSize: Optional[str] = Field(default=None, description="Content size indicator") - - -registerModelLabels( - "ObservationPreview", - {"en": "Observation Preview", "fr": "Aperçu d'observation"}, - { - "name": {"en": "Name", "fr": "Nom"}, - "mime": {"en": "MIME", "fr": "MIME"}, - "snippet": {"en": "Snippet", "fr": "Extrait"}, - }, -) - + mimeType: Optional[str] = Field(default=None, description="MIME type", json_schema_extra={"label": "MIME-Typ"}) + size: Optional[str] = Field(default=None, description="File size", json_schema_extra={"label": "Größe"}) + created: Optional[str] = Field(default=None, description="Creation timestamp", json_schema_extra={"label": "Erstellt"}) + modified: Optional[str] = Field(default=None, description="Modification timestamp", json_schema_extra={"label": "Geändert"}) + typeGroup: Optional[str] = Field(default=None, description="Document type group", json_schema_extra={"label": "Typgruppe"}) + documentId: Optional[str] = Field(default=None, description="Document ID", json_schema_extra={"label": "Dokument-ID"}) + reference: Optional[str] = Field(default=None, description="Document reference", json_schema_extra={"label": "Referenz"}) + contentSize: Optional[str] = Field(default=None, description="Content size indicator", json_schema_extra={"label": "Inhaltsgröße"}) class Observation(BaseModel): success: bool = Field(description="Action execution success flag") @@ -518,20 +254,6 @@ class Observation(BaseModel): default=None, description="Content analysis results" ) - -registerModelLabels( - "Observation", - {"en": "Observation", "fr": "Observation"}, - { - "success": {"en": "Success", "fr": "Succès"}, - "resultLabel": {"en": "Result Label", "fr": "Étiquette du résultat"}, - "documentsCount": {"en": "Documents Count", "fr": "Nombre de documents"}, - "previews": {"en": "Previews", "fr": "Aperçus"}, - "notes": {"en": "Notes", "fr": "Notes"}, - }, -) - - class TaskStatus(str, Enum): PENDING = "pending" RUNNING = "running" @@ -539,64 +261,27 @@ class TaskStatus(str, Enum): FAILED = "failed" CANCELLED = "cancelled" - -registerModelLabels( - "TaskStatus", - {"en": "Task Status", "fr": "Statut de la tâche"}, - { - "PENDING": {"en": "Pending", "fr": "En attente"}, - "RUNNING": {"en": "Running", "fr": "En cours"}, - "COMPLETED": {"en": "Completed", "fr": "Terminé"}, - "FAILED": {"en": "Failed", "fr": "Échec"}, - "CANCELLED": {"en": "Cancelled", "fr": "Annulé"}, - }, -) - - +@i18nModel("Dokumentaustausch") class DocumentExchange(BaseModel): - documentsLabel: str = Field(description="Label for the set of documents") - documents: List[str] = Field( - default_factory=list, description="List of document references" - ) - - -registerModelLabels( - "DocumentExchange", - {"en": "Document Exchange", "fr": "Échange de documents"}, - { - "documentsLabel": {"en": "Documents Label", "fr": "Label des documents"}, - "documents": {"en": "Documents", "fr": "Documents"}, - }, -) - + documentsLabel: str = Field(description="Label for the set of documents", json_schema_extra={"label": "Dokumenten-Label"}) + documents: List[str] = Field(default_factory=list, description="List of document references", json_schema_extra={"label": "Dokumente"}) +@i18nModel("Aufgaben-Aktion") class ActionItem(BaseModel): - id: str = Field(..., description="Action ID") - execMethod: str = Field(..., description="Method to execute") - execAction: str = Field(..., description="Action to perform") - execParameters: Dict[str, Any] = Field( - default_factory=dict, description="Action parameters" - ) - execResultLabel: Optional[str] = Field( - None, description="Label for the set of result documents" - ) - expectedDocumentFormats: Optional[List[Dict[str, str]]] = Field( - None, description="Expected document formats (optional)" - ) - userMessage: Optional[str] = Field( - None, description="User-friendly message in user's language" - ) - status: TaskStatus = Field(default=TaskStatus.PENDING, description="Action status") - error: Optional[str] = Field(None, description="Error message if action failed") - retryCount: int = Field(default=0, description="Number of retries attempted") - retryMax: int = Field(default=3, description="Maximum number of retries") - processingTime: Optional[float] = Field( - None, description="Processing time in seconds" - ) - timestamp: float = Field( - ..., description="When the action was executed (UTC timestamp in seconds)" - ) - result: Optional[str] = Field(None, description="Result of the action") + id: str = Field(..., description="Action ID", json_schema_extra={"label": "Aktions-ID"}) + execMethod: str = Field(..., description="Method to execute", json_schema_extra={"label": "Methode"}) + execAction: str = Field(..., description="Action to perform", json_schema_extra={"label": "Aktion"}) + execParameters: Dict[str, Any] = Field(default_factory=dict, description="Action parameters", json_schema_extra={"label": "Parameter"}) + execResultLabel: Optional[str] = Field(None, description="Label for the set of result documents", json_schema_extra={"label": "Ergebnis-Label"}) + expectedDocumentFormats: Optional[List[Dict[str, str]]] = Field(None, description="Expected document formats (optional)", json_schema_extra={"label": "Erwartete Dokumentformate"}) + userMessage: Optional[str] = Field(None, description="User-friendly message in user's language", json_schema_extra={"label": "Benutzernachricht"}) + status: TaskStatus = Field(default=TaskStatus.PENDING, description="Action status", json_schema_extra={"label": "Status"}) + error: Optional[str] = Field(None, description="Error message if action failed", json_schema_extra={"label": "Fehler"}) + retryCount: int = Field(default=0, description="Number of retries attempted", json_schema_extra={"label": "Wiederholungen"}) + retryMax: int = Field(default=3, description="Maximum number of retries", json_schema_extra={"label": "Max. Wiederholungen"}) + processingTime: Optional[float] = Field(None, description="Processing time in seconds", json_schema_extra={"label": "Bearbeitungszeit"}) + timestamp: float = Field(..., description="When the action was executed (UTC timestamp in seconds)", json_schema_extra={"label": "Zeitstempel"}) + result: Optional[str] = Field(None, description="Result of the action", json_schema_extra={"label": "Ergebnis"}) def setSuccess(self, result: str = None) -> None: """Set the action as successful with optional result""" @@ -610,191 +295,59 @@ class ActionItem(BaseModel): self.status = TaskStatus.FAILED self.error = error_message +@i18nModel("Chat-Aufgabenergebnis") +class ChatTaskResult(BaseModel): + taskId: str = Field(..., description="Task ID", json_schema_extra={"label": "Aufgaben-ID"}) + status: TaskStatus = Field(default=TaskStatus.PENDING, description="Task status", json_schema_extra={"label": "Status"}) + success: bool = Field(..., description="Whether the task was successful", json_schema_extra={"label": "Erfolg"}) + feedback: Optional[str] = Field(None, description="Task feedback message", json_schema_extra={"label": "Rückmeldung"}) + error: Optional[str] = Field(None, description="Error message if task failed", json_schema_extra={"label": "Fehler"}) -registerModelLabels( - "ActionItem", - {"en": "Task Action", "fr": "Action de tâche"}, - { - "id": {"en": "Action ID", "fr": "ID de l'action"}, - "execMethod": {"en": "Method", "fr": "Méthode"}, - "execAction": {"en": "Action", "fr": "Action"}, - "execParameters": {"en": "Parameters", "fr": "Paramètres"}, - "execResultLabel": {"en": "Result Label", "fr": "Label du résultat"}, - "expectedDocumentFormats": { - "en": "Expected Document Formats", - "fr": "Formats de documents attendus", - }, - "userMessage": {"en": "User Message", "fr": "Message utilisateur"}, - "status": {"en": "Status", "fr": "Statut"}, - "error": {"en": "Error", "fr": "Erreur"}, - "retryCount": {"en": "Retry Count", "fr": "Nombre de tentatives"}, - "retryMax": {"en": "Max Retries", "fr": "Tentatives max"}, - "processingTime": {"en": "Processing Time", "fr": "Temps de traitement"}, - "timestamp": {"en": "Timestamp", "fr": "Horodatage"}, - "result": {"en": "Result", "fr": "Résultat"}, - }, -) - - -class TaskResult(BaseModel): - taskId: str = Field(..., description="Task ID") - status: TaskStatus = Field(default=TaskStatus.PENDING, description="Task status") - success: bool = Field(..., description="Whether the task was successful") - feedback: Optional[str] = Field(None, description="Task feedback message") - error: Optional[str] = Field(None, description="Error message if task failed") - - -registerModelLabels( - "TaskResult", - {"en": "Task Result", "fr": "Résultat de tâche"}, - { - "taskId": {"en": "Task ID", "fr": "ID de la tâche"}, - "status": {"en": "Status", "fr": "Statut"}, - "success": {"en": "Success", "fr": "Succès"}, - "feedback": {"en": "Feedback", "fr": "Retour"}, - "error": {"en": "Error", "fr": "Erreur"}, - }, -) - - +@i18nModel("Aufgabe") class TaskItem(BaseModel): - id: str = Field(..., description="Task ID") - workflowId: str = Field(..., description="Workflow ID") - userInput: str = Field(..., description="User input that triggered the task") - status: TaskStatus = Field(default=TaskStatus.PENDING, description="Task status") - error: Optional[str] = Field(None, description="Error message if task failed") - startedAt: Optional[float] = Field( - None, description="When the task started (UTC timestamp in seconds)" - ) - finishedAt: Optional[float] = Field( - None, description="When the task finished (UTC timestamp in seconds)" - ) - actionList: List[ActionItem] = Field( - default_factory=list, description="List of actions to execute" - ) - retryCount: int = Field(default=0, description="Number of retries attempted") - retryMax: int = Field(default=3, description="Maximum number of retries") - rollbackOnFailure: bool = Field( - default=True, description="Whether to rollback on failure" - ) - dependencies: List[str] = Field( - default_factory=list, description="List of task IDs this task depends on" - ) - feedback: Optional[str] = Field(None, description="Task feedback message") - processingTime: Optional[float] = Field( - None, description="Total processing time in seconds" - ) - resultLabels: Optional[Dict[str, Any]] = Field( - default_factory=dict, description="Map of result labels to their values" - ) - - -registerModelLabels( - "TaskItem", - {"en": "Task", "fr": "Tâche"}, - { - "id": {"en": "Task ID", "fr": "ID de la tâche"}, - "workflowId": {"en": "Workflow ID", "fr": "ID du workflow"}, - "userInput": {"en": "User Input", "fr": "Entrée utilisateur"}, - "status": {"en": "Status", "fr": "Statut"}, - "error": {"en": "Error", "fr": "Erreur"}, - "startedAt": {"en": "Started At", "fr": "Démarré à"}, - "finishedAt": {"en": "Finished At", "fr": "Terminé à"}, - "actionList": {"en": "Actions", "fr": "Actions"}, - "retryCount": {"en": "Retry Count", "fr": "Nombre de tentatives"}, - "retryMax": {"en": "Max Retries", "fr": "Tentatives max"}, - "processingTime": {"en": "Processing Time", "fr": "Temps de traitement"}, - }, -) - + id: str = Field(..., description="Task ID", json_schema_extra={"label": "Aufgaben-ID"}) + workflowId: str = Field(..., description="Workflow ID", json_schema_extra={"label": "Workflow-ID"}) + userInput: str = Field(..., description="User input that triggered the task", json_schema_extra={"label": "Benutzereingabe"}) + status: TaskStatus = Field(default=TaskStatus.PENDING, description="Task status", json_schema_extra={"label": "Status"}) + error: Optional[str] = Field(None, description="Error message if task failed", json_schema_extra={"label": "Fehler"}) + startedAt: Optional[float] = Field(None, description="When the task started (UTC timestamp in seconds)", json_schema_extra={"label": "Gestartet am"}) + finishedAt: Optional[float] = Field(None, description="When the task finished (UTC timestamp in seconds)", json_schema_extra={"label": "Beendet am"}) + actionList: List[ActionItem] = Field(default_factory=list, description="List of actions to execute", json_schema_extra={"label": "Aktionen"}) + retryCount: int = Field(default=0, description="Number of retries attempted", json_schema_extra={"label": "Wiederholungen"}) + retryMax: int = Field(default=3, description="Maximum number of retries", json_schema_extra={"label": "Max. Wiederholungen"}) + rollbackOnFailure: bool = Field(default=True, description="Whether to rollback on failure", json_schema_extra={"label": "Bei Fehler zurücksetzen"}) + dependencies: List[str] = Field(default_factory=list, description="List of task IDs this task depends on", json_schema_extra={"label": "Abhängigkeiten"}) + feedback: Optional[str] = Field(None, description="Task feedback message", json_schema_extra={"label": "Rückmeldung"}) + processingTime: Optional[float] = Field(None, description="Total processing time in seconds", json_schema_extra={"label": "Bearbeitungszeit"}) + resultLabels: Optional[Dict[str, Any]] = Field(default_factory=dict, description="Map of result labels to their values", json_schema_extra={"label": "Ergebnis-Labels"}) +@i18nModel("Aufgabenschritt") class TaskStep(BaseModel): - id: str - objective: str - dependencies: Optional[list[str]] = Field(default_factory=list) - successCriteria: Optional[list[str]] = Field(default_factory=list) + id: str = Field(description="Task identifier", json_schema_extra={"label": "ID"}) + objective: str = Field(description="Task objective", json_schema_extra={"label": "Ziel"}) + dependencies: Optional[list[str]] = Field(default_factory=list, json_schema_extra={"label": "ID"}) + successCriteria: Optional[list[str]] = Field(default_factory=list, json_schema_extra={"label": "Erfolgskriterien"}) estimatedComplexity: Optional[str] = None - userMessage: Optional[str] = Field( - None, description="User-friendly message in user's language" - ) + userMessage: Optional[str] = Field(None, description="User-friendly message in user's language", json_schema_extra={"label": "Benutzernachricht"}) # Format details extracted from intent analysis - dataType: Optional[str] = Field( - None, description="Expected data type (text, numbers, documents, etc.)" - ) - expectedFormats: Optional[List[str]] = Field( - None, description="Expected output file format extensions (e.g., ['docx', 'pdf', 'xlsx']). Use actual file extensions, not conceptual terms." - ) - qualityRequirements: Optional[Dict[str, Any]] = Field( - None, description="Quality requirements and constraints" - ) - - -registerModelLabels( - "TaskStep", - {"en": "Task Step", "fr": "Étape de tâche"}, - { - "id": {"en": "ID", "fr": "ID"}, - "objective": {"en": "Objective", "fr": "Objectif"}, - "dependencies": {"en": "Dependencies", "fr": "Dépendances"}, - "successCriteria": {"en": "Success Criteria", "fr": "Critères de succès"}, - "estimatedComplexity": { - "en": "Estimated Complexity", - "fr": "Complexité estimée", - }, - "userMessage": {"en": "User Message", "fr": "Message utilisateur"}, - "expectedFormats": {"en": "Expected Formats", "fr": "Formats attendus"}, - }, -) - + dataType: Optional[str] = Field(None, description="Expected data type (text, numbers, documents, etc.)", json_schema_extra={"label": "Datentyp"}) + expectedFormats: Optional[List[str]] = Field(None, description="Expected output file format extensions (e.g., ['docx', 'pdf', 'xlsx']). Use actual file extensions, not conceptual terms.", json_schema_extra={"label": "Erwartete Formate"}) + qualityRequirements: Optional[Dict[str, Any]] = Field(None, description="Quality requirements and constraints", json_schema_extra={"label": "Qualitätsanforderungen"}) +@i18nModel("Aufgabenübergabe") +@i18nModel("Aufgabenübergabe") class TaskHandover(BaseModel): - taskId: str = Field(description="Target task ID") - sourceTask: Optional[str] = Field(None, description="Source task ID") - inputDocuments: List[DocumentExchange] = Field( - default_factory=list, description="Available input documents" - ) - outputDocuments: List[DocumentExchange] = Field( - default_factory=list, description="Produced output documents" - ) - context: Dict[str, Any] = Field(default_factory=dict, description="Task context") - previousResults: List[str] = Field( - default_factory=list, description="Previous result summaries" - ) - improvements: List[str] = Field( - default_factory=list, description="Improvement suggestions" - ) - workflowSummary: Optional[str] = Field( - None, description="Summarized workflow context" - ) - messageHistory: List[str] = Field( - default_factory=list, description="Key message summaries" - ) - timestamp: float = Field( - ..., description="When the handover was created (UTC timestamp in seconds)" - ) - handoverType: str = Field( - default="task", description="Type of handover: task, phase, or workflow" - ) - - -registerModelLabels( - "TaskHandover", - {"en": "Task Handover", "fr": "Transfert de tâche"}, - { - "taskId": {"en": "Task ID", "fr": "ID de la tâche"}, - "sourceTask": {"en": "Source Task", "fr": "Tâche source"}, - "inputDocuments": {"en": "Input Documents", "fr": "Documents d'entrée"}, - "outputDocuments": {"en": "Output Documents", "fr": "Documents de sortie"}, - "context": {"en": "Context", "fr": "Contexte"}, - "previousResults": {"en": "Previous Results", "fr": "Résultats précédents"}, - "improvements": {"en": "Improvements", "fr": "Améliorations"}, - "workflowSummary": {"en": "Workflow Summary", "fr": "Résumé du workflow"}, - "messageHistory": {"en": "Message History", "fr": "Historique des messages"}, - "timestamp": {"en": "Timestamp", "fr": "Horodatage"}, - "handoverType": {"en": "Handover Type", "fr": "Type de transfert"}, - }, -) - + taskId: str = Field(description="Target task ID", json_schema_extra={"label": "Aufgaben-ID"}) + sourceTask: Optional[str] = Field(None, description="Source task ID", json_schema_extra={"label": "Quell-Aufgabe"}) + inputDocuments: List[DocumentExchange] = Field(default_factory=list, description="Available input documents", json_schema_extra={"label": "Eingabedokumente"}) + outputDocuments: List[DocumentExchange] = Field(default_factory=list, description="Produced output documents", json_schema_extra={"label": "Ausgabedokumente"}) + context: Dict[str, Any] = Field(default_factory=dict, description="Task context", json_schema_extra={"label": "Kontext"}) + previousResults: List[str] = Field(default_factory=list, description="Previous result summaries", json_schema_extra={"label": "Vorherige Ergebnisse"}) + improvements: List[str] = Field(default_factory=list, description="Improvement suggestions", json_schema_extra={"label": "Verbesserungen"}) + workflowSummary: Optional[str] = Field(None, description="Summarized workflow context", json_schema_extra={"label": "Workflow-Zusammenfassung"}) + messageHistory: List[str] = Field(default_factory=list, description="Key message summaries", json_schema_extra={"label": "Nachrichtenverlauf"}) + timestamp: float = Field(..., description="When the handover was created (UTC timestamp in seconds)", json_schema_extra={"label": "Zeitstempel"}) + handoverType: str = Field(default="task", description="Type of handover: task, phase, or workflow", json_schema_extra={"label": "Übergabetyp"}) class TaskContext(BaseModel): taskStep: TaskStep @@ -849,7 +402,6 @@ class TaskContext(BaseModel): self.improvements = [] self.improvements.append(improvement) - class ReviewContext(BaseModel): taskStep: TaskStep taskActions: Optional[list] = Field(default_factory=list) @@ -858,99 +410,40 @@ class ReviewContext(BaseModel): workflowId: Optional[str] = None previousResults: Optional[list[str]] = Field(default_factory=list) - +@i18nModel("Prüfergebnis") +@i18nModel("Prüfergebnis") class ReviewResult(BaseModel): status: str reason: Optional[str] = None - improvements: Optional[list[str]] = Field(default_factory=list) - qualityScore: Optional[float] = Field(default=5.0, description="Quality score (0-10)") - missingOutputs: Optional[list[str]] = Field(default_factory=list) - metCriteria: Optional[list[str]] = Field(default_factory=list) - unmetCriteria: Optional[list[str]] = Field(default_factory=list) + improvements: Optional[list[str]] = Field(default_factory=list, json_schema_extra={"label": "Verbesserungen"}) + qualityScore: Optional[float] = Field(default=5.0, description="Quality score (0-10)", json_schema_extra={"label": "Qualitätsscore"}) + missingOutputs: Optional[list[str]] = Field(default_factory=list, json_schema_extra={"label": "Fehlende Ausgaben"}) + metCriteria: Optional[list[str]] = Field(default_factory=list, json_schema_extra={"label": "Erfüllte Kriterien"}) + unmetCriteria: Optional[list[str]] = Field(default_factory=list, json_schema_extra={"label": "Nicht erfüllte Kriterien"}) confidence: Optional[float] = 0.5 - userMessage: Optional[str] = Field( - None, description="User-friendly message in user's language" - ) + userMessage: Optional[str] = Field(None, description="User-friendly message in user's language", json_schema_extra={"label": "Benutzernachricht"}) # NEW: Concrete next action guidance (when status is "continue") - nextAction: Optional[str] = Field( - None, description="Specific action to execute next (e.g., 'ai.convert', 'ai.process', 'ai.reformat')" - ) - nextActionParameters: Optional[Dict[str, Any]] = Field( - None, description="Parameters for the next action (e.g., {'fromFormat': 'json', 'toFormat': 'csv'})" - ) - nextActionObjective: Optional[str] = Field( - None, description="What this specific action will achieve" - ) - - -registerModelLabels( - "ReviewResult", - {"en": "Review Result", "fr": "Résultat de l'évaluation"}, - { - "status": {"en": "Status", "fr": "Statut"}, - "reason": {"en": "Reason", "fr": "Raison"}, - "improvements": {"en": "Improvements", "fr": "Améliorations"}, - "qualityScore": {"en": "Quality Score", "fr": "Score de qualité"}, - "missingOutputs": {"en": "Missing Outputs", "fr": "Sorties manquantes"}, - "metCriteria": {"en": "Met Criteria", "fr": "Critères respectés"}, - "unmetCriteria": {"en": "Unmet Criteria", "fr": "Critères non respectés"}, - "confidence": {"en": "Confidence", "fr": "Confiance"}, - "userMessage": {"en": "User Message", "fr": "Message utilisateur"}, - }, -) - + nextAction: Optional[str] = Field(None, description="Specific action to execute next (e.g., 'ai.convert', 'ai.process', 'ai.reformat')", json_schema_extra={"label": "Nächste Aktion"}) + nextActionParameters: Optional[Dict[str, Any]] = Field(None, description="Parameters for the next action (e.g., {'fromFormat': 'json', 'toFormat': 'csv'})", json_schema_extra={"label": "Parameter nächste Aktion"}) + nextActionObjective: Optional[str] = Field(None, description="What this specific action will achieve", json_schema_extra={"label": "Ziel nächste Aktion"}) +@i18nModel("Aufgabenplan") class TaskPlan(BaseModel): - overview: str - tasks: list[TaskStep] - userMessage: Optional[str] = Field( - None, description="Overall user-friendly message for the task plan" - ) - - -registerModelLabels( - "TaskPlan", - {"en": "Task Plan", "fr": "Plan de tâches"}, - { - "overview": {"en": "Overview", "fr": "Aperçu"}, - "tasks": {"en": "Tasks", "fr": "Tâches"}, - "userMessage": {"en": "User Message", "fr": "Message utilisateur"}, - }, -) + overview: str = Field(json_schema_extra={"label": "Überblick"}) + tasks: list[TaskStep] = Field(json_schema_extra={"label": "Aufgaben"}) + userMessage: Optional[str] = Field(None, description="Overall user-friendly message for the task plan", json_schema_extra={"label": "Benutzernachricht"}) # Forward references resolved automatically since ChatWorkflow is defined above - +@i18nModel("Prompt-Platzhalter") class PromptPlaceholder(BaseModel): - label: str - content: str - summaryAllowed: bool = Field( - default=False, - description="Whether host may summarize content before sending to AI", - ) - - -registerModelLabels( - "PromptPlaceholder", - {"en": "Prompt Placeholder", "fr": "Espace réservé d'invite"}, - { - "label": {"en": "Label", "fr": "Libellé"}, - "content": {"en": "Content", "fr": "Contenu"}, - "summaryAllowed": {"en": "Summary Allowed", "fr": "Résumé autorisé"}, - }, -) - + label: str = Field(json_schema_extra={"label": "Bezeichnung"}) + content: str = Field(json_schema_extra={"label": "Inhalt"}) + summaryAllowed: bool = Field(default=False, + description="Whether host may summarize content before sending to AI", json_schema_extra={"label": "Zusammenfassung erlaubt"}) +@i18nModel("Prompt-Paket") class PromptBundle(BaseModel): - prompt: str - placeholders: List[PromptPlaceholder] = Field(default_factory=list) + prompt: str = Field(json_schema_extra={"label": "Prompt"}) + placeholders: List[PromptPlaceholder] = Field(default_factory=list, json_schema_extra={"label": "Prompt"}) - -registerModelLabels( - "PromptBundle", - {"en": "Prompt Bundle", "fr": "Lot d'invite"}, - { - "prompt": {"en": "Prompt", "fr": "Invite"}, - "placeholders": {"en": "Placeholders", "fr": "Espaces réservés"}, - }, -) diff --git a/modules/datamodels/datamodelDataSource.py b/modules/datamodels/datamodelDataSource.py index 1d432041..441d7e7d 100644 --- a/modules/datamodels/datamodelDataSource.py +++ b/modules/datamodels/datamodelDataSource.py @@ -9,66 +9,81 @@ Google Drive folder, FTP directory, etc.) for agent-accessible data containers. from typing import Dict, Any, Optional from pydantic import BaseModel, Field from modules.datamodels.datamodelBase import PowerOnModel -from modules.shared.attributeUtils import registerModelLabels +from modules.shared.i18nRegistry import i18nModel import uuid +@i18nModel("Datenquelle") class DataSource(PowerOnModel): - """Configured external data source linked to a UserConnection.""" - id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key") - connectionId: str = Field(description="FK to UserConnection") - sourceType: str = Field( - description="sharepointFolder, googleDriveFolder, outlookFolder, ftpFolder, clickupList (path under /team/...)" + """Konfigurierte externe Datenquelle verknuepft mit einer UserConnection.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + json_schema_extra={"label": "ID"}, + ) + connectionId: str = Field( + description="FK to UserConnection", + json_schema_extra={"label": "Verbindungs-ID"}, + ) + sourceType: str = Field( + description="sharepointFolder, googleDriveFolder, outlookFolder, ftpFolder, clickupList (path under /team/...)", + json_schema_extra={"label": "Quellentyp"}, + ) + path: str = Field( + description="External path (e.g. '/sites/MySite/Documents/Reports')", + json_schema_extra={"label": "Pfad"}, + ) + label: str = Field( + description="User-visible label (often the last path segment)", + json_schema_extra={"label": "Bezeichnung"}, ) - path: str = Field(description="External path (e.g. '/sites/MySite/Documents/Reports')") - label: str = Field(description="User-visible label (often the last path segment)") displayPath: Optional[str] = Field( default=None, description="Human-readable full path for UI (connection-relative, slash-separated)", + json_schema_extra={"label": "Anzeigepfad"}, + ) + featureInstanceId: Optional[str] = Field( + default=None, + description="Scoped to feature instance", + json_schema_extra={"label": "Feature-Instanz"}, + ) + mandateId: Optional[str] = Field( + default=None, + description="Mandate scope", + json_schema_extra={"label": "Mandanten-ID"}, + ) + userId: str = Field( + default="", + description="Owner user ID", + json_schema_extra={"label": "Benutzer-ID"}, + ) + autoSync: bool = Field( + default=False, + description="Automatically sync on schedule", + json_schema_extra={"label": "Auto-Sync"}, + ) + lastSynced: Optional[float] = Field( + default=None, + description="Last sync timestamp", + json_schema_extra={"label": "Letzter Sync"}, ) - featureInstanceId: Optional[str] = Field(default=None, description="Scoped to feature instance") - mandateId: Optional[str] = Field(default=None, description="Mandate scope") - userId: str = Field(default="", description="Owner user ID") - autoSync: bool = Field(default=False, description="Automatically sync on schedule") - lastSynced: Optional[float] = Field(default=None, description="Last sync timestamp") scope: str = Field( default="personal", description="Data visibility scope: personal, featureInstance, mandate, global", - json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ + json_schema_extra={"label": "Sichtbarkeit", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ {"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}}, {"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}}, {"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}}, {"value": "global", "label": {"en": "Global", "de": "Global"}}, - ]} + ]}, ) neutralize: bool = Field( default=False, description="Whether this data source should be neutralized before AI processing", - json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False} + json_schema_extra={"label": "Neutralisieren", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}, ) -registerModelLabels( - "DataSource", - {"en": "Data Source", "de": "Datenquelle", "fr": "Source de données"}, - { - "id": {"en": "ID", "de": "ID", "fr": "ID"}, - "connectionId": {"en": "Connection ID", "de": "Verbindungs-ID", "fr": "ID de connexion"}, - "sourceType": {"en": "Source Type", "de": "Quellentyp", "fr": "Type de source"}, - "path": {"en": "Path", "de": "Pfad", "fr": "Chemin"}, - "label": {"en": "Label", "de": "Bezeichnung", "fr": "Libellé"}, - "displayPath": {"en": "Display path", "de": "Anzeigepfad", "fr": "Chemin affiché"}, - "featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance de fonctionnalité"}, - "mandateId": {"en": "Mandate ID", "de": "Mandanten-ID", "fr": "ID du mandat"}, - "userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"}, - "autoSync": {"en": "Auto Sync", "de": "Auto-Sync", "fr": "Synchro auto"}, - "lastSynced": {"en": "Last Synced", "de": "Letzter Sync", "fr": "Dernier sync"}, - "scope": {"en": "Scope", "de": "Sichtbarkeit"}, - "neutralize": {"en": "Neutralize", "de": "Neutralisieren"}, - }, -) - - class ExternalEntry(BaseModel): """An item (file or folder) from an external data source.""" name: str = Field(description="Item name") diff --git a/modules/datamodels/datamodelDocref.py b/modules/datamodels/datamodelDocref.py index b4c5924e..e4a43bd2 100644 --- a/modules/datamodels/datamodelDocref.py +++ b/modules/datamodels/datamodelDocref.py @@ -6,7 +6,7 @@ Document reference models for typed document references in workflows. from typing import List, Optional from pydantic import BaseModel, Field -from modules.shared.attributeUtils import registerModelLabels +from modules.shared.i18nRegistry import i18nModel class DocumentReference(BaseModel): @@ -14,11 +14,19 @@ class DocumentReference(BaseModel): pass +@i18nModel("Dokumentlisten-Referenz") class DocumentListReference(DocumentReference): """Reference to a document list via message label""" - messageId: Optional[str] = Field(None, description="Optional message ID for cross-round references") - label: str = Field(description="Document list label") - + messageId: Optional[str] = Field( + None, + description="Optional message ID for cross-round references", + json_schema_extra={"label": "Nachrichten-ID"}, + ) + label: str = Field( + description="Document list label", + json_schema_extra={"label": "Bezeichnung"}, + ) + def to_string(self) -> str: """Convert to string format: docList:messageId:label or docList:label""" if self.messageId: @@ -26,11 +34,19 @@ class DocumentListReference(DocumentReference): return f"docList:{self.label}" +@i18nModel("Dokumentelement-Referenz") class DocumentItemReference(DocumentReference): """Reference to a specific document item""" - documentId: str = Field(description="Document ID") - fileName: Optional[str] = Field(None, description="Optional file name") - + documentId: str = Field( + description="Document ID", + json_schema_extra={"label": "Dokument-ID"}, + ) + fileName: Optional[str] = Field( + None, + description="Optional file name", + json_schema_extra={"label": "Dateiname"}, + ) + def to_string(self) -> str: """Convert to string format: docItem:documentId:fileName or docItem:documentId""" if self.fileName: @@ -38,21 +54,23 @@ class DocumentItemReference(DocumentReference): return f"docItem:{self.documentId}" +@i18nModel("Dokumentreferenz-Liste") class DocumentReferenceList(BaseModel): """List of document references with conversion methods""" references: List[DocumentReference] = Field( default_factory=list, - description="List of document references" + description="List of document references", + json_schema_extra={"label": "Referenzen"}, ) - + def to_string_list(self) -> List[str]: """Convert all references to string list""" return [ref.to_string() for ref in self.references] - + @classmethod def from_string_list(cls, stringList: List[str]) -> "DocumentReferenceList": """Parse string list to typed references - + Supports formats: - docList:label - docList:messageId:label @@ -60,13 +78,13 @@ class DocumentReferenceList(BaseModel): - docItem:documentId:fileName """ references = [] - + for refStr in stringList: if not refStr or not isinstance(refStr, str): continue - + refStr = refStr.strip() - + # Parse docList: references if refStr.startswith("docList:"): parts = refStr[8:].split(":", 1) # Remove "docList:" prefix @@ -77,7 +95,7 @@ class DocumentReferenceList(BaseModel): elif len(parts) == 1 and parts[0]: # docList:label references.append(DocumentListReference(label=parts[0])) - + # Parse docItem: references elif refStr.startswith("docItem:"): parts = refStr[8:].split(":", 1) # Remove "docItem:" prefix @@ -88,33 +106,12 @@ class DocumentReferenceList(BaseModel): elif len(parts) == 1 and parts[0]: # docItem:documentId references.append(DocumentItemReference(documentId=parts[0])) - + # Unknown format - skip or log warning else: # Try to parse as simple string (backward compatibility) # Assume it's a label if it doesn't match known patterns if refStr: references.append(DocumentListReference(label=refStr)) - + return cls(references=references) - - -registerModelLabels( - "DocumentReference", - {"en": "Document Reference", "fr": "Référence de document"}, - { - "messageId": {"en": "Message ID", "fr": "ID du message"}, - "label": {"en": "Label", "fr": "Étiquette"}, - "documentId": {"en": "Document ID", "fr": "ID du document"}, - "fileName": {"en": "File Name", "fr": "Nom du fichier"}, - }, -) - -registerModelLabels( - "DocumentReferenceList", - {"en": "Document Reference List", "fr": "Liste de références de documents"}, - { - "references": {"en": "References", "fr": "Références"}, - }, -) - diff --git a/modules/datamodels/datamodelFeatureDataSource.py b/modules/datamodels/datamodelFeatureDataSource.py index 02de0a67..39d03367 100644 --- a/modules/datamodels/datamodelFeatureDataSource.py +++ b/modules/datamodels/datamodelFeatureDataSource.py @@ -9,54 +9,69 @@ so the agent can query structured feature data (e.g. TrusteePosition rows). from typing import Dict, Optional from pydantic import BaseModel, Field from modules.datamodels.datamodelBase import PowerOnModel -from modules.shared.attributeUtils import registerModelLabels +from modules.shared.i18nRegistry import i18nModel import uuid +@i18nModel("Feature-Datenquelle") class FeatureDataSource(PowerOnModel): - """A feature-instance table attached as data source in the AI workspace.""" - id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key") - featureInstanceId: str = Field(description="FK to FeatureInstance") - featureCode: str = Field(description="Feature code (e.g. trustee, commcoach)") - tableName: str = Field(description="Table name from DATA_OBJECTS meta (e.g. TrusteePosition)") - objectKey: str = Field(description="RBAC object key (e.g. data.feature.trustee.TrusteePosition)") - label: str = Field(description="User-visible label") - mandateId: str = Field(default="", description="Mandate scope") - userId: str = Field(default="", description="Owner user ID") - workspaceInstanceId: str = Field(description="Workspace instance where this source is used") + """Feature-Instanz-Tabelle als Datenquelle im AI-Workspace.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + json_schema_extra={"label": "ID"}, + ) + featureInstanceId: str = Field( + description="FK to FeatureInstance", + json_schema_extra={"label": "Feature-Instanz"}, + ) + featureCode: str = Field( + description="Feature code (e.g. trustee, commcoach)", + json_schema_extra={"label": "Feature"}, + ) + tableName: str = Field( + description="Table name from DATA_OBJECTS meta (e.g. TrusteePosition)", + json_schema_extra={"label": "Tabelle"}, + ) + objectKey: str = Field( + description="RBAC object key (e.g. data.feature.trustee.TrusteePosition)", + json_schema_extra={"label": "Objekt-Schluessel"}, + ) + label: str = Field( + description="User-visible label", + json_schema_extra={"label": "Bezeichnung"}, + ) + mandateId: str = Field( + default="", + description="Mandate scope", + json_schema_extra={"label": "Mandant"}, + ) + userId: str = Field( + default="", + description="Owner user ID", + json_schema_extra={"label": "Benutzer"}, + ) + workspaceInstanceId: str = Field( + description="Workspace instance where this source is used", + json_schema_extra={"label": "Workspace"}, + ) scope: str = Field( default="personal", description="Data visibility scope: personal, featureInstance, mandate, global", - json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ + json_schema_extra={"label": "Sichtbarkeit", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ {"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}}, {"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}}, {"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}}, {"value": "global", "label": {"en": "Global", "de": "Global"}}, - ]} + ]}, ) neutralize: bool = Field( default=False, description="Whether this data source should be neutralized before AI processing", - json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False} + json_schema_extra={"label": "Neutralisieren", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}, ) recordFilter: Optional[Dict[str, str]] = Field( default=None, description="Record-level filter applied when querying this table, e.g. {'sessionId': 'abc-123'}", + json_schema_extra={"label": "Datensatzfilter"}, ) - - -registerModelLabels( - "FeatureDataSource", - {"en": "Feature Data Source", "de": "Feature-Datenquelle", "fr": "Source de données fonctionnalité"}, - { - "id": {"en": "ID", "de": "ID", "fr": "ID"}, - "featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"}, - "featureCode": {"en": "Feature", "de": "Feature", "fr": "Fonctionnalité"}, - "tableName": {"en": "Table", "de": "Tabelle", "fr": "Table"}, - "objectKey": {"en": "Object Key", "de": "Objekt-Schlüssel", "fr": "Clé objet"}, - "label": {"en": "Label", "de": "Bezeichnung", "fr": "Libellé"}, - "mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"}, - "userId": {"en": "User", "de": "Benutzer", "fr": "Utilisateur"}, - "workspaceInstanceId": {"en": "Workspace", "de": "Workspace", "fr": "Espace de travail"}, - }, -) diff --git a/modules/datamodels/datamodelFeatures.py b/modules/datamodels/datamodelFeatures.py index 3134a18e..93a7fae9 100644 --- a/modules/datamodels/datamodelFeatures.py +++ b/modules/datamodels/datamodelFeatures.py @@ -6,85 +6,56 @@ import uuid from typing import Optional, Dict, Any from pydantic import BaseModel, Field from modules.datamodels.datamodelBase import PowerOnModel -from modules.shared.attributeUtils import registerModelLabels +from modules.shared.i18nRegistry import i18nModel from modules.datamodels.datamodelUtils import TextMultilingual +@i18nModel("Feature") class Feature(PowerOnModel): - """ - Feature-Definition (global, z.B. 'trustee', 'chatbot'). - Features sind die verfügbaren Funktionalitäten der Plattform. - """ + """Feature-Definition (global, z.B. 'trustee', 'chatbot'). Verfuegbare Funktionalitaeten der Plattform.""" code: str = Field( description="Unique feature code (Primary Key), z.B. 'trustee', 'chatbot'", - json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True} + json_schema_extra={"label": "Code", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True} ) label: TextMultilingual = Field( description="Feature label in multiple languages (I18n)", - json_schema_extra={"frontend_type": "multilingual", "frontend_readonly": False, "frontend_required": True} + json_schema_extra={"label": "Bezeichnung", "frontend_type": "multilingual", "frontend_readonly": False, "frontend_required": True} ) icon: str = Field( default="", description="Icon identifier for the feature", - json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False} + json_schema_extra={"label": "Symbol", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False} ) -registerModelLabels( - "Feature", - {"en": "Feature", "de": "Feature", "fr": "Fonctionnalité"}, - { - "code": {"en": "Code", "de": "Code", "fr": "Code"}, - "label": {"en": "Label", "de": "Bezeichnung", "fr": "Libellé"}, - "icon": {"en": "Icon", "de": "Symbol", "fr": "Icône"}, - }, -) - - +@i18nModel("Feature-Instanz") class FeatureInstance(PowerOnModel): - """ - Instanz eines Features in einem Mandanten. - Ein Mandant kann mehrere Instanzen desselben Features haben. - """ + """Instanz eines Features in einem Mandanten. Ein Mandant kann mehrere Instanzen desselben Features haben.""" id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the feature instance", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False} ) featureCode: str = Field( - description="FK → Feature.code", - json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": True} + description="FK -> Feature.code", + json_schema_extra={"label": "Feature", "frontend_type": "select", "frontend_readonly": True, "frontend_required": True} ) mandateId: str = Field( - description="FK → Mandate.id (CASCADE DELETE)", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True} + description="FK -> Mandate.id (CASCADE DELETE)", + json_schema_extra={"label": "Mandant", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True} ) label: str = Field( default="", description="Instance label, z.B. 'Buchhaltung 2025'", - json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True} + json_schema_extra={"label": "Bezeichnung", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True} ) enabled: bool = Field( default=True, description="Whether this feature instance is enabled", - json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False} + json_schema_extra={"label": "Aktiviert", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False} ) config: Optional[Dict[str, Any]] = Field( default=None, description="Instance-specific configuration (JSONB). Structure depends on featureCode.", - json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False} + json_schema_extra={"label": "Konfiguration", "frontend_type": "json", "frontend_readonly": False, "frontend_required": False} ) - - -registerModelLabels( - "FeatureInstance", - {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance de fonctionnalité"}, - { - "id": {"en": "ID", "de": "ID", "fr": "ID"}, - "featureCode": {"en": "Feature", "de": "Feature", "fr": "Fonctionnalité"}, - "mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"}, - "label": {"en": "Label", "de": "Bezeichnung", "fr": "Libellé"}, - "enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"}, - "config": {"en": "Configuration", "de": "Konfiguration", "fr": "Configuration"}, - }, -) diff --git a/modules/datamodels/datamodelFileFolder.py b/modules/datamodels/datamodelFileFolder.py index 23cd197b..73222e51 100644 --- a/modules/datamodels/datamodelFileFolder.py +++ b/modules/datamodels/datamodelFileFolder.py @@ -5,26 +5,34 @@ from typing import Optional from pydantic import BaseModel, Field from modules.datamodels.datamodelBase import PowerOnModel -from modules.shared.attributeUtils import registerModelLabels +from modules.shared.i18nRegistry import i18nModel import uuid +@i18nModel("Dateiordner") class FileFolder(PowerOnModel): - id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) - name: str = Field(description="Folder name", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}) - parentId: Optional[str] = Field(default=None, description="Parent folder ID (null = root)", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}) - mandateId: Optional[str] = Field(default=None, description="Mandate context", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) - featureInstanceId: Optional[str] = Field(default=None, description="Feature instance context", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) - - -registerModelLabels( - "FileFolder", - {"en": "File Folder", "fr": "Dossier de fichiers"}, - { - "id": {"en": "ID", "fr": "ID"}, - "name": {"en": "Name", "fr": "Nom"}, - "parentId": {"en": "Parent Folder", "fr": "Dossier parent"}, - "mandateId": {"en": "Mandate ID", "fr": "ID du mandat"}, - "featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance"}, - }, -) + """Hierarchischer Ordner fuer die Dateiverwaltung.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + ) + name: str = Field( + description="Folder name", + json_schema_extra={"label": "Name", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True}, + ) + parentId: Optional[str] = Field( + default=None, + description="Parent folder ID (null = root)", + json_schema_extra={"label": "Uebergeordneter Ordner", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False}, + ) + mandateId: Optional[str] = Field( + default=None, + description="Mandate context", + json_schema_extra={"label": "Mandanten-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + ) + featureInstanceId: Optional[str] = Field( + default=None, + description="Feature instance context", + json_schema_extra={"label": "Feature-Instanz-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + ) diff --git a/modules/datamodels/datamodelFiles.py b/modules/datamodels/datamodelFiles.py index b8a44d2c..333120d1 100644 --- a/modules/datamodels/datamodelFiles.py +++ b/modules/datamodels/datamodelFiles.py @@ -5,66 +5,110 @@ from typing import Dict, Any, List, Optional, Union from pydantic import BaseModel, Field from modules.datamodels.datamodelBase import PowerOnModel -from modules.shared.attributeUtils import registerModelLabels +from modules.shared.i18nRegistry import i18nModel import uuid import base64 +@i18nModel("Datei") class FileItem(PowerOnModel): - id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) - mandateId: Optional[str] = Field(default="", description="ID of the mandate this file belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) - featureInstanceId: Optional[str] = Field(default="", description="ID of the feature instance this file belongs to", json_schema_extra={"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={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}) - mimeType: str = Field(description="MIME type of the file", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) - fileHash: str = Field(description="Hash of the file", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) - fileSize: int = Field(description="Size of the file in bytes", json_schema_extra={"frontend_type": "integer", "frontend_readonly": True, "frontend_required": False}) - tags: Optional[List[str]] = Field(default=None, description="Tags for categorization and search", json_schema_extra={"frontend_type": "tags", "frontend_readonly": False, "frontend_required": False}) - folderId: Optional[str] = Field(default=None, description="ID of the parent folder", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}) - description: Optional[str] = Field(default=None, description="User-provided description of the file", json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False}) - status: Optional[str] = Field(default=None, description="Processing status: pending, extracted, embedding, indexed, failed", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) + """Metadaten einer gespeicherten Datei.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + ) + mandateId: Optional[str] = Field( + default="", + description="ID of the mandate this file belongs to", + json_schema_extra={"label": "Mandanten-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + ) + featureInstanceId: Optional[str] = Field( + default="", + 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}, + ) + fileHash: str = Field( + description="Hash of the file", + json_schema_extra={"label": "Datei-Hash", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + ) + fileSize: int = Field( + description="Size of the file in bytes", + json_schema_extra={"label": "Dateigroesse", "frontend_type": "integer", "frontend_readonly": True, "frontend_required": False}, + ) + tags: Optional[List[str]] = Field( + default=None, + description="Tags for categorization and search", + json_schema_extra={"label": "Tags", "frontend_type": "tags", "frontend_readonly": False, "frontend_required": False}, + ) + folderId: Optional[str] = Field( + default=None, + description="ID of the parent folder", + json_schema_extra={"label": "Ordner-ID", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False}, + ) + description: Optional[str] = Field( + default=None, + description="User-provided description of the file", + json_schema_extra={"label": "Beschreibung", "frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False}, + ) + status: Optional[str] = Field( + default=None, + description="Processing status: pending, extracted, embedding, indexed, failed", + json_schema_extra={"label": "Status", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + ) scope: str = Field( default="personal", description="Data visibility scope: personal, featureInstance, mandate, global", - json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ + json_schema_extra={"label": "Sichtbarkeit", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ {"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}}, {"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}}, {"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}}, {"value": "global", "label": {"en": "Global", "de": "Global"}}, - ]} + ]}, ) neutralize: bool = Field( default=False, description="Whether this file should be neutralized before AI processing", - json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False} + json_schema_extra={"label": "Neutralisieren", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}, ) -registerModelLabels( - "FileItem", - {"en": "File Item", "fr": "Élément de fichier"}, - { - "id": {"en": "ID", "fr": "ID"}, - "mandateId": {"en": "Mandate ID", "fr": "ID du mandat"}, - "featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité"}, - "fileName": {"en": "fileName", "fr": "Nom de fichier"}, - "mimeType": {"en": "MIME Type", "fr": "Type MIME"}, - "fileHash": {"en": "File Hash", "fr": "Hash du fichier"}, - "fileSize": {"en": "File Size", "fr": "Taille du fichier"}, - "tags": {"en": "Tags", "fr": "Tags"}, - "folderId": {"en": "Folder ID", "fr": "ID du dossier"}, - "description": {"en": "Description", "fr": "Description"}, - "status": {"en": "Status", "fr": "Statut"}, - "scope": {"en": "Scope", "de": "Sichtbarkeit"}, - "neutralize": {"en": "Neutralize", "de": "Neutralisieren"}, - }, -) +@i18nModel("Datei-Vorschau") class FilePreview(BaseModel): - content: Union[str, bytes] = Field(description="File content (text or binary)") - mimeType: str = Field(description="MIME type of the file") - fileName: str = Field(description="Original fileName") - isText: bool = Field(description="Whether the content is text (True) or binary (False)") - encoding: Optional[str] = Field(None, description="Text encoding if content is text") - size: int = Field(description="Size of the content in bytes") + """Vorschau-Inhalt einer Datei fuer die Anzeige.""" + content: Union[str, bytes] = Field( + description="File content (text or binary)", + json_schema_extra={"label": "Inhalt"}, + ) + mimeType: str = Field( + description="MIME type of the file", + json_schema_extra={"label": "MIME-Typ"}, + ) + fileName: str = Field( + description="Original fileName", + json_schema_extra={"label": "Dateiname"}, + ) + isText: bool = Field( + description="Whether the content is text (True) or binary (False)", + json_schema_extra={"label": "Ist Text"}, + ) + encoding: Optional[str] = Field( + None, + description="Text encoding if content is text", + json_schema_extra={"label": "Kodierung"}, + ) + size: int = Field( + description="Size of the content in bytes", + json_schema_extra={"label": "Groesse"}, + ) def toDictWithBase64Encoding(self) -> Dict[str, Any]: """Convert to dictionary with base64 encoding for binary content.""" @@ -72,29 +116,21 @@ class FilePreview(BaseModel): if isinstance(data.get("content"), bytes): data["content"] = base64.b64encode(data["content"]).decode("utf-8") return data -registerModelLabels( - "FilePreview", - {"en": "File Preview", "fr": "Aperçu du fichier"}, - { - "content": {"en": "Content", "fr": "Contenu"}, - "mimeType": {"en": "MIME Type", "fr": "Type MIME"}, - "fileName": {"en": "fileName", "fr": "Nom de fichier"}, - "isText": {"en": "Is Text", "fr": "Est du texte"}, - "encoding": {"en": "Encoding", "fr": "Encodage"}, - "size": {"en": "Size", "fr": "Taille"}, - }, -) + +@i18nModel("Dateidaten") class FileData(PowerOnModel): - id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key") - data: str = Field(description="File data content") - base64Encoded: bool = Field(description="Whether the data is base64 encoded") -registerModelLabels( - "FileData", - {"en": "File Data", "fr": "Données de fichier"}, - { - "id": {"en": "ID", "fr": "ID"}, - "data": {"en": "Data", "fr": "Données"}, - "base64Encoded": {"en": "Base64 Encoded", "fr": "Encodé en Base64"}, - }, -) + """Rohdaten einer Datei (z.B. Base64).""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + json_schema_extra={"label": "ID"}, + ) + data: str = Field( + description="File data content", + json_schema_extra={"label": "Daten"}, + ) + base64Encoded: bool = Field( + description="Whether the data is base64 encoded", + json_schema_extra={"label": "Base64-kodiert"}, + ) diff --git a/modules/datamodels/datamodelInvitation.py b/modules/datamodels/datamodelInvitation.py index 709e5021..4808bd55 100644 --- a/modules/datamodels/datamodelInvitation.py +++ b/modules/datamodels/datamodelInvitation.py @@ -10,9 +10,10 @@ import secrets from typing import Optional, List from pydantic import BaseModel, Field from modules.datamodels.datamodelBase import PowerOnModel -from modules.shared.attributeUtils import registerModelLabels +from modules.shared.i18nRegistry import i18nModel +@i18nModel("Einladung") class Invitation(PowerOnModel): """ Einladungs-Token für neue User. @@ -21,103 +22,76 @@ class Invitation(PowerOnModel): id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the invitation", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False} ) token: str = Field( default_factory=lambda: secrets.token_urlsafe(32), description="Secure invitation token", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"label": "Token", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False} ) - - # Ziel der Einladung + mandateId: str = Field( description="FK → Mandate.id - Target mandate for the invitation", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True} + json_schema_extra={"label": "Mandant", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True} ) featureInstanceId: Optional[str] = Field( default=None, description="Optional FK → FeatureInstance.id - Direct access to specific feature", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"label": "Feature-Instanz", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False} ) roleIds: List[str] = Field( default_factory=list, description="List of Role IDs to assign to the invited user", - json_schema_extra={"frontend_type": "multiselect", "frontend_readonly": False, "frontend_required": True} + json_schema_extra={"label": "Rollen", "frontend_type": "multiselect", "frontend_readonly": False, "frontend_required": True} ) - - # Einladungs-Details + targetUsername: Optional[str] = Field( default=None, description="Username of the invited user (must match on acceptance)", - json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False} + json_schema_extra={"label": "Ziel-Benutzername", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False} ) email: Optional[str] = Field( default=None, description="Email address to send invitation link (optional)", - json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": False} + json_schema_extra={"label": "E-Mail (optional)", "frontend_type": "email", "frontend_readonly": False, "frontend_required": False} ) expiresAt: float = Field( description="When the invitation expires (UTC timestamp)", - json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": True} + json_schema_extra={"label": "Gueltig bis", "frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": True} ) - - # Status + usedBy: Optional[str] = Field( default=None, description="User ID of the person who used the invitation", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"label": "Verwendet von", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False} ) usedAt: Optional[float] = Field( default=None, description="When the invitation was used (UTC timestamp)", - json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"label": "Verwendet am", "frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False} ) revokedAt: Optional[float] = Field( default=None, description="When the invitation was revoked (UTC timestamp)", - json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"label": "Widerrufen am", "frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False} ) - - # Email-Status + emailSent: Optional[bool] = Field( default=False, description="Whether the invitation email was successfully sent", - json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"label": "E-Mail gesendet", "frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False} ) - - # Einschränkungen + maxUses: int = Field( default=1, ge=1, le=100, description="Maximum number of times this invitation can be used", - json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": False} + json_schema_extra={"label": "Max. Verwendungen", "frontend_type": "number", "frontend_readonly": False, "frontend_required": False} ) currentUses: int = Field( default=0, ge=0, description="Current number of times this invitation has been used", - json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"label": "Aktuelle Verwendungen", "frontend_type": "number", "frontend_readonly": True, "frontend_required": False} ) - - -registerModelLabels( - "Invitation", - {"en": "Invitation", "de": "Einladung", "fr": "Invitation"}, - { - "id": {"en": "ID", "de": "ID", "fr": "ID"}, - "token": {"en": "Token", "de": "Token", "fr": "Jeton"}, - "mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"}, - "featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"}, - "roleIds": {"en": "Roles", "de": "Rollen", "fr": "Rôles"}, - "targetUsername": {"en": "Target Username", "de": "Ziel-Benutzername", "fr": "Nom d'utilisateur cible"}, - "email": {"en": "Email (optional)", "de": "E-Mail (optional)", "fr": "Email (optionnel)"}, - "expiresAt": {"en": "Expires At", "de": "Gültig bis", "fr": "Expire le"}, - "usedBy": {"en": "Used By", "de": "Verwendet von", "fr": "Utilisé par"}, - "usedAt": {"en": "Used At", "de": "Verwendet am", "fr": "Utilisé le"}, - "revokedAt": {"en": "Revoked At", "de": "Widerrufen am", "fr": "Révoqué le"}, - "emailSent": {"en": "Email Sent", "de": "E-Mail gesendet", "fr": "Email envoyé"}, - "maxUses": {"en": "Max Uses", "de": "Max. Verwendungen", "fr": "Utilisations max"}, - "currentUses": {"en": "Current Uses", "de": "Aktuelle Verwendungen", "fr": "Utilisations actuelles"}, - }, -) diff --git a/modules/datamodels/datamodelKnowledge.py b/modules/datamodels/datamodelKnowledge.py index 7ac12c15..7432a30c 100644 --- a/modules/datamodels/datamodelKnowledge.py +++ b/modules/datamodels/datamodelKnowledge.py @@ -15,173 +15,231 @@ Vector fields use json_schema_extra={"db_type": "vector(1536)"} for pgvector. from typing import Dict, Any, List, Optional from pydantic import BaseModel, Field from modules.datamodels.datamodelBase import PowerOnModel -from modules.shared.attributeUtils import registerModelLabels +from modules.shared.i18nRegistry import i18nModel from modules.shared.timeUtils import getUtcTimestamp import uuid +@i18nModel("Datei-Inhaltsindex") class FileContentIndex(PowerOnModel): - """Structural index of a file's content objects. Created without AI. - Scope is mirrored from FileItem (poweron_management) at indexing time.""" - id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key (typically = fileId)") - userId: str = Field(description="Owner user ID") - featureInstanceId: str = Field(default="", description="Feature instance scope") - mandateId: str = Field(default="", description="Mandate scope") - fileName: str = Field(description="Original file name") - mimeType: str = Field(description="MIME type of the file") - containerPath: Optional[str] = Field(default=None, description="Path within a container (e.g. 'archive.zip/folder/report.pdf')") - totalObjects: int = Field(default=0, description="Total number of content objects extracted") - totalSize: int = Field(default=0, description="Total size of all content objects in bytes") - structure: Dict[str, Any] = Field(default_factory=dict, description="Structural overview (pages, sections, hierarchy)") - objectSummary: List[Dict[str, Any]] = Field(default_factory=list, description="Compact summary per content object") - extractedAt: float = Field(default_factory=getUtcTimestamp, description="Extraction timestamp") - status: str = Field(default="pending", description="Processing status: pending, extracted, embedding, indexed, failed") + """Struktureller Index der Inhaltsobjekte einer Datei.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key (typically = fileId)", + json_schema_extra={"label": "ID"}, + ) + userId: str = Field( + description="Owner user ID", + json_schema_extra={"label": "Benutzer-ID"}, + ) + featureInstanceId: str = Field( + default="", + description="Feature instance scope", + json_schema_extra={"label": "Feature-Instanz-ID"}, + ) + mandateId: str = Field( + default="", + description="Mandate scope", + json_schema_extra={"label": "Mandanten-ID"}, + ) + fileName: str = Field( + description="Original file name", + json_schema_extra={"label": "Dateiname"}, + ) + mimeType: str = Field( + description="MIME type of the file", + json_schema_extra={"label": "MIME-Typ"}, + ) + containerPath: Optional[str] = Field( + default=None, + description="Path within a container (e.g. 'archive.zip/folder/report.pdf')", + json_schema_extra={"label": "Container-Pfad"}, + ) + totalObjects: int = Field( + default=0, + description="Total number of content objects extracted", + json_schema_extra={"label": "Anzahl Objekte"}, + ) + totalSize: int = Field( + default=0, + description="Total size of all content objects in bytes", + json_schema_extra={"label": "Gesamtgroesse"}, + ) + structure: Dict[str, Any] = Field( + default_factory=dict, + description="Structural overview (pages, sections, hierarchy)", + json_schema_extra={"label": "Struktur"}, + ) + objectSummary: List[Dict[str, Any]] = Field( + default_factory=list, + description="Compact summary per content object", + json_schema_extra={"label": "Objekt-Zusammenfassung"}, + ) + extractedAt: float = Field( + default_factory=getUtcTimestamp, + description="Extraction timestamp", + json_schema_extra={"label": "Extrahiert am"}, + ) + status: str = Field( + default="pending", + description="Processing status: pending, extracted, embedding, indexed, failed", + json_schema_extra={"label": "Status"}, + ) scope: str = Field( default="personal", description="Data visibility scope: personal, featureInstance, mandate, global", + json_schema_extra={"label": "Sichtbarkeit"}, ) neutralizationStatus: Optional[str] = Field( default=None, description="Neutralization status: completed, failed, skipped, None = not required", + json_schema_extra={"label": "Neutralisierungsstatus"}, ) isNeutralized: bool = Field( default=False, description="True if content was neutralized before indexing", + json_schema_extra={"label": "Neutralisiert"}, ) -registerModelLabels( - "FileContentIndex", - {"en": "File Content Index", "fr": "Index du contenu de fichier"}, - { - "id": {"en": "ID", "fr": "ID"}, - "userId": {"en": "User ID", "fr": "ID utilisateur"}, - "featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance"}, - "mandateId": {"en": "Mandate ID", "fr": "ID du mandat"}, - "fileName": {"en": "File Name", "fr": "Nom de fichier"}, - "mimeType": {"en": "MIME Type", "fr": "Type MIME"}, - "containerPath": {"en": "Container Path", "fr": "Chemin du conteneur"}, - "totalObjects": {"en": "Total Objects", "fr": "Nombre total d'objets"}, - "totalSize": {"en": "Total Size", "fr": "Taille totale"}, - "structure": {"en": "Structure", "fr": "Structure"}, - "objectSummary": {"en": "Object Summary", "fr": "Résumé des objets"}, - "extractedAt": {"en": "Extracted At", "fr": "Extrait le"}, - "status": {"en": "Status", "fr": "Statut"}, - "scope": {"en": "Scope", "de": "Sichtbarkeit"}, - "neutralizationStatus": {"en": "Neutralization Status", "de": "Neutralisierungsstatus"}, - "isNeutralized": {"en": "Is Neutralized", "de": "Neutralisiert"}, - }, -) - - +@i18nModel("Inhalts-Chunk") class ContentChunk(PowerOnModel): - """Persisted content chunk with embedding vector. Reusable across workflows. - Scalar content object (or chunk thereof) with pgvector embedding.""" - id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key") - contentObjectId: str = Field(description="Reference to the content object within FileContentIndex") - fileId: str = Field(description="FK to the source file") - userId: str = Field(description="Owner user ID") - featureInstanceId: str = Field(default="", description="Feature instance scope") - contentType: str = Field(description="Content type: text, image, videostream, audiostream, other") - data: str = Field(description="Content data (text, base64, URL)") - contextRef: Dict[str, Any] = Field(default_factory=dict, description="Context reference (page, position, label)") - summary: Optional[str] = Field(default=None, description="AI-generated summary (on demand)") - chunkMetadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata") + """Persistierter Inhalts-Chunk mit Embedding-Vektor.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + json_schema_extra={"label": "ID"}, + ) + contentObjectId: str = Field( + description="Reference to the content object within FileContentIndex", + json_schema_extra={"label": "Inhaltsobjekt-ID"}, + ) + fileId: str = Field( + description="FK to the source file", + json_schema_extra={"label": "Datei-ID"}, + ) + userId: str = Field( + description="Owner user ID", + json_schema_extra={"label": "Benutzer-ID"}, + ) + featureInstanceId: str = Field( + default="", + description="Feature instance scope", + json_schema_extra={"label": "Feature-Instanz-ID"}, + ) + contentType: str = Field( + description="Content type: text, image, videostream, audiostream, other", + json_schema_extra={"label": "Inhaltstyp"}, + ) + data: str = Field( + description="Content data (text, base64, URL)", + json_schema_extra={"label": "Daten"}, + ) + contextRef: Dict[str, Any] = Field( + default_factory=dict, + description="Context reference (page, position, label)", + json_schema_extra={"label": "Kontext-Referenz"}, + ) + summary: Optional[str] = Field( + default=None, + description="AI-generated summary (on demand)", + json_schema_extra={"label": "Zusammenfassung"}, + ) + chunkMetadata: Dict[str, Any] = Field( + default_factory=dict, + description="Additional metadata", + json_schema_extra={"label": "Metadaten"}, + ) embedding: Optional[List[float]] = Field( - default=None, description="pgvector embedding (NOT NULL for text chunks)", - json_schema_extra={"db_type": "vector(1536)"} + default=None, + description="pgvector embedding (NOT NULL for text chunks)", + json_schema_extra={"label": "Embedding", "db_type": "vector(1536)"}, ) -registerModelLabels( - "ContentChunk", - {"en": "Content Chunk", "fr": "Fragment de contenu"}, - { - "id": {"en": "ID", "fr": "ID"}, - "contentObjectId": {"en": "Content Object ID", "fr": "ID de l'objet de contenu"}, - "fileId": {"en": "File ID", "fr": "ID du fichier"}, - "userId": {"en": "User ID", "fr": "ID utilisateur"}, - "featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance"}, - "contentType": {"en": "Content Type", "fr": "Type de contenu"}, - "data": {"en": "Data", "fr": "Données"}, - "contextRef": {"en": "Context Reference", "fr": "Référence contextuelle"}, - "summary": {"en": "Summary", "fr": "Résumé"}, - "chunkMetadata": {"en": "Metadata", "fr": "Métadonnées"}, - "embedding": {"en": "Embedding", "fr": "Vecteur d'embedding"}, - }, -) - - +@i18nModel("Runden-Speicher") class RoundMemory(PowerOnModel): - """Persistent per-round memory for agent tool results, file refs, and decisions. - - Stored after each agent round so that RAG can retrieve relevant context - even after the ConversationManager summarises older messages away. - """ - id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key") - workflowId: str = Field(description="FK to the workflow") - roundNumber: int = Field(default=0, description="Agent round that produced this memory") - memoryType: str = Field( - description="Category: file_ref, tool_result, decision, data_source_ref" + """Persistenter Speicher pro Agenten-Runde.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + json_schema_extra={"label": "ID"}, + ) + workflowId: str = Field( + description="FK to the workflow", + json_schema_extra={"label": "Workflow-ID"}, + ) + roundNumber: int = Field( + default=0, + description="Agent round that produced this memory", + json_schema_extra={"label": "Rundennummer"}, + ) + memoryType: str = Field( + description="Category: file_ref, tool_result, decision, data_source_ref", + json_schema_extra={"label": "Speichertyp"}, + ) + key: str = Field( + description="Dedup key, e.g. 'readFile:' or 'plan'", + json_schema_extra={"label": "Schluessel"}, + ) + summary: str = Field( + default="", + description="Compact summary (max ~2000 chars)", + json_schema_extra={"label": "Zusammenfassung"}, ) - key: str = Field(description="Dedup key, e.g. 'readFile:' or 'plan'") - summary: str = Field(default="", description="Compact summary (max ~2000 chars)") fullData: Optional[str] = Field( default=None, description="Full tool output when small enough (max ~8000 chars)", + json_schema_extra={"label": "Volldaten"}, + ) + fileIds: List[str] = Field( + default_factory=list, + description="Referenced file IDs", + json_schema_extra={"label": "Datei-IDs"}, ) - fileIds: List[str] = Field(default_factory=list, description="Referenced file IDs") embedding: Optional[List[float]] = Field( default=None, description="Embedding of summary for semantic retrieval", - json_schema_extra={"db_type": "vector(1536)"}, + json_schema_extra={"label": "Embedding", "db_type": "vector(1536)"}, ) -registerModelLabels( - "RoundMemory", - {"en": "Round Memory", "fr": "Mémoire de tour"}, - { - "id": {"en": "ID", "fr": "ID"}, - "workflowId": {"en": "Workflow ID", "fr": "ID du workflow"}, - "roundNumber": {"en": "Round Number", "fr": "Numéro de tour"}, - "memoryType": {"en": "Memory Type", "fr": "Type de mémoire"}, - "key": {"en": "Key", "fr": "Clé"}, - "summary": {"en": "Summary", "fr": "Résumé"}, - "fullData": {"en": "Full Data", "fr": "Données complètes"}, - "fileIds": {"en": "File IDs", "fr": "IDs de fichier"}, - "embedding": {"en": "Embedding", "fr": "Vecteur d'embedding"}, - }, -) - - +@i18nModel("Workflow-Speicher") class WorkflowMemory(PowerOnModel): - """Workflow-scoped key-value cache for entities and facts. - Extracted during agent rounds, persisted for cross-round and cross-workflow reuse.""" - id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key") - workflowId: str = Field(description="FK to the workflow") - userId: str = Field(description="Owner user ID") - featureInstanceId: str = Field(default="", description="Feature instance scope") - key: str = Field(description="Key identifier (e.g. 'entity:companyName')") - value: str = Field(description="Extracted value") - source: str = Field(default="extraction", description="Origin: extraction, tool, conversation, summary") - embedding: Optional[List[float]] = Field( - default=None, description="Optional embedding for semantic lookup", - json_schema_extra={"db_type": "vector(1536)"} + """Workflow-spezifischer Key-Value-Cache fuer Entitaeten und Fakten.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + json_schema_extra={"label": "ID"}, + ) + workflowId: str = Field( + description="FK to the workflow", + json_schema_extra={"label": "Workflow-ID"}, + ) + userId: str = Field( + description="Owner user ID", + json_schema_extra={"label": "Benutzer-ID"}, + ) + featureInstanceId: str = Field( + default="", + description="Feature instance scope", + json_schema_extra={"label": "Feature-Instanz-ID"}, + ) + key: str = Field( + description="Key identifier (e.g. 'entity:companyName')", + json_schema_extra={"label": "Schluessel"}, + ) + value: str = Field( + description="Extracted value", + json_schema_extra={"label": "Wert"}, + ) + source: str = Field( + default="extraction", + description="Origin: extraction, tool, conversation, summary", + json_schema_extra={"label": "Quelle"}, + ) + embedding: Optional[List[float]] = Field( + default=None, + description="Optional embedding for semantic lookup", + json_schema_extra={"label": "Embedding", "db_type": "vector(1536)"}, ) - - -registerModelLabels( - "WorkflowMemory", - {"en": "Workflow Memory", "fr": "Mémoire de workflow"}, - { - "id": {"en": "ID", "fr": "ID"}, - "workflowId": {"en": "Workflow ID", "fr": "ID du workflow"}, - "userId": {"en": "User ID", "fr": "ID utilisateur"}, - "featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance"}, - "key": {"en": "Key", "fr": "Clé"}, - "value": {"en": "Value", "fr": "Valeur"}, - "source": {"en": "Source", "fr": "Source"}, - "embedding": {"en": "Embedding", "fr": "Vecteur d'embedding"}, - }, -) diff --git a/modules/datamodels/datamodelMembership.py b/modules/datamodels/datamodelMembership.py index ce753d15..0cf8468f 100644 --- a/modules/datamodels/datamodelMembership.py +++ b/modules/datamodels/datamodelMembership.py @@ -10,9 +10,10 @@ Rollen werden über Junction Tables verknüpft für saubere CASCADE DELETE. import uuid from pydantic import BaseModel, Field from modules.datamodels.datamodelBase import PowerOnModel -from modules.shared.attributeUtils import registerModelLabels +from modules.shared.i18nRegistry import i18nModel +@i18nModel("Benutzer-Mandant") class UserMandate(PowerOnModel): """ User-Mitgliedschaft in einem Mandanten. @@ -21,36 +22,24 @@ class UserMandate(PowerOnModel): id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the user-mandate membership", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False} + json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False} ) userId: str = Field( description="FK → User.id (CASCADE DELETE)", - json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/users/", "frontend_fk_display_field": "username"} + json_schema_extra={"label": "Benutzer", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/users/", "frontend_fk_display_field": "username"} ) mandateId: str = Field( description="FK → Mandate.id (CASCADE DELETE)", - json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/mandates/", "frontend_fk_display_field": "label"} + json_schema_extra={"label": "Mandant", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/mandates/", "frontend_fk_display_field": "label"} ) enabled: bool = Field( default=True, description="Whether this membership is enabled", - json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False} + json_schema_extra={"label": "Aktiviert", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False} ) - # Rollen werden via Junction Table UserMandateRole verknüpft - - -registerModelLabels( - "UserMandate", - {"en": "User Mandate", "de": "Benutzer-Mandant", "fr": "Mandat utilisateur"}, - { - "id": {"en": "ID", "de": "ID", "fr": "ID"}, - "userId": {"en": "User", "de": "Benutzer", "fr": "Utilisateur"}, - "mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"}, - "enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"}, - }, -) +@i18nModel("Feature-Zugang") class FeatureAccess(PowerOnModel): """ User-Zugriff auf eine Feature-Instanz. @@ -59,36 +48,24 @@ class FeatureAccess(PowerOnModel): id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the feature access", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False} + json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False} ) userId: str = Field( description="FK → User.id (CASCADE DELETE)", - json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/users/", "frontend_fk_display_field": "username"} + json_schema_extra={"label": "Benutzer", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/users/", "frontend_fk_display_field": "username"} ) featureInstanceId: str = Field( description="FK → FeatureInstance.id (CASCADE DELETE)", - json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/feature-instances/", "frontend_fk_display_field": "name"} + json_schema_extra={"label": "Feature-Instanz", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/feature-instances/", "frontend_fk_display_field": "name"} ) enabled: bool = Field( default=True, description="Whether this feature access is enabled", - json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False} + json_schema_extra={"label": "Aktiviert", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False} ) - # Rollen werden via Junction Table FeatureAccessRole verknüpft - - -registerModelLabels( - "FeatureAccess", - {"en": "Feature Access", "de": "Feature-Zugang", "fr": "Accès fonctionnalité"}, - { - "id": {"en": "ID", "de": "ID", "fr": "ID"}, - "userId": {"en": "User", "de": "Benutzer", "fr": "Utilisateur"}, - "featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"}, - "enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"}, - }, -) +@i18nModel("Benutzer-Mandant-Rolle") class UserMandateRole(PowerOnModel): """ Junction Table: UserMandate zu Role. @@ -97,29 +74,19 @@ class UserMandateRole(PowerOnModel): id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the junction record", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False} + json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False} ) userMandateId: str = Field( description="FK → UserMandate.id (CASCADE DELETE)", - json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/user-mandates/", "frontend_fk_display_field": "userId"} + json_schema_extra={"label": "Benutzer-Mandant", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/user-mandates/", "frontend_fk_display_field": "userId"} ) roleId: str = Field( description="FK → Role.id (CASCADE DELETE)", - json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"} + json_schema_extra={"label": "Rolle", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"} ) -registerModelLabels( - "UserMandateRole", - {"en": "User Mandate Role", "de": "Benutzer-Mandant-Rolle", "fr": "Rôle mandat utilisateur"}, - { - "id": {"en": "ID", "de": "ID", "fr": "ID"}, - "userMandateId": {"en": "User Mandate", "de": "Benutzer-Mandant", "fr": "Mandat utilisateur"}, - "roleId": {"en": "Role", "de": "Rolle", "fr": "Rôle"}, - }, -) - - +@i18nModel("Feature-Zugang-Rolle") class FeatureAccessRole(PowerOnModel): """ Junction Table: FeatureAccess zu Role. @@ -128,24 +95,13 @@ class FeatureAccessRole(PowerOnModel): id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the junction record", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False} + json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False} ) featureAccessId: str = Field( description="FK → FeatureAccess.id (CASCADE DELETE)", - json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/feature-access/", "frontend_fk_display_field": "userId"} + json_schema_extra={"label": "Feature-Zugang", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/feature-access/", "frontend_fk_display_field": "userId"} ) roleId: str = Field( description="FK → Role.id (CASCADE DELETE)", - json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"} + json_schema_extra={"label": "Rolle", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"} ) - - -registerModelLabels( - "FeatureAccessRole", - {"en": "Feature Access Role", "de": "Feature-Zugang-Rolle", "fr": "Rôle accès fonctionnalité"}, - { - "id": {"en": "ID", "de": "ID", "fr": "ID"}, - "featureAccessId": {"en": "Feature Access", "de": "Feature-Zugang", "fr": "Accès fonctionnalité"}, - "roleId": {"en": "Role", "de": "Rolle", "fr": "Rôle"}, - }, -) diff --git a/modules/datamodels/datamodelMessaging.py b/modules/datamodels/datamodelMessaging.py index ebacc9d4..d7671da1 100644 --- a/modules/datamodels/datamodelMessaging.py +++ b/modules/datamodels/datamodelMessaging.py @@ -7,7 +7,7 @@ from typing import Optional from enum import Enum from pydantic import BaseModel, Field, ConfigDict from modules.datamodels.datamodelBase import PowerOnModel -from modules.shared.attributeUtils import registerModelLabels +from modules.shared.i18nRegistry import i18nModel class MessagingChannel(str, Enum): @@ -26,86 +26,137 @@ class DeliveryStatus(str, Enum): FAILED = "failed" +@i18nModel("Messaging-Abonnement") class MessagingSubscription(PowerOnModel): """Data model for messaging subscriptions""" id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the subscription", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={ + "frontend_type": "text", + "frontend_readonly": True, + "frontend_required": False, + "label": "ID", + }, ) subscriptionId: str = Field( description="Unique subscription identifier (e.g., 'system_errors', 'audit_login')", - json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True} + json_schema_extra={ + "frontend_type": "text", + "frontend_readonly": False, + "frontend_required": True, + "label": "Abonnement-ID", + }, ) subscriptionLabel: str = Field( description="Display name of the subscription", - json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True} + json_schema_extra={ + "frontend_type": "text", + "frontend_readonly": False, + "frontend_required": True, + "label": "Bezeichnung", + }, ) mandateId: str = Field( description="ID of the mandate this subscription belongs to", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={ + "frontend_type": "text", + "frontend_readonly": True, + "frontend_required": False, + "label": "Mandanten-ID", + }, ) featureInstanceId: str = Field( description="ID of the feature instance this subscription belongs to", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={ + "frontend_type": "text", + "frontend_readonly": True, + "frontend_required": False, + "label": "Feature-Instanz-ID", + }, ) description: Optional[str] = Field( default=None, description="Description of the subscription", - json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False} + json_schema_extra={ + "frontend_type": "textarea", + "frontend_readonly": False, + "frontend_required": False, + "label": "Beschreibung", + }, ) isSystemSubscription: bool = Field( default=False, description="Whether this is a system subscription (only admin can create)", - json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={ + "frontend_type": "checkbox", + "frontend_readonly": True, + "frontend_required": False, + "label": "System-Abonnement", + }, ) enabled: bool = Field( default=True, description="Whether the subscription is enabled", - json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False} + json_schema_extra={ + "frontend_type": "checkbox", + "frontend_readonly": False, + "frontend_required": False, + "label": "Aktiviert", + }, ) model_config = ConfigDict(use_enum_values=True) -registerModelLabels( - "MessagingSubscription", - {"en": "Messaging Subscription", "fr": "Abonnement de messagerie"}, - { - "id": {"en": "ID", "fr": "ID"}, - "subscriptionId": {"en": "Subscription ID", "fr": "ID d'abonnement"}, - "subscriptionLabel": {"en": "Subscription Label", "fr": "Label d'abonnement"}, - "mandateId": {"en": "Mandate ID", "fr": "ID du mandat"}, - "featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"}, - "description": {"en": "Description", "fr": "Description"}, - "isSystemSubscription": {"en": "System Subscription", "fr": "Abonnement système"}, - "enabled": {"en": "Enabled", "fr": "Activé"}, - }, -) - - +@i18nModel("Messaging-Registrierung") class MessagingSubscriptionRegistration(BaseModel): """Data model for user registrations to messaging subscriptions""" id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the registration", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={ + "frontend_type": "text", + "frontend_readonly": True, + "frontend_required": False, + "label": "ID", + }, ) mandateId: str = Field( description="ID of the mandate this registration belongs to", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={ + "frontend_type": "text", + "frontend_readonly": True, + "frontend_required": False, + "label": "Mandanten-ID", + }, ) featureInstanceId: str = Field( description="ID of the feature instance this registration belongs to", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={ + "frontend_type": "text", + "frontend_readonly": True, + "frontend_required": False, + "label": "Feature-Instanz-ID", + }, ) subscriptionId: str = Field( description="ID of the subscription this registration belongs to", - json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True} + json_schema_extra={ + "frontend_type": "text", + "frontend_readonly": False, + "frontend_required": True, + "label": "Abonnement-ID", + }, ) userId: str = Field( description="ID of the user registered to this subscription", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={ + "frontend_type": "text", + "frontend_readonly": True, + "frontend_required": False, + "label": "Benutzer-ID", + }, ) channel: MessagingChannel = Field( description="Channel type for this registration", @@ -117,62 +168,83 @@ class MessagingSubscriptionRegistration(BaseModel): {"value": "email", "label": {"en": "Email", "fr": "Email"}}, {"value": "sms", "label": {"en": "SMS", "fr": "SMS"}}, {"value": "whatsapp", "label": {"en": "WhatsApp", "fr": "WhatsApp"}}, - {"value": "teams_chat", "label": {"en": "Teams Chat", "fr": "Chat Teams"}} - ] - } + {"value": "teams_chat", "label": {"en": "Teams Chat", "fr": "Chat Teams"}}, + ], + "label": "Kanal", + }, ) channelConfig: str = Field( default="", description="Channel-specific configuration (e.g., email address, phone number, Teams user ID)", - json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False} + json_schema_extra={ + "frontend_type": "text", + "frontend_readonly": False, + "frontend_required": False, + "label": "Kanal-Konfiguration", + }, ) enabled: bool = Field( default=True, description="Whether this registration is enabled", - json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False} + json_schema_extra={ + "frontend_type": "checkbox", + "frontend_readonly": False, + "frontend_required": False, + "label": "Aktiviert", + }, ) model_config = ConfigDict(use_enum_values=True) -registerModelLabels( - "MessagingSubscriptionRegistration", - {"en": "Messaging Registration", "fr": "Inscription à la messagerie"}, - { - "id": {"en": "ID", "fr": "ID"}, - "mandateId": {"en": "Mandate ID", "fr": "ID du mandat"}, - "featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"}, - "subscriptionId": {"en": "Subscription ID", "fr": "ID d'abonnement"}, - "userId": {"en": "User ID", "fr": "ID utilisateur"}, - "channel": {"en": "Channel", "fr": "Canal"}, - "channelConfig": {"en": "Channel Config", "fr": "Configuration du canal"}, - "enabled": {"en": "Enabled", "fr": "Activé"}, - }, -) - - +@i18nModel("Messaging-Zustellung") class MessagingDelivery(BaseModel): """Data model for individual message deliveries""" id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the delivery", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={ + "frontend_type": "text", + "frontend_readonly": True, + "frontend_required": False, + "label": "ID", + }, ) mandateId: str = Field( description="ID of the mandate this delivery belongs to", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={ + "frontend_type": "text", + "frontend_readonly": True, + "frontend_required": False, + "label": "Mandanten-ID", + }, ) featureInstanceId: str = Field( description="ID of the feature instance this delivery belongs to", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={ + "frontend_type": "text", + "frontend_readonly": True, + "frontend_required": False, + "label": "Feature-Instanz-ID", + }, ) subscriptionId: str = Field( description="ID of the subscription this delivery belongs to", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={ + "frontend_type": "text", + "frontend_readonly": True, + "frontend_required": False, + "label": "Abonnement-ID", + }, ) userId: str = Field( description="ID of the user receiving this delivery", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={ + "frontend_type": "text", + "frontend_readonly": True, + "frontend_required": False, + "label": "Benutzer-ID", + }, ) channel: MessagingChannel = Field( description="Channel used for this delivery", @@ -184,9 +256,10 @@ class MessagingDelivery(BaseModel): {"value": "email", "label": {"en": "Email", "fr": "Email"}}, {"value": "sms", "label": {"en": "SMS", "fr": "SMS"}}, {"value": "whatsapp", "label": {"en": "WhatsApp", "fr": "WhatsApp"}}, - {"value": "teams_chat", "label": {"en": "Teams Chat", "fr": "Chat Teams"}} - ] - } + {"value": "teams_chat", "label": {"en": "Teams Chat", "fr": "Chat Teams"}}, + ], + "label": "Kanal", + }, ) status: DeliveryStatus = Field( default=DeliveryStatus.PENDING, @@ -198,112 +271,113 @@ class MessagingDelivery(BaseModel): "frontend_options": [ {"value": "pending", "label": {"en": "Pending", "fr": "En attente"}}, {"value": "sent", "label": {"en": "Sent", "fr": "Envoyé"}}, - {"value": "failed", "label": {"en": "Failed", "fr": "Échoué"}} - ] - } + {"value": "failed", "label": {"en": "Failed", "fr": "Échoué"}}, + ], + "label": "Status", + }, ) errorMessage: Optional[str] = Field( default=None, description="Error message if delivery failed", - json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={ + "frontend_type": "textarea", + "frontend_readonly": True, + "frontend_required": False, + "label": "Fehlermeldung", + }, ) sentAt: Optional[float] = Field( default=None, description="When the delivery was sent (UTC timestamp in seconds)", - json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={ + "frontend_type": "datetime", + "frontend_readonly": True, + "frontend_required": False, + "label": "Gesendet am", + }, ) model_config = ConfigDict(use_enum_values=True) -registerModelLabels( - "MessagingDelivery", - {"en": "Messaging Delivery", "fr": "Livraison de messagerie"}, - { - "id": {"en": "ID", "fr": "ID"}, - "mandateId": {"en": "Mandate ID", "fr": "ID du mandat"}, - "featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"}, - "subscriptionId": {"en": "Subscription ID", "fr": "ID d'abonnement"}, - "userId": {"en": "User ID", "fr": "ID utilisateur"}, - "channel": {"en": "Channel", "fr": "Canal"}, - "status": {"en": "Status", "fr": "Statut"}, - "errorMessage": {"en": "Error Message", "fr": "Message d'erreur"}, - "sentAt": {"en": "Sent At", "fr": "Envoyé le"}, - }, -) - - +@i18nModel("Messaging-Ereignisparameter") class MessagingEventParameters(BaseModel): """Data model for event parameters passed to subscription functions""" triggerData: dict = Field( default_factory=dict, description="Event data from trigger as dictionary/JSON", - json_schema_extra={"frontend_type": "json", "frontend_readonly": False, "frontend_required": False} + json_schema_extra={ + "frontend_type": "json", + "frontend_readonly": False, + "frontend_required": False, + "label": "Trigger-Daten", + }, ) -registerModelLabels( - "MessagingEventParameters", - {"en": "Messaging Event Parameters", "fr": "Paramètres d'événement de messagerie"}, - { - "triggerData": {"en": "Trigger Data", "fr": "Données de déclenchement"}, - }, -) - - -registerModelLabels( - "MessagingSendResult", - {"en": "Messaging Send Result", "fr": "Résultat d'envoi de messagerie"}, - { - "success": {"en": "Success", "fr": "Succès"}, - "deliveryId": {"en": "Delivery ID", "fr": "ID de livraison"}, - "errorMessage": {"en": "Error Message", "fr": "Message d'erreur"}, - }, -) - - -registerModelLabels( - "MessagingSubscriptionExecutionResult", - {"en": "Messaging Subscription Execution Result", "fr": "Résultat d'exécution d'abonnement"}, - { - "success": {"en": "Success", "fr": "Succès"}, - "messagesSent": {"en": "Messages Sent", "fr": "Messages envoyés"}, - "errorMessage": {"en": "Error Message", "fr": "Message d'erreur"}, - }, -) - - +@i18nModel("Messaging-Sendeergebnis") class MessagingSendResult(BaseModel): """Data model for sendMessage result""" success: bool = Field( description="Whether the message was sent successfully", - json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": True} + json_schema_extra={ + "frontend_type": "checkbox", + "frontend_readonly": True, + "frontend_required": True, + "label": "Erfolg", + }, ) deliveryId: Optional[str] = Field( default=None, description="ID of the created MessagingDelivery record", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={ + "frontend_type": "text", + "frontend_readonly": True, + "frontend_required": False, + "label": "Zustellungs-ID", + }, ) errorMessage: Optional[str] = Field( default=None, description="Error message if sending failed", - json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={ + "frontend_type": "textarea", + "frontend_readonly": True, + "frontend_required": False, + "label": "Fehlermeldung", + }, ) +@i18nModel("Messaging-Abonnement-Ausführung") class MessagingSubscriptionExecutionResult(BaseModel): """Data model for subscription function execution result""" success: bool = Field( description="Whether the subscription execution was successful", - json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": True} + json_schema_extra={ + "frontend_type": "checkbox", + "frontend_readonly": True, + "frontend_required": True, + "label": "Erfolg", + }, ) messagesSent: int = Field( default=0, description="Number of messages sent", - json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={ + "frontend_type": "number", + "frontend_readonly": True, + "frontend_required": False, + "label": "Gesendete Nachrichten", + }, ) errorMessage: Optional[str] = Field( default=None, description="Error message if execution failed", - json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={ + "frontend_type": "textarea", + "frontend_readonly": True, + "frontend_required": False, + "label": "Fehlermeldung", + }, ) diff --git a/modules/datamodels/datamodelNotification.py b/modules/datamodels/datamodelNotification.py index f5af0f55..9dfa2b7e 100644 --- a/modules/datamodels/datamodelNotification.py +++ b/modules/datamodels/datamodelNotification.py @@ -10,7 +10,7 @@ from typing import Optional, List from enum import Enum from pydantic import BaseModel, Field, ConfigDict from modules.datamodels.datamodelBase import PowerOnModel -from modules.shared.attributeUtils import registerModelLabels +from modules.shared.i18nRegistry import i18nModel class NotificationType(str, Enum): @@ -29,20 +29,25 @@ class NotificationStatus(str, Enum): DISMISSED = "dismissed" # Verworfen/Geschlossen +@i18nModel("Benachrichtigungs-Aktion") class NotificationAction(BaseModel): """Possible action for a notification""" actionId: str = Field( - description="Unique identifier for the action (e.g., 'accept', 'decline')" + description="Unique identifier for the action (e.g., 'accept', 'decline')", + json_schema_extra={"label": "Aktions-ID"}, ) label: str = Field( - description="Display label for the action button" + description="Display label for the action button", + json_schema_extra={"label": "Bezeichnung"}, ) style: str = Field( default="default", - description="Button style: 'primary', 'danger', 'default'" + description="Button style: 'primary', 'danger', 'default'", + json_schema_extra={"label": "Stil"}, ) +@i18nModel("Benachrichtigung") class UserNotification(PowerOnModel): """ In-app notification for a user. @@ -51,18 +56,18 @@ class UserNotification(PowerOnModel): id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the notification", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False} ) userId: str = Field( description="Target user ID for this notification", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True} + json_schema_extra={"label": "Benutzer", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True} ) - - # Notification type and status + type: NotificationType = Field( default=NotificationType.SYSTEM, description="Type of notification", json_schema_extra={ + "label": "Typ", "frontend_type": "select", "frontend_readonly": True, "frontend_required": True, @@ -78,6 +83,7 @@ class UserNotification(PowerOnModel): default=NotificationStatus.UNREAD, description="Current status of the notification", json_schema_extra={ + "label": "Status", "frontend_type": "select", "frontend_readonly": True, "frontend_required": False, @@ -89,115 +95,63 @@ class UserNotification(PowerOnModel): ] } ) - - # Content + title: str = Field( description="Notification title", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True} + json_schema_extra={"label": "Titel", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True} ) message: str = Field( description="Notification message/body", - json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": True} + json_schema_extra={"label": "Nachricht", "frontend_type": "textarea", "frontend_readonly": True, "frontend_required": True} ) icon: Optional[str] = Field( default=None, description="Optional icon identifier (e.g., 'mail', 'warning', 'info')", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"label": "Symbol", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False} ) - - # Reference to triggering object (for actionable notifications) + referenceType: Optional[str] = Field( default=None, description="Type of referenced object (e.g., 'Invitation', 'Workflow')", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"label": "Referenz-Typ", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False} ) referenceId: Optional[str] = Field( default=None, description="ID of referenced object", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"label": "Referenz-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False} ) - - # Actions (for actionable notifications like invitations) + actions: Optional[List[NotificationAction]] = Field( default=None, description="List of possible actions for this notification", - json_schema_extra={"frontend_type": "json", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"label": "Aktionen", "frontend_type": "json", "frontend_readonly": True, "frontend_required": False} ) - - # Action result (when user takes action) + actionTaken: Optional[str] = Field( default=None, description="Which action was taken (actionId)", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"label": "Durchgefuehrte Aktion", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False} ) actionResult: Optional[str] = Field( default=None, description="Result message from the action", - json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"label": "Aktions-Ergebnis", "frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False} ) - - # Timestamps + readAt: Optional[float] = Field( default=None, description="When the notification was read (UTC timestamp)", - json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"label": "Gelesen am", "frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False} ) actionedAt: Optional[float] = Field( default=None, description="When action was taken (UTC timestamp)", - json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"label": "Bearbeitet am", "frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False} ) expiresAt: Optional[float] = Field( default=None, description="When the notification expires (optional, UTC timestamp)", - json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"label": "Gueltig bis", "frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False} ) - + model_config = ConfigDict(use_enum_values=True) - - -registerModelLabels( - "UserNotification", - {"en": "Notification", "de": "Benachrichtigung", "fr": "Notification"}, - { - "id": {"en": "ID", "de": "ID", "fr": "ID"}, - "userId": {"en": "User", "de": "Benutzer", "fr": "Utilisateur"}, - "type": {"en": "Type", "de": "Typ", "fr": "Type"}, - "status": {"en": "Status", "de": "Status", "fr": "Statut"}, - "title": {"en": "Title", "de": "Titel", "fr": "Titre"}, - "message": {"en": "Message", "de": "Nachricht", "fr": "Message"}, - "icon": {"en": "Icon", "de": "Symbol", "fr": "Icône"}, - "referenceType": {"en": "Reference Type", "de": "Referenz-Typ", "fr": "Type de référence"}, - "referenceId": {"en": "Reference ID", "de": "Referenz-ID", "fr": "ID de référence"}, - "actions": {"en": "Actions", "de": "Aktionen", "fr": "Actions"}, - "actionTaken": {"en": "Action Taken", "de": "Durchgeführte Aktion", "fr": "Action effectuée"}, - "actionResult": {"en": "Action Result", "de": "Aktions-Ergebnis", "fr": "Résultat de l'action"}, - "readAt": {"en": "Read At", "de": "Gelesen am", "fr": "Lu le"}, - "actionedAt": {"en": "Actioned At", "de": "Bearbeitet am", "fr": "Traité le"}, - "expiresAt": {"en": "Expires At", "de": "Gültig bis", "fr": "Expire le"}, - }, -) - - -registerModelLabels( - "NotificationType", - {"en": "Notification Type", "de": "Benachrichtigungs-Typ", "fr": "Type de notification"}, - { - "invitation": {"en": "Invitation", "de": "Einladung", "fr": "Invitation"}, - "system": {"en": "System", "de": "System", "fr": "Système"}, - "workflow": {"en": "Workflow", "de": "Workflow", "fr": "Workflow"}, - "mention": {"en": "Mention", "de": "Erwähnung", "fr": "Mention"}, - }, -) - - -registerModelLabels( - "NotificationStatus", - {"en": "Notification Status", "de": "Benachrichtigungs-Status", "fr": "Statut de notification"}, - { - "unread": {"en": "Unread", "de": "Ungelesen", "fr": "Non lu"}, - "read": {"en": "Read", "de": "Gelesen", "fr": "Lu"}, - "actioned": {"en": "Actioned", "de": "Bearbeitet", "fr": "Traité"}, - "dismissed": {"en": "Dismissed", "de": "Verworfen", "fr": "Rejeté"}, - }, -) diff --git a/modules/datamodels/datamodelRbac.py b/modules/datamodels/datamodelRbac.py index b9e0cb91..c9829458 100644 --- a/modules/datamodels/datamodelRbac.py +++ b/modules/datamodels/datamodelRbac.py @@ -14,7 +14,7 @@ from typing import Optional from enum import Enum from pydantic import BaseModel, Field from modules.datamodels.datamodelBase import PowerOnModel -from modules.shared.attributeUtils import registerModelLabels +from modules.shared.i18nRegistry import i18nModel from modules.datamodels.datamodelUtils import TextMultilingual from modules.datamodels.datamodelUam import AccessLevel @@ -26,6 +26,7 @@ class AccessRuleContext(str, Enum): RESOURCE = "RESOURCE" # System resources (AI models, actions, etc.) +@i18nModel("Rolle") class Role(PowerOnModel): """ Data model for RBAC roles. @@ -41,56 +42,42 @@ class Role(PowerOnModel): id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the role", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False} + json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False} ) roleLabel: str = Field( description="Unique role label identifier (e.g., 'admin', 'user', 'viewer')", - json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True} + json_schema_extra={"label": "Rollen-Label", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True} ) description: TextMultilingual = Field( description="Role description in multiple languages", - json_schema_extra={"frontend_type": "multilingual", "frontend_readonly": False, "frontend_required": True} + json_schema_extra={"label": "Beschreibung", "frontend_type": "multilingual", "frontend_readonly": False, "frontend_required": True} ) # KONTEXT - IMMUTABLE nach Create (nur Create/Delete, kein Update!) mandateId: Optional[str] = Field( default=None, description="FK → Mandate.id (CASCADE DELETE). Null = Global/Template role.", - json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_visible": True, "frontend_required": False, "frontend_fk_source": "/api/mandates/", "frontend_fk_display_field": "label"} + json_schema_extra={"label": "Mandant", "frontend_type": "select", "frontend_readonly": True, "frontend_visible": True, "frontend_required": False, "frontend_fk_source": "/api/mandates/", "frontend_fk_display_field": "label"} ) featureInstanceId: Optional[str] = Field( default=None, description="FK → FeatureInstance.id (CASCADE DELETE). Null = Mandate-level or Global role.", - json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_visible": True, "frontend_required": False, "frontend_fk_source": "/api/feature-instances/", "frontend_fk_display_field": "name"} + json_schema_extra={"label": "Feature-Instanz", "frontend_type": "select", "frontend_readonly": True, "frontend_visible": True, "frontend_required": False, "frontend_fk_source": "/api/feature-instances/", "frontend_fk_display_field": "name"} ) featureCode: Optional[str] = Field( default=None, description="Feature code (z.B. 'trustee') - für Template-Rollen", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False} + json_schema_extra={"label": "Feature-Code", "frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False} ) - + isSystemRole: bool = Field( default=False, description="Whether this is a system role that cannot be deleted", - json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"label": "System-Rolle", "frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False} ) -registerModelLabels( - "Role", - {"en": "Role", "de": "Rolle", "fr": "Rôle"}, - { - "id": {"en": "ID", "de": "ID", "fr": "ID"}, - "roleLabel": {"en": "Role Label", "de": "Rollen-Label", "fr": "Label du rôle"}, - "description": {"en": "Description", "de": "Beschreibung", "fr": "Description"}, - "mandateId": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"}, - "featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"}, - "featureCode": {"en": "Feature Code", "de": "Feature-Code", "fr": "Code fonctionnalité"}, - "isSystemRole": {"en": "System Role", "de": "System-Rolle", "fr": "Rôle système"}, - }, -) - - +@i18nModel("Zugriffsregel") class AccessRule(PowerOnModel): """ Data model for access control rules. @@ -101,15 +88,15 @@ class AccessRule(PowerOnModel): id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the access rule", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False} + json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False} ) roleId: str = Field( description="FK → Role.id (CASCADE DELETE!)", - json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"} + json_schema_extra={"label": "Rolle", "frontend_type": "select", "frontend_readonly": True, "frontend_required": True, "frontend_fk_source": "/api/rbac/roles", "frontend_fk_display_field": "roleLabel"} ) context: AccessRuleContext = Field( description="Context type: DATA (database), UI (interface), RESOURCE (system resources). IMMUTABLE!", - json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": True, "frontend_options": [ + json_schema_extra={"label": "Kontext", "frontend_type": "select", "frontend_readonly": True, "frontend_required": True, "frontend_options": [ {"value": "DATA", "label": {"en": "Data", "de": "Daten", "fr": "Données"}}, {"value": "UI", "label": {"en": "UI", "de": "Oberfläche", "fr": "Interface"}}, {"value": "RESOURCE", "label": {"en": "Resource", "de": "Ressource", "fr": "Ressource"}} @@ -118,17 +105,17 @@ class AccessRule(PowerOnModel): item: Optional[str] = Field( default=None, description="Item identifier (null = all items in context). Format: DATA: '' or '
.', UI: cascading string (e.g., 'playground.voice.settings'), RESOURCE: cascading string (e.g., 'ai.model.anthropic')", - json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False} + json_schema_extra={"label": "Element", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False} ) view: bool = Field( default=False, description="View permission: if true, item is visible/enabled. Only objects with view=true are shown.", - json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": True} + json_schema_extra={"label": "Anzeigen", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": True} ) read: Optional[AccessLevel] = Field( default=None, description="Read permission level (only for DATA context)", - json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ + json_schema_extra={"label": "Lesen", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ {"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}}, {"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}}, {"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}}, @@ -138,7 +125,7 @@ class AccessRule(PowerOnModel): create: Optional[AccessLevel] = Field( default=None, description="Create permission level (only for DATA context)", - json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ + json_schema_extra={"label": "Erstellen", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ {"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}}, {"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}}, {"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}}, @@ -148,7 +135,7 @@ class AccessRule(PowerOnModel): update: Optional[AccessLevel] = Field( default=None, description="Update permission level (only for DATA context)", - json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ + json_schema_extra={"label": "Aktualisieren", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ {"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}}, {"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}}, {"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}}, @@ -158,7 +145,7 @@ class AccessRule(PowerOnModel): delete: Optional[AccessLevel] = Field( default=None, description="Delete permission level (only for DATA context)", - json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ + json_schema_extra={"label": "Loeschen", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ {"value": "a", "label": {"en": "All Records", "de": "Alle Datensätze", "fr": "Tous les enregistrements"}}, {"value": "m", "label": {"en": "My Records", "de": "Meine Datensätze", "fr": "Mes enregistrements"}}, {"value": "g", "label": {"en": "Group Records", "de": "Gruppen-Datensätze", "fr": "Enregistrements du groupe"}}, @@ -167,23 +154,6 @@ class AccessRule(PowerOnModel): ) -registerModelLabels( - "AccessRule", - {"en": "Access Rule", "de": "Zugriffsregel", "fr": "Règle d'accès"}, - { - "id": {"en": "ID", "de": "ID", "fr": "ID"}, - "roleId": {"en": "Role", "de": "Rolle", "fr": "Rôle"}, - "context": {"en": "Context", "de": "Kontext", "fr": "Contexte"}, - "item": {"en": "Item", "de": "Element", "fr": "Élément"}, - "view": {"en": "View", "de": "Anzeigen", "fr": "Vue"}, - "read": {"en": "Read", "de": "Lesen", "fr": "Lecture"}, - "create": {"en": "Create", "de": "Erstellen", "fr": "Créer"}, - "update": {"en": "Update", "de": "Aktualisieren", "fr": "Mettre à jour"}, - "delete": {"en": "Delete", "de": "Löschen", "fr": "Supprimer"}, - }, -) - - # IMMUTABLE Fields Definition - für Enforcement auf Application-Level IMMUTABLE_FIELDS = { "Role": ["mandateId", "featureInstanceId", "featureCode"], diff --git a/modules/datamodels/datamodelSecurity.py b/modules/datamodels/datamodelSecurity.py index dc8c26e6..52237226 100644 --- a/modules/datamodels/datamodelSecurity.py +++ b/modules/datamodels/datamodelSecurity.py @@ -12,7 +12,7 @@ Multi-Tenant Design: from typing import Optional, Any from pydantic import BaseModel, Field, ConfigDict, model_validator from modules.datamodels.datamodelBase import PowerOnModel -from modules.shared.attributeUtils import registerModelLabels +from modules.shared.i18nRegistry import i18nModel from modules.shared.timeUtils import getUtcTimestamp from .datamodelUam import AuthAuthority from enum import Enum @@ -31,46 +31,79 @@ class TokenPurpose(str, Enum): DATA_CONNECTION = "dataConnection" +@i18nModel("Token") class Token(PowerOnModel): """ Authentication Token model. - + Multi-Tenant Design: - Token ist User-gebunden, NICHT Mandant-gebunden - Ermöglicht parallele Arbeit in mehreren Mandanten - Mandant-Kontext wird per Request-Header bestimmt """ - id: Optional[str] = None - userId: str - authority: AuthAuthority + id: Optional[str] = Field( + default=None, + json_schema_extra={"label": "ID"}, + ) + userId: str = Field( + ..., + json_schema_extra={"label": "Benutzer-ID"}, + ) + authority: AuthAuthority = Field( + ..., + json_schema_extra={"label": "Autoritaet"}, + ) connectionId: Optional[str] = Field( - None, description="ID of the connection this token belongs to" + None, + description="ID of the connection this token belongs to", + json_schema_extra={"label": "Verbindungs-ID"}, ) tokenPurpose: Optional[TokenPurpose] = Field( default=None, description="authSession = gateway login JWT; dataConnection = provider OAuth for a connection", + json_schema_extra={"label": "Token-Verwendung"}, + ) + tokenAccess: str = Field( + ..., + json_schema_extra={"label": "Zugriffstoken"}, + ) + tokenType: str = Field( + default="bearer", + json_schema_extra={"label": "Token-Typ"}, ) - tokenAccess: str - tokenType: str = "bearer" expiresAt: float = Field( - description="When the token expires (UTC timestamp in seconds)" + description="When the token expires (UTC timestamp in seconds)", + json_schema_extra={"label": "Laeuft ab am"}, + ) + tokenRefresh: Optional[str] = Field( + default=None, + json_schema_extra={"label": "Refresh-Token"}, ) - tokenRefresh: Optional[str] = None status: TokenStatus = Field( - default=TokenStatus.ACTIVE, description="Token status: active/revoked" + default=TokenStatus.ACTIVE, + description="Token status: active/revoked", + json_schema_extra={"label": "Status"}, ) revokedAt: Optional[float] = Field( - None, description="When the token was revoked (UTC timestamp in seconds)" + None, + description="When the token was revoked (UTC timestamp in seconds)", + json_schema_extra={"label": "Widerrufen am"}, ) revokedBy: Optional[str] = Field( - None, description="User ID who revoked the token (admin/self)" + None, + description="User ID who revoked the token (admin/self)", + json_schema_extra={"label": "Widerrufen von"}, + ) + reason: Optional[str] = Field( + None, + description="Optional revocation reason", + json_schema_extra={"label": "Grund"}, ) - reason: Optional[str] = Field(None, description="Optional revocation reason") sessionId: Optional[str] = Field( - None, description="Logical session grouping for logout revocation" + None, + description="Logical session grouping for logout revocation", + json_schema_extra={"label": "Sitzungs-ID"}, ) - # ENTFERNT: mandateId - Token ist nicht mehr Mandant-spezifisch - # Mandant-Kontext wird per Request-Header (X-Mandate-Id) bestimmt model_config = ConfigDict(use_enum_values=True) @@ -91,51 +124,44 @@ class Token(PowerOnModel): return data -registerModelLabels( - "Token", - {"en": "Token", "de": "Token", "fr": "Jeton"}, - { - "id": {"en": "ID", "de": "ID", "fr": "ID"}, - "userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"}, - "authority": {"en": "Authority", "de": "Autorität", "fr": "Autorité"}, - "connectionId": {"en": "Connection ID", "de": "Verbindungs-ID", "fr": "ID de connexion"}, - "tokenPurpose": {"en": "Token purpose", "de": "Token-Verwendung", "fr": "Usage du jeton"}, - "tokenAccess": {"en": "Access Token", "de": "Zugriffstoken", "fr": "Jeton d'accès"}, - "tokenType": {"en": "Token Type", "de": "Token-Typ", "fr": "Type de jeton"}, - "expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"}, - "tokenRefresh": {"en": "Refresh Token", "de": "Refresh-Token", "fr": "Jeton de rafraîchissement"}, - "status": {"en": "Status", "de": "Status", "fr": "Statut"}, - "revokedAt": {"en": "Revoked At", "de": "Widerrufen am", "fr": "Révoqué le"}, - "revokedBy": {"en": "Revoked By", "de": "Widerrufen von", "fr": "Révoqué par"}, - "reason": {"en": "Reason", "de": "Grund", "fr": "Raison"}, - "sessionId": {"en": "Session ID", "de": "Sitzungs-ID", "fr": "ID de session"}, - }, -) - - +@i18nModel("Authentifizierungsereignis") class AuthEvent(PowerOnModel): """Authentication event for audit logging.""" - id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the auth event", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) - userId: str = Field(description="ID of the user this event belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) - eventType: str = Field(description="Type of authentication event (e.g., 'login', 'logout', 'token_refresh')", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) - timestamp: float = Field(default_factory=getUtcTimestamp, description="Unix timestamp when the event occurred", json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": True}) - ipAddress: Optional[str] = Field(default=None, description="IP address from which the event originated", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) - userAgent: Optional[str] = Field(default=None, description="User agent string from the request", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) - success: bool = Field(default=True, description="Whether the authentication event was successful", json_schema_extra={"frontend_type": "boolean", "frontend_readonly": True, "frontend_required": True}) - details: Optional[str] = Field(default=None, description="Additional details about the event", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) - - -registerModelLabels( - "AuthEvent", - {"en": "Authentication Event", "de": "Authentifizierungsereignis", "fr": "Événement d'authentification"}, - { - "id": {"en": "ID", "de": "ID", "fr": "ID"}, - "userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"}, - "eventType": {"en": "Event Type", "de": "Ereignistyp", "fr": "Type d'événement"}, - "timestamp": {"en": "Timestamp", "de": "Zeitstempel", "fr": "Horodatage"}, - "ipAddress": {"en": "IP Address", "de": "IP-Adresse", "fr": "Adresse IP"}, - "userAgent": {"en": "User Agent", "de": "User-Agent", "fr": "Agent utilisateur"}, - "success": {"en": "Success", "de": "Erfolgreich", "fr": "Succès"}, - "details": {"en": "Details", "de": "Details", "fr": "Détails"}, - }, -) + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Unique ID of the auth event", + json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + ) + userId: str = Field( + description="ID of the user this event belongs to", + json_schema_extra={"label": "Benutzer-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}, + ) + eventType: str = Field( + description="Type of authentication event (e.g., 'login', 'logout', 'token_refresh')", + json_schema_extra={"label": "Ereignistyp", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}, + ) + timestamp: float = Field( + default_factory=getUtcTimestamp, + description="Unix timestamp when the event occurred", + json_schema_extra={"label": "Zeitstempel", "frontend_type": "datetime", "frontend_readonly": True, "frontend_required": True}, + ) + ipAddress: Optional[str] = Field( + default=None, + description="IP address from which the event originated", + json_schema_extra={"label": "IP-Adresse", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + ) + userAgent: Optional[str] = Field( + default=None, + description="User agent string from the request", + json_schema_extra={"label": "User-Agent", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + ) + success: bool = Field( + default=True, + description="Whether the authentication event was successful", + json_schema_extra={"label": "Erfolgreich", "frontend_type": "boolean", "frontend_readonly": True, "frontend_required": True}, + ) + details: Optional[str] = Field( + default=None, + description="Additional details about the event", + json_schema_extra={"label": "Details", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + ) diff --git a/modules/datamodels/datamodelSubscription.py b/modules/datamodels/datamodelSubscription.py index 1791e7a9..16f6789d 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.attributeUtils import registerModelLabels +from modules.shared.i18nRegistry import i18nModel import uuid @@ -55,123 +55,224 @@ class BillingPeriodEnum(str, Enum): # Catalog: SubscriptionPlan (static, in-memory) # ============================================================================ +@i18nModel("Abonnement-Plan") class SubscriptionPlan(BaseModel): - """Plan definition (catalog entry). Not stored per mandate — static.""" - planKey: str = Field(..., description="Unique plan identifier") - selectableByUser: bool = Field(default=True, description="Whether users can choose this plan in the UI") + """Plan-Definition (Katalog). Nicht pro Mandat gespeichert — statisch.""" + planKey: str = Field( + ..., + description="Unique plan identifier", + json_schema_extra={"label": "Plan"}, + ) + selectableByUser: bool = Field( + default=True, + description="Whether users can choose this plan in the UI", + json_schema_extra={"label": "Waehlbar"}, + ) - title: Dict[str, str] = Field(default_factory=dict, description="Multilingual title (en/de/fr)") - description: Dict[str, str] = Field(default_factory=dict, description="Multilingual description") + title: Dict[str, str] = Field( + default_factory=dict, + description="Multilingual title (en/de/fr)", + json_schema_extra={"label": "Titel"}, + ) + description: Dict[str, str] = Field( + default_factory=dict, + description="Multilingual description", + json_schema_extra={"label": "Beschreibung"}, + ) - currency: str = Field(default="CHF", description="Billing currency") - billingPeriod: BillingPeriodEnum = Field(default=BillingPeriodEnum.MONTHLY, description="Recurring interval") - pricePerUserCHF: float = Field(default=0.0, description="Price per active user per period") - pricePerFeatureInstanceCHF: float = Field(default=0.0, description="Price per active feature instance per period") - autoRenew: bool = Field(default=True, description="Stripe renews automatically at period end") + currency: str = Field( + default="CHF", + description="Billing currency", + json_schema_extra={"label": "Waehrung"}, + ) + billingPeriod: BillingPeriodEnum = Field( + default=BillingPeriodEnum.MONTHLY, + description="Recurring interval", + json_schema_extra={"label": "Abrechnungszeitraum"}, + ) + pricePerUserCHF: float = Field( + default=0.0, + description="Price per active user per period", + json_schema_extra={"label": "Preis pro User (CHF)"}, + ) + pricePerFeatureInstanceCHF: float = Field( + default=0.0, + description="Price per active feature instance per period", + json_schema_extra={"label": "Preis pro Instanz (CHF)"}, + ) + autoRenew: bool = Field( + default=True, + description="Stripe renews automatically at period end", + json_schema_extra={"label": "Auto-Verlaengerung"}, + ) - maxUsers: Optional[int] = Field(None, description="Hard cap on active users (None = unlimited)") - maxFeatureInstances: Optional[int] = Field(None, description="Hard cap on active feature instances (None = unlimited)") - trialDays: Optional[int] = Field(None, description="Trial duration in days (only for trial plans)") - maxDataVolumeMB: Optional[int] = Field(None, description="Soft-limit for data volume in MB per mandate (None = unlimited)") - budgetAiCHF: float = Field(default=0.0, description="AI budget (CHF) included in subscription price per billing period") - successorPlanKey: Optional[str] = Field(None, description="Plan to transition to when trial ends") - - -registerModelLabels( - "SubscriptionPlan", - {"en": "Subscription Plan", "de": "Abonnement-Plan", "fr": "Plan d'abonnement"}, - { - "planKey": {"en": "Plan", "de": "Plan", "fr": "Plan"}, - "selectableByUser": {"en": "Selectable", "de": "Wählbar", "fr": "Sélectionnable"}, - "billingPeriod": {"en": "Billing Period", "de": "Abrechnungszeitraum", "fr": "Période de facturation"}, - "pricePerUserCHF": {"en": "Price per User (CHF)", "de": "Preis pro User (CHF)"}, - "pricePerFeatureInstanceCHF": {"en": "Price per Instance (CHF)", "de": "Preis pro Instanz (CHF)"}, - "maxUsers": {"en": "Max Users", "de": "Max. Benutzer", "fr": "Max. utilisateurs"}, - "maxFeatureInstances": {"en": "Max Instances", "de": "Max. Instanzen", "fr": "Max. instances"}, - "maxDataVolumeMB": {"en": "Data Volume (MB)", "de": "Datenvolumen (MB)"}, - "budgetAiCHF": {"en": "AI Budget (CHF)", "de": "AI-Budget (CHF)"}, - }, -) + maxUsers: Optional[int] = Field( + None, + description="Hard cap on active users (None = unlimited)", + json_schema_extra={"label": "Max. Benutzer"}, + ) + maxFeatureInstances: Optional[int] = Field( + None, + description="Hard cap on active feature instances (None = unlimited)", + json_schema_extra={"label": "Max. Instanzen"}, + ) + trialDays: Optional[int] = Field( + None, + description="Trial duration in days (only for trial plans)", + json_schema_extra={"label": "Probentage"}, + ) + maxDataVolumeMB: Optional[int] = Field( + None, + description="Soft-limit for data volume in MB per mandate (None = unlimited)", + json_schema_extra={"label": "Datenvolumen (MB)"}, + ) + budgetAiCHF: float = Field( + default=0.0, + description="AI budget (CHF) included in subscription price per billing period", + json_schema_extra={"label": "AI-Budget (CHF)"}, + ) + successorPlanKey: Optional[str] = Field( + None, + description="Plan to transition to when trial ends", + json_schema_extra={"label": "Nachfolge-Plan"}, + ) # ============================================================================ # Stripe Price mapping (persisted in DB, auto-created at bootstrap) # ============================================================================ +@i18nModel("Stripe-Planpreise") class StripePlanPrice(BaseModel): - """Persisted mapping from planKey to Stripe Product/Price IDs. - Auto-created at startup — no manual configuration needed. - Uses separate Stripe Products for users and instances for clear invoice labels.""" - id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key") - planKey: str = Field(..., description="Reference to SubscriptionPlan.planKey") - stripeProductId: str = Field("", description="Legacy single-product ID (unused)") - stripeProductIdUsers: Optional[str] = Field(None, description="Stripe Product ID for user licenses") - stripeProductIdInstances: Optional[str] = Field(None, description="Stripe Product ID for feature instances") - stripePriceIdUsers: Optional[str] = Field(None, description="Stripe Price ID for user-seat line item") - stripePriceIdInstances: Optional[str] = Field(None, description="Stripe Price ID for instance line item") - - -registerModelLabels( - "StripePlanPrice", - {"en": "Stripe Plan Prices", "de": "Stripe-Planpreise"}, - { - "planKey": {"en": "Plan", "de": "Plan"}, - "stripeProductIdUsers": {"en": "Product (Users)", "de": "Produkt (User)"}, - "stripeProductIdInstances": {"en": "Product (Instances)", "de": "Produkt (Instanzen)"}, - "stripePriceIdUsers": {"en": "Price ID (Users)", "de": "Preis-ID (User)"}, - "stripePriceIdInstances": {"en": "Price ID (Instances)", "de": "Preis-ID (Instanzen)"}, - }, -) + """Persistierte Zuordnung planKey zu Stripe Product/Price IDs.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + json_schema_extra={"label": "ID"}, + ) + planKey: str = Field( + ..., + description="Reference to SubscriptionPlan.planKey", + json_schema_extra={"label": "Plan"}, + ) + stripeProductId: str = Field( + "", + description="Legacy single-product ID (unused)", + json_schema_extra={"label": "Stripe-Produkt-ID (Legacy)"}, + ) + stripeProductIdUsers: Optional[str] = Field( + None, + description="Stripe Product ID for user licenses", + json_schema_extra={"label": "Produkt (User)"}, + ) + stripeProductIdInstances: Optional[str] = Field( + None, + description="Stripe Product ID for feature instances", + json_schema_extra={"label": "Produkt (Instanzen)"}, + ) + stripePriceIdUsers: Optional[str] = Field( + None, + description="Stripe Price ID for user-seat line item", + json_schema_extra={"label": "Preis-ID (User)"}, + ) + stripePriceIdInstances: Optional[str] = Field( + None, + description="Stripe Price ID for instance line item", + json_schema_extra={"label": "Preis-ID (Instanzen)"}, + ) # ============================================================================ # Instance: MandateSubscription # ============================================================================ +@i18nModel("Mandanten-Abonnement") class MandateSubscription(PowerOnModel): - """A subscription instance bound to a specific mandate. - See wiki/concepts/Subscription-State-Machine.md for state transitions.""" - id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key") - mandateId: str = Field(..., description="Foreign key to Mandate") - planKey: str = Field(..., description="Reference to SubscriptionPlan.planKey") + """Abonnement-Instanz gebunden an einen Mandanten.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + json_schema_extra={"label": "ID"}, + ) + mandateId: str = Field( + ..., + description="Foreign key to Mandate", + json_schema_extra={"label": "Mandanten-ID"}, + ) + planKey: str = Field( + ..., + description="Reference to SubscriptionPlan.planKey", + json_schema_extra={"label": "Plan"}, + ) - status: SubscriptionStatusEnum = Field(default=SubscriptionStatusEnum.PENDING, description="Current lifecycle status") - recurring: bool = Field(default=True, description="True: auto-renews at period end. False: expires at period end (gekuendigt).") + status: SubscriptionStatusEnum = Field( + default=SubscriptionStatusEnum.PENDING, + description="Current lifecycle status", + json_schema_extra={"label": "Status"}, + ) + recurring: bool = Field( + default=True, + description="True: auto-renews at period end. False: expires at period end (gekuendigt).", + json_schema_extra={"label": "Wiederkehrend"}, + ) - startedAt: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), description="Record creation timestamp") - effectiveFrom: Optional[datetime] = Field(None, description="When this subscription becomes operative. None = immediate. Set for SCHEDULED subs.") - endedAt: Optional[datetime] = Field(None, description="When subscription ended (terminal)") - currentPeriodStart: Optional[datetime] = Field(None, description="Current billing period start (synced from Stripe)") - currentPeriodEnd: Optional[datetime] = Field(None, description="Current billing period end (synced from Stripe)") - trialEndsAt: Optional[datetime] = Field(None, description="Trial expiry timestamp") + startedAt: datetime = Field( + default_factory=lambda: datetime.now(timezone.utc), + description="Record creation timestamp", + json_schema_extra={"label": "Gestartet"}, + ) + effectiveFrom: Optional[datetime] = Field( + None, + description="When this subscription becomes operative. None = immediate. Set for SCHEDULED subs.", + json_schema_extra={"label": "Wirksam ab"}, + ) + endedAt: Optional[datetime] = Field( + None, + description="When subscription ended (terminal)", + json_schema_extra={"label": "Beendet"}, + ) + currentPeriodStart: Optional[datetime] = Field( + None, + description="Current billing period start (synced from Stripe)", + json_schema_extra={"label": "Periodenbeginn"}, + ) + currentPeriodEnd: Optional[datetime] = Field( + None, + description="Current billing period end (synced from Stripe)", + json_schema_extra={"label": "Periodenende"}, + ) + trialEndsAt: Optional[datetime] = Field( + None, + description="Trial expiry timestamp", + json_schema_extra={"label": "Trial endet"}, + ) - snapshotPricePerUserCHF: float = Field(default=0.0, description="Price snapshot at activation (for invoice history)") - snapshotPricePerInstanceCHF: float = Field(default=0.0, description="Price snapshot at activation") + snapshotPricePerUserCHF: float = Field( + default=0.0, + description="Price snapshot at activation (for invoice history)", + json_schema_extra={"label": "Preis/User (CHF)"}, + ) + snapshotPricePerInstanceCHF: float = Field( + default=0.0, + description="Price snapshot at activation", + json_schema_extra={"label": "Preis/Instanz (CHF)"}, + ) - stripeSubscriptionId: Optional[str] = Field(None, description="Stripe Subscription ID (sub_xxx)") - stripeItemIdUsers: Optional[str] = Field(None, description="Stripe Subscription Item ID for user seats") - stripeItemIdInstances: Optional[str] = Field(None, description="Stripe Subscription Item ID for feature instances") - - -registerModelLabels( - "MandateSubscription", - {"en": "Mandate Subscription", "de": "Mandanten-Abonnement", "fr": "Abonnement du mandat"}, - { - "id": {"en": "ID", "de": "ID"}, - "mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"}, - "planKey": {"en": "Plan", "de": "Plan"}, - "status": {"en": "Status", "de": "Status"}, - "recurring": {"en": "Recurring", "de": "Wiederkehrend"}, - "startedAt": {"en": "Started", "de": "Gestartet"}, - "effectiveFrom": {"en": "Effective From", "de": "Wirksam ab"}, - "endedAt": {"en": "Ended", "de": "Beendet"}, - "currentPeriodStart": {"en": "Period Start", "de": "Periodenbeginn"}, - "currentPeriodEnd": {"en": "Period End", "de": "Periodenende"}, - "trialEndsAt": {"en": "Trial Ends", "de": "Trial endet"}, - "snapshotPricePerUserCHF": {"en": "Price/User (CHF)", "de": "Preis/User (CHF)"}, - "snapshotPricePerInstanceCHF": {"en": "Price/Instance (CHF)", "de": "Preis/Instanz (CHF)"}, - }, -) + stripeSubscriptionId: Optional[str] = Field( + None, + description="Stripe Subscription ID (sub_xxx)", + json_schema_extra={"label": "Stripe-Abonnement-ID"}, + ) + stripeItemIdUsers: Optional[str] = Field( + None, + description="Stripe Subscription Item ID for user seats", + json_schema_extra={"label": "Stripe-Item (User)"}, + ) + stripeItemIdInstances: Optional[str] = Field( + None, + description="Stripe Subscription Item ID for feature instances", + json_schema_extra={"label": "Stripe-Item (Instanzen)"}, + ) # ============================================================================ @@ -225,10 +326,10 @@ BUILTIN_PLANS: Dict[str, SubscriptionPlan] = { "STANDARD_YEARLY": SubscriptionPlan( planKey="STANDARD_YEARLY", selectableByUser=True, - title={"en": "Standard (Yearly)", "de": "Standard (Jährlich)", "fr": "Standard (Annuel)"}, + title={"en": "Standard (Yearly)", "de": "Standard (Jaehrlich)", "fr": "Standard (Annuel)"}, description={ "en": "Usage-based billing per active user and feature instance, billed yearly. Includes 120 CHF AI budget.", - "de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, jährlich. Inkl. 120 CHF AI-Budget.", + "de": "Nutzungsbasierte Abrechnung pro aktivem User und Feature-Instanz, jaehrlich. Inkl. 120 CHF AI-Budget.", }, billingPeriod=BillingPeriodEnum.YEARLY, pricePerUserCHF=948.0, diff --git a/modules/datamodels/datamodelUam.py b/modules/datamodels/datamodelUam.py index 35e9ec7c..e33bf7d8 100644 --- a/modules/datamodels/datamodelUam.py +++ b/modules/datamodels/datamodelUam.py @@ -14,7 +14,7 @@ from typing import Optional, List, Dict, Any from enum import Enum from pydantic import BaseModel, Field, EmailStr, field_validator, computed_field from modules.datamodels.datamodelBase import PowerOnModel -from modules.shared.attributeUtils import registerModelLabels +from modules.shared.i18nRegistry import i18nModel from modules.shared.timeUtils import getUtcTimestamp @@ -61,6 +61,7 @@ class UserPermissions(BaseModel): ) +@i18nModel("Mandant") class Mandate(PowerOnModel): """ Mandate (Mandant/Tenant) model. @@ -69,31 +70,31 @@ class Mandate(PowerOnModel): id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the mandate", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False} + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False, "label": "ID"}, ) name: str = Field( description="Name of the mandate", - json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True} + json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True, "label": "Name"}, ) label: Optional[str] = Field( default=None, description="Display label of the mandate", - json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False} + json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False, "label": "Label"}, ) enabled: bool = Field( default=True, description="Indicates whether the mandate is enabled", - json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False} + json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False, "label": "Aktiviert"}, ) isSystem: bool = Field( default=False, description="Whether this is a system mandate (e.g. root mandate). Cannot be deleted.", - json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": True, "frontend_required": False, "label": "System-Mandant"}, ) deletedAt: Optional[float] = Field( default=None, description="Timestamp when the mandate was soft-deleted. After 30 days, hard-delete is triggered.", - json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False} + json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Gelöscht am"}, ) @field_validator('isSystem', mode='before') @@ -104,38 +105,91 @@ class Mandate(PowerOnModel): return False return v -registerModelLabels( - "Mandate", - {"en": "Mandate", "de": "Mandant", "fr": "Mandat"}, - { - "id": {"en": "ID", "de": "ID", "fr": "ID"}, - "name": {"en": "Name", "de": "Name", "fr": "Nom"}, - "label": {"en": "Label", "de": "Label", "fr": "Libellé"}, - "enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"}, - "isSystem": {"en": "System Mandate", "de": "System-Mandant", "fr": "Mandat système"}, - "deletedAt": {"en": "Deleted at", "de": "Gelöscht am", "fr": "Supprimé le"}, - }, -) - - +@i18nModel("Benutzerverbindung") class UserConnection(PowerOnModel): - id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the connection", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) - userId: str = Field(description="ID of the user this connection belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) - authority: AuthAuthority = Field(description="Authentication authority", json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": "/api/connections/authorities/options"}) - externalId: str = Field(description="User ID in the external system", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) - externalUsername: str = Field(description="Username in the external system", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}) - externalEmail: Optional[EmailStr] = Field(None, description="Email in the external system", json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": False}) - status: ConnectionStatus = Field(default=ConnectionStatus.ACTIVE, description="Connection status", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": "/api/connections/statuses/options"}) - connectedAt: float = Field(default_factory=getUtcTimestamp, description="When the connection was established (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}) - lastChecked: float = Field(default_factory=getUtcTimestamp, description="When the connection was last verified (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}) - expiresAt: Optional[float] = Field(None, description="When the connection expires (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}) - tokenStatus: Optional[str] = Field(None, description="Current token status: active, expired, none", json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": [ - {"value": "active", "label": {"en": "Active", "fr": "Actif"}}, - {"value": "expired", "label": {"en": "Expired", "fr": "Expiré"}}, - {"value": "none", "label": {"en": "None", "fr": "Aucun"}}, - ]}) - tokenExpiresAt: Optional[float] = Field(None, description="When the current token expires (UTC timestamp in seconds)", json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False}) - grantedScopes: Optional[List[str]] = Field(None, description="OAuth scopes granted for this connection", json_schema_extra={"frontend_type": "list", "frontend_readonly": True, "frontend_required": False}) + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Unique ID of the connection", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"}, + ) + userId: str = Field( + description="ID of the user this connection belongs to", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Benutzer-ID"}, + ) + authority: AuthAuthority = Field( + description="Authentication authority", + json_schema_extra={ + "frontend_type": "select", + "frontend_readonly": True, + "frontend_required": False, + "frontend_options": "/api/connections/authorities/options", + "label": "Autorität", + }, + ) + externalId: str = Field( + description="User ID in the external system", + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Externe ID"}, + ) + externalUsername: str = Field( + description="Username in the external system", + json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False, "label": "Externer Benutzername"}, + ) + externalEmail: Optional[EmailStr] = Field( + None, + description="Email in the external system", + json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": False, "label": "Externe E-Mail"}, + ) + status: ConnectionStatus = Field( + default=ConnectionStatus.ACTIVE, + description="Connection status", + json_schema_extra={ + "frontend_type": "select", + "frontend_readonly": False, + "frontend_required": False, + "frontend_options": "/api/connections/statuses/options", + "label": "Status", + }, + ) + connectedAt: float = Field( + default_factory=getUtcTimestamp, + description="When the connection was established (UTC timestamp in seconds)", + json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Verbunden am"}, + ) + lastChecked: float = Field( + default_factory=getUtcTimestamp, + description="When the connection was last verified (UTC timestamp in seconds)", + json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Zuletzt geprüft"}, + ) + expiresAt: Optional[float] = Field( + None, + description="When the connection expires (UTC timestamp in seconds)", + json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Läuft ab am"}, + ) + tokenStatus: Optional[str] = Field( + None, + description="Current token status: active, expired, none", + json_schema_extra={ + "frontend_type": "select", + "frontend_readonly": True, + "frontend_required": False, + "frontend_options": [ + {"value": "active", "label": {"en": "Active", "fr": "Actif"}}, + {"value": "expired", "label": {"en": "Expired", "fr": "Expiré"}}, + {"value": "none", "label": {"en": "None", "fr": "Aucun"}}, + ], + "label": "Verbindungsstatus", + }, + ) + tokenExpiresAt: Optional[float] = Field( + None, + description="When the current token expires (UTC timestamp in seconds)", + json_schema_extra={"frontend_type": "timestamp", "frontend_readonly": True, "frontend_required": False, "label": "Token läuft ab am"}, + ) + grantedScopes: Optional[List[str]] = Field( + None, + description="OAuth scopes granted for this connection", + json_schema_extra={"frontend_type": "list", "frontend_readonly": True, "frontend_required": False, "label": "Gewährte Berechtigungen"}, + ) @computed_field @computed_field @@ -157,29 +211,7 @@ class UserConnection(PowerOnModel): return f"{authorityLabels.get(self.authority.value, self.authority.value)}: {self.externalUsername}" -registerModelLabels( - "UserConnection", - {"en": "User Connection", "de": "Benutzerverbindung", "fr": "Connexion utilisateur"}, - { - "id": {"en": "ID", "de": "ID", "fr": "ID"}, - "userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"}, - "authority": {"en": "Authority", "de": "Autorität", "fr": "Autorité"}, - "externalId": {"en": "External ID", "de": "Externe ID", "fr": "ID externe"}, - "externalUsername": {"en": "External Username", "de": "Externer Benutzername", "fr": "Nom d'utilisateur externe"}, - "externalEmail": {"en": "External Email", "de": "Externe E-Mail", "fr": "Email externe"}, - "status": {"en": "Status", "de": "Status", "fr": "Statut"}, - "connectedAt": {"en": "Connected At", "de": "Verbunden am", "fr": "Connecté le"}, - "lastChecked": {"en": "Last Checked", "de": "Zuletzt geprüft", "fr": "Dernière vérification"}, - "expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"}, - "tokenStatus": {"en": "Connection Status", "de": "Verbindungsstatus", "fr": "Statut de connexion"}, - "tokenExpiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"}, - "grantedScopes": {"en": "Granted Scopes", "de": "Gewährte Berechtigungen", "fr": "Autorisations accordées"}, - "connectionReference": {"en": "Connection Reference", "de": "Verbindungsreferenz", "fr": "Référence de connexion"}, - "displayLabel": {"en": "Display Label", "de": "Anzeigebezeichnung", "fr": "Libellé d'affichage"}, - }, -) - - +@i18nModel("Benutzer") class User(PowerOnModel): """ User model. @@ -193,31 +225,37 @@ class User(PowerOnModel): id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the user", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False} + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False, "label": "ID"}, ) username: str = Field( description="Username for login (immutable after creation)", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True} + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Benutzername"}, ) email: Optional[EmailStr] = Field( default=None, description="Email address of the user", - json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": True} + json_schema_extra={"frontend_type": "email", "frontend_readonly": False, "frontend_required": True, "label": "E-Mail"}, ) fullName: Optional[str] = Field( default=None, description="Full name of the user", - json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False} + json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False, "label": "Vollständiger Name"}, ) language: str = Field( default="de", description="Preferred language of the user (ISO 639-1 code: de, en, fr, it)", - json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": True, "frontend_options": [ - {"value": "de", "label": {"en": "Deutsch", "de": "Deutsch", "fr": "Allemand"}}, - {"value": "en", "label": {"en": "English", "de": "Englisch", "fr": "Anglais"}}, - {"value": "fr", "label": {"en": "Français", "de": "Französisch", "fr": "Français"}}, - {"value": "it", "label": {"en": "Italiano", "de": "Italienisch", "fr": "Italien"}}, - ]} + json_schema_extra={ + "frontend_type": "select", + "frontend_readonly": False, + "frontend_required": True, + "frontend_options": [ + {"value": "de", "label": {"en": "Deutsch", "de": "Deutsch", "fr": "Allemand"}}, + {"value": "en", "label": {"en": "English", "de": "Englisch", "fr": "Anglais"}}, + {"value": "fr", "label": {"en": "Français", "de": "Französisch", "fr": "Français"}}, + {"value": "it", "label": {"en": "Italiano", "de": "Italienisch", "fr": "Italien"}}, + ], + "label": "Sprache", + }, ) @field_validator('language', mode='before') @@ -245,13 +283,13 @@ class User(PowerOnModel): enabled: bool = Field( default=True, description="Indicates whether the user is enabled", - json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False} + json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False, "label": "Aktiviert"}, ) isSysAdmin: bool = Field( default=False, description="Global SysAdmin flag. SysAdmin = System-Zugriff, KEIN Daten-Zugriff!", - json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False} + json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False, "label": "System-Admin"}, ) @field_validator('isSysAdmin', mode='before') @@ -265,48 +303,45 @@ class User(PowerOnModel): authenticationAuthority: AuthAuthority = Field( default=AuthAuthority.LOCAL, description="Primary authentication authority", - json_schema_extra={"frontend_type": "select", "frontend_readonly": True, "frontend_required": False, "frontend_options": "/api/connections/authorities/options"} + json_schema_extra={ + "frontend_type": "select", + "frontend_readonly": True, + "frontend_required": False, + "frontend_options": "/api/connections/authorities/options", + "label": "Authentifizierung", + }, ) roleLabels: List[str] = Field( default_factory=list, description="Role labels (from DB or enriched when loading users)", - json_schema_extra={"frontend_type": "multiselect", "frontend_readonly": True, "frontend_visible": False, "frontend_required": False}, + json_schema_extra={ + "frontend_type": "multiselect", + "frontend_readonly": True, + "frontend_visible": False, + "frontend_required": False, + "label": "Rollen-Labels", + }, ) -registerModelLabels( - "User", - {"en": "User", "de": "Benutzer", "fr": "Utilisateur"}, - { - "id": {"en": "ID", "de": "ID", "fr": "ID"}, - "username": {"en": "Username", "de": "Benutzername", "fr": "Nom d'utilisateur"}, - "email": {"en": "Email", "de": "E-Mail", "fr": "Email"}, - "fullName": {"en": "Full Name", "de": "Vollständiger Name", "fr": "Nom complet"}, - "language": {"en": "Language", "de": "Sprache", "fr": "Langue"}, - "enabled": {"en": "Enabled", "de": "Aktiviert", "fr": "Activé"}, - "isSysAdmin": {"en": "System Admin", "de": "System-Admin", "fr": "Admin système"}, - "authenticationAuthority": {"en": "Auth Authority", "de": "Authentifizierung", "fr": "Autorité d'authentification"}, - "roleLabels": {"en": "Role Labels", "de": "Rollen-Labels", "fr": "Libellés de rôles"}, - }, -) - - +@i18nModel("Benutzerzugang") class UserInDB(User): """User model with password hash for database storage.""" - hashedPassword: Optional[str] = Field(None, description="Hash of the user password") - resetToken: Optional[str] = Field(None, description="Password reset token (UUID)") - resetTokenExpires: Optional[float] = Field(None, description="Reset token expiration (UTC timestamp in seconds)") - - -registerModelLabels( - "UserInDB", - {"en": "User Access", "de": "Benutzerzugang", "fr": "Accès de l'utilisateur"}, - { - "hashedPassword": {"en": "Password hash", "de": "Passwort-Hash", "fr": "Hachage de mot de passe"}, - "resetToken": {"en": "Reset Token", "de": "Reset-Token", "fr": "Jeton de réinitialisation"}, - "resetTokenExpires": {"en": "Reset Token Expires", "de": "Token läuft ab", "fr": "Expiration du jeton"}, - }, -) + hashedPassword: Optional[str] = Field( + None, + description="Hash of the user password", + json_schema_extra={"label": "Passwort-Hash"}, + ) + resetToken: Optional[str] = Field( + None, + description="Password reset token (UUID)", + json_schema_extra={"label": "Reset-Token"}, + ) + resetTokenExpires: Optional[float] = Field( + None, + description="Reset token expiration (UTC timestamp in seconds)", + json_schema_extra={"label": "Token läuft ab"}, + ) def _normalizeTtsVoiceMap(value: Any) -> Optional[Dict[str, str]]: @@ -336,17 +371,50 @@ def _normalizeTtsVoiceMap(value: Any) -> Optional[Dict[str, str]]: return out if out else None +@i18nModel("Spracheinstellungen") class UserVoicePreferences(PowerOnModel): """User-level voice/language preferences, shared across all features.""" - id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key") - userId: str = Field(description="User ID") - mandateId: Optional[str] = Field(default=None, description="Mandate scope (None = global for user)") - sttLanguage: str = Field(default="de-DE", description="Speech-to-text language code") - ttsLanguage: str = Field(default="de-DE", description="Text-to-speech language code") - ttsVoice: Optional[str] = Field(default=None, description="Preferred TTS voice identifier") - ttsVoiceMap: Optional[Dict[str, str]] = Field(default=None, description="Language-to-voice mapping") - translationSourceLanguage: Optional[str] = Field(default=None, description="Source language for translations") - translationTargetLanguage: Optional[str] = Field(default=None, description="Target language for translations") + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + json_schema_extra={"label": "ID"}, + ) + userId: str = Field(description="User ID", json_schema_extra={"label": "Benutzer-ID"}) + mandateId: Optional[str] = Field( + default=None, + description="Mandate scope (None = global for user)", + json_schema_extra={"label": "Mandanten-ID"}, + ) + sttLanguage: str = Field( + default="de-DE", + description="Speech-to-text language code", + json_schema_extra={"label": "STT-Sprache"}, + ) + ttsLanguage: str = Field( + default="de-DE", + description="Text-to-speech language code", + json_schema_extra={"label": "TTS-Sprache"}, + ) + ttsVoice: Optional[str] = Field( + default=None, + description="Preferred TTS voice identifier", + json_schema_extra={"label": "TTS-Stimme"}, + ) + ttsVoiceMap: Optional[Dict[str, str]] = Field( + default=None, + description="Language-to-voice mapping", + json_schema_extra={"label": "Stimmen-Zuordnung"}, + ) + translationSourceLanguage: Optional[str] = Field( + default=None, + description="Source language for translations", + json_schema_extra={"label": "Übersetzung Quelle"}, + ) + translationTargetLanguage: Optional[str] = Field( + default=None, + description="Target language for translations", + json_schema_extra={"label": "Übersetzung Ziel"}, + ) @field_validator("ttsVoiceMap", mode="before") @classmethod @@ -354,18 +422,3 @@ class UserVoicePreferences(PowerOnModel): return _normalizeTtsVoiceMap(value) -registerModelLabels( - "UserVoicePreferences", - {"en": "Voice Preferences", "de": "Spracheinstellungen", "fr": "Préférences vocales"}, - { - "id": {"en": "ID", "de": "ID", "fr": "ID"}, - "userId": {"en": "User ID", "de": "Benutzer-ID", "fr": "ID utilisateur"}, - "mandateId": {"en": "Mandate ID", "de": "Mandanten-ID", "fr": "ID du mandat"}, - "sttLanguage": {"en": "STT Language", "de": "STT-Sprache", "fr": "Langue STT"}, - "ttsLanguage": {"en": "TTS Language", "de": "TTS-Sprache", "fr": "Langue TTS"}, - "ttsVoice": {"en": "TTS Voice", "de": "TTS-Stimme", "fr": "Voix TTS"}, - "ttsVoiceMap": {"en": "Voice Map", "de": "Stimmen-Zuordnung", "fr": "Carte des voix"}, - "translationSourceLanguage": {"en": "Translation Source", "de": "Übersetzung Quelle", "fr": "Langue source"}, - "translationTargetLanguage": {"en": "Translation Target", "de": "Übersetzung Ziel", "fr": "Langue cible"}, - }, -) diff --git a/modules/datamodels/datamodelUiLanguage.py b/modules/datamodels/datamodelUiLanguage.py index 3fec1878..8154f735 100644 --- a/modules/datamodels/datamodelUiLanguage.py +++ b/modules/datamodels/datamodelUiLanguage.py @@ -7,7 +7,7 @@ from typing import List, Literal from pydantic import BaseModel, Field from modules.datamodels.datamodelBase import PowerOnModel -from modules.shared.attributeUtils import registerModelLabels +from modules.shared.i18nRegistry import i18nModel UiLanguageStatus = Literal["complete", "incomplete", "generating"] @@ -20,7 +20,7 @@ class I18nEntry(BaseModel): "db.management.files.name" for backend data objects. key: German plaintext (the canonical identifier across all sets). value: For xx (base set): UI context description for AI translation. - For language sets (de, en, …): the translated text. + For language sets (de, en, ...): the translated text. """ context: str = Field( @@ -37,17 +37,15 @@ class I18nEntry(BaseModel): ) +@i18nModel("UI-Sprachset") class UiLanguageSet(PowerOnModel): - """One row per language. id = ISO 639-1 code or 'xx' (base set). - - The xx set is the master: key = German plaintext, value = UI context for AI. - All other sets (incl. de) are AI-generated translations. - """ + """Ein Sprachset pro Sprache. id = ISO 639-1 Code oder 'xx' (Basisset). Enthaelt alle Uebersetzungen.""" id: str = Field( ..., description="ISO 639-1 language code or 'xx' for the base set", json_schema_extra={ + "label": "Code", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True, @@ -57,6 +55,7 @@ class UiLanguageSet(PowerOnModel): ..., description="Human-readable language name", json_schema_extra={ + "label": "Bezeichnung", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True, @@ -66,6 +65,7 @@ class UiLanguageSet(PowerOnModel): default_factory=list, description="Translation entries: list of {context, key, value}", json_schema_extra={ + "label": "Eintraege", "frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False, @@ -75,6 +75,7 @@ class UiLanguageSet(PowerOnModel): default="complete", description="complete | incomplete | generating", json_schema_extra={ + "label": "Status", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, @@ -89,21 +90,9 @@ class UiLanguageSet(PowerOnModel): default=False, description="True only for the xx base set", json_schema_extra={ + "label": "Standard", "frontend_type": "boolean", "frontend_readonly": False, "frontend_required": False, }, ) - - -registerModelLabels( - "UiLanguageSet", - {"en": "UI Language Set", "de": "UI-Sprachset"}, - { - "id": {"en": "Code", "de": "Code"}, - "label": {"en": "Label", "de": "Bezeichnung"}, - "entries": {"en": "Entries", "de": "Einträge"}, - "status": {"en": "Status", "de": "Status"}, - "isDefault": {"en": "Default", "de": "Standard"}, - }, -) diff --git a/modules/datamodels/datamodelUtils.py b/modules/datamodels/datamodelUtils.py index 1088cb31..d187687e 100644 --- a/modules/datamodels/datamodelUtils.py +++ b/modules/datamodels/datamodelUtils.py @@ -2,20 +2,40 @@ # All rights reserved. """Utility datamodels: Prompt, TextMultilingual.""" -from typing import Dict, Optional +from typing import Any, Dict, Optional from pydantic import BaseModel, Field, field_validator from modules.datamodels.datamodelBase import PowerOnModel -from modules.shared.attributeUtils import registerModelLabels +from modules.shared.i18nRegistry import i18nModel import uuid +@i18nModel("Prompt") class Prompt(PowerOnModel): - id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) - mandateId: str = Field(default="", description="ID of the mandate this prompt belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) - isSystem: bool = Field(default=False, description="System prompt visible to all users (read-only for non-SysAdmin)", json_schema_extra={"frontend_type": "boolean", "frontend_readonly": True, "frontend_required": False}) - content: str = Field(description="Content of the prompt", json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": True}) - name: str = Field(description="Name of the prompt", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": True}) - + """Benutzer- oder System-Prompt fuer die KI.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + ) + mandateId: str = Field( + default="", + description="ID of the mandate this prompt belongs to", + json_schema_extra={"label": "Mandanten-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + ) + isSystem: bool = Field( + default=False, + description="System prompt visible to all users (read-only for non-SysAdmin)", + json_schema_extra={"label": "System", "frontend_type": "boolean", "frontend_readonly": True, "frontend_required": False}, + ) + content: str = Field( + description="Content of the prompt", + json_schema_extra={"label": "Inhalt", "frontend_type": "textarea", "frontend_readonly": False, "frontend_required": True}, + ) + name: str = Field( + description="Name of the prompt", + json_schema_extra={"label": "Name", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True}, + ) + @field_validator('isSystem', mode='before') @classmethod def _coerceIsSystem(cls, v): @@ -23,62 +43,64 @@ class Prompt(PowerOnModel): if v is None: return False return v -registerModelLabels( - "Prompt", - {"en": "Prompt", "fr": "Invite"}, - { - "id": {"en": "ID", "fr": "ID"}, - "mandateId": {"en": "Mandate ID", "fr": "ID du mandat"}, - "isSystem": {"en": "System", "fr": "Système"}, - "content": {"en": "Content", "fr": "Contenu"}, - "name": {"en": "Name", "fr": "Nom"}, - }, -) class TextMultilingual(BaseModel): - """ - Multilingual text field supporting multiple languages. - Default languages: en (English), ge (German), fr (French), it (Italian) - English (en) is the default/required language. - """ + """Multilingual text field. Language codes follow ISO 639-1 (en, de, fr, it, …).""" en: str = Field(description="English text (default language, required)") - ge: Optional[str] = Field(None, description="German text") + de: Optional[str] = Field(None, description="German text") fr: Optional[str] = Field(None, description="French text") it: Optional[str] = Field(None, description="Italian text") - + @field_validator('en') @classmethod - def validate_en_required(cls, v): - """Ensure English text is not empty""" + def _validateEnRequired(cls, v): if not v or not v.strip(): raise ValueError("English text (en) is required and cannot be empty") return v - + def model_dump(self, **kwargs) -> Dict[str, str]: - """Return as dictionary, filtering out None values""" result = {} - for lang in ['en', 'ge', 'fr', 'it']: - value = getattr(self, lang, None) + for key in self.model_fields: + value = getattr(self, key, None) if value is not None: - result[lang] = value + result[key] = value return result - + @classmethod def from_dict(cls, data: Dict[str, str]) -> 'TextMultilingual': - """Create TextMultilingual from dictionary""" - return cls( - en=data.get('en', ''), - ge=data.get('ge'), - fr=data.get('fr'), - it=data.get('it') - ) - + fields = {k: data[k] for k in cls.model_fields if k in data} + fields.setdefault('en', '') + return cls(**fields) + def get_text(self, lang: str = 'en') -> str: - """Get text for a specific language, fallback to English if not available""" + """Get text for *lang*. Falls back to English.""" value = getattr(self, lang, None) if value: return value - return self.en # Fallback to English + return self.en + + @classmethod + def fromUniform(cls, text: str) -> "TextMultilingual": + """Same string in all languages (bootstrap / i18n key until per-language values exist in DB).""" + t = text.strip() + if not t: + raise ValueError("Text must be non-empty") + return cls(en=t, de=t, fr=t, it=t) +def coerce_text_multilingual(val: Any) -> TextMultilingual: + """Normalize str, dict, or TextMultilingual for Role.description and similar fields.""" + if isinstance(val, TextMultilingual): + return val + if isinstance(val, dict): + if not val: + return TextMultilingual.fromUniform("—") + d = {k: val[k] for k in TextMultilingual.model_fields if k in val and val[k] is not None} + if not d.get("en"): + d["en"] = (d.get("de") or d.get("fr") or "—").strip() or "—" + return TextMultilingual(**{k: d[k] for k in TextMultilingual.model_fields if k in d}) + if isinstance(val, str) and val.strip(): + return TextMultilingual.fromUniform(val) + return TextMultilingual.fromUniform("—") + diff --git a/modules/datamodels/datamodelWorkflow.py b/modules/datamodels/datamodelWorkflow.py index 1a1e49e8..490d9fb0 100644 --- a/modules/datamodels/datamodelWorkflow.py +++ b/modules/datamodels/datamodelWorkflow.py @@ -6,45 +6,52 @@ Workflow execution models for action definitions, AI responses, and workflow-lev from typing import Dict, Any, List, Optional, TYPE_CHECKING from pydantic import BaseModel, Field -from modules.shared.attributeUtils import registerModelLabels +from modules.shared.i18nRegistry import i18nModel from modules.shared.jsonUtils import extractJsonString, tryParseJson, repairBrokenJson # Import DocumentReferenceList at runtime (needed for ActionDefinition) from modules.datamodels.datamodelDocref import DocumentReferenceList +@i18nModel("Aktionsdefinition") class ActionDefinition(BaseModel): """Action definition with selection and parameters from planning phase""" # Core action selection (Stage 1) - action: str = Field(description="Compound action name (method.action)") - actionObjective: str = Field(description="Objective for this action") + action: str = Field(description="Compound action name (method.action)", json_schema_extra={"label": "Aktion"}) + actionObjective: str = Field(description="Objective for this action", json_schema_extra={"label": "Aktionsziel"}) userMessage: Optional[str] = Field( None, - description="User-friendly message in user's language explaining what this action will do (generated by AI in prompts)" + description="User-friendly message in user's language explaining what this action will do (generated by AI in prompts)", + json_schema_extra={"label": "Benutzernachricht"}, ) parametersContext: Optional[str] = Field( None, - description="Context for parameter generation" + description="Context for parameter generation", + json_schema_extra={"label": "Parameter-Kontext"}, ) learnings: List[str] = Field( default_factory=list, - description="Learnings from previous actions" + description="Learnings from previous actions", + json_schema_extra={"label": "Erkenntnisse"}, ) # Resources (ALWAYS defined in Stage 1 if action needs them) documentList: Optional[DocumentReferenceList] = Field( None, - description="Document references (ALWAYS defined in Stage 1 if action needs documents)" + description="Document references (ALWAYS defined in Stage 1 if action needs documents)", + json_schema_extra={"label": "Dokumentenliste"}, ) connectionReference: Optional[str] = Field( None, - description="Connection reference (ALWAYS defined in Stage 1 if action needs connection)" + description="Connection reference (ALWAYS defined in Stage 1 if action needs connection)", + json_schema_extra={"label": "Verbindungsreferenz"}, ) # Parameters (may be defined in Stage 1 OR Stage 2, depending on action and actionObjective) parameters: Optional[Dict[str, Any]] = Field( None, - description="Action-specific parameters (generated in Stage 2 for complex actions, or inferred from actionObjective for simple actions)" + description="Action-specific parameters (generated in Stage 2 for complex actions, or inferred from actionObjective for simple actions)", + json_schema_extra={"label": "Parameter"}, ) def hasParameters(self) -> bool: @@ -75,34 +82,47 @@ class ActionDefinition(BaseModel): self.connectionReference = connectionRef +@i18nModel("KI-Antwort-Metadaten") class AiResponseMetadata(BaseModel): """Metadata for AI response (varies by operation type).""" # Document Generation Metadata - title: Optional[str] = Field(None, description="Document title") - filename: Optional[str] = Field(None, description="Document filename") + title: Optional[str] = Field(None, description="Document title", json_schema_extra={"label": "Titel"}) + filename: Optional[str] = Field(None, description="Document filename", json_schema_extra={"label": "Dateiname"}) # Operation-Specific Metadata - operationType: Optional[str] = Field(None, description="Type of operation performed") - schemaVersion: Optional[str] = Field(None, description="Schema version (e.g., 'parameters_v1')", alias="schema") - extractionMethod: Optional[str] = Field(None, description="Method used for extraction") - sourceDocuments: Optional[List[str]] = Field(None, description="Source document references") + operationType: Optional[str] = Field(None, description="Type of operation performed", json_schema_extra={"label": "Vorgangstyp"}) + schemaVersion: Optional[str] = Field( + None, + description="Schema version (e.g., 'parameters_v1')", + alias="schema", + json_schema_extra={"label": "Schema-Version"}, + ) + extractionMethod: Optional[str] = Field(None, description="Method used for extraction", json_schema_extra={"label": "Extraktionsmethode"}) + sourceDocuments: Optional[List[str]] = Field(None, description="Source document references", json_schema_extra={"label": "Quelldokumente"}) # Additional metadata (for extensibility) - additionalData: Optional[Dict[str, Any]] = Field(None, description="Additional operation-specific metadata") - - -class DocumentData(BaseModel): - """Single document in response""" - documentName: str = Field(description="Document name") - documentData: Any = Field(description="Document data (can be str, bytes, dict, etc.)") - mimeType: str = Field(description="MIME type of the document") - sourceJson: Optional[Dict[str, Any]] = Field( + additionalData: Optional[Dict[str, Any]] = Field( None, - description="Source JSON structure (preserved when rendering to xlsx/docx/pdf)" + description="Additional operation-specific metadata", + json_schema_extra={"label": "Zusätzliche Daten"}, ) +@i18nModel("Dokumentdaten") +class DocumentData(BaseModel): + """Single document in response""" + documentName: str = Field(description="Document name", json_schema_extra={"label": "Dokumentname"}) + documentData: Any = Field(description="Document data (can be str, bytes, dict, etc.)", json_schema_extra={"label": "Dokumentdaten"}) + mimeType: str = Field(description="MIME type of the document", json_schema_extra={"label": "MIME-Typ"}) + sourceJson: Optional[Dict[str, Any]] = Field( + None, + description="Source JSON structure (preserved when rendering to xlsx/docx/pdf)", + json_schema_extra={"label": "Quell-JSON"}, + ) + + +@i18nModel("Extraktionsparameter") class ExtractContentParameters(BaseModel): """Parameters for extraction action. @@ -110,24 +130,34 @@ class ExtractContentParameters(BaseModel): All action parameter models follow this pattern: defined in the same module as the action. However, since this is a workflow-level model used across the system, it's defined here. """ - documentList: DocumentReferenceList = Field(description="Document references to extract content from") + documentList: DocumentReferenceList = Field( + description="Document references to extract content from", + json_schema_extra={"label": "Dokumentenliste"}, + ) extractionOptions: Optional[Any] = Field( # ExtractionOptions - forward reference None, - description="Extraction options (determined dynamically based on task and document characteristics)" + description="Extraction options (determined dynamically based on task and document characteristics)", + json_schema_extra={"label": "Extraktionsoptionen"}, ) +@i18nModel("KI-Antwort") class AiResponse(BaseModel): """Unified response from all AI calls (planning, text, documents)""" - content: str = Field(description="Response content (JSON string for planning, text for analysis, unified JSON for documents)") + content: str = Field( + description="Response content (JSON string for planning, text for analysis, unified JSON for documents)", + json_schema_extra={"label": "Inhalt"}, + ) metadata: Optional[AiResponseMetadata] = Field( None, - description="Response metadata (varies by operation type)" + description="Response metadata (varies by operation type)", + json_schema_extra={"label": "Metadaten"}, ) documents: Optional[List[DocumentData]] = Field( None, - description="Generated documents (only for document generation operations)" + description="Generated documents (only for document generation operations)", + json_schema_extra={"label": "Dokumente"}, ) def toJson(self) -> Dict[str, Any]: @@ -186,278 +216,88 @@ class AiResponse(BaseModel): # Workflow-level models +@i18nModel("Anfragekontext") class RequestContext(BaseModel): """Normalized request context from user input""" - originalPrompt: str = Field(description="Original user prompt") + originalPrompt: str = Field(description="Original user prompt", json_schema_extra={"label": "Ursprüngliche Eingabe"}) documents: List[Any] = Field( # ChatDocument - forward reference default_factory=list, - description="Documents provided by user" + description="Documents provided by user", + json_schema_extra={"label": "Dokumente"}, ) - userLanguage: str = Field(description="User's language") + userLanguage: str = Field(description="User's language", json_schema_extra={"label": "Benutzersprache"}) detectedComplexity: str = Field( - description="Complexity level: simple, moderate, complex" + description="Complexity level: simple, moderate, complex", + json_schema_extra={"label": "Erkannte Komplexität"}, ) - requiresDocuments: bool = Field(default=False, description="Whether request requires documents") - requiresWebResearch: bool = Field(default=False, description="Whether request requires web research") - requiresAnalysis: bool = Field(default=False, description="Whether request requires analysis") - expectedOutputFormat: Optional[str] = Field(None, description="Expected output format") - expectedOutputType: Optional[str] = Field(None, description="Expected output type: answer, document, analysis") + requiresDocuments: bool = Field(default=False, description="Whether request requires documents", json_schema_extra={"label": "Benötigt Dokumente"}) + requiresWebResearch: bool = Field(default=False, description="Whether request requires web research", json_schema_extra={"label": "Benötigt Web-Recherche"}) + requiresAnalysis: bool = Field(default=False, description="Whether request requires analysis", json_schema_extra={"label": "Benötigt Analyse"}) + expectedOutputFormat: Optional[str] = Field(None, description="Expected output format", json_schema_extra={"label": "Erwartetes Ausgabeformat"}) + expectedOutputType: Optional[str] = Field(None, description="Expected output type: answer, document, analysis", json_schema_extra={"label": "Erwarteter Ausgabetyp"}) +@i18nModel("Verständnis-Ergebnis") class UnderstandingResult(BaseModel): """Result from initial understanding phase (combined AI call)""" parameters: Dict[str, Any] = Field( default_factory=dict, - description="Basic parameters (language, format, detail level)" + description="Basic parameters (language, format, detail level)", + json_schema_extra={"label": "Parameter"}, ) intention: Dict[str, Any] = Field( default_factory=dict, - description="User intention (primaryGoal, secondaryGoals, intentionType)" + description="User intention (primaryGoal, secondaryGoals, intentionType)", + json_schema_extra={"label": "Absicht"}, ) context: Dict[str, Any] = Field( default_factory=dict, - description="Extracted context (topics, requirements, constraints)" + description="Extracted context (topics, requirements, constraints)", + json_schema_extra={"label": "Kontext"}, ) documentReferences: List[Dict[str, Any]] = Field( default_factory=list, - description="Document references with purpose and relevance" + description="Document references with purpose and relevance", + json_schema_extra={"label": "Dokumentenreferenzen"}, ) tasks: List["TaskDefinition"] = Field( # Forward reference default_factory=list, - description="Task definitions with deliverables" + description="Task definitions with deliverables", + json_schema_extra={"label": "Aufgaben"}, ) +@i18nModel("Aufgabenbeschreibung") class TaskDefinition(BaseModel): """Task definition from understanding phase""" - id: str = Field(description="Task identifier") - objective: str = Field(description="Task objective") + id: str = Field(description="Task identifier", json_schema_extra={"label": "Aufgaben-ID"}) + objective: str = Field(description="Task objective", json_schema_extra={"label": "Ziel"}) deliverable: Dict[str, Any] = Field( - description="Deliverable specification (type, format, style, detailLevel)" + description="Deliverable specification (type, format, style, detailLevel)", + json_schema_extra={"label": "Lieferobjekt"}, ) - requiresWebResearch: bool = Field(default=False, description="Whether task requires web research") - requiresDocumentAnalysis: bool = Field(default=False, description="Whether task requires document analysis") - requiresContentGeneration: bool = Field(default=True, description="Whether task requires content generation") + requiresWebResearch: bool = Field(default=False, description="Whether task requires web research", json_schema_extra={"label": "Benötigt Web-Recherche"}) + requiresDocumentAnalysis: bool = Field(default=False, description="Whether task requires document analysis", json_schema_extra={"label": "Benötigt Dokumentenanalyse"}) + requiresContentGeneration: bool = Field(default=True, description="Whether task requires content generation", json_schema_extra={"label": "Benötigt Inhaltserstellung"}) requiredDocuments: List[str] = Field( default_factory=list, - description="Document references needed for this task" + description="Document references needed for this task", + json_schema_extra={"label": "Benötigte Dokumente"}, ) extractionOptions: Optional[Any] = Field( # ExtractionOptions - forward reference None, - description="Extraction options for document processing (determined dynamically based on task and document characteristics)" + description="Extraction options for document processing (determined dynamically based on task and document characteristics)", + json_schema_extra={"label": "Extraktionsoptionen"}, ) -class TaskResult(BaseModel): +@i18nModel("Workflow-Aufgabenergebnis") +class WorkflowTaskResult(BaseModel): """Result from task execution""" - taskId: str = Field(description="Task identifier") - actionResult: Any = Field(description="ActionResult from task execution") # ActionResult - forward reference - - -# Register model labels for UI -registerModelLabels( - "RequestContext", - {"en": "Request Context", "fr": "Contexte de la demande"}, - { - "originalPrompt": {"en": "Original Prompt", "fr": "Invite originale"}, - "documents": {"en": "Documents", "fr": "Documents"}, - "userLanguage": {"en": "User Language", "fr": "Langue de l'utilisateur"}, - "detectedComplexity": {"en": "Detected Complexity", "fr": "Complexité détectée"}, - "requiresDocuments": {"en": "Requires Documents", "fr": "Nécessite des documents"}, - "requiresWebResearch": {"en": "Requires Web Research", "fr": "Nécessite une recherche web"}, - "requiresAnalysis": {"en": "Requires Analysis", "fr": "Nécessite une analyse"}, - "expectedOutputFormat": {"en": "Expected Output Format", "fr": "Format de sortie attendu"}, - "expectedOutputType": {"en": "Expected Output Type", "fr": "Type de sortie attendu"}, - }, -) - -registerModelLabels( - "UnderstandingResult", - {"en": "Understanding Result", "fr": "Résultat de compréhension"}, - { - "parameters": {"en": "Parameters", "fr": "Paramètres"}, - "intention": {"en": "Intention", "fr": "Intention"}, - "context": {"en": "Context", "fr": "Contexte"}, - "documentReferences": {"en": "Document References", "fr": "Références de documents"}, - "tasks": {"en": "Tasks", "fr": "Tâches"}, - }, -) - -registerModelLabels( - "TaskDefinition", - {"en": "Task Definition", "fr": "Définition de tâche"}, - { - "id": {"en": "Task ID", "fr": "ID de la tâche"}, - "objective": {"en": "Objective", "fr": "Objectif"}, - "deliverable": {"en": "Deliverable", "fr": "Livrable"}, - "requiresWebResearch": {"en": "Requires Web Research", "fr": "Nécessite une recherche web"}, - "requiresDocumentAnalysis": {"en": "Requires Document Analysis", "fr": "Nécessite une analyse de documents"}, - "requiresContentGeneration": {"en": "Requires Content Generation", "fr": "Nécessite une génération de contenu"}, - "requiredDocuments": {"en": "Required Documents", "fr": "Documents requis"}, - "extractionOptions": {"en": "Extraction Options", "fr": "Options d'extraction"}, - }, -) - -registerModelLabels( - "TaskResult", - {"en": "Task Result", "fr": "Résultat de tâche"}, - { - "taskId": {"en": "Task ID", "fr": "ID de la tâche"}, - "actionResult": {"en": "Action Result", "fr": "Résultat de l'action"}, - }, -) - -registerModelLabels( - "RequestContext", - {"en": "Request Context", "fr": "Contexte de la demande"}, - { - "originalPrompt": {"en": "Original Prompt", "fr": "Invite originale"}, - "documents": {"en": "Documents", "fr": "Documents"}, - "userLanguage": {"en": "User Language", "fr": "Langue de l'utilisateur"}, - "detectedComplexity": {"en": "Detected Complexity", "fr": "Complexité détectée"}, - "requiresDocuments": {"en": "Requires Documents", "fr": "Nécessite des documents"}, - "requiresWebResearch": {"en": "Requires Web Research", "fr": "Nécessite une recherche web"}, - "requiresAnalysis": {"en": "Requires Analysis", "fr": "Nécessite une analyse"}, - "expectedOutputFormat": {"en": "Expected Output Format", "fr": "Format de sortie attendu"}, - "expectedOutputType": {"en": "Expected Output Type", "fr": "Type de sortie attendu"}, - }, -) - -registerModelLabels( - "UnderstandingResult", - {"en": "Understanding Result", "fr": "Résultat de compréhension"}, - { - "parameters": {"en": "Parameters", "fr": "Paramètres"}, - "intention": {"en": "Intention", "fr": "Intention"}, - "context": {"en": "Context", "fr": "Contexte"}, - "documentReferences": {"en": "Document References", "fr": "Références de documents"}, - "tasks": {"en": "Tasks", "fr": "Tâches"}, - }, -) - -registerModelLabels( - "TaskDefinition", - {"en": "Task Definition", "fr": "Définition de tâche"}, - { - "id": {"en": "Task ID", "fr": "ID de la tâche"}, - "objective": {"en": "Objective", "fr": "Objectif"}, - "deliverable": {"en": "Deliverable", "fr": "Livrable"}, - "requiresWebResearch": {"en": "Requires Web Research", "fr": "Nécessite une recherche web"}, - "requiresDocumentAnalysis": {"en": "Requires Document Analysis", "fr": "Nécessite une analyse de documents"}, - "requiresContentGeneration": {"en": "Requires Content Generation", "fr": "Nécessite une génération de contenu"}, - "requiredDocuments": {"en": "Required Documents", "fr": "Documents requis"}, - "extractionOptions": {"en": "Extraction Options", "fr": "Options d'extraction"}, - }, -) - -registerModelLabels( - "TaskResult", - {"en": "Task Result", "fr": "Résultat de tâche"}, - { - "taskId": {"en": "Task ID", "fr": "ID de la tâche"}, - "actionResult": {"en": "Action Result", "fr": "Résultat de l'action"}, - }, -) - -# Register model labels for UI -registerModelLabels( - "ActionDefinition", - {"en": "Action Definition", "fr": "Définition d'action"}, - { - "action": {"en": "Action", "fr": "Action"}, - "actionObjective": {"en": "Action Objective", "fr": "Objectif de l'action"}, - "parametersContext": {"en": "Parameters Context", "fr": "Contexte des paramètres"}, - "learnings": {"en": "Learnings", "fr": "Apprentissages"}, - "documentList": {"en": "Document List", "fr": "Liste de documents"}, - "connectionReference": {"en": "Connection Reference", "fr": "Référence de connexion"}, - "parameters": {"en": "Parameters", "fr": "Paramètres"}, - }, -) - -registerModelLabels( - "AiResponse", - {"en": "AI Response", "fr": "Réponse IA"}, - { - "content": {"en": "Content", "fr": "Contenu"}, - "metadata": {"en": "Metadata", "fr": "Métadonnées"}, - "documents": {"en": "Documents", "fr": "Documents"}, - }, -) - -registerModelLabels( - "AiResponseMetadata", - {"en": "AI Response Metadata", "fr": "Métadonnées de réponse IA"}, - { - "title": {"en": "Title", "fr": "Titre"}, - "filename": {"en": "Filename", "fr": "Nom de fichier"}, - "operationType": {"en": "Operation Type", "fr": "Type d'opération"}, - "schemaVersion": {"en": "Schema Version", "fr": "Version du schéma"}, - "extractionMethod": {"en": "Extraction Method", "fr": "Méthode d'extraction"}, - "sourceDocuments": {"en": "Source Documents", "fr": "Documents sources"}, - }, -) - -registerModelLabels( - "DocumentData", - {"en": "Document Data", "fr": "Données de document"}, - { - "documentName": {"en": "Document Name", "fr": "Nom du document"}, - "documentData": {"en": "Document Data", "fr": "Données du document"}, - "mimeType": {"en": "MIME Type", "fr": "Type MIME"}, - }, -) - -registerModelLabels( - "RequestContext", - {"en": "Request Context", "fr": "Contexte de requête"}, - { - "originalPrompt": {"en": "Original Prompt", "fr": "Invite originale"}, - "documents": {"en": "Documents", "fr": "Documents"}, - "userLanguage": {"en": "User Language", "fr": "Langue de l'utilisateur"}, - "detectedComplexity": {"en": "Detected Complexity", "fr": "Complexité détectée"}, - "requiresDocuments": {"en": "Requires Documents", "fr": "Nécessite des documents"}, - "requiresWebResearch": {"en": "Requires Web Research", "fr": "Nécessite une recherche web"}, - "requiresAnalysis": {"en": "Requires Analysis", "fr": "Nécessite une analyse"}, - }, -) - -registerModelLabels( - "UnderstandingResult", - {"en": "Understanding Result", "fr": "Résultat de compréhension"}, - { - "parameters": {"en": "Parameters", "fr": "Paramètres"}, - "intention": {"en": "Intention", "fr": "Intention"}, - "context": {"en": "Context", "fr": "Contexte"}, - "documentReferences": {"en": "Document References", "fr": "Références de documents"}, - "tasks": {"en": "Tasks", "fr": "Tâches"}, - }, -) - -registerModelLabels( - "TaskDefinition", - {"en": "Task Definition", "fr": "Définition de tâche"}, - { - "id": {"en": "ID", "fr": "ID"}, - "objective": {"en": "Objective", "fr": "Objectif"}, - "deliverable": {"en": "Deliverable", "fr": "Livrable"}, - "requiresWebResearch": {"en": "Requires Web Research", "fr": "Nécessite une recherche web"}, - "requiresDocumentAnalysis": {"en": "Requires Document Analysis", "fr": "Nécessite une analyse de document"}, - "requiresContentGeneration": {"en": "Requires Content Generation", "fr": "Nécessite une génération de contenu"}, - "requiredDocuments": {"en": "Required Documents", "fr": "Documents requis"}, - "extractionOptions": {"en": "Extraction Options", "fr": "Options d'extraction"}, - }, -) - -registerModelLabels( - "TaskResult", - {"en": "Task Result", "fr": "Résultat de tâche"}, - { - "taskId": {"en": "Task ID", "fr": "ID de tâche"}, - "actionResult": {"en": "Action Result", "fr": "Résultat d'action"}, - }, -) + taskId: str = Field(description="Task identifier", json_schema_extra={"label": "Aufgaben-ID"}) + actionResult: Any = Field(description="ActionResult from task execution", json_schema_extra={"label": "Aktionsergebnis"}) # ActionResult - forward reference diff --git a/modules/datamodels/datamodelWorkflowActions.py b/modules/datamodels/datamodelWorkflowActions.py index 8bac1fd5..09c07c14 100644 --- a/modules/datamodels/datamodelWorkflowActions.py +++ b/modules/datamodels/datamodelWorkflowActions.py @@ -6,85 +6,97 @@ from typing import Optional, Any, Union, List, Dict, Callable, Awaitable from pydantic import BaseModel, Field from modules.datamodels.datamodelChat import ActionResult from modules.shared.frontendTypes import FrontendType -from modules.shared.attributeUtils import registerModelLabels +from modules.shared.i18nRegistry import i18nModel +@i18nModel("Workflow-Aktionsparameter") class WorkflowActionParameter(BaseModel): """ Parameter schema definition for a workflow action. - + This defines the structure and UI rendering for a single action parameter, NOT the actual parameter values (those are in ActionDefinition.parameters). """ - name: str = Field(description="Parameter name") - type: str = Field(description="Python type as string: 'str', 'int', 'bool', 'List[str]', etc.") - frontendType: FrontendType = Field(description="UI rendering type (from global FrontendType enum)") + name: str = Field( + description="Parameter name", + json_schema_extra={"label": "Name"}, + ) + type: str = Field( + description="Python type as string: 'str', 'int', 'bool', 'List[str]', etc.", + json_schema_extra={"label": "Typ"}, + ) + frontendType: FrontendType = Field( + description="UI rendering type (from global FrontendType enum)", + json_schema_extra={"label": "Frontend-Typ"}, + ) frontendOptions: Optional[Union[str, List[str]]] = Field( None, - description="Options for select/multiselect/custom types. String reference (e.g., 'user.connection') or list of strings (e.g., ['txt', 'json']). For custom types, this is automatically set to the API endpoint." + description="Options for select/multiselect/custom types. String reference (e.g., 'user.connection') or list of strings (e.g., ['txt', 'json']). For custom types, this is automatically set to the API endpoint.", + json_schema_extra={"label": "Frontend-Optionen"}, + ) + required: bool = Field( + False, + description="Whether parameter is required", + json_schema_extra={"label": "Pflichtfeld"}, + ) + default: Optional[Any] = Field( + None, + description="Default value", + json_schema_extra={"label": "Standard"}, + ) + description: str = Field( + "", + description="Parameter description", + json_schema_extra={"label": "Beschreibung"}, ) - required: bool = Field(False, description="Whether parameter is required") - default: Optional[Any] = Field(None, description="Default value") - description: str = Field("", description="Parameter description") validation: Optional[Dict[str, Any]] = Field( None, - description="Validation rules (e.g., {'min': 1, 'max': 100})" + description="Validation rules (e.g., {'min': 1, 'max': 100})", + json_schema_extra={"label": "Validierung"}, ) +@i18nModel("Workflow-Aktionsdefinition") class WorkflowActionDefinition(BaseModel): """ Complete schema definition of a workflow action. - + This defines the metadata, parameters, and execution function for an action. This is different from datamodelWorkflow.ActionDefinition which contains actual execution values (action, actionObjective, parameters with values). - + This class defines the ACTION SCHEMA, not the execution plan. """ actionId: str = Field( - description="Unique action identifier for RBAC (format: 'module.actionName', e.g., 'outlook.readEmails')" + description="Unique action identifier for RBAC (format: 'module.actionName', e.g., 'outlook.readEmails')", + json_schema_extra={"label": "Aktions-ID"}, + ) + description: str = Field( + description="Action description", + json_schema_extra={"label": "Beschreibung"}, ) - description: str = Field(description="Action description") parameters: Dict[str, WorkflowActionParameter] = Field( default_factory=dict, - description="Parameter schema definitions" + description="Parameter schema definitions", + json_schema_extra={"label": "Parameter"}, ) execute: Optional[Callable] = Field( None, - description="Execution function - async function that takes parameters dict and returns ActionResult. Set dynamically." + description="Execution function - async function that takes parameters dict and returns ActionResult. Set dynamically.", + json_schema_extra={"label": "Ausfuehrung"}, + ) + category: Optional[str] = Field( + None, + description="Action category for grouping", + json_schema_extra={"label": "Kategorie"}, + ) + tags: List[str] = Field( + default_factory=list, + description="Tags for search/filtering", + json_schema_extra={"label": "Tags"}, + ) + dynamicMode: bool = Field( + False, + description="Whether this action is available in dynamic workflow mode (only tagged actions are visible in action planning and refinement prompts)", + json_schema_extra={"label": "Dynamischer Modus"}, ) - category: Optional[str] = Field(None, description="Action category for grouping") - tags: List[str] = Field(default_factory=list, description="Tags for search/filtering") - dynamicMode: bool = Field(False, description="Whether this action is available in dynamic workflow mode (only tagged actions are visible in action planning and refinement prompts)") - - -# Register model labels for UI -registerModelLabels( - "WorkflowActionDefinition", - {"en": "Workflow Action Definition", "fr": "Définition d'action de workflow"}, - { - "actionId": {"en": "Action ID", "fr": "ID d'action"}, - "description": {"en": "Description", "fr": "Description"}, - "parameters": {"en": "Parameters", "fr": "Paramètres"}, - "category": {"en": "Category", "fr": "Catégorie"}, - "tags": {"en": "Tags", "fr": "Étiquettes"}, - "dynamicMode": {"en": "Dynamic Mode", "fr": "Mode dynamique"}, - }, -) - -registerModelLabels( - "WorkflowActionParameter", - {"en": "Workflow Action Parameter", "fr": "Paramètre d'action de workflow"}, - { - "name": {"en": "Name", "fr": "Nom"}, - "type": {"en": "Type", "fr": "Type"}, - "frontendType": {"en": "Frontend Type", "fr": "Type frontend"}, - "frontendOptions": {"en": "Frontend Options", "fr": "Options frontend"}, - "required": {"en": "Required", "fr": "Requis"}, - "default": {"en": "Default", "fr": "Par défaut"}, - "description": {"en": "Description", "fr": "Description"}, - "validation": {"en": "Validation", "fr": "Validation"}, - }, -) - diff --git a/modules/features/chatbot/mainChatbot.py b/modules/features/chatbot/mainChatbot.py index 33f8ae2f..79f970c6 100644 --- a/modules/features/chatbot/mainChatbot.py +++ b/modules/features/chatbot/mainChatbot.py @@ -12,14 +12,14 @@ logger = logging.getLogger(__name__) # Feature metadata FEATURE_CODE = "chatbot" -FEATURE_LABEL = {"en": "Chatbot", "de": "Chatbot", "fr": "Chatbot"} +FEATURE_LABEL = "Chatbot" FEATURE_ICON = "mdi-robot" # UI Objects for RBAC catalog UI_OBJECTS = [ { "objectKey": "ui.feature.chatbot.conversations", - "label": {"en": "Conversations", "de": "Konversationen", "fr": "Conversations"}, + "label": "Konversationen", "meta": {"area": "conversations"} } ] @@ -28,22 +28,22 @@ UI_OBJECTS = [ RESOURCE_OBJECTS = [ { "objectKey": "resource.feature.chatbot.startStream", - "label": {"en": "Start Chat (Stream)", "de": "Chat starten (Stream)", "fr": "Démarrer chat (Stream)"}, + "label": "Chat starten (Stream)", "meta": {"endpoint": "/api/chatbot/{instanceId}/start/stream", "method": "POST"} }, { "objectKey": "resource.feature.chatbot.stop", - "label": {"en": "Stop Chat", "de": "Chat stoppen", "fr": "Arrêter chat"}, + "label": "Chat stoppen", "meta": {"endpoint": "/api/chatbot/{instanceId}/stop/{workflowId}", "method": "POST"} }, { "objectKey": "resource.feature.chatbot.threads", - "label": {"en": "Get Threads", "de": "Threads abrufen", "fr": "Récupérer threads"}, + "label": "Threads abrufen", "meta": {"endpoint": "/api/chatbot/{instanceId}/threads", "method": "GET"} }, { "objectKey": "resource.feature.chatbot.delete", - "label": {"en": "Delete Chat", "de": "Chat löschen", "fr": "Supprimer chat"}, + "label": "Chat löschen", "meta": {"endpoint": "/api/chatbot/{instanceId}/{workflowId}", "method": "DELETE"} }, ] @@ -74,11 +74,7 @@ REQUIRED_SERVICES = [ TEMPLATE_ROLES = [ { "roleLabel": "chatbot-viewer", - "description": { - "en": "Chatbot Viewer - View chat threads (read-only)", - "de": "Chatbot Betrachter - Chat-Threads ansehen (nur lesen)", - "fr": "Visualiseur Chatbot - Consulter les threads (lecture seule)" - }, + "description": "Chatbot Betrachter - Chat-Threads ansehen (nur lesen)", "accessRules": [ # UI: only threads view, NO active chat {"context": "UI", "item": "ui.feature.chatbot.threads", "view": True}, @@ -90,11 +86,7 @@ TEMPLATE_ROLES = [ }, { "roleLabel": "chatbot-user", - "description": { - "en": "Chatbot User - Use the chatbot and manage own threads", - "de": "Chatbot Benutzer - Chatbot nutzen und eigene Threads verwalten", - "fr": "Utilisateur Chatbot - Utiliser le chatbot et gérer ses threads" - }, + "description": "Chatbot Benutzer - Chatbot nutzen und eigene Threads verwalten", "accessRules": [ # UI: full access to all views {"context": "UI", "item": "ui.feature.chatbot.conversations", "view": True}, @@ -110,11 +102,7 @@ TEMPLATE_ROLES = [ }, { "roleLabel": "chatbot-admin", - "description": { - "en": "Chatbot Admin - Full access to all chatbot features", - "de": "Chatbot Admin - Vollzugriff auf alle Chatbot-Funktionen", - "fr": "Administrateur Chatbot - Accès complet à toutes les fonctions chatbot" - }, + "description": "Chatbot Admin - Vollzugriff auf alle Chatbot-Funktionen", "accessRules": [ # Full UI access {"context": "UI", "item": None, "view": True}, @@ -391,7 +379,8 @@ def _syncTemplateRolesToDb() -> int: try: from modules.interfaces.interfaceDbApp import getRootInterface from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext - + from modules.datamodels.datamodelUtils import coerce_text_multilingual + rootInterface = getRootInterface() # Get existing template roles for this feature (Pydantic models) @@ -412,7 +401,7 @@ def _syncTemplateRolesToDb() -> int: # Create new template role newRole = Role( roleLabel=roleLabel, - description=roleTemplate.get("description", {}), + description=coerce_text_multilingual(roleTemplate.get("description", {})), featureCode=FEATURE_CODE, mandateId=None, # Global template featureInstanceId=None, diff --git a/modules/features/chatbot/routeFeatureChatbot.py b/modules/features/chatbot/routeFeatureChatbot.py index 821e7ae9..fa7ab93c 100644 --- a/modules/features/chatbot/routeFeatureChatbot.py +++ b/modules/features/chatbot/routeFeatureChatbot.py @@ -32,6 +32,8 @@ from modules.features.chatbot.interfaceFeatureChatbot import ChatbotConversation # Import chatbot feature from modules.features.chatbot import chatProcess from modules.features.chatbot.mainChatbot import getEventManager +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeFeatureChatbot") # Pre-warm AI connectors when this router loads (before first request). # Ensures connectors are ready; avoids 4–8 s delay on first chatbot message. @@ -265,7 +267,7 @@ async def stream_chatbot_start( if not workflow: raise HTTPException( status_code=500, - detail="Failed to create or load workflow" + detail=routeApiMsg("Failed to create or load workflow") ) # Get event queue for the workflow @@ -562,7 +564,7 @@ def delete_chatbot( if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to delete workflow" + detail=routeApiMsg("Failed to delete workflow") ) return { diff --git a/modules/features/commcoach/mainCommcoach.py b/modules/features/commcoach/mainCommcoach.py index d21da056..3b4f66c1 100644 --- a/modules/features/commcoach/mainCommcoach.py +++ b/modules/features/commcoach/mainCommcoach.py @@ -11,23 +11,23 @@ from typing import Dict, List, Any logger = logging.getLogger(__name__) FEATURE_CODE = "commcoach" -FEATURE_LABEL = {"en": "Communication Coach", "de": "Kommunikations-Coach", "fr": "Coach Communication"} +FEATURE_LABEL = "Kommunikations-Coach" FEATURE_ICON = "mdi-account-voice" UI_OBJECTS = [ { "objectKey": "ui.feature.commcoach.dashboard", - "label": {"en": "Dashboard", "de": "Dashboard", "fr": "Tableau de bord"}, + "label": "Dashboard", "meta": {"area": "dashboard"} }, { "objectKey": "ui.feature.commcoach.coaching", - "label": {"en": "Coaching & Dossier", "de": "Coaching & Dossier", "fr": "Coaching & Dossier"}, + "label": "Coaching & Dossier", "meta": {"area": "coaching"} }, { "objectKey": "ui.feature.commcoach.settings", - "label": {"en": "Settings", "de": "Einstellungen", "fr": "Parametres"}, + "label": "Einstellungen", "meta": {"area": "settings"} }, ] @@ -35,7 +35,7 @@ UI_OBJECTS = [ DATA_OBJECTS = [ { "objectKey": "data.feature.commcoach.CoachingContext", - "label": {"en": "Coaching Context", "de": "Coaching-Kontext", "fr": "Contexte coaching"}, + "label": "Coaching-Kontext", "meta": { "table": "CoachingContext", "fields": ["id", "title", "category", "status"], @@ -45,7 +45,7 @@ DATA_OBJECTS = [ }, { "objectKey": "data.feature.commcoach.CoachingSession", - "label": {"en": "Coaching Session", "de": "Coaching-Session", "fr": "Session coaching"}, + "label": "Coaching-Session", "meta": { "table": "CoachingSession", "fields": ["id", "contextId", "status", "summary"], @@ -55,12 +55,12 @@ DATA_OBJECTS = [ }, { "objectKey": "data.feature.commcoach.CoachingMessage", - "label": {"en": "Coaching Message", "de": "Coaching-Nachricht", "fr": "Message coaching"}, + "label": "Coaching-Nachricht", "meta": {"table": "CoachingMessage", "fields": ["id", "sessionId", "role", "content"]} }, { "objectKey": "data.feature.commcoach.CoachingTask", - "label": {"en": "Coaching Task", "de": "Coaching-Aufgabe", "fr": "Tache coaching"}, + "label": "Coaching-Aufgabe", "meta": { "table": "CoachingTask", "fields": ["id", "contextId", "title", "status"], @@ -70,27 +70,27 @@ DATA_OBJECTS = [ }, { "objectKey": "data.feature.commcoach.CoachingScore", - "label": {"en": "Coaching Score", "de": "Coaching-Score", "fr": "Score coaching"}, + "label": "Coaching-Score", "meta": {"table": "CoachingScore", "fields": ["id", "dimension", "score", "trend"]} }, { "objectKey": "data.feature.commcoach.CoachingUserProfile", - "label": {"en": "User Profile", "de": "Benutzerprofil", "fr": "Profil utilisateur"}, + "label": "Benutzerprofil", "meta": {"table": "CoachingUserProfile", "fields": ["id", "userId", "dailyReminderEnabled"]} }, { "objectKey": "data.feature.commcoach.CoachingPersona", - "label": {"en": "Coaching Persona", "de": "Coaching-Persona", "fr": "Persona coaching"}, + "label": "Coaching-Persona", "meta": {"table": "CoachingPersona", "fields": ["id", "key", "label", "gender"]} }, { "objectKey": "data.feature.commcoach.CoachingBadge", - "label": {"en": "Coaching Badge", "de": "Coaching-Auszeichnung", "fr": "Badge coaching"}, + "label": "Coaching-Auszeichnung", "meta": {"table": "CoachingBadge", "fields": ["id", "badgeKey", "awardedAt"]} }, { "objectKey": "data.feature.commcoach.*", - "label": {"en": "All CommCoach Data", "de": "Alle CommCoach-Daten", "fr": "Toutes les donnees CommCoach"}, + "label": "Alle CommCoach-Daten", "meta": {"wildcard": True} }, ] @@ -98,27 +98,27 @@ DATA_OBJECTS = [ RESOURCE_OBJECTS = [ { "objectKey": "resource.feature.commcoach.context.create", - "label": {"en": "Create Context", "de": "Kontext erstellen", "fr": "Creer contexte"}, + "label": "Kontext erstellen", "meta": {"endpoint": "/api/commcoach/{instanceId}/contexts", "method": "POST"} }, { "objectKey": "resource.feature.commcoach.context.archive", - "label": {"en": "Archive Context", "de": "Kontext archivieren", "fr": "Archiver contexte"}, + "label": "Kontext archivieren", "meta": {"endpoint": "/api/commcoach/{instanceId}/contexts/{contextId}/archive", "method": "POST"} }, { "objectKey": "resource.feature.commcoach.session.start", - "label": {"en": "Start Session", "de": "Session starten", "fr": "Demarrer session"}, + "label": "Session starten", "meta": {"endpoint": "/api/commcoach/{instanceId}/contexts/{contextId}/sessions/start", "method": "POST"} }, { "objectKey": "resource.feature.commcoach.session.complete", - "label": {"en": "Complete Session", "de": "Session abschliessen", "fr": "Terminer session"}, + "label": "Session abschliessen", "meta": {"endpoint": "/api/commcoach/{instanceId}/sessions/{sessionId}/complete", "method": "POST"} }, { "objectKey": "resource.feature.commcoach.task.manage", - "label": {"en": "Manage Tasks", "de": "Aufgaben verwalten", "fr": "Gerer taches"}, + "label": "Aufgaben verwalten", "meta": {"endpoint": "/api/commcoach/{instanceId}/contexts/{contextId}/tasks", "method": "POST"} }, ] @@ -126,30 +126,22 @@ RESOURCE_OBJECTS = [ TEMPLATE_ROLES = [ { "roleLabel": "commcoach-viewer", - "description": { - "en": "Communication Coach Viewer - View coaching data (read-only)", - "de": "Kommunikations-Coach Betrachter - Coaching-Daten ansehen (nur lesen)", - "fr": "Visualiseur Coach Communication - Consulter les donnees coaching (lecture seule)", - }, + "description": "Kommunikations-Coach Betrachter - Coaching-Daten ansehen (nur lesen)", "accessRules": [ {"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.commcoach.coaching", "view": True}, - {"context": "UI", "item": "ui.feature.commcoach.dossier", "view": True}, {"context": "UI", "item": "ui.feature.commcoach.settings", "view": True}, {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, + # Viewer: keine RESOURCE-Endpunkte (Mutationen); Regel explizit fuer konsistente Kontext-Matrix + {"context": "RESOURCE", "item": None, "view": False}, ], }, { "roleLabel": "commcoach-user", - "description": { - "en": "Communication Coach User - Can manage own coaching contexts and sessions", - "de": "Kommunikations-Coach Benutzer - Kann eigene Coaching-Kontexte und Sessions verwalten", - "fr": "Utilisateur Coach Communication - Peut gerer ses propres contextes et sessions", - }, + "description": "Kommunikations-Coach Benutzer - Kann eigene Coaching-Kontexte und Sessions verwalten", "accessRules": [ {"context": "UI", "item": "ui.feature.commcoach.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.commcoach.coaching", "view": True}, - {"context": "UI", "item": "ui.feature.commcoach.dossier", "view": True}, {"context": "UI", "item": "ui.feature.commcoach.settings", "view": True}, {"context": "DATA", "item": "data.feature.commcoach.CoachingContext", "view": True, "read": "m", "create": "m", "update": "m", "delete": "m"}, {"context": "DATA", "item": "data.feature.commcoach.CoachingSession", "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"}, @@ -166,11 +158,7 @@ TEMPLATE_ROLES = [ }, { "roleLabel": "commcoach-admin", - "description": { - "en": "Communication Coach Admin - All UI and API actions; data scoped to own records", - "de": "Kommunikations-Coach Admin - Alle UI- und API-Aktionen; Daten nur eigene Datensaetze", - "fr": "Administrateur Coach Communication - Toute l'UI et les API; donnees propres", - }, + "description": "Kommunikations-Coach Admin - Alle UI- und API-Aktionen; Daten nur eigene Datensaetze", "accessRules": [ {"context": "UI", "item": None, "view": True}, {"context": "RESOURCE", "item": None, "view": True}, @@ -271,6 +259,7 @@ def _syncTemplateRolesToDb() -> int: try: from modules.interfaces.interfaceDbApp import getRootInterface from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext + from modules.datamodels.datamodelUtils import coerce_text_multilingual rootInterface = getRootInterface() existingRoles = rootInterface.getRolesByFeatureCode(FEATURE_CODE) @@ -287,7 +276,7 @@ def _syncTemplateRolesToDb() -> int: else: newRole = Role( roleLabel=roleLabel, - description=roleTemplate.get("description", {}), + description=coerce_text_multilingual(roleTemplate.get("description", {})), featureCode=FEATURE_CODE, mandateId=None, featureInstanceId=None, diff --git a/modules/features/commcoach/routeFeatureCommcoach.py b/modules/features/commcoach/routeFeatureCommcoach.py index 8ffd3eca..99ae798e 100644 --- a/modules/features/commcoach/routeFeatureCommcoach.py +++ b/modules/features/commcoach/routeFeatureCommcoach.py @@ -33,6 +33,8 @@ from .datamodelCommcoach import ( StartSessionRequest, CreatePersonaRequest, UpdatePersonaRequest, ) from .serviceCommcoach import CommcoachService, emitSessionEvent, getSessionEventQueue, cleanupSessionEvents +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeFeatureCommcoach") logger = logging.getLogger(__name__) _activeProcessTasks: dict = {} @@ -78,14 +80,14 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str: raise HTTPException(status_code=404, detail=f"Feature instance '{instanceId}' not found") mandateId = instance.get("mandateId") if isinstance(instance, dict) else getattr(instance, "mandateId", None) if not mandateId: - raise HTTPException(status_code=500, detail="Feature instance has no mandateId") + raise HTTPException(status_code=500, detail=routeApiMsg("Feature instance has no mandateId")) return str(mandateId) def _validateOwnership(record: dict, context: RequestContext, fieldName: str = "userId") -> None: """Strict ownership check. SysAdmin does NOT bypass for content access.""" if record.get(fieldName) != str(context.user.id): - raise HTTPException(status_code=404, detail="Not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Not found")) # ========================================================================= @@ -158,7 +160,7 @@ async def getContext( ctx = interface.getContext(contextId) if not ctx: - raise HTTPException(status_code=404, detail="Context not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) _validateOwnership(ctx, context) tasks = interface.getTasks(contextId, userId) @@ -187,7 +189,7 @@ async def updateContext( ctx = interface.getContext(contextId) if not ctx: - raise HTTPException(status_code=404, detail="Context not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) _validateOwnership(ctx, context) updates = body.model_dump(exclude_none=True) @@ -208,7 +210,7 @@ async def deleteContext( ctx = interface.getContext(contextId) if not ctx: - raise HTTPException(status_code=404, detail="Context not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) _validateOwnership(ctx, context) interface.deleteContext(contextId) @@ -228,7 +230,7 @@ async def archiveContext( ctx = interface.getContext(contextId) if not ctx: - raise HTTPException(status_code=404, detail="Context not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) _validateOwnership(ctx, context) updated = interface.updateContext(contextId, {"status": CoachingContextStatus.ARCHIVED.value}) @@ -249,7 +251,7 @@ async def activateContext( ctx = interface.getContext(contextId) if not ctx: - raise HTTPException(status_code=404, detail="Context not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) _validateOwnership(ctx, context) updated = interface.updateContext(contextId, {"status": CoachingContextStatus.ACTIVE.value}) @@ -274,7 +276,7 @@ async def listSessions( ctx = interface.getContext(contextId) if not ctx: - raise HTTPException(status_code=404, detail="Context not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) _validateOwnership(ctx, context) sessions = interface.getSessions(contextId, userId) @@ -297,7 +299,7 @@ async def startSession( ctx = interface.getContext(contextId) if not ctx: - raise HTTPException(status_code=404, detail="Context not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) _validateOwnership(ctx, context) activeSession = interface.getActiveSession(contextId, userId) @@ -420,7 +422,7 @@ async def getSession( session = interface.getSession(sessionId) if not session: - raise HTTPException(status_code=404, detail="Session not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Session not found")) _validateOwnership(session, context) messages = interface.getMessages(sessionId) @@ -441,7 +443,7 @@ async def completeSession( session = interface.getSession(sessionId) if not session: - raise HTTPException(status_code=404, detail="Session not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Session not found")) _validateOwnership(session, context) if session.get("status") != CoachingSessionStatus.ACTIVE.value: @@ -466,7 +468,7 @@ async def cancelSession( session = interface.getSession(sessionId) if not session: - raise HTTPException(status_code=404, detail="Session not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Session not found")) _validateOwnership(session, context) from modules.shared.timeUtils import getIsoTimestamp @@ -496,11 +498,11 @@ async def sendMessageStream( session = interface.getSession(sessionId) if not session: - raise HTTPException(status_code=404, detail="Session not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Session not found")) _validateOwnership(session, context) if session.get("status") != CoachingSessionStatus.ACTIVE.value: - raise HTTPException(status_code=400, detail="Session is not active") + raise HTTPException(status_code=400, detail=routeApiMsg("Session is not active")) contextId = session.get("contextId") service = CommcoachService(context.user, mandateId, instanceId) @@ -572,15 +574,15 @@ async def sendAudioStream( session = interface.getSession(sessionId) if not session: - raise HTTPException(status_code=404, detail="Session not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Session not found")) _validateOwnership(session, context) if session.get("status") != CoachingSessionStatus.ACTIVE.value: - raise HTTPException(status_code=400, detail="Session is not active") + raise HTTPException(status_code=400, detail=routeApiMsg("Session is not active")) audioBody = await request.body() if not audioBody: - raise HTTPException(status_code=400, detail="No audio data received") + raise HTTPException(status_code=400, detail=routeApiMsg("No audio data received")) from .serviceCommcoach import _getUserVoicePrefs language, _ = _getUserVoicePrefs(str(context.user.id), mandateId) @@ -640,7 +642,7 @@ async def streamSession( session = interface.getSession(sessionId) if not session: - raise HTTPException(status_code=404, detail="Session not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Session not found")) _validateOwnership(session, context) async def _eventGenerator(): @@ -708,7 +710,7 @@ async def createTask( ctx = interface.getContext(contextId) if not ctx: - raise HTTPException(status_code=404, detail="Context not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) _validateOwnership(ctx, context) taskData = CoachingTask( @@ -739,7 +741,7 @@ async def updateTask( task = interface.getTask(taskId) if not task: - raise HTTPException(status_code=404, detail="Task not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Task not found")) _validateOwnership(task, context) updates = body.model_dump(exclude_none=True) @@ -761,7 +763,7 @@ async def updateTaskStatus( task = interface.getTask(taskId) if not task: - raise HTTPException(status_code=404, detail="Task not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Task not found")) _validateOwnership(task, context) updates = {"status": body.status.value} @@ -786,7 +788,7 @@ async def deleteTask( task = interface.getTask(taskId) if not task: - raise HTTPException(status_code=404, detail="Task not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Task not found")) _validateOwnership(task, context) interface.deleteTask(taskId) @@ -867,7 +869,7 @@ async def exportDossier( ctx = interface.getContext(contextId) if not ctx: - raise HTTPException(status_code=404, detail="Context not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Context not found")) _validateOwnership(ctx, context) tasks = interface.getTasks(contextId, userId) @@ -902,7 +904,7 @@ async def exportSession( session = interface.getSession(sessionId) if not session: - raise HTTPException(status_code=404, detail="Session not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Session not found")) _validateOwnership(session, context) contextId = session.get("contextId") @@ -983,9 +985,9 @@ async def updatePersonaRoute( persona = interface.getPersona(personaId) if not persona: - raise HTTPException(status_code=404, detail="Persona not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Persona not found")) if persona.get("category") == "builtin": - raise HTTPException(status_code=403, detail="Builtin personas cannot be edited") + raise HTTPException(status_code=403, detail=routeApiMsg("Builtin personas cannot be edited")) _validateOwnership(persona, context) updates = body.model_dump(exclude_none=True) @@ -1006,9 +1008,9 @@ async def deletePersonaRoute( persona = interface.getPersona(personaId) if not persona: - raise HTTPException(status_code=404, detail="Persona not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Persona not found")) if persona.get("category") == "builtin": - raise HTTPException(status_code=403, detail="Builtin personas cannot be deleted") + raise HTTPException(status_code=403, detail=routeApiMsg("Builtin personas cannot be deleted")) _validateOwnership(persona, context) interface.deletePersona(personaId) diff --git a/modules/features/commcoach/tests/test_mainCommcoach.py b/modules/features/commcoach/tests/test_mainCommcoach.py index 6be563b6..bed151c8 100644 --- a/modules/features/commcoach/tests/test_mainCommcoach.py +++ b/modules/features/commcoach/tests/test_mainCommcoach.py @@ -17,9 +17,8 @@ class TestFeatureMetadata: assert FEATURE_CODE == "commcoach" def test_featureLabel(self): - assert "de" in FEATURE_LABEL - assert "en" in FEATURE_LABEL - assert "Coach" in FEATURE_LABEL["de"] + assert isinstance(FEATURE_LABEL, str) + assert "Coach" in FEATURE_LABEL def test_featureIcon(self): assert FEATURE_ICON.startswith("mdi-") @@ -37,17 +36,17 @@ class TestFeatureDefinition: class TestRbacObjects: def test_uiObjectsExist(self): objs = getUiObjects() - assert len(objs) >= 4 + assert len(objs) >= 3 keys = [o["objectKey"] for o in objs] assert "ui.feature.commcoach.dashboard" in keys assert "ui.feature.commcoach.coaching" in keys - assert "ui.feature.commcoach.dossier" in keys assert "ui.feature.commcoach.settings" in keys def test_uiObjectsHaveLabels(self): for obj in getUiObjects(): assert "label" in obj - assert "de" in obj["label"] + assert isinstance(obj["label"], str) + assert len(obj["label"]) > 0 def test_dataObjectsExist(self): objs = getDataObjects() @@ -94,7 +93,7 @@ class TestTemplateRoles: def test_roleHasDescription(self): for role in getTemplateRoles(): assert "description" in role - assert "de" in role["description"] + assert isinstance(role["description"], str) and len(role["description"].strip()) > 0 def test_roleHasAccessRules(self): for role in getTemplateRoles(): diff --git a/modules/features/graphicalEditor/datamodelFeatureGraphicalEditor.py b/modules/features/graphicalEditor/datamodelFeatureGraphicalEditor.py index d8aeef05..e6a5b103 100644 --- a/modules/features/graphicalEditor/datamodelFeatureGraphicalEditor.py +++ b/modules/features/graphicalEditor/datamodelFeatureGraphicalEditor.py @@ -6,7 +6,7 @@ from enum import Enum from typing import Dict, Any, List, Optional from pydantic import BaseModel, Field from modules.datamodels.datamodelBase import PowerOnModel -from modules.shared.attributeUtils import registerModelLabels +from modules.shared.i18nRegistry import i18nModel import uuid @@ -54,437 +54,341 @@ class AutoTemplateScope(str, Enum): # AutoWorkflow # --------------------------------------------------------------------------- +@i18nModel("Workflow") class AutoWorkflow(PowerOnModel): id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Primary key", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"}, ) mandateId: str = Field( description="Mandate ID", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Mandanten-ID"}, ) featureInstanceId: str = Field( description="Feature instance ID", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Feature-Instanz-ID"}, ) label: str = Field( description="User-friendly workflow name", - json_schema_extra={"frontend_type": "text", "frontend_required": True}, + json_schema_extra={"frontend_type": "text", "frontend_required": True, "label": "Bezeichnung"}, ) description: Optional[str] = Field( default=None, description="Workflow description", - json_schema_extra={"frontend_type": "textarea", "frontend_required": False}, + json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Beschreibung"}, ) tags: List[str] = Field( default_factory=list, description="Tags for categorization", - json_schema_extra={"frontend_type": "tags", "frontend_required": False}, + json_schema_extra={"frontend_type": "tags", "frontend_required": False, "label": "Tags"}, ) isTemplate: bool = Field( default=False, description="Whether this workflow is a template", - json_schema_extra={"frontend_type": "checkbox", "frontend_required": False}, + json_schema_extra={"frontend_type": "checkbox", "frontend_required": False, "label": "Ist Vorlage"}, ) templateSourceId: Optional[str] = Field( default=None, description="ID of the template this workflow was created from", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Vorlagen-Quelle"}, ) templateScope: Optional[str] = Field( default=None, description="Template scope: user, instance, mandate, system (AutoTemplateScope)", - json_schema_extra={"frontend_type": "select", "frontend_required": False}, + json_schema_extra={"frontend_type": "select", "frontend_required": False, "label": "Vorlagen-Bereich"}, ) sharedReadOnly: bool = Field( default=False, description="If true, shared template is read-only for non-owners", - json_schema_extra={"frontend_type": "checkbox", "frontend_required": False}, + json_schema_extra={"frontend_type": "checkbox", "frontend_required": False, "label": "Freigabe nur-lesen"}, ) currentVersionId: Optional[str] = Field( default=None, description="ID of the currently published AutoVersion", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Aktuelle Version"}, ) active: bool = Field( default=True, description="Whether workflow is active", - json_schema_extra={"frontend_type": "checkbox", "frontend_required": False}, + json_schema_extra={"frontend_type": "checkbox", "frontend_required": False, "label": "Aktiv"}, ) eventId: Optional[str] = Field( default=None, description="Scheduler event ID for incremental sync", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Event-ID"}, ) notifyOnFailure: bool = Field( default=True, description="Send notification (in-app + email) when a run fails", - json_schema_extra={"frontend_type": "checkbox", "frontend_required": False}, + json_schema_extra={"frontend_type": "checkbox", "frontend_required": False, "label": "Bei Fehler benachrichtigen"}, ) # Legacy fields kept for backward compatibility during transition graph: Dict[str, Any] = Field( default_factory=dict, description="Graph with nodes and connections (legacy; prefer AutoVersion.graph)", - json_schema_extra={"frontend_type": "textarea", "frontend_required": False}, + json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Graph"}, ) invocations: List[Dict[str, Any]] = Field( default_factory=list, description="Entry points / starts (manual, form, schedule, webhook, ...)", - json_schema_extra={"frontend_type": "textarea", "frontend_required": False}, + json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Starts / Einstiegspunkte"}, ) -registerModelLabels( - "AutoWorkflow", - {"en": "Workflow", "de": "Workflow", "fr": "Workflow"}, - { - "id": {"en": "ID", "de": "ID", "fr": "ID"}, - "mandateId": {"en": "Mandate ID", "de": "Mandanten-ID", "fr": "ID du mandat"}, - "featureInstanceId": {"en": "Feature Instance ID", "de": "Feature-Instanz-ID", "fr": "ID instance"}, - "label": {"en": "Label", "de": "Bezeichnung", "fr": "Libellé"}, - "description": {"en": "Description", "de": "Beschreibung", "fr": "Description"}, - "tags": {"en": "Tags", "de": "Tags", "fr": "Tags"}, - "isTemplate": {"en": "Is Template", "de": "Ist Vorlage", "fr": "Est modèle"}, - "templateSourceId": {"en": "Template Source", "de": "Vorlagen-Quelle", "fr": "Source du modèle"}, - "templateScope": {"en": "Template Scope", "de": "Vorlagen-Bereich", "fr": "Portée du modèle"}, - "sharedReadOnly": {"en": "Shared Read-Only", "de": "Freigabe nur-lesen", "fr": "Partage lecture seule"}, - "currentVersionId": {"en": "Current Version", "de": "Aktuelle Version", "fr": "Version actuelle"}, - "active": {"en": "Active", "de": "Aktiv", "fr": "Actif"}, - "eventId": {"en": "Event ID", "de": "Event-ID", "fr": "ID événement"}, - "graph": {"en": "Graph", "de": "Graph", "fr": "Graphe"}, - "invocations": {"en": "Starts / Entry points", "de": "Starts / Einstiegspunkte", "fr": "Points d'entrée"}, - }, -) - - # --------------------------------------------------------------------------- # AutoVersion # --------------------------------------------------------------------------- +@i18nModel("Workflow-Version") class AutoVersion(PowerOnModel): id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Primary key", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"}, ) workflowId: str = Field( description="FK -> AutoWorkflow", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}, + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Workflow-ID"}, ) versionNumber: int = Field( default=1, description="Incrementing version number", - json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False}, + json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Version"}, ) status: str = Field( default=AutoWorkflowStatus.DRAFT.value, description="Version status: draft, published, archived", - json_schema_extra={"frontend_type": "select", "frontend_required": False}, + json_schema_extra={"frontend_type": "select", "frontend_required": False, "label": "Status"}, ) graph: Dict[str, Any] = Field( default_factory=dict, description="Graph with nodes and connections (incl. node parameters)", - json_schema_extra={"frontend_type": "textarea", "frontend_required": True}, + json_schema_extra={"frontend_type": "textarea", "frontend_required": True, "label": "Graph"}, ) invocations: List[Dict[str, Any]] = Field( default_factory=list, description="Entry points / starts for this version", - json_schema_extra={"frontend_type": "textarea", "frontend_required": False}, + json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Einstiegspunkte"}, ) publishedAt: Optional[float] = Field( default=None, description="Timestamp when version was published", - json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False}, + json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False, "label": "Veröffentlicht am"}, ) publishedBy: Optional[str] = Field( default=None, description="User ID who published this version", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Veröffentlicht von"}, ) -registerModelLabels( - "AutoVersion", - {"en": "Workflow Version", "de": "Workflow-Version", "fr": "Version workflow"}, - { - "id": {"en": "ID", "de": "ID", "fr": "ID"}, - "workflowId": {"en": "Workflow ID", "de": "Workflow-ID", "fr": "ID workflow"}, - "versionNumber": {"en": "Version", "de": "Version", "fr": "Version"}, - "status": {"en": "Status", "de": "Status", "fr": "Statut"}, - "graph": {"en": "Graph", "de": "Graph", "fr": "Graphe"}, - "invocations": {"en": "Entry Points", "de": "Einstiegspunkte", "fr": "Points d'entrée"}, - "publishedAt": {"en": "Published At", "de": "Veröffentlicht am", "fr": "Publié le"}, - "publishedBy": {"en": "Published By", "de": "Veröffentlicht von", "fr": "Publié par"}, - }, -) - - # --------------------------------------------------------------------------- # AutoRun # --------------------------------------------------------------------------- +@i18nModel("Workflow-Ausführung") class AutoRun(PowerOnModel): id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Primary key", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"}, ) workflowId: str = Field( description="Workflow ID", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}, + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Workflow-ID"}, ) mandateId: Optional[str] = Field( default=None, description="Mandate ID for cross-feature querying", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Mandanten-ID"}, ) ownerId: Optional[str] = Field( default=None, description="User ID who triggered this run", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Auslöser"}, ) versionId: Optional[str] = Field( default=None, description="AutoVersion ID used for this run", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Versions-ID"}, ) status: str = Field( default=AutoRunStatus.RUNNING.value, description="Status: running, paused, completed, failed, cancelled", - json_schema_extra={"frontend_type": "text", "frontend_required": False}, + json_schema_extra={"frontend_type": "text", "frontend_required": False, "label": "Status"}, ) trigger: Dict[str, Any] = Field( default_factory=dict, description="Trigger info (type, entryPointId, payload, etc.)", - json_schema_extra={"frontend_type": "textarea", "frontend_required": False}, + json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Auslöser"}, ) startedAt: Optional[float] = Field( default=None, description="Run start timestamp", - json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False}, + json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False, "label": "Gestartet am"}, ) completedAt: Optional[float] = Field( default=None, description="Run completion timestamp", - json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False}, + json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False, "label": "Abgeschlossen am"}, ) nodeOutputs: Dict[str, Any] = Field( default_factory=dict, description="Outputs from executed nodes", - json_schema_extra={"frontend_type": "textarea", "frontend_required": False}, + json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Node-Ausgaben"}, ) currentNodeId: Optional[str] = Field( default=None, description="Node ID when paused (human task / email wait)", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "Aktueller Knoten"}, ) resumeContext: Dict[str, Any] = Field( default_factory=dict, description="Context for resume (connectionMap, inputSources, etc.)", - json_schema_extra={"frontend_type": "textarea", "frontend_required": False}, + json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Wiederaufnahme-Kontext"}, ) error: Optional[str] = Field( default=None, description="Error message if failed", - json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False}, + json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False, "label": "Fehler"}, ) costTokens: int = Field( default=0, description="Total tokens consumed by AI nodes", - json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False}, + json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Verbrauchte Tokens"}, ) costCredits: float = Field( default=0.0, description="Total credits consumed", - json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False}, + json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Verbrauchte Credits"}, ) -registerModelLabels( - "AutoRun", - {"en": "Workflow Run", "de": "Workflow-Ausführung", "fr": "Exécution workflow"}, - { - "id": {"en": "ID", "de": "ID", "fr": "ID"}, - "workflowId": {"en": "Workflow ID", "de": "Workflow-ID", "fr": "ID workflow"}, - "mandateId": {"en": "Mandate ID", "de": "Mandanten-ID", "fr": "ID du mandat"}, - "ownerId": {"en": "Owner", "de": "Auslöser", "fr": "Propriétaire"}, - "versionId": {"en": "Version ID", "de": "Versions-ID", "fr": "ID version"}, - "status": {"en": "Status", "de": "Status", "fr": "Statut"}, - "trigger": {"en": "Trigger", "de": "Auslöser", "fr": "Déclencheur"}, - "startedAt": {"en": "Started At", "de": "Gestartet am", "fr": "Démarré le"}, - "completedAt": {"en": "Completed At", "de": "Abgeschlossen am", "fr": "Terminé le"}, - "nodeOutputs": {"en": "Node Outputs", "de": "Node-Ausgaben", "fr": "Sorties nœuds"}, - "currentNodeId": {"en": "Current Node", "de": "Aktueller Knoten", "fr": "Nœud actuel"}, - "resumeContext": {"en": "Resume Context", "de": "Wiederaufnahme-Kontext", "fr": "Contexte reprise"}, - "error": {"en": "Error", "de": "Fehler", "fr": "Erreur"}, - "costTokens": {"en": "Tokens Used", "de": "Verbrauchte Tokens", "fr": "Tokens utilisés"}, - "costCredits": {"en": "Credits Used", "de": "Verbrauchte Credits", "fr": "Crédits utilisés"}, - }, -) - - # --------------------------------------------------------------------------- # AutoStepLog # --------------------------------------------------------------------------- +@i18nModel("Schritt-Protokoll") class AutoStepLog(PowerOnModel): id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Primary key", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"}, ) runId: str = Field( description="FK -> AutoRun", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}, + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Lauf-ID"}, ) nodeId: str = Field( description="Node ID in the graph", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}, + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Knoten-ID"}, ) nodeType: str = Field( description="Node type (e.g. ai.chat, email.send)", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}, + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Knotentyp"}, ) status: str = Field( default=AutoStepStatus.PENDING.value, description="Step status: pending, running, completed, failed, skipped", - json_schema_extra={"frontend_type": "text", "frontend_required": False}, + json_schema_extra={"frontend_type": "text", "frontend_required": False, "label": "Status"}, ) inputSnapshot: Dict[str, Any] = Field( default_factory=dict, description="Snapshot of inputs at execution time", - json_schema_extra={"frontend_type": "textarea", "frontend_required": False}, + json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Eingabe-Snapshot"}, ) output: Dict[str, Any] = Field( default_factory=dict, description="Node output", - json_schema_extra={"frontend_type": "textarea", "frontend_required": False}, + json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Ausgabe"}, ) error: Optional[str] = Field( default=None, description="Error message if step failed", - json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False}, + json_schema_extra={"frontend_type": "textarea", "frontend_readonly": True, "frontend_required": False, "label": "Fehler"}, ) startedAt: Optional[float] = Field( default=None, description="Step start timestamp", - json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False}, + json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False, "label": "Gestartet am"}, ) completedAt: Optional[float] = Field( default=None, description="Step completion timestamp", - json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False}, + json_schema_extra={"frontend_type": "datetime", "frontend_readonly": True, "frontend_required": False, "label": "Abgeschlossen am"}, ) durationMs: Optional[int] = Field( default=None, description="Execution duration in milliseconds", - json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False}, + json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Dauer (ms)"}, ) tokensUsed: int = Field( default=0, description="Tokens consumed by this step", - json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False}, + json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Verbrauchte Tokens"}, ) retryCount: int = Field( default=0, description="Number of retries executed", - json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False}, + json_schema_extra={"frontend_type": "number", "frontend_readonly": True, "frontend_required": False, "label": "Wiederholungen"}, ) -registerModelLabels( - "AutoStepLog", - {"en": "Step Log", "de": "Schritt-Protokoll", "fr": "Journal d'étape"}, - { - "id": {"en": "ID", "de": "ID", "fr": "ID"}, - "runId": {"en": "Run ID", "de": "Lauf-ID", "fr": "ID exécution"}, - "nodeId": {"en": "Node ID", "de": "Knoten-ID", "fr": "ID nœud"}, - "nodeType": {"en": "Node Type", "de": "Knotentyp", "fr": "Type nœud"}, - "status": {"en": "Status", "de": "Status", "fr": "Statut"}, - "inputSnapshot": {"en": "Input Snapshot", "de": "Eingabe-Snapshot", "fr": "Snapshot entrée"}, - "output": {"en": "Output", "de": "Ausgabe", "fr": "Sortie"}, - "error": {"en": "Error", "de": "Fehler", "fr": "Erreur"}, - "startedAt": {"en": "Started At", "de": "Gestartet am", "fr": "Démarré le"}, - "completedAt": {"en": "Completed At", "de": "Abgeschlossen am", "fr": "Terminé le"}, - "durationMs": {"en": "Duration (ms)", "de": "Dauer (ms)", "fr": "Durée (ms)"}, - "tokensUsed": {"en": "Tokens Used", "de": "Verbrauchte Tokens", "fr": "Tokens utilisés"}, - "retryCount": {"en": "Retry Count", "de": "Wiederholungen", "fr": "Nombre de tentatives"}, - }, -) - - # --------------------------------------------------------------------------- # AutoTask # --------------------------------------------------------------------------- +@i18nModel("Aufgabe") class AutoTask(PowerOnModel): id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Primary key", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False, "label": "ID"}, ) runId: str = Field( description="FK -> AutoRun", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}, + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Lauf-ID"}, ) workflowId: str = Field( description="Workflow ID", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}, + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Workflow-ID"}, ) nodeId: str = Field( description="Node ID in the graph", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}, + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Knoten-ID"}, ) nodeType: str = Field( description="Node type: form, approval, upload, comment, review, selection, confirmation", - json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}, + json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True, "label": "Knotentyp"}, ) config: Dict[str, Any] = Field( default_factory=dict, description="Node config (form schema, approval text, etc.)", - json_schema_extra={"frontend_type": "textarea", "frontend_required": False}, + json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Konfiguration"}, ) assigneeId: Optional[str] = Field( default=None, description="User ID assigned to complete the task", - json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}, + json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False, "label": "Zugewiesen an"}, ) status: str = Field( default=AutoTaskStatus.PENDING.value, description="Status: pending, completed, cancelled, expired", - json_schema_extra={"frontend_type": "text", "frontend_required": False}, + json_schema_extra={"frontend_type": "text", "frontend_required": False, "label": "Status"}, ) result: Optional[Dict[str, Any]] = Field( default=None, description="Task result (form data, approval decision, etc.)", - json_schema_extra={"frontend_type": "textarea", "frontend_required": False}, + json_schema_extra={"frontend_type": "textarea", "frontend_required": False, "label": "Ergebnis"}, ) expiresAt: Optional[float] = Field( default=None, description="Expiration timestamp for the task", - json_schema_extra={"frontend_type": "datetime", "frontend_required": False}, + json_schema_extra={"frontend_type": "datetime", "frontend_required": False, "label": "Läuft ab am"}, ) -registerModelLabels( - "AutoTask", - {"en": "Task", "de": "Aufgabe", "fr": "Tâche"}, - { - "id": {"en": "ID", "de": "ID", "fr": "ID"}, - "runId": {"en": "Run ID", "de": "Lauf-ID", "fr": "ID exécution"}, - "workflowId": {"en": "Workflow ID", "de": "Workflow-ID", "fr": "ID workflow"}, - "nodeId": {"en": "Node ID", "de": "Knoten-ID", "fr": "ID nœud"}, - "nodeType": {"en": "Node Type", "de": "Knotentyp", "fr": "Type nœud"}, - "config": {"en": "Config", "de": "Konfiguration", "fr": "Configuration"}, - "assigneeId": {"en": "Assignee", "de": "Zugewiesen an", "fr": "Assigné à"}, - "status": {"en": "Status", "de": "Status", "fr": "Statut"}, - "result": {"en": "Result", "de": "Ergebnis", "fr": "Résultat"}, - "expiresAt": {"en": "Expires At", "de": "Läuft ab am", "fr": "Expire le"}, - }, -) - - # --------------------------------------------------------------------------- # Backward-compatible aliases for transition period # --------------------------------------------------------------------------- diff --git a/modules/features/graphicalEditor/entryPoints.py b/modules/features/graphicalEditor/entryPoints.py index 2bcc74ce..07129545 100644 --- a/modules/features/graphicalEditor/entryPoints.py +++ b/modules/features/graphicalEditor/entryPoints.py @@ -30,22 +30,18 @@ def default_manual_entry_point() -> Dict[str, Any]: "kind": "manual", "category": "on_demand", "enabled": True, - "title": { - "de": "Jetzt ausführen", - "en": "Run now", - "fr": "Exécuter", - }, + "title": "Jetzt ausführen", "description": {}, "config": {}, } -def _normalize_title(title: Any) -> Dict[str, str]: +def _normalize_title(title: Any) -> str: if isinstance(title, dict): - return {k: str(v) for k, v in title.items() if v is not None} + return str(title.get("de") or title.get("en") or title.get("fr") or "").strip() if isinstance(title, str) and title.strip(): - return {"de": title, "en": title, "fr": title} - return {"de": "Start", "en": "Start", "fr": "Départ"} + return title.strip() + return "Start" def normalize_invocation_entry(raw: Dict[str, Any]) -> Dict[str, Any]: diff --git a/modules/features/graphicalEditor/mainGraphicalEditor.py b/modules/features/graphicalEditor/mainGraphicalEditor.py index 5a8917e2..a2bff9bc 100644 --- a/modules/features/graphicalEditor/mainGraphicalEditor.py +++ b/modules/features/graphicalEditor/mainGraphicalEditor.py @@ -21,28 +21,28 @@ REQUIRED_SERVICES = [ {"serviceKey": "clickup", "meta": {"usage": "ClickUp actions"}}, {"serviceKey": "generation", "meta": {"usage": "file.create document rendering"}}, ] -FEATURE_LABEL = {"en": "Graphical Editor", "de": "Grafischer Editor", "fr": "Éditeur graphique"} +FEATURE_LABEL = "Grafischer Editor" FEATURE_ICON = "mdi-sitemap" UI_OBJECTS = [ { "objectKey": "ui.feature.graphicalEditor.editor", - "label": {"en": "Editor", "de": "Editor", "fr": "Éditeur"}, + "label": "Editor", "meta": {"area": "editor"} }, { "objectKey": "ui.feature.graphicalEditor.workflows", - "label": {"en": "Workflows", "de": "Workflows", "fr": "Workflows"}, + "label": "Workflows", "meta": {"area": "workflows"} }, { "objectKey": "ui.feature.graphicalEditor.templates", - "label": {"en": "Templates", "de": "Vorlagen", "fr": "Modèles"}, + "label": "Vorlagen", "meta": {"area": "templates"} }, { "objectKey": "ui.feature.graphicalEditor.workflows-tasks", - "label": {"en": "Tasks", "de": "Tasks", "fr": "Tâches"}, + "label": "Tasks", "meta": {"area": "tasks"} }, ] @@ -50,17 +50,17 @@ UI_OBJECTS = [ RESOURCE_OBJECTS = [ { "objectKey": "resource.feature.graphicalEditor.dashboard", - "label": {"en": "Access Dashboard", "de": "Dashboard aufrufen", "fr": "Acceder au tableau de bord"}, + "label": "Dashboard aufrufen", "meta": {"endpoint": "/api/workflows/{instanceId}/info", "method": "GET"} }, { "objectKey": "resource.feature.graphicalEditor.node-types", - "label": {"en": "Get Node Types", "de": "Node-Typen abrufen", "fr": "Obtenir types de nœuds"}, + "label": "Node-Typen abrufen", "meta": {"endpoint": "/api/workflows/{instanceId}/node-types", "method": "GET"} }, { "objectKey": "resource.feature.graphicalEditor.execute", - "label": {"en": "Execute Workflow", "de": "Workflow ausführen", "fr": "Exécuter le workflow"}, + "label": "Workflow ausführen", "meta": {"endpoint": "/api/workflows/{instanceId}/execute", "method": "POST"} }, ] @@ -68,11 +68,7 @@ RESOURCE_OBJECTS = [ TEMPLATE_ROLES = [ { "roleLabel": "graphicalEditor-viewer", - "description": { - "en": "GraphicalEditor Viewer - View workflows (read-only)", - "de": "Grafischer Editor Betrachter - Workflows ansehen (nur lesen)", - "fr": "Visualiseur Éditeur graphique - Consulter les workflows (lecture seule)", - }, + "description": "Grafischer Editor Betrachter - Workflows ansehen (nur lesen)", "accessRules": [ {"context": "UI", "item": "ui.feature.graphicalEditor.workflows", "view": True}, {"context": "UI", "item": "ui.feature.graphicalEditor.workflows-tasks", "view": True}, @@ -82,11 +78,7 @@ TEMPLATE_ROLES = [ }, { "roleLabel": "graphicalEditor-user", - "description": { - "en": "GraphicalEditor User - Use flow builder", - "de": "Grafischer Editor Benutzer - Flow-Builder nutzen", - "fr": "Utilisateur Éditeur graphique - Utiliser le flow builder", - }, + "description": "Grafischer Editor Benutzer - Flow-Builder nutzen", "accessRules": [ {"context": "UI", "item": "ui.feature.graphicalEditor.editor", "view": True}, {"context": "UI", "item": "ui.feature.graphicalEditor.workflows", "view": True}, @@ -100,11 +92,7 @@ TEMPLATE_ROLES = [ }, { "roleLabel": "graphicalEditor-admin", - "description": { - "en": "GraphicalEditor Admin - Full UI and API for the instance; data remains user-scoped (MY)", - "de": "Grafischer Editor Admin - Volle UI und API für die Instanz; Daten weiterhin benutzerspezifisch (MY)", - "fr": "Administrateur Éditeur graphique - UI et API complets pour l'instance; donnees limitees a l'utilisateur (MY)", - }, + "description": "Grafischer Editor Admin - Volle UI und API für die Instanz; Daten weiterhin benutzerspezifisch (MY)", "accessRules": [ {"context": "UI", "item": None, "view": True}, {"context": "RESOURCE", "item": None, "view": True}, @@ -272,6 +260,7 @@ def _syncTemplateRolesToDb() -> int: try: from modules.interfaces.interfaceDbApp import getRootInterface from modules.datamodels.datamodelRbac import Role + from modules.datamodels.datamodelUtils import coerce_text_multilingual rootInterface = getRootInterface() existingRoles = rootInterface.getRolesByFeatureCode(FEATURE_CODE) @@ -285,7 +274,7 @@ def _syncTemplateRolesToDb() -> int: else: newRole = Role( roleLabel=roleLabel, - description=template.get("description", {}), + description=coerce_text_multilingual(template.get("description", {})), featureCode=FEATURE_CODE, mandateId=None, featureInstanceId=None, diff --git a/modules/features/graphicalEditor/nodeDefinitions/ai.py b/modules/features/graphicalEditor/nodeDefinitions/ai.py index 8586f9c4..b8a1cc02 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/ai.py +++ b/modules/features/graphicalEditor/nodeDefinitions/ai.py @@ -5,14 +5,14 @@ AI_NODES = [ { "id": "ai.prompt", "category": "ai", - "label": {"en": "Prompt", "de": "Prompt", "fr": "Invite"}, - "description": {"en": "Enter a prompt and AI does something", "de": "Prompt eingeben und KI führt aus", "fr": "Entrer une invite et l'IA exécute"}, + "label": "Prompt", + "description": "Prompt eingeben und KI führt aus", "parameters": [ {"name": "aiPrompt", "type": "string", "required": True, "frontendType": "textarea", - "description": {"en": "AI prompt", "de": "KI-Prompt", "fr": "Invite IA"}}, + "description": "KI-Prompt"}, {"name": "outputFormat", "type": "string", "required": False, "frontendType": "select", "frontendOptions": {"options": ["text", "json", "emailDraft"]}, - "description": {"en": "Output format", "de": "Ausgabeformat", "fr": "Format de sortie"}, "default": "text"}, + "description": "Ausgabeformat", "default": "text"}, ], "inputs": 1, "outputs": 1, @@ -25,11 +25,11 @@ AI_NODES = [ { "id": "ai.webResearch", "category": "ai", - "label": {"en": "Web Research", "de": "Web-Recherche", "fr": "Recherche web"}, - "description": {"en": "Research on the web", "de": "Recherche im Web", "fr": "Recherche sur le web"}, + "label": "Web-Recherche", + "description": "Recherche im Web", "parameters": [ {"name": "prompt", "type": "string", "required": True, "frontendType": "textarea", - "description": {"en": "Research query", "de": "Recherche-Anfrage", "fr": "Requête de recherche"}}, + "description": "Recherche-Anfrage"}, ], "inputs": 1, "outputs": 1, @@ -42,12 +42,12 @@ AI_NODES = [ { "id": "ai.summarizeDocument", "category": "ai", - "label": {"en": "Summarize Document", "de": "Dokument zusammenfassen", "fr": "Résumer document"}, - "description": {"en": "Summarize document content", "de": "Dokumentinhalt zusammenfassen", "fr": "Résumer le contenu du document"}, + "label": "Dokument zusammenfassen", + "description": "Dokumentinhalt zusammenfassen", "parameters": [ {"name": "summaryLength", "type": "string", "required": False, "frontendType": "select", "frontendOptions": {"options": ["short", "medium", "long"]}, - "description": {"en": "Short, medium, or long", "de": "Kurz, mittel oder lang", "fr": "Court, moyen ou long"}, "default": "medium"}, + "description": "Kurz, mittel oder lang", "default": "medium"}, ], "inputs": 1, "outputs": 1, @@ -60,12 +60,12 @@ AI_NODES = [ { "id": "ai.translateDocument", "category": "ai", - "label": {"en": "Translate Document", "de": "Dokument übersetzen", "fr": "Traduire document"}, - "description": {"en": "Translate document to target language", "de": "Dokument in Zielsprache übersetzen", "fr": "Traduire le document"}, + "label": "Dokument übersetzen", + "description": "Dokument in Zielsprache übersetzen", "parameters": [ {"name": "targetLanguage", "type": "string", "required": True, "frontendType": "select", "frontendOptions": {"options": ["en", "de", "fr", "it", "es", "pt", "nl"]}, - "description": {"en": "Target language", "de": "Zielsprache", "fr": "Langue cible"}}, + "description": "Zielsprache"}, ], "inputs": 1, "outputs": 1, @@ -78,12 +78,12 @@ AI_NODES = [ { "id": "ai.convertDocument", "category": "ai", - "label": {"en": "Convert Document", "de": "Dokument konvertieren", "fr": "Convertir document"}, - "description": {"en": "Convert document to another format", "de": "Dokument in anderes Format konvertieren", "fr": "Convertir le document"}, + "label": "Dokument konvertieren", + "description": "Dokument in anderes Format konvertieren", "parameters": [ {"name": "targetFormat", "type": "string", "required": True, "frontendType": "select", "frontendOptions": {"options": ["pdf", "docx", "txt", "html", "md"]}, - "description": {"en": "Target format", "de": "Zielformat", "fr": "Format cible"}}, + "description": "Zielformat"}, ], "inputs": 1, "outputs": 1, @@ -96,11 +96,11 @@ AI_NODES = [ { "id": "ai.generateDocument", "category": "ai", - "label": {"en": "Generate Document", "de": "Dokument generieren", "fr": "Générer document"}, - "description": {"en": "Generate document from prompt", "de": "Dokument aus Prompt generieren", "fr": "Générer un document"}, + "label": "Dokument generieren", + "description": "Dokument aus Prompt generieren", "parameters": [ {"name": "prompt", "type": "string", "required": True, "frontendType": "textarea", - "description": {"en": "Generation prompt", "de": "Generierungs-Prompt", "fr": "Invite de génération"}}, + "description": "Generierungs-Prompt"}, ], "inputs": 1, "outputs": 1, @@ -113,14 +113,14 @@ AI_NODES = [ { "id": "ai.generateCode", "category": "ai", - "label": {"en": "Generate Code", "de": "Code generieren", "fr": "Générer code"}, - "description": {"en": "Generate code from description", "de": "Code aus Beschreibung generieren", "fr": "Générer du code"}, + "label": "Code generieren", + "description": "Code aus Beschreibung generieren", "parameters": [ {"name": "prompt", "type": "string", "required": True, "frontendType": "textarea", - "description": {"en": "Code generation prompt", "de": "Code-Generierungs-Prompt", "fr": "Invite de génération de code"}}, + "description": "Code-Generierungs-Prompt"}, {"name": "language", "type": "string", "required": False, "frontendType": "select", "frontendOptions": {"options": ["python", "javascript", "typescript", "java", "csharp", "go"]}, - "description": {"en": "Programming language", "de": "Programmiersprache", "fr": "Langage de programmation"}, "default": "python"}, + "description": "Programmiersprache", "default": "python"}, ], "inputs": 1, "outputs": 1, diff --git a/modules/features/graphicalEditor/nodeDefinitions/clickup.py b/modules/features/graphicalEditor/nodeDefinitions/clickup.py index 0d3c75af..03504f73 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/clickup.py +++ b/modules/features/graphicalEditor/nodeDefinitions/clickup.py @@ -6,26 +6,26 @@ CLICKUP_NODES = [ { "id": "clickup.searchTasks", "category": "clickup", - "label": {"en": "Search tasks", "de": "Aufgaben suchen", "fr": "Rechercher tâches"}, - "description": {"en": "Search tasks in a workspace", "de": "Aufgaben in einem Workspace suchen", "fr": "Rechercher des tâches"}, + "label": "Aufgaben suchen", + "description": "Aufgaben in einem Workspace suchen", "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", - "description": {"en": "ClickUp connection", "de": "ClickUp-Verbindung", "fr": "Connexion ClickUp"}}, + "description": "ClickUp-Verbindung"}, {"name": "teamId", "type": "string", "required": True, "frontendType": "text", - "description": {"en": "Workspace (team) ID", "de": "Team-/Workspace-ID", "fr": "ID équipe"}}, + "description": "Team-/Workspace-ID"}, {"name": "query", "type": "string", "required": True, "frontendType": "text", - "description": {"en": "Search query", "de": "Suchbegriff", "fr": "Requête"}}, + "description": "Suchbegriff"}, {"name": "page", "type": "number", "required": False, "frontendType": "number", - "description": {"en": "Page", "de": "Seite", "fr": "Page"}, "default": 0}, + "description": "Seite", "default": 0}, {"name": "listId", "type": "string", "required": False, "frontendType": "clickupList", "frontendOptions": {"dependsOn": "connectionReference"}, - "description": {"en": "Search in this list", "de": "In dieser Liste suchen", "fr": "Rechercher dans cette liste"}}, + "description": "In dieser Liste suchen"}, {"name": "includeClosed", "type": "boolean", "required": False, "frontendType": "checkbox", - "description": {"en": "Include closed tasks", "de": "Erledigte einbeziehen", "fr": "Inclure terminées"}, "default": False}, + "description": "Erledigte einbeziehen", "default": False}, {"name": "fullTaskData", "type": "boolean", "required": False, "frontendType": "checkbox", - "description": {"en": "Return full task data", "de": "Vollständige Daten", "fr": "Données complètes"}, "default": False}, + "description": "Vollständige Daten", "default": False}, {"name": "matchNameOnly", "type": "boolean", "required": False, "frontendType": "checkbox", - "description": {"en": "Match title only", "de": "Nur Titel", "fr": "Titre uniquement"}, "default": True}, + "description": "Nur Titel", "default": True}, ], "inputs": 1, "outputs": 1, @@ -38,18 +38,18 @@ CLICKUP_NODES = [ { "id": "clickup.listTasks", "category": "clickup", - "label": {"en": "List tasks", "de": "Aufgaben auflisten", "fr": "Lister les tâches"}, - "description": {"en": "List tasks in a list", "de": "Aufgaben einer Liste auflisten", "fr": "Lister les tâches"}, + "label": "Aufgaben auflisten", + "description": "Aufgaben einer Liste auflisten", "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", - "description": {"en": "ClickUp connection", "de": "ClickUp-Verbindung", "fr": "Connexion ClickUp"}}, + "description": "ClickUp-Verbindung"}, {"name": "pathQuery", "type": "string", "required": True, "frontendType": "clickupList", "frontendOptions": {"dependsOn": "connectionReference"}, - "description": {"en": "Path to list", "de": "Pfad zur Liste", "fr": "Chemin vers la liste"}}, + "description": "Pfad zur Liste"}, {"name": "page", "type": "number", "required": False, "frontendType": "number", - "description": {"en": "Page", "de": "Seite", "fr": "Page"}, "default": 0}, + "description": "Seite", "default": 0}, {"name": "includeClosed", "type": "boolean", "required": False, "frontendType": "checkbox", - "description": {"en": "Include closed", "de": "Erledigte einbeziehen", "fr": "Inclure terminées"}, "default": False}, + "description": "Erledigte einbeziehen", "default": False}, ], "inputs": 1, "outputs": 1, @@ -62,15 +62,15 @@ CLICKUP_NODES = [ { "id": "clickup.getTask", "category": "clickup", - "label": {"en": "Get task", "de": "Aufgabe abrufen", "fr": "Obtenir la tâche"}, - "description": {"en": "Get one task by ID or path", "de": "Eine Aufgabe abrufen", "fr": "Obtenir une tâche"}, + "label": "Aufgabe abrufen", + "description": "Eine Aufgabe abrufen", "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", - "description": {"en": "ClickUp connection", "de": "ClickUp-Verbindung", "fr": "Connexion ClickUp"}}, + "description": "ClickUp-Verbindung"}, {"name": "taskId", "type": "string", "required": False, "frontendType": "text", - "description": {"en": "Task ID", "de": "Task-ID", "fr": "ID tâche"}}, + "description": "Task-ID"}, {"name": "pathQuery", "type": "string", "required": False, "frontendType": "text", - "description": {"en": "Or path .../task/{id}", "de": "Oder Pfad", "fr": "Ou chemin"}}, + "description": "Oder Pfad"}, ], "inputs": 1, "outputs": 1, @@ -83,39 +83,39 @@ CLICKUP_NODES = [ { "id": "clickup.createTask", "category": "clickup", - "label": {"en": "Create task", "de": "Aufgabe erstellen", "fr": "Créer une tâche"}, - "description": {"en": "Create a task in a list", "de": "Aufgabe erstellen", "fr": "Créer une tâche"}, + "label": "Aufgabe erstellen", + "description": "Aufgabe erstellen", "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", - "description": {"en": "ClickUp connection", "de": "ClickUp-Verbindung", "fr": "Connexion ClickUp"}}, + "description": "ClickUp-Verbindung"}, {"name": "teamId", "type": "string", "required": False, "frontendType": "text", - "description": {"en": "Workspace (team)", "de": "Workspace", "fr": "Équipe"}}, + "description": "Workspace"}, {"name": "pathQuery", "type": "string", "required": False, "frontendType": "clickupList", "frontendOptions": {"dependsOn": "connectionReference"}, - "description": {"en": "Path to list", "de": "Pfad zur Liste", "fr": "Chemin"}}, + "description": "Pfad zur Liste"}, {"name": "listId", "type": "string", "required": False, "frontendType": "text", - "description": {"en": "List ID", "de": "Listen-ID", "fr": "ID liste"}}, + "description": "Listen-ID"}, {"name": "name", "type": "string", "required": True, "frontendType": "text", - "description": {"en": "Task name", "de": "Name", "fr": "Nom"}}, + "description": "Name"}, {"name": "description", "type": "string", "required": False, "frontendType": "textarea", - "description": {"en": "Description", "de": "Beschreibung", "fr": "Description"}}, + "description": "Beschreibung"}, {"name": "taskStatus", "type": "string", "required": False, "frontendType": "text", - "description": {"en": "Status", "de": "Status", "fr": "Statut"}}, + "description": "Status"}, {"name": "taskPriority", "type": "string", "required": False, "frontendType": "select", "frontendOptions": {"options": ["1", "2", "3", "4"]}, - "description": {"en": "Priority 1-4", "de": "Priorität 1-4", "fr": "Priorité 1-4"}}, + "description": "Priorität 1-4"}, {"name": "taskDueDateMs", "type": "string", "required": False, "frontendType": "text", - "description": {"en": "Due date (Unix ms)", "de": "Fälligkeit (ms)", "fr": "Échéance (ms)"}}, + "description": "Fälligkeit (ms)"}, {"name": "taskAssigneeIds", "type": "object", "required": False, "frontendType": "json", - "description": {"en": "Assignee user ids", "de": "Zugewiesene", "fr": "Assignés"}}, + "description": "Zugewiesene"}, {"name": "taskTimeEstimateMs", "type": "string", "required": False, "frontendType": "text", - "description": {"en": "Time estimate (ms)", "de": "Zeitschätzung (ms)", "fr": "Estimation (ms)"}}, + "description": "Zeitschätzung (ms)"}, {"name": "taskTimeEstimateHours", "type": "string", "required": False, "frontendType": "text", - "description": {"en": "Time estimate (hours)", "de": "Zeitschätzung (h)", "fr": "Heures"}}, + "description": "Zeitschätzung (h)"}, {"name": "customFieldValues", "type": "object", "required": False, "frontendType": "json", - "description": {"en": "Custom fields", "de": "Benutzerdefinierte Felder", "fr": "Champs personnalisés"}}, + "description": "Benutzerdefinierte Felder"}, {"name": "taskFields", "type": "string", "required": False, "frontendType": "json", - "description": {"en": "Extra JSON (advanced)", "de": "Zusätzliches JSON", "fr": "JSON avancé"}}, + "description": "Zusätzliches JSON"}, ], "inputs": 1, "outputs": 1, @@ -128,19 +128,19 @@ CLICKUP_NODES = [ { "id": "clickup.updateTask", "category": "clickup", - "label": {"en": "Update task", "de": "Aufgabe aktualisieren", "fr": "Mettre à jour la tâche"}, - "description": {"en": "Update task fields", "de": "Felder der Aufgabe ändern", "fr": "Mettre à jour les champs"}, + "label": "Aufgabe aktualisieren", + "description": "Felder der Aufgabe ändern", "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", - "description": {"en": "ClickUp connection", "de": "ClickUp-Verbindung", "fr": "Connexion ClickUp"}}, + "description": "ClickUp-Verbindung"}, {"name": "taskId", "type": "string", "required": False, "frontendType": "text", - "description": {"en": "Task ID", "de": "Task-ID", "fr": "ID tâche"}}, + "description": "Task-ID"}, {"name": "path", "type": "string", "required": False, "frontendType": "text", - "description": {"en": "Or path to task", "de": "Oder Pfad", "fr": "Ou chemin"}}, + "description": "Oder Pfad"}, {"name": "taskUpdateEntries", "type": "object", "required": False, "frontendType": "keyValueRows", - "description": {"en": "Fields to update", "de": "Zu ändernde Felder", "fr": "Champs à mettre à jour"}}, + "description": "Zu ändernde Felder"}, {"name": "taskUpdate", "type": "string", "required": False, "frontendType": "json", - "description": {"en": "JSON body (advanced)", "de": "JSON für API", "fr": "Corps JSON"}}, + "description": "JSON für API"}, ], "inputs": 1, "outputs": 1, @@ -153,17 +153,17 @@ CLICKUP_NODES = [ { "id": "clickup.uploadAttachment", "category": "clickup", - "label": {"en": "Upload attachment", "de": "Anhang hochladen", "fr": "Téléverser pièce jointe"}, - "description": {"en": "Upload file to a task", "de": "Datei an Task anhängen", "fr": "Joindre un fichier"}, + "label": "Anhang hochladen", + "description": "Datei an Task anhängen", "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", - "description": {"en": "ClickUp connection", "de": "ClickUp-Verbindung", "fr": "Connexion ClickUp"}}, + "description": "ClickUp-Verbindung"}, {"name": "taskId", "type": "string", "required": False, "frontendType": "text", - "description": {"en": "Task ID", "de": "Task-ID", "fr": "ID tâche"}}, + "description": "Task-ID"}, {"name": "path", "type": "string", "required": False, "frontendType": "text", - "description": {"en": "Or path to task", "de": "Oder Pfad", "fr": "Ou chemin"}}, + "description": "Oder Pfad"}, {"name": "fileName", "type": "string", "required": False, "frontendType": "text", - "description": {"en": "File name", "de": "Dateiname", "fr": "Nom du fichier"}}, + "description": "Dateiname"}, ], "inputs": 1, "outputs": 1, diff --git a/modules/features/graphicalEditor/nodeDefinitions/data.py b/modules/features/graphicalEditor/nodeDefinitions/data.py index a96c7ee5..e68f3d3d 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/data.py +++ b/modules/features/graphicalEditor/nodeDefinitions/data.py @@ -5,12 +5,12 @@ DATA_NODES = [ { "id": "data.aggregate", "category": "data", - "label": {"en": "Aggregate", "de": "Sammeln", "fr": "Agréger"}, - "description": {"en": "Collect results from loop iterations", "de": "Ergebnisse aus Schleifen-Iterationen sammeln", "fr": "Collecter les résultats des itérations"}, + "label": "Sammeln", + "description": "Ergebnisse aus Schleifen-Iterationen sammeln", "parameters": [ {"name": "mode", "type": "string", "required": False, "frontendType": "select", "frontendOptions": {"options": ["collect", "concat", "sum", "count"]}, - "description": {"en": "Aggregation mode", "de": "Aggregationsmodus", "fr": "Mode d'agrégation"}, "default": "collect"}, + "description": "Aggregationsmodus", "default": "collect"}, ], "inputs": 1, "outputs": 1, @@ -22,11 +22,11 @@ DATA_NODES = [ { "id": "data.transform", "category": "data", - "label": {"en": "Transform", "de": "Umwandeln", "fr": "Transformer"}, - "description": {"en": "Map and restructure data", "de": "Daten umstrukturieren", "fr": "Restructurer les données"}, + "label": "Umwandeln", + "description": "Daten umstrukturieren", "parameters": [ {"name": "mappings", "type": "json", "required": True, "frontendType": "mappingTable", - "description": {"en": "Field mappings", "de": "Feld-Zuordnungen", "fr": "Correspondances"}, "default": []}, + "description": "Feld-Zuordnungen", "default": []}, ], "inputs": 1, "outputs": 1, @@ -38,11 +38,11 @@ DATA_NODES = [ { "id": "data.filter", "category": "data", - "label": {"en": "Filter", "de": "Filtern", "fr": "Filtrer"}, - "description": {"en": "Filter items by condition", "de": "Elemente nach Bedingung filtern", "fr": "Filtrer par condition"}, + "label": "Filtern", + "description": "Elemente nach Bedingung filtern", "parameters": [ {"name": "condition", "type": "string", "required": True, "frontendType": "filterExpression", - "description": {"en": "Filter condition", "de": "Filterbedingung", "fr": "Condition de filtre"}}, + "description": "Filterbedingung"}, ], "inputs": 1, "outputs": 1, diff --git a/modules/features/graphicalEditor/nodeDefinitions/email.py b/modules/features/graphicalEditor/nodeDefinitions/email.py index 87ea5244..8dd6e9c5 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/email.py +++ b/modules/features/graphicalEditor/nodeDefinitions/email.py @@ -5,23 +5,23 @@ EMAIL_NODES = [ { "id": "email.checkEmail", "category": "email", - "label": {"en": "Check Email", "de": "E-Mail prüfen", "fr": "Vérifier email"}, - "description": {"en": "Check for new emails", "de": "Neue E-Mails prüfen", "fr": "Vérifier les nouveaux emails"}, + "label": "E-Mail prüfen", + "description": "Neue E-Mails prüfen", "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", - "description": {"en": "Email account connection", "de": "E-Mail-Konto Verbindung", "fr": "Connexion compte email"}}, + "description": "E-Mail-Konto Verbindung"}, {"name": "folder", "type": "string", "required": False, "frontendType": "text", - "description": {"en": "Folder (e.g. Inbox)", "de": "Ordner", "fr": "Dossier"}, "default": "Inbox"}, + "description": "Ordner", "default": "Inbox"}, {"name": "limit", "type": "number", "required": False, "frontendType": "number", - "description": {"en": "Max emails to fetch", "de": "Max E-Mails", "fr": "Max emails"}, "default": 100}, + "description": "Max E-Mails", "default": 100}, {"name": "fromAddress", "type": "string", "required": False, "frontendType": "text", - "description": {"en": "Only emails from this address", "de": "Nur von dieser Adresse", "fr": "Seulement de cette adresse"}, "default": ""}, + "description": "Nur von dieser Adresse", "default": ""}, {"name": "subjectContains", "type": "string", "required": False, "frontendType": "text", - "description": {"en": "Subject must contain", "de": "Betreff muss enthalten", "fr": "Le sujet doit contenir"}, "default": ""}, + "description": "Betreff muss enthalten", "default": ""}, {"name": "hasAttachment", "type": "boolean", "required": False, "frontendType": "checkbox", - "description": {"en": "Only with attachments", "de": "Nur mit Anhängen", "fr": "Avec pièces jointes"}, "default": False}, + "description": "Nur mit Anhängen", "default": False}, {"name": "filter", "type": "string", "required": False, "frontendType": "text", - "description": {"en": "Advanced: raw filter", "de": "Erweitert: Filter-Text", "fr": "Avancé: filtre brut"}, "default": ""}, + "description": "Erweitert: Filter-Text", "default": ""}, ], "inputs": 1, "outputs": 1, @@ -34,29 +34,29 @@ EMAIL_NODES = [ { "id": "email.searchEmail", "category": "email", - "label": {"en": "Search Email", "de": "E-Mail suchen", "fr": "Rechercher email"}, - "description": {"en": "Search or find emails", "de": "E-Mails suchen", "fr": "Rechercher des emails"}, + "label": "E-Mail suchen", + "description": "E-Mails suchen", "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", - "description": {"en": "Email account connection", "de": "E-Mail-Konto Verbindung", "fr": "Connexion compte email"}}, + "description": "E-Mail-Konto Verbindung"}, {"name": "query", "type": "string", "required": False, "frontendType": "text", - "description": {"en": "Search term", "de": "Suchbegriff", "fr": "Terme de recherche"}, "default": ""}, + "description": "Suchbegriff", "default": ""}, {"name": "folder", "type": "string", "required": False, "frontendType": "text", - "description": {"en": "Folder to search", "de": "Ordner", "fr": "Dossier"}, "default": "Inbox"}, + "description": "Ordner", "default": "Inbox"}, {"name": "limit", "type": "number", "required": False, "frontendType": "number", - "description": {"en": "Max emails", "de": "Max E-Mails", "fr": "Max emails"}, "default": 100}, + "description": "Max E-Mails", "default": 100}, {"name": "fromAddress", "type": "string", "required": False, "frontendType": "text", - "description": {"en": "From address", "de": "Von Adresse", "fr": "De l'adresse"}, "default": ""}, + "description": "Von Adresse", "default": ""}, {"name": "toAddress", "type": "string", "required": False, "frontendType": "text", - "description": {"en": "To address", "de": "An Adresse", "fr": "À l'adresse"}, "default": ""}, + "description": "An Adresse", "default": ""}, {"name": "subjectContains", "type": "string", "required": False, "frontendType": "text", - "description": {"en": "Subject contains", "de": "Betreff enthält", "fr": "Sujet contient"}, "default": ""}, + "description": "Betreff enthält", "default": ""}, {"name": "bodyContains", "type": "string", "required": False, "frontendType": "text", - "description": {"en": "Body contains", "de": "Inhalt enthält", "fr": "Corps contient"}, "default": ""}, + "description": "Inhalt enthält", "default": ""}, {"name": "hasAttachment", "type": "boolean", "required": False, "frontendType": "checkbox", - "description": {"en": "With attachments", "de": "Mit Anhängen", "fr": "Avec pièces jointes"}, "default": False}, + "description": "Mit Anhängen", "default": False}, {"name": "filter", "type": "string", "required": False, "frontendType": "text", - "description": {"en": "Advanced: raw KQL", "de": "Erweitert: KQL-Filter", "fr": "Avancé: filtre KQL"}, "default": ""}, + "description": "Erweitert: KQL-Filter", "default": ""}, ], "inputs": 1, "outputs": 1, @@ -69,17 +69,17 @@ EMAIL_NODES = [ { "id": "email.draftEmail", "category": "email", - "label": {"en": "Draft Email", "de": "E-Mail entwerfen", "fr": "Brouillon email"}, - "description": {"en": "Create a draft email", "de": "E-Mail-Entwurf erstellen", "fr": "Créer un brouillon"}, + "label": "E-Mail entwerfen", + "description": "E-Mail-Entwurf erstellen", "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", - "description": {"en": "Email account", "de": "E-Mail-Konto", "fr": "Compte email"}}, + "description": "E-Mail-Konto"}, {"name": "subject", "type": "string", "required": True, "frontendType": "text", - "description": {"en": "Subject", "de": "Betreff", "fr": "Sujet"}}, + "description": "Betreff"}, {"name": "body", "type": "string", "required": True, "frontendType": "textarea", - "description": {"en": "Body", "de": "Inhalt", "fr": "Corps"}}, + "description": "Inhalt"}, {"name": "to", "type": "string", "required": False, "frontendType": "text", - "description": {"en": "Recipient(s)", "de": "Empfänger", "fr": "Destinataire(s)"}, "default": ""}, + "description": "Empfänger", "default": ""}, ], "inputs": 1, "outputs": 1, diff --git a/modules/features/graphicalEditor/nodeDefinitions/file.py b/modules/features/graphicalEditor/nodeDefinitions/file.py index 9f5bea7a..bed2cbc7 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/file.py +++ b/modules/features/graphicalEditor/nodeDefinitions/file.py @@ -5,26 +5,22 @@ FILE_NODES = [ { "id": "file.create", "category": "file", - "label": {"en": "Create File", "de": "Datei erstellen", "fr": "Créer fichier"}, - "description": { - "en": "Create a file from context (text/markdown from AI).", - "de": "Erstellt eine Datei aus Kontext (Text/Markdown von KI).", - "fr": "Crée un fichier à partir du contexte.", - }, + "label": "Datei erstellen", + "description": "Erstellt eine Datei aus Kontext (Text/Markdown von KI).", "parameters": [ {"name": "contentSources", "type": "json", "required": False, "frontendType": "json", - "description": {"en": "Context source refs", "de": "Kontext-Quellen", "fr": "Sources de contexte"}, "default": []}, + "description": "Kontext-Quellen", "default": []}, {"name": "outputFormat", "type": "string", "required": True, "frontendType": "select", "frontendOptions": {"options": ["docx", "pdf", "txt", "html", "md"]}, - "description": {"en": "Output format", "de": "Ausgabeformat", "fr": "Format de sortie"}, "default": "docx"}, + "description": "Ausgabeformat", "default": "docx"}, {"name": "title", "type": "string", "required": False, "frontendType": "text", - "description": {"en": "Document title", "de": "Dokumenttitel", "fr": "Titre du document"}}, + "description": "Dokumenttitel"}, {"name": "templateName", "type": "string", "required": False, "frontendType": "select", "frontendOptions": {"options": ["default", "corporate", "minimal"]}, - "description": {"en": "Style preset", "de": "Stil-Vorlage", "fr": "Prését style"}}, + "description": "Stil-Vorlage"}, {"name": "language", "type": "string", "required": False, "frontendType": "select", "frontendOptions": {"options": ["de", "en", "fr"]}, - "description": {"en": "Language", "de": "Sprache", "fr": "Langue"}, "default": "de"}, + "description": "Sprache", "default": "de"}, ], "inputs": 1, "outputs": 1, diff --git a/modules/features/graphicalEditor/nodeDefinitions/flow.py b/modules/features/graphicalEditor/nodeDefinitions/flow.py index c3d0a84d..087f7391 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/flow.py +++ b/modules/features/graphicalEditor/nodeDefinitions/flow.py @@ -5,20 +5,20 @@ FLOW_NODES = [ { "id": "flow.ifElse", "category": "flow", - "label": {"en": "If / Else", "de": "Wenn / Sonst", "fr": "Si / Sinon"}, - "description": {"en": "Branch based on condition", "de": "Verzweigung nach Bedingung", "fr": "Branche selon condition"}, + "label": "Wenn / Sonst", + "description": "Verzweigung nach Bedingung", "parameters": [ { "name": "condition", "type": "string", "required": True, "frontendType": "condition", - "description": {"en": "Condition to evaluate", "de": "Bedingung", "fr": "Condition"}, + "description": "Bedingung", }, ], "inputs": 1, "outputs": 2, - "outputLabels": {"en": ["Yes", "No"], "de": ["Ja", "Nein"], "fr": ["Oui", "Non"]}, + "outputLabels": ["Ja", "Nein"], "inputPorts": {0: {"accepts": ["Transit"]}}, "outputPorts": {0: {"schema": "Transit"}, 1: {"schema": "Transit"}}, "executor": "flow", @@ -27,22 +27,22 @@ FLOW_NODES = [ { "id": "flow.switch", "category": "flow", - "label": {"en": "Switch", "de": "Switch", "fr": "Switch"}, - "description": {"en": "Multiple branches based on value", "de": "Mehrere Zweige nach Wert", "fr": "Branches multiples selon valeur"}, + "label": "Switch", + "description": "Mehrere Zweige nach Wert", "parameters": [ { "name": "value", "type": "string", "required": True, "frontendType": "text", - "description": {"en": "Value to match", "de": "Zu vergleichender Wert", "fr": "Valeur à comparer"}, + "description": "Zu vergleichender Wert", }, { "name": "cases", "type": "array", "required": False, "frontendType": "caseList", - "description": {"en": "List of cases", "de": "Fälle", "fr": "Cas"}, + "description": "Fälle", }, ], "inputs": 1, @@ -55,15 +55,15 @@ FLOW_NODES = [ { "id": "flow.loop", "category": "flow", - "label": {"en": "Loop / For Each", "de": "Schleife / Für Jedes", "fr": "Boucle / Pour Chaque"}, - "description": {"en": "Iterate over array items", "de": "Über Array-Elemente iterieren", "fr": "Itérer sur les éléments"}, + "label": "Schleife / Für Jedes", + "description": "Über Array-Elemente iterieren", "parameters": [ { "name": "items", "type": "string", "required": True, "frontendType": "text", - "description": {"en": "Path to array (e.g. {{input.items}})", "de": "Pfad zum Array", "fr": "Chemin vers le tableau"}, + "description": "Pfad zum Array", }, ], "inputs": 1, @@ -76,8 +76,8 @@ FLOW_NODES = [ { "id": "flow.merge", "category": "flow", - "label": {"en": "Merge", "de": "Zusammenführen", "fr": "Fusionner"}, - "description": {"en": "Merge multiple branches", "de": "Mehrere Zweige zusammenführen", "fr": "Fusionner plusieurs branches"}, + "label": "Zusammenführen", + "description": "Mehrere Zweige zusammenführen", "parameters": [ { "name": "mode", @@ -85,7 +85,7 @@ FLOW_NODES = [ "required": False, "frontendType": "select", "frontendOptions": {"options": ["first", "all", "append"]}, - "description": {"en": "Merge mode", "de": "Zusammenführungsmodus", "fr": "Mode de fusion"}, + "description": "Zusammenführungsmodus", "default": "first", }, ], diff --git a/modules/features/graphicalEditor/nodeDefinitions/input.py b/modules/features/graphicalEditor/nodeDefinitions/input.py index 4d15de46..20547635 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/input.py +++ b/modules/features/graphicalEditor/nodeDefinitions/input.py @@ -5,19 +5,15 @@ INPUT_NODES = [ { "id": "input.form", "category": "input", - "label": {"en": "Form", "de": "Formular", "fr": "Formulaire"}, - "description": {"en": "User fills out a form", "de": "Benutzer füllt ein Formular aus", "fr": "L'utilisateur remplit un formulaire"}, + "label": "Formular", + "description": "Benutzer füllt ein Formular aus", "parameters": [ { "name": "fields", "type": "json", "required": True, "frontendType": "fieldBuilder", - "description": { - "en": "Form fields: [{name, type, label, required, options?}]", - "de": "Formularfelder", - "fr": "Champs du formulaire", - }, + "description": "Formularfelder", "default": [], }, ], @@ -31,16 +27,16 @@ INPUT_NODES = [ { "id": "input.approval", "category": "input", - "label": {"en": "Approval", "de": "Genehmigung", "fr": "Approbation"}, - "description": {"en": "User approves or rejects", "de": "Benutzer genehmigt oder lehnt ab", "fr": "L'utilisateur approuve ou rejette"}, + "label": "Genehmigung", + "description": "Benutzer genehmigt oder lehnt ab", "parameters": [ {"name": "title", "type": "string", "required": True, "frontendType": "text", - "description": {"en": "Approval title", "de": "Genehmigungstitel", "fr": "Titre"}}, + "description": "Genehmigungstitel"}, {"name": "description", "type": "string", "required": False, "frontendType": "textarea", - "description": {"en": "What to approve", "de": "Was genehmigt werden soll", "fr": "Ce qu'il faut approuver"}}, + "description": "Was genehmigt werden soll"}, {"name": "approvalType", "type": "string", "required": False, "frontendType": "select", "frontendOptions": {"options": ["generic", "document"]}, - "description": {"en": "Type: document or generic", "de": "Typ: document oder generic", "fr": "Type"}, "default": "generic"}, + "description": "Typ: document oder generic", "default": "generic"}, ], "inputs": 1, "outputs": 1, @@ -52,18 +48,18 @@ INPUT_NODES = [ { "id": "input.upload", "category": "input", - "label": {"en": "Upload", "de": "Upload", "fr": "Téléversement"}, - "description": {"en": "User uploads file(s)", "de": "Benutzer lädt Datei(en) hoch", "fr": "L'utilisateur téléverse des fichiers"}, + "label": "Upload", + "description": "Benutzer lädt Datei(en) hoch", "parameters": [ {"name": "accept", "type": "string", "required": False, "frontendType": "text", - "description": {"en": "Accept string for file input (e.g. .pdf,image/*)", "de": "Accept-String", "fr": "Chaîne accept"}, "default": ""}, + "description": "Accept-String", "default": ""}, {"name": "allowedTypes", "type": "json", "required": False, "frontendType": "multiselect", "frontendOptions": {"options": ["pdf", "docx", "xlsx", "pptx", "txt", "csv", "jpg", "png", "gif"]}, - "description": {"en": "Selected file types", "de": "Ausgewählte Dateitypen", "fr": "Types sélectionnés"}, "default": []}, + "description": "Ausgewählte Dateitypen", "default": []}, {"name": "maxSize", "type": "number", "required": False, "frontendType": "number", - "description": {"en": "Max file size in MB", "de": "Max. Dateigröße in MB", "fr": "Taille max en Mo"}, "default": 10}, + "description": "Max. Dateigröße in MB", "default": 10}, {"name": "multiple", "type": "boolean", "required": False, "frontendType": "checkbox", - "description": {"en": "Allow multiple files", "de": "Mehrere Dateien erlauben", "fr": "Autoriser plusieurs fichiers"}, "default": False}, + "description": "Mehrere Dateien erlauben", "default": False}, ], "inputs": 1, "outputs": 1, @@ -75,13 +71,13 @@ INPUT_NODES = [ { "id": "input.comment", "category": "input", - "label": {"en": "Comment", "de": "Kommentar", "fr": "Commentaire"}, - "description": {"en": "User adds a comment", "de": "Benutzer fügt einen Kommentar hinzu", "fr": "L'utilisateur ajoute un commentaire"}, + "label": "Kommentar", + "description": "Benutzer fügt einen Kommentar hinzu", "parameters": [ {"name": "placeholder", "type": "string", "required": False, "frontendType": "text", - "description": {"en": "Placeholder text", "de": "Platzhalter", "fr": "Texte indicatif"}, "default": ""}, + "description": "Platzhalter", "default": ""}, {"name": "required", "type": "boolean", "required": False, "frontendType": "checkbox", - "description": {"en": "Comment required", "de": "Kommentar erforderlich", "fr": "Commentaire requis"}, "default": True}, + "description": "Kommentar erforderlich", "default": True}, ], "inputs": 1, "outputs": 1, @@ -93,14 +89,14 @@ INPUT_NODES = [ { "id": "input.review", "category": "input", - "label": {"en": "Review", "de": "Prüfung", "fr": "Revue"}, - "description": {"en": "User reviews content", "de": "Benutzer prüft Inhalt", "fr": "L'utilisateur révise le contenu"}, + "label": "Prüfung", + "description": "Benutzer prüft Inhalt", "parameters": [ {"name": "contentRef", "type": "string", "required": True, "frontendType": "text", - "description": {"en": "Reference to content", "de": "Referenz auf Inhalt", "fr": "Référence au contenu"}}, + "description": "Referenz auf Inhalt"}, {"name": "reviewType", "type": "string", "required": False, "frontendType": "select", "frontendOptions": {"options": ["generic", "document"]}, - "description": {"en": "Type of review", "de": "Art der Prüfung", "fr": "Type de revue"}, "default": "generic"}, + "description": "Art der Prüfung", "default": "generic"}, ], "inputs": 1, "outputs": 1, @@ -112,13 +108,13 @@ INPUT_NODES = [ { "id": "input.selection", "category": "input", - "label": {"en": "Selection", "de": "Auswahl", "fr": "Sélection"}, - "description": {"en": "User selects from options", "de": "Benutzer wählt aus Optionen", "fr": "L'utilisateur choisit parmi les options"}, + "label": "Auswahl", + "description": "Benutzer wählt aus Optionen", "parameters": [ {"name": "options", "type": "json", "required": True, "frontendType": "keyValueRows", - "description": {"en": "Options: [{value, label}]", "de": "Optionen", "fr": "Options"}, "default": []}, + "description": "Optionen", "default": []}, {"name": "multiple", "type": "boolean", "required": False, "frontendType": "checkbox", - "description": {"en": "Allow multiple selection", "de": "Mehrfachauswahl erlauben", "fr": "Sélection multiple"}, "default": False}, + "description": "Mehrfachauswahl erlauben", "default": False}, ], "inputs": 1, "outputs": 1, @@ -130,15 +126,15 @@ INPUT_NODES = [ { "id": "input.confirmation", "category": "input", - "label": {"en": "Confirmation", "de": "Bestätigung", "fr": "Confirmation"}, - "description": {"en": "User confirms yes/no", "de": "Benutzer bestätigt Ja/Nein", "fr": "L'utilisateur confirme oui/non"}, + "label": "Bestätigung", + "description": "Benutzer bestätigt Ja/Nein", "parameters": [ {"name": "question", "type": "string", "required": True, "frontendType": "text", - "description": {"en": "Question to confirm", "de": "Zu bestätigende Frage", "fr": "Question à confirmer"}}, + "description": "Zu bestätigende Frage"}, {"name": "confirmLabel", "type": "string", "required": False, "frontendType": "text", - "description": {"en": "Label for confirm button", "de": "Label für Bestätigen-Button", "fr": "Libellé confirmer"}, "default": "Confirm"}, + "description": "Label für Bestätigen-Button", "default": "Confirm"}, {"name": "rejectLabel", "type": "string", "required": False, "frontendType": "text", - "description": {"en": "Label for reject button", "de": "Label für Ablehnen-Button", "fr": "Libellé refuser"}, "default": "Reject"}, + "description": "Label für Ablehnen-Button", "default": "Reject"}, ], "inputs": 1, "outputs": 1, diff --git a/modules/features/graphicalEditor/nodeDefinitions/sharepoint.py b/modules/features/graphicalEditor/nodeDefinitions/sharepoint.py index 5490499f..199285c8 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/sharepoint.py +++ b/modules/features/graphicalEditor/nodeDefinitions/sharepoint.py @@ -5,17 +5,17 @@ SHAREPOINT_NODES = [ { "id": "sharepoint.findFile", "category": "sharepoint", - "label": {"en": "Find File", "de": "Datei finden", "fr": "Trouver fichier"}, - "description": {"en": "Find file by path or search", "de": "Datei nach Pfad oder Suche finden", "fr": "Trouver fichier par chemin ou recherche"}, + "label": "Datei finden", + "description": "Datei nach Pfad oder Suche finden", "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", - "description": {"en": "SharePoint connection", "de": "SharePoint-Verbindung", "fr": "Connexion SharePoint"}}, + "description": "SharePoint-Verbindung"}, {"name": "searchQuery", "type": "string", "required": True, "frontendType": "text", - "description": {"en": "Search query or path", "de": "Suchanfrage oder Pfad", "fr": "Requête ou chemin"}}, + "description": "Suchanfrage oder Pfad"}, {"name": "site", "type": "string", "required": False, "frontendType": "text", - "description": {"en": "Optional site hint", "de": "Optionaler Site-Hinweis", "fr": "Indication de site"}, "default": ""}, + "description": "Optionaler Site-Hinweis", "default": ""}, {"name": "maxResults", "type": "number", "required": False, "frontendType": "number", - "description": {"en": "Max results", "de": "Max Ergebnisse", "fr": "Max résultats"}, "default": 1000}, + "description": "Max Ergebnisse", "default": 1000}, ], "inputs": 1, "outputs": 1, @@ -28,14 +28,14 @@ SHAREPOINT_NODES = [ { "id": "sharepoint.readFile", "category": "sharepoint", - "label": {"en": "Read File", "de": "Datei lesen", "fr": "Lire fichier"}, - "description": {"en": "Extract content from file", "de": "Inhalt aus Datei extrahieren", "fr": "Extraire le contenu du fichier"}, + "label": "Datei lesen", + "description": "Inhalt aus Datei extrahieren", "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", - "description": {"en": "SharePoint connection", "de": "SharePoint-Verbindung", "fr": "Connexion SharePoint"}}, + "description": "SharePoint-Verbindung"}, {"name": "pathQuery", "type": "string", "required": True, "frontendType": "sharepointFile", "frontendOptions": {"dependsOn": "connectionReference"}, - "description": {"en": "File path", "de": "Dateipfad", "fr": "Chemin"}}, + "description": "Dateipfad"}, ], "inputs": 1, "outputs": 1, @@ -48,14 +48,14 @@ SHAREPOINT_NODES = [ { "id": "sharepoint.uploadFile", "category": "sharepoint", - "label": {"en": "Upload File", "de": "Datei hochladen", "fr": "Téléverser fichier"}, - "description": {"en": "Upload file to SharePoint", "de": "Datei zu SharePoint hochladen", "fr": "Téléverser fichier vers SharePoint"}, + "label": "Datei hochladen", + "description": "Datei zu SharePoint hochladen", "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", - "description": {"en": "SharePoint connection", "de": "SharePoint-Verbindung", "fr": "Connexion SharePoint"}}, + "description": "SharePoint-Verbindung"}, {"name": "pathQuery", "type": "string", "required": True, "frontendType": "sharepointFolder", "frontendOptions": {"dependsOn": "connectionReference"}, - "description": {"en": "Target folder path", "de": "Zielordner-Pfad", "fr": "Chemin du dossier cible"}}, + "description": "Zielordner-Pfad"}, ], "inputs": 1, "outputs": 1, @@ -68,14 +68,14 @@ SHAREPOINT_NODES = [ { "id": "sharepoint.listFiles", "category": "sharepoint", - "label": {"en": "List Files", "de": "Dateien auflisten", "fr": "Lister fichiers"}, - "description": {"en": "List files in folder", "de": "Dateien in Ordner auflisten", "fr": "Lister les fichiers"}, + "label": "Dateien auflisten", + "description": "Dateien in Ordner auflisten", "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", - "description": {"en": "SharePoint connection", "de": "SharePoint-Verbindung", "fr": "Connexion SharePoint"}}, + "description": "SharePoint-Verbindung"}, {"name": "pathQuery", "type": "string", "required": False, "frontendType": "sharepointFolder", "frontendOptions": {"dependsOn": "connectionReference"}, - "description": {"en": "Folder path", "de": "Ordnerpfad", "fr": "Chemin du dossier"}, "default": "/"}, + "description": "Ordnerpfad", "default": "/"}, ], "inputs": 1, "outputs": 1, @@ -88,14 +88,14 @@ SHAREPOINT_NODES = [ { "id": "sharepoint.downloadFile", "category": "sharepoint", - "label": {"en": "Download File", "de": "Datei herunterladen", "fr": "Télécharger fichier"}, - "description": {"en": "Download file from path", "de": "Datei vom Pfad herunterladen", "fr": "Télécharger le fichier"}, + "label": "Datei herunterladen", + "description": "Datei vom Pfad herunterladen", "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", - "description": {"en": "SharePoint connection", "de": "SharePoint-Verbindung", "fr": "Connexion SharePoint"}}, + "description": "SharePoint-Verbindung"}, {"name": "pathQuery", "type": "string", "required": True, "frontendType": "sharepointFile", "frontendOptions": {"dependsOn": "connectionReference"}, - "description": {"en": "Full file path", "de": "Vollständiger Dateipfad", "fr": "Chemin complet du fichier"}}, + "description": "Vollständiger Dateipfad"}, ], "inputs": 1, "outputs": 1, @@ -108,17 +108,17 @@ SHAREPOINT_NODES = [ { "id": "sharepoint.copyFile", "category": "sharepoint", - "label": {"en": "Copy File", "de": "Datei kopieren", "fr": "Copier fichier"}, - "description": {"en": "Copy file to destination", "de": "Datei an Ziel kopieren", "fr": "Copier le fichier"}, + "label": "Datei kopieren", + "description": "Datei an Ziel kopieren", "parameters": [ {"name": "connectionReference", "type": "string", "required": True, "frontendType": "userConnection", - "description": {"en": "SharePoint connection", "de": "SharePoint-Verbindung", "fr": "Connexion SharePoint"}}, + "description": "SharePoint-Verbindung"}, {"name": "sourcePath", "type": "string", "required": True, "frontendType": "sharepointFile", "frontendOptions": {"dependsOn": "connectionReference"}, - "description": {"en": "Source file path", "de": "Quelldatei-Pfad", "fr": "Chemin fichier source"}}, + "description": "Quelldatei-Pfad"}, {"name": "destPath", "type": "string", "required": True, "frontendType": "sharepointFolder", "frontendOptions": {"dependsOn": "connectionReference"}, - "description": {"en": "Destination folder", "de": "Zielordner", "fr": "Dossier cible"}}, + "description": "Zielordner"}, ], "inputs": 1, "outputs": 1, diff --git a/modules/features/graphicalEditor/nodeDefinitions/triggers.py b/modules/features/graphicalEditor/nodeDefinitions/triggers.py index ab9d75ed..c25fffbe 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/triggers.py +++ b/modules/features/graphicalEditor/nodeDefinitions/triggers.py @@ -5,12 +5,8 @@ TRIGGER_NODES = [ { "id": "trigger.manual", "category": "trigger", - "label": {"en": "Start", "de": "Start", "fr": "Départ"}, - "description": { - "en": "Manual, API, or background triggers (webhook, email, …).", - "de": "Manuell, API oder Hintergrund-Starts (Webhook, E-Mail, …).", - "fr": "Manuel, API ou déclencheurs en arrière-plan.", - }, + "label": "Start", + "description": "Manuell, API oder Hintergrund-Starts (Webhook, E-Mail, …).", "parameters": [], "inputs": 0, "outputs": 1, @@ -22,19 +18,15 @@ TRIGGER_NODES = [ { "id": "trigger.form", "category": "trigger", - "label": {"en": "Start (form)", "de": "Start (Formular)", "fr": "Départ (formulaire)"}, - "description": { - "en": "Form fields are filled at run time; configure fields on this node.", - "de": "Felder werden beim Start befüllt; konfigurieren Sie die Felder auf dieser Node.", - "fr": "Les champs sont remplis au démarrage.", - }, + "label": "Start (Formular)", + "description": "Felder werden beim Start befüllt; konfigurieren Sie die Felder auf dieser Node.", "parameters": [ { "name": "formFields", "type": "json", "required": False, "frontendType": "fieldBuilder", - "description": {"en": "Field definitions", "de": "Felddefinitionen", "fr": "Définitions"}, + "description": "Felddefinitionen", }, ], "inputs": 0, @@ -47,19 +39,15 @@ TRIGGER_NODES = [ { "id": "trigger.schedule", "category": "trigger", - "label": {"en": "Start (schedule)", "de": "Start (Zeitplan)", "fr": "Départ (planification)"}, - "description": { - "en": "Cron expression for scheduled runs (configure on this node).", - "de": "Cron-Ausdruck für geplante Läufe.", - "fr": "Expression cron pour les exécutions planifiées.", - }, + "label": "Start (Zeitplan)", + "description": "Cron-Ausdruck für geplante Läufe.", "parameters": [ { "name": "cron", "type": "string", "required": False, "frontendType": "cron", - "description": {"en": "Cron expression", "de": "Cron-Ausdruck", "fr": "Expression cron"}, + "description": "Cron-Ausdruck", }, ], "inputs": 0, diff --git a/modules/features/graphicalEditor/nodeDefinitions/trustee.py b/modules/features/graphicalEditor/nodeDefinitions/trustee.py index 7d57c91c..a242f2ae 100644 --- a/modules/features/graphicalEditor/nodeDefinitions/trustee.py +++ b/modules/features/graphicalEditor/nodeDefinitions/trustee.py @@ -5,21 +5,17 @@ TRUSTEE_NODES = [ { "id": "trustee.refreshAccountingData", "category": "trustee", - "label": {"en": "Refresh Accounting Data", "de": "Buchhaltungsdaten aktualisieren", "fr": "Actualiser données comptables"}, - "description": { - "en": "Import/refresh accounting data from external system (e.g. Abacus).", - "de": "Buchhaltungsdaten aus externem System importieren/aktualisieren.", - "fr": "Importer/actualiser les données comptables.", - }, + "label": "Buchhaltungsdaten aktualisieren", + "description": "Buchhaltungsdaten aus externem System importieren/aktualisieren.", "parameters": [ {"name": "featureInstanceId", "type": "string", "required": True, "frontendType": "hidden", - "description": {"en": "Trustee feature instance ID", "de": "Trustee Feature-Instanz-ID", "fr": "ID instance Trustee"}}, + "description": "Trustee Feature-Instanz-ID"}, {"name": "forceRefresh", "type": "boolean", "required": False, "frontendType": "checkbox", - "description": {"en": "Force re-import", "de": "Import erzwingen", "fr": "Forcer la réimportation"}, "default": False}, + "description": "Import erzwingen", "default": False}, {"name": "dateFrom", "type": "string", "required": False, "frontendType": "date", - "description": {"en": "Start date (YYYY-MM-DD)", "de": "Startdatum", "fr": "Date début"}, "default": ""}, + "description": "Startdatum", "default": ""}, {"name": "dateTo", "type": "string", "required": False, "frontendType": "date", - "description": {"en": "End date (YYYY-MM-DD)", "de": "Enddatum", "fr": "Date fin"}, "default": ""}, + "description": "Enddatum", "default": ""}, ], "inputs": 1, "outputs": 1, @@ -32,22 +28,18 @@ TRUSTEE_NODES = [ { "id": "trustee.extractFromFiles", "category": "trustee", - "label": {"en": "Extract Documents", "de": "Dokumente extrahieren", "fr": "Extraire documents"}, - "description": { - "en": "Extract document type and data from PDF/JPG via AI.", - "de": "Dokumenttyp und Daten aus PDF/JPG per AI extrahieren.", - "fr": "Extraire type et données de PDF/JPG par IA.", - }, + "label": "Dokumente extrahieren", + "description": "Dokumenttyp und Daten aus PDF/JPG per AI extrahieren.", "parameters": [ {"name": "connectionReference", "type": "string", "required": False, "frontendType": "userConnection", - "description": {"en": "SharePoint connection", "de": "SharePoint-Verbindung", "fr": "Connexion SharePoint"}, "default": ""}, + "description": "SharePoint-Verbindung", "default": ""}, {"name": "sharepointFolder", "type": "string", "required": False, "frontendType": "sharepointFolder", "frontendOptions": {"dependsOn": "connectionReference"}, - "description": {"en": "SharePoint folder path", "de": "SharePoint-Ordnerpfad", "fr": "Chemin dossier SharePoint"}, "default": ""}, + "description": "SharePoint-Ordnerpfad", "default": ""}, {"name": "featureInstanceId", "type": "string", "required": True, "frontendType": "hidden", - "description": {"en": "Trustee feature instance ID", "de": "Trustee Feature-Instanz-ID", "fr": "ID instance Trustee"}}, + "description": "Trustee Feature-Instanz-ID"}, {"name": "prompt", "type": "string", "required": False, "frontendType": "textarea", - "description": {"en": "AI prompt for extraction", "de": "AI-Prompt für Extraktion", "fr": "Prompt IA"}, "default": ""}, + "description": "AI-Prompt für Extraktion", "default": ""}, ], "inputs": 1, "outputs": 1, @@ -60,17 +52,13 @@ TRUSTEE_NODES = [ { "id": "trustee.processDocuments", "category": "trustee", - "label": {"en": "Process Documents", "de": "Dokumente verarbeiten", "fr": "Traiter documents"}, - "description": { - "en": "Create TrusteeDocument + TrusteePosition from extraction result.", - "de": "TrusteeDocument + TrusteePosition aus Extraktionsergebnis erstellen.", - "fr": "Créer TrusteeDocument + TrusteePosition.", - }, + "label": "Dokumente verarbeiten", + "description": "TrusteeDocument + TrusteePosition aus Extraktionsergebnis erstellen.", "parameters": [ {"name": "documentList", "type": "string", "required": True, "frontendType": "text", - "description": {"en": "Reference to extraction result", "de": "Referenz auf Ergebnis", "fr": "Référence au résultat"}}, + "description": "Referenz auf Ergebnis"}, {"name": "featureInstanceId", "type": "string", "required": True, "frontendType": "hidden", - "description": {"en": "Trustee feature instance ID", "de": "Trustee Feature-Instanz-ID", "fr": "ID instance Trustee"}}, + "description": "Trustee Feature-Instanz-ID"}, ], "inputs": 1, "outputs": 1, @@ -83,17 +71,13 @@ TRUSTEE_NODES = [ { "id": "trustee.syncToAccounting", "category": "trustee", - "label": {"en": "Sync to Accounting", "de": "In Buchhaltung synchronisieren", "fr": "Synchroniser comptabilité"}, - "description": { - "en": "Push trustee positions to accounting system.", - "de": "Trustee-Positionen in Buchhaltungssystem übertragen.", - "fr": "Transférer les positions vers la comptabilité.", - }, + "label": "In Buchhaltung synchronisieren", + "description": "Trustee-Positionen in Buchhaltungssystem übertragen.", "parameters": [ {"name": "documentList", "type": "string", "required": True, "frontendType": "text", - "description": {"en": "Reference to processed documents", "de": "Referenz auf Ergebnis", "fr": "Référence au résultat"}}, + "description": "Referenz auf Ergebnis"}, {"name": "featureInstanceId", "type": "string", "required": True, "frontendType": "hidden", - "description": {"en": "Trustee feature instance ID", "de": "Trustee Feature-Instanz-ID", "fr": "ID instance Trustee"}}, + "description": "Trustee Feature-Instanz-ID"}, ], "inputs": 1, "outputs": 1, diff --git a/modules/features/graphicalEditor/nodeRegistry.py b/modules/features/graphicalEditor/nodeRegistry.py index 3c42608b..81cce9c7 100644 --- a/modules/features/graphicalEditor/nodeRegistry.py +++ b/modules/features/graphicalEditor/nodeRegistry.py @@ -61,16 +61,16 @@ def getNodeTypesForApi( nodes = getNodeTypes(services, language) localized = [_localizeNode(n, language) for n in nodes] categories = [ - {"id": "trigger", "label": {"en": "Trigger", "de": "Trigger", "fr": "Déclencheur"}}, - {"id": "input", "label": {"en": "Input/Human", "de": "Eingabe/Mensch", "fr": "Entrée/Humain"}}, - {"id": "flow", "label": {"en": "Flow", "de": "Ablauf", "fr": "Flux"}}, - {"id": "data", "label": {"en": "Data", "de": "Daten", "fr": "Données"}}, - {"id": "ai", "label": {"en": "AI", "de": "KI", "fr": "IA"}}, - {"id": "file", "label": {"en": "File", "de": "Datei", "fr": "Fichier"}}, - {"id": "email", "label": {"en": "Email", "de": "E-Mail", "fr": "Email"}}, - {"id": "sharepoint", "label": {"en": "SharePoint", "de": "SharePoint", "fr": "SharePoint"}}, - {"id": "clickup", "label": {"en": "ClickUp", "de": "ClickUp", "fr": "ClickUp"}}, - {"id": "trustee", "label": {"en": "Trustee", "de": "Treuhand", "fr": "Fiduciaire"}}, + {"id": "trigger", "label": "Trigger"}, + {"id": "input", "label": "Eingabe/Mensch"}, + {"id": "flow", "label": "Ablauf"}, + {"id": "data", "label": "Daten"}, + {"id": "ai", "label": "KI"}, + {"id": "file", "label": "Datei"}, + {"id": "email", "label": "E-Mail"}, + {"id": "sharepoint", "label": "SharePoint"}, + {"id": "clickup", "label": "ClickUp"}, + {"id": "trustee", "label": "Treuhand"}, ] catalogSerialized = {} diff --git a/modules/features/graphicalEditor/portTypes.py b/modules/features/graphicalEditor/portTypes.py index 523109b0..7de0e6fd 100644 --- a/modules/features/graphicalEditor/portTypes.py +++ b/modules/features/graphicalEditor/portTypes.py @@ -24,7 +24,7 @@ logger = logging.getLogger(__name__) class PortField(BaseModel): name: str type: str # str, int, bool, List[str], List[Document], Dict[str,Any] - description: Dict[str, str] = {} # {en, de, fr} + description: str = "" required: bool = True @@ -57,97 +57,97 @@ class OutputPortDef(BaseModel): PORT_TYPE_CATALOG: Dict[str, PortSchema] = { "DocumentList": PortSchema(name="DocumentList", fields=[ PortField(name="documents", type="List[Document]", - description={"en": "List of documents", "de": "Dokumentenliste", "fr": "Liste de documents"}), + description="Dokumentenliste"), ]), "FileList": PortSchema(name="FileList", fields=[ PortField(name="files", type="List[File]", - description={"en": "List of files", "de": "Dateiliste", "fr": "Liste de fichiers"}), + description="Dateiliste"), ]), "EmailDraft": PortSchema(name="EmailDraft", fields=[ PortField(name="subject", type="str", - description={"en": "Subject", "de": "Betreff", "fr": "Sujet"}), + description="Betreff"), PortField(name="body", type="str", - description={"en": "Body", "de": "Inhalt", "fr": "Corps"}), + description="Inhalt"), PortField(name="to", type="List[str]", - description={"en": "Recipients", "de": "Empfänger", "fr": "Destinataires"}), + description="Empfänger"), PortField(name="cc", type="List[str]", required=False, - description={"en": "CC", "de": "CC", "fr": "CC"}), + description="CC"), PortField(name="attachments", type="List[Document]", required=False, - description={"en": "Attachments", "de": "Anhänge", "fr": "Pièces jointes"}), + description="Anhänge"), ]), "EmailList": PortSchema(name="EmailList", fields=[ PortField(name="emails", type="List[Email]", - description={"en": "Emails", "de": "E-Mails", "fr": "Emails"}), + description="E-Mails"), ]), "TaskList": PortSchema(name="TaskList", fields=[ PortField(name="tasks", type="List[Task]", - description={"en": "Tasks", "de": "Aufgaben", "fr": "Tâches"}), + description="Aufgaben"), ]), "TaskResult": PortSchema(name="TaskResult", fields=[ PortField(name="success", type="bool", - description={"en": "Success", "de": "Erfolg", "fr": "Succès"}), + description="Erfolg"), PortField(name="taskId", type="str", - description={"en": "Task ID", "de": "Aufgaben-ID", "fr": "ID tâche"}), + description="Aufgaben-ID"), PortField(name="task", type="Dict", - description={"en": "Task data", "de": "Aufgabendaten", "fr": "Données tâche"}), + description="Aufgabendaten"), ]), "FormPayload": PortSchema(name="FormPayload", fields=[ PortField(name="payload", type="Dict[str,Any]", - description={"en": "Form data", "de": "Formulardaten", "fr": "Données formulaire"}), + description="Formulardaten"), ]), "AiResult": PortSchema(name="AiResult", fields=[ PortField(name="prompt", type="str", - description={"en": "Prompt", "de": "Prompt", "fr": "Invite"}), + description="Prompt"), PortField(name="response", type="str", - description={"en": "Response text", "de": "Antworttext", "fr": "Texte réponse"}), + description="Antworttext"), PortField(name="responseData", type="Dict", required=False, - description={"en": "Structured response", "de": "Strukturierte Antwort", "fr": "Réponse structurée"}), + description="Strukturierte Antwort"), PortField(name="context", type="str", - description={"en": "Context", "de": "Kontext", "fr": "Contexte"}), + description="Kontext"), PortField(name="documents", type="List[Document]", - description={"en": "Documents", "de": "Dokumente", "fr": "Documents"}), + description="Dokumente"), ]), "BoolResult": PortSchema(name="BoolResult", fields=[ PortField(name="result", type="bool", - description={"en": "Result", "de": "Ergebnis", "fr": "Résultat"}), + description="Ergebnis"), PortField(name="reason", type="str", required=False, - description={"en": "Reason", "de": "Begründung", "fr": "Raison"}), + description="Begründung"), ]), "TextResult": PortSchema(name="TextResult", fields=[ PortField(name="text", type="str", - description={"en": "Text", "de": "Text", "fr": "Texte"}), + description="Text"), ]), "LoopItem": PortSchema(name="LoopItem", fields=[ PortField(name="currentItem", type="Any", - description={"en": "Current item", "de": "Aktuelles Element", "fr": "Élément courant"}), + description="Aktuelles Element"), PortField(name="currentIndex", type="int", - description={"en": "Current index", "de": "Aktueller Index", "fr": "Index courant"}), + description="Aktueller Index"), PortField(name="items", type="List[Any]", - description={"en": "All items", "de": "Alle Elemente", "fr": "Tous les éléments"}), + description="Alle Elemente"), PortField(name="count", type="int", - description={"en": "Total count", "de": "Gesamtanzahl", "fr": "Nombre total"}), + description="Gesamtanzahl"), ]), "AggregateResult": PortSchema(name="AggregateResult", fields=[ PortField(name="items", type="List[Any]", - description={"en": "Collected items", "de": "Gesammelte Elemente", "fr": "Éléments collectés"}), + description="Gesammelte Elemente"), PortField(name="count", type="int", - description={"en": "Count", "de": "Anzahl", "fr": "Nombre"}), + description="Anzahl"), ]), "MergeResult": PortSchema(name="MergeResult", fields=[ PortField(name="inputs", type="Dict[int,Any]", - description={"en": "Inputs by port", "de": "Eingaben nach Port", "fr": "Entrées par port"}), + description="Eingaben nach Port"), PortField(name="first", type="Any", - description={"en": "First available", "de": "Erstes verfügbares", "fr": "Premier disponible"}), + description="Erstes verfügbares"), PortField(name="merged", type="Dict", - description={"en": "Merged data", "de": "Zusammengeführte Daten", "fr": "Données fusionnées"}), + description="Zusammengeführte Daten"), ]), "ActionResult": PortSchema(name="ActionResult", fields=[ PortField(name="success", type="bool", - description={"en": "Success", "de": "Erfolg", "fr": "Succès"}), + description="Erfolg"), PortField(name="error", type="str", required=False, - description={"en": "Error", "de": "Fehler", "fr": "Erreur"}), + description="Fehler"), PortField(name="data", type="Dict", required=False, - description={"en": "Result data", "de": "Ergebnisdaten", "fr": "Données résultat"}), + description="Ergebnisdaten"), ]), "Transit": PortSchema(name="Transit", fields=[]), } @@ -479,10 +479,16 @@ def _deriveFormPayloadSchema(node: Dict[str, Any]) -> Optional[PortSchema]: portFields = [] for f in fields_param: if isinstance(f, dict) and f.get("name"): + _lab = f.get("label") + _desc = ( + str(_lab.get("de") or _lab.get("en") or f["name"]) + if isinstance(_lab, dict) + else str(_lab if _lab is not None else f["name"]) + ) portFields.append(PortField( name=f["name"], type=f.get("type", "str"), - description=f.get("label", {}) if isinstance(f.get("label"), dict) else {"en": str(f.get("label", f["name"]))}, + description=_desc, required=f.get("required", False), )) return PortSchema(name="FormPayload_dynamic", fields=portFields) if portFields else None @@ -499,6 +505,6 @@ def _deriveTransformSchema(node: Dict[str, Any]) -> Optional[PortSchema]: portFields.append(PortField( name=m["outputField"], type=m.get("type", "str"), - description={"en": m.get("label", m["outputField"])}, + description=str(m.get("label", m["outputField"])), )) return PortSchema(name="Transform_dynamic", fields=portFields) if portFields else None diff --git a/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py b/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py index 3c1f4649..c347f622 100644 --- a/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py +++ b/modules/features/graphicalEditor/routeFeatureGraphicalEditor.py @@ -26,6 +26,8 @@ from modules.workflows.automation2.runEnvelope import ( normalize_run_envelope, ) from modules.features.graphicalEditor.entryPoints import find_invocation +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeFeatureGraphicalEditor") logger = logging.getLogger(__name__) @@ -48,13 +50,13 @@ def _build_execute_run_envelope( if not workflow: raise HTTPException( status_code=400, - detail="entryPointId requires a saved workflow (workflowId must refer to a stored workflow)", + detail=routeApiMsg("entryPointId requires a saved workflow (workflowId must refer to a stored workflow)"), ) inv = find_invocation(workflow, entry_point_id) if not inv: - raise HTTPException(status_code=400, detail="entryPointId not found on workflow") + raise HTTPException(status_code=400, detail=routeApiMsg("entryPointId not found on workflow")) if not inv.get("enabled", True): - raise HTTPException(status_code=400, detail="entry point is disabled") + raise HTTPException(status_code=400, detail=routeApiMsg("entry point is disabled")) kind = inv.get("kind", "manual") trig_map = { "manual": "manual", @@ -107,7 +109,7 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str: raise HTTPException(status_code=404, detail=f"Feature instance {instanceId} not found") featureAccess = rootInterface.getFeatureAccess(str(context.user.id), instanceId) if not featureAccess or not featureAccess.enabled: - raise HTTPException(status_code=403, detail="Access denied to this feature instance") + raise HTTPException(status_code=403, detail=routeApiMsg("Access denied to this feature instance")) return str(instance.mandateId) if instance.mandateId else "" @@ -327,7 +329,7 @@ def create_draft_version( iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) version = iface.createDraftVersion(workflowId) if not version: - raise HTTPException(status_code=404, detail="Workflow not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) return version @@ -345,7 +347,7 @@ def publish_version( userId = str(context.user.id) if context.user else None version = iface.publishVersion(versionId, userId=userId) if not version: - raise HTTPException(status_code=400, detail="Version not found or not in draft status") + raise HTTPException(status_code=400, detail=routeApiMsg("Version not found or not in draft status")) return version @@ -362,7 +364,7 @@ def unpublish_version( iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) version = iface.unpublishVersion(versionId) if not version: - raise HTTPException(status_code=400, detail="Version not found or not published") + raise HTTPException(status_code=400, detail=routeApiMsg("Version not found or not published")) return version @@ -379,7 +381,7 @@ def archive_version( iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) version = iface.archiveVersion(versionId) if not version: - raise HTTPException(status_code=404, detail="Version not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Version not found")) return version @@ -442,11 +444,11 @@ def create_template_from_workflow( workflowId = body.get("workflowId") scope = body.get("scope", "user") if not workflowId: - raise HTTPException(status_code=400, detail="workflowId required") + raise HTTPException(status_code=400, detail=routeApiMsg("workflowId required")) iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) template = iface.createTemplateFromWorkflow(workflowId, scope=scope) if not template: - raise HTTPException(status_code=404, detail="Workflow not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) return template @@ -463,7 +465,7 @@ def copy_template( iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) workflow = iface.copyTemplateToUser(templateId) if not workflow: - raise HTTPException(status_code=404, detail="Template not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Template not found")) return workflow @@ -480,11 +482,11 @@ def share_template( mandateId = _validateInstanceAccess(instanceId, context) scope = body.get("scope") if not scope or scope not in ("user", "instance", "mandate", "system"): - raise HTTPException(status_code=400, detail="scope must be user, instance, mandate, or system") + raise HTTPException(status_code=400, detail=routeApiMsg("scope must be user, instance, mandate, or system")) iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) template = iface.shareTemplate(templateId, scope=scope) if not template: - raise HTTPException(status_code=404, detail="Template not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Template not found")) return template @@ -506,12 +508,12 @@ async def post_editor_chat( mandateId = _validateInstanceAccess(instanceId, context) message = body.get("message", "") if not message: - raise HTTPException(status_code=400, detail="message required") + raise HTTPException(status_code=400, detail=routeApiMsg("message required")) iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) wf = iface.getWorkflow(workflowId) if not wf: - raise HTTPException(status_code=404, detail="Workflow not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) userLanguage = body.get("userLanguage", "de") conversationHistory = body.get("conversationHistory") or [] @@ -946,7 +948,7 @@ def get_workflow( iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) wf = iface.getWorkflow(workflowId) if not wf: - raise HTTPException(status_code=404, detail="Workflow not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) return wf @@ -979,7 +981,7 @@ def update_workflow( iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) updated = iface.updateWorkflow(workflowId, body) if not updated: - raise HTTPException(status_code=404, detail="Workflow not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) return updated @@ -995,7 +997,7 @@ def delete_workflow( mandateId = _validateInstanceAccess(instanceId, context) iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) if not iface.deleteWorkflow(workflowId): - raise HTTPException(status_code=404, detail="Workflow not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) return {"success": True} @@ -1015,20 +1017,20 @@ async def post_workflow_webhook( iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) wf = iface.getWorkflow(workflowId) if not wf or not wf.get("graph"): - raise HTTPException(status_code=404, detail="Workflow not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) inv = find_invocation(wf, entryPointId) if not inv: - raise HTTPException(status_code=404, detail="Entry point not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Entry point not found")) if inv.get("kind") != "webhook": - raise HTTPException(status_code=400, detail="Entry point is not a webhook") + raise HTTPException(status_code=400, detail=routeApiMsg("Entry point is not a webhook")) if not inv.get("enabled", True): - raise HTTPException(status_code=400, detail="Entry point is disabled") + raise HTTPException(status_code=400, detail=routeApiMsg("Entry point is disabled")) cfg = inv.get("config") or {} secret = cfg.get("webhookSecret") if secret: hdr = request.headers.get("X-Webhook-Secret") if hdr != str(secret): - raise HTTPException(status_code=403, detail="Invalid webhook secret") + raise HTTPException(status_code=403, detail=routeApiMsg("Invalid webhook secret")) services = getGraphicalEditorServices( context.user, @@ -1083,14 +1085,14 @@ async def post_workflow_form_submit( iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) wf = iface.getWorkflow(workflowId) if not wf or not wf.get("graph"): - raise HTTPException(status_code=404, detail="Workflow not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) inv = find_invocation(wf, entryPointId) if not inv: - raise HTTPException(status_code=404, detail="Entry point not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Entry point not found")) if inv.get("kind") != "form": - raise HTTPException(status_code=400, detail="Entry point is not a form") + raise HTTPException(status_code=400, detail=routeApiMsg("Entry point is not a form")) if not inv.get("enabled", True): - raise HTTPException(status_code=400, detail="Entry point is disabled") + raise HTTPException(status_code=400, detail=routeApiMsg("Entry point is disabled")) services = getGraphicalEditorServices( context.user, @@ -1161,7 +1163,7 @@ def get_workflow_runs( mandateId = _validateInstanceAccess(instanceId, context) iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) if not iface.getWorkflow(workflowId): - raise HTTPException(status_code=404, detail="Workflow not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Workflow not found")) runs = iface.getRunsByWorkflow(workflowId) return {"runs": runs} @@ -1200,16 +1202,16 @@ async def resume_run( iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) run = iface.getRun(runId) if not run: - raise HTTPException(status_code=404, detail="Run not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) taskId = body.get("taskId") result = body.get("result") if not taskId or result is None: - raise HTTPException(status_code=400, detail="taskId and result required") + raise HTTPException(status_code=400, detail=routeApiMsg("taskId and result required")) task = iface.getTask(taskId) if not task or task.get("runId") != runId: - raise HTTPException(status_code=404, detail="Task not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Task not found")) if task.get("status") != "pending": - raise HTTPException(status_code=400, detail="Task already completed") + raise HTTPException(status_code=400, detail=routeApiMsg("Task already completed")) iface.updateTask(taskId, status="completed", result=result) nodeId = task.get("nodeId") nodeOutputs = dict(run.get("nodeOutputs") or {}) @@ -1217,7 +1219,7 @@ async def resume_run( workflowId = run.get("workflowId") wf = iface.getWorkflow(workflowId) if workflowId else None if not wf or not wf.get("graph"): - raise HTTPException(status_code=400, detail="Workflow graph not found") + raise HTTPException(status_code=400, detail=routeApiMsg("Workflow graph not found")) graph = wf["graph"] services = getGraphicalEditorServices(context.user, mandateId=mandateId, featureInstanceId=instanceId) resume_result = await executeGraph( @@ -1280,16 +1282,16 @@ async def complete_task( iface = getGraphicalEditorInterface(context.user, mandateId, instanceId) task = iface.getTask(taskId) if not task: - raise HTTPException(status_code=404, detail="Task not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Task not found")) runId = task.get("runId") result = body.get("result") if result is None: - raise HTTPException(status_code=400, detail="result required") + raise HTTPException(status_code=400, detail=routeApiMsg("result required")) run = iface.getRun(runId) if not run: - raise HTTPException(status_code=404, detail="Run not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) if task.get("status") != "pending": - raise HTTPException(status_code=400, detail="Task already completed") + raise HTTPException(status_code=400, detail=routeApiMsg("Task already completed")) iface.updateTask(taskId, status="completed", result=result) nodeId = task.get("nodeId") nodeOutputs = dict(run.get("nodeOutputs") or {}) @@ -1297,7 +1299,7 @@ async def complete_task( workflowId = run.get("workflowId") wf = iface.getWorkflow(workflowId) if workflowId else None if not wf or not wf.get("graph"): - raise HTTPException(status_code=400, detail="Workflow graph not found") + raise HTTPException(status_code=400, detail=routeApiMsg("Workflow graph not found")) graph = wf["graph"] services = getGraphicalEditorServices(context.user, mandateId=mandateId, featureInstanceId=instanceId) return await executeGraph( diff --git a/modules/features/neutralization/datamodelFeatureNeutralizer.py b/modules/features/neutralization/datamodelFeatureNeutralizer.py index cc111950..0c353072 100644 --- a/modules/features/neutralization/datamodelFeatureNeutralizer.py +++ b/modules/features/neutralization/datamodelFeatureNeutralizer.py @@ -7,7 +7,7 @@ from enum import Enum from typing import Optional from pydantic import BaseModel, Field from modules.datamodels.datamodelBase import PowerOnModel -from modules.shared.attributeUtils import registerModelLabels +from modules.shared.i18nRegistry import i18nModel class DataScope(str, Enum): @@ -17,83 +17,128 @@ class DataScope(str, Enum): GLOBAL = "global" +@i18nModel("Daten-Neutralisierung Konfiguration") class DataNeutraliserConfig(PowerOnModel): - id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the configuration", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) - mandateId: str = Field(description="ID of the mandate this configuration belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) - featureInstanceId: str = Field(description="ID of the feature instance this configuration belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) - userId: str = Field(description="ID of the user who created this configuration", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) - enabled: bool = Field(default=True, description="Whether data neutralization is enabled", json_schema_extra={"frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}) - scope: str = Field(default="personal", description="Data visibility scope: personal, featureInstance, mandate, global", json_schema_extra={"frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ - {"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}}, - {"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}}, - {"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}}, - {"value": "global", "label": {"en": "Global", "de": "Global"}}, - ]}) - neutralizationStatus: str = Field(default="not_required", description="Status of neutralization: pending, completed, failed, not_required", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) - namesToParse: str = Field(default="", description="Multiline list of names to parse for neutralization", json_schema_extra={"frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False}) - sharepointSourcePath: str = Field(default="", description="SharePoint path to read files for neutralization", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}) - sharepointTargetPath: str = Field(default="", description="SharePoint path to store neutralized files", json_schema_extra={"frontend_type": "text", "frontend_readonly": False, "frontend_required": False}) -registerModelLabels( - "DataNeutraliserConfig", - {"en": "Data Neutralization Config", "fr": "Configuration de neutralisation des données"}, - { - "id": {"en": "ID", "fr": "ID"}, - "mandateId": {"en": "Mandate ID", "fr": "ID de mandat"}, - "featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"}, - "userId": {"en": "User ID", "fr": "ID utilisateur"}, - "enabled": {"en": "Enabled", "fr": "Activé"}, - "scope": {"en": "Scope", "fr": "Portée"}, - "neutralizationStatus": {"en": "Neutralization Status", "fr": "Statut de neutralisation"}, - "namesToParse": {"en": "Names to Parse", "fr": "Noms à analyser"}, - "sharepointSourcePath": {"en": "Source Path", "fr": "Chemin source"}, - "sharepointTargetPath": {"en": "Target Path", "fr": "Chemin cible"}, - }, -) + """Konfiguration fuer die Daten-Neutralisierung.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Unique ID of the configuration", + json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + ) + mandateId: str = Field( + description="ID of the mandate this configuration belongs to", + json_schema_extra={"label": "Mandanten-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}, + ) + featureInstanceId: str = Field( + description="ID of the feature instance this configuration belongs to", + json_schema_extra={"label": "Feature-Instanz-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}, + ) + userId: str = Field( + description="ID of the user who created this configuration", + json_schema_extra={"label": "Benutzer-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}, + ) + enabled: bool = Field( + default=True, + description="Whether data neutralization is enabled", + json_schema_extra={"label": "Aktiviert", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False}, + ) + scope: str = Field( + default="personal", + description="Data visibility scope: personal, featureInstance, mandate, global", + json_schema_extra={"label": "Sichtbarkeit", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, "frontend_options": [ + {"value": "personal", "label": {"en": "Personal", "de": "Persönlich"}}, + {"value": "featureInstance", "label": {"en": "Feature Instance", "de": "Feature-Instanz"}}, + {"value": "mandate", "label": {"en": "Mandate", "de": "Mandant"}}, + {"value": "global", "label": {"en": "Global", "de": "Global"}}, + ]}, + ) + neutralizationStatus: str = Field( + default="not_required", + description="Status of neutralization: pending, completed, failed, not_required", + json_schema_extra={"label": "Neutralisierungsstatus", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + ) + namesToParse: str = Field( + default="", + description="Multiline list of names to parse for neutralization", + json_schema_extra={"label": "Zu parsende Namen", "frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False}, + ) + sharepointSourcePath: str = Field( + default="", + description="SharePoint path to read files for neutralization", + json_schema_extra={"label": "SharePoint Quellpfad", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False}, + ) + sharepointTargetPath: str = Field( + default="", + description="SharePoint path to store neutralized files", + json_schema_extra={"label": "SharePoint Zielpfad", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False}, + ) + +@i18nModel("Neutralisiertes Datenattribut") class DataNeutralizerAttributes(BaseModel): - id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Unique ID of the attribute mapping (used as UID in neutralized files)", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) - mandateId: str = Field(description="ID of the mandate this attribute belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) - featureInstanceId: str = Field(description="ID of the feature instance this attribute belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) - userId: str = Field(description="ID of the user who created this attribute", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) - originalText: str = Field(description="Original text that was neutralized", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) - fileId: Optional[str] = Field(default=None, description="ID of the file this attribute belongs to", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) - patternType: str = Field(description="Type of pattern that matched (email, phone, name, etc.)", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) + """Zuordnung Originaltext zu Platzhalter fuer neutralisierte Daten.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Unique ID of the attribute mapping (used as UID in neutralized files)", + json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + ) + mandateId: str = Field( + description="ID of the mandate this attribute belongs to", + json_schema_extra={"label": "Mandanten-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}, + ) + featureInstanceId: str = Field( + description="ID of the feature instance this attribute belongs to", + json_schema_extra={"label": "Feature-Instanz-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}, + ) + userId: str = Field( + description="ID of the user who created this attribute", + json_schema_extra={"label": "Benutzer-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}, + ) + originalText: str = Field( + description="Original text that was neutralized", + json_schema_extra={"label": "Originaltext", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}, + ) + fileId: Optional[str] = Field( + default=None, + description="ID of the file this attribute belongs to", + json_schema_extra={"label": "Datei-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + ) + patternType: str = Field( + description="Type of pattern that matched (email, phone, name, etc.)", + json_schema_extra={"label": "Mustertyp", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}, + ) +@i18nModel("Neutralisierungs-Snapshot") class DataNeutralizationSnapshot(BaseModel): - """Stores the full neutralized text (with embedded placeholders) per source.""" - id: str = Field(default_factory=lambda: str(uuid.uuid4())) - mandateId: str = Field(description="Mandate scope") - featureInstanceId: str = Field(default="", description="Feature instance scope") - userId: str = Field(description="User who triggered neutralization") - sourceLabel: str = Field(description="Human label, e.g. 'Prompt', 'Kontext', 'Nachricht 3'") - neutralizedText: str = Field(description="Full text with [type.uuid] placeholders embedded") - placeholderCount: int = Field(default=0, description="Number of placeholders in the text") -registerModelLabels( - "DataNeutralizerAttributes", - {"en": "Neutralized Data Attribute", "fr": "Attribut de données neutralisées"}, - { - "id": {"en": "ID", "fr": "ID"}, - "mandateId": {"en": "Mandate ID", "fr": "ID de mandat"}, - "featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance de fonctionnalité"}, - "userId": {"en": "User ID", "fr": "ID utilisateur"}, - "originalText": {"en": "Original Text", "fr": "Texte original"}, - "fileId": {"en": "File ID", "fr": "ID de fichier"}, - "patternType": {"en": "Pattern Type", "fr": "Type de modèle"}, - }, -) -registerModelLabels( - "DataNeutralizationSnapshot", - {"en": "Neutralization Snapshot", "de": "Neutralisierungs-Snapshot"}, - { - "id": {"en": "ID"}, - "mandateId": {"en": "Mandate ID"}, - "featureInstanceId": {"en": "Feature Instance ID"}, - "userId": {"en": "User ID"}, - "sourceLabel": {"en": "Source", "de": "Quelle"}, - "neutralizedText": {"en": "Neutralized Text", "de": "Neutralisierter Text"}, - "placeholderCount": {"en": "Placeholders", "de": "Platzhalter"}, - }, -) - - + """Speichert den vollstaendigen neutralisierten Text (mit Platzhaltern) pro Quelle.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + json_schema_extra={"label": "ID"}, + ) + mandateId: str = Field( + description="Mandate scope", + json_schema_extra={"label": "Mandanten-ID"}, + ) + featureInstanceId: str = Field( + default="", + description="Feature instance scope", + json_schema_extra={"label": "Feature-Instanz-ID"}, + ) + userId: str = Field( + description="User who triggered neutralization", + json_schema_extra={"label": "Benutzer-ID"}, + ) + sourceLabel: str = Field( + description="Human label, e.g. 'Prompt', 'Kontext', 'Nachricht 3'", + json_schema_extra={"label": "Quelle"}, + ) + neutralizedText: str = Field( + description="Full text with [type.uuid] placeholders embedded", + json_schema_extra={"label": "Neutralisierter Text"}, + ) + placeholderCount: int = Field( + default=0, + description="Number of placeholders in the text", + json_schema_extra={"label": "Platzhalter"}, + ) diff --git a/modules/features/neutralization/mainNeutralization.py b/modules/features/neutralization/mainNeutralization.py index bfe97a13..2c69fe7b 100644 --- a/modules/features/neutralization/mainNeutralization.py +++ b/modules/features/neutralization/mainNeutralization.py @@ -12,14 +12,14 @@ logger = logging.getLogger(__name__) # Feature metadata FEATURE_CODE = "neutralization" -FEATURE_LABEL = {"en": "Neutralization", "de": "Neutralisierung", "fr": "Neutralisation"} +FEATURE_LABEL = "Neutralisierung" FEATURE_ICON = "mdi-shield-check" # UI Objects for RBAC catalog UI_OBJECTS = [ { "objectKey": "ui.feature.neutralization.playground", - "label": {"en": "Playground", "de": "Spielwiese", "fr": "Bac à sable"}, + "label": "Spielwiese", "meta": {"area": "playground"} } ] @@ -28,17 +28,17 @@ UI_OBJECTS = [ RESOURCE_OBJECTS = [ { "objectKey": "resource.feature.neutralization.process.text", - "label": {"en": "Process Text", "de": "Text verarbeiten", "fr": "Traiter texte"}, + "label": "Text verarbeiten", "meta": {"endpoint": "/api/neutralization/process/text", "method": "POST"} }, { "objectKey": "resource.feature.neutralization.process.files", - "label": {"en": "Process Files", "de": "Dateien verarbeiten", "fr": "Traiter fichiers"}, + "label": "Dateien verarbeiten", "meta": {"endpoint": "/api/neutralization/process/files", "method": "POST"} }, { "objectKey": "resource.feature.neutralization.config.update", - "label": {"en": "Update Config", "de": "Konfiguration aktualisieren", "fr": "Mettre à jour config"}, + "label": "Konfiguration aktualisieren", "meta": {"endpoint": "/api/neutralization/config", "method": "PUT"} }, ] @@ -47,11 +47,7 @@ RESOURCE_OBJECTS = [ TEMPLATE_ROLES = [ { "roleLabel": "neutralization-viewer", - "description": { - "en": "Neutralization Viewer - View neutralization data (read-only)", - "de": "Neutralisierungs-Betrachter - Neutralisierungsdaten einsehen (nur lesen)", - "fr": "Visualiseur neutralisation - Consulter les données de neutralisation (lecture seule)", - }, + "description": "Neutralisierungs-Betrachter - Neutralisierungsdaten einsehen (nur lesen)", "accessRules": [ {"context": "UI", "item": "ui.feature.neutralization.playground", "view": True}, {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, @@ -59,11 +55,7 @@ TEMPLATE_ROLES = [ }, { "roleLabel": "neutralization-user", - "description": { - "en": "Neutralization User - Use neutralization tools and manage own data", - "de": "Neutralisierungs-Benutzer - Neutralisierungstools nutzen und eigene Daten verwalten", - "fr": "Utilisateur neutralisation - Utiliser les outils et gérer ses propres données", - }, + "description": "Neutralisierungs-Benutzer - Neutralisierungstools nutzen und eigene Daten verwalten", "accessRules": [ {"context": "UI", "item": "ui.feature.neutralization.playground", "view": True}, {"context": "UI", "item": "ui.feature.neutralization.attributes", "view": True}, @@ -72,11 +64,7 @@ TEMPLATE_ROLES = [ }, { "roleLabel": "neutralization-admin", - "description": { - "en": "Neutralization Administrator - Full access to neutralization settings and data", - "de": "Neutralisierungs-Administrator - Vollzugriff auf Neutralisierungs-Einstellungen und Daten", - "fr": "Administrateur neutralisation - Accès complet aux paramètres et données", - }, + "description": "Neutralisierungs-Administrator - Vollzugriff auf Neutralisierungs-Einstellungen und Daten", "accessRules": [ {"context": "UI", "item": None, "view": True}, {"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"}, @@ -84,11 +72,7 @@ TEMPLATE_ROLES = [ }, { "roleLabel": "neutralization-analyst", - "description": { - "en": "Neutralization Analyst - Analyze and process neutralization data", - "de": "Neutralisierungs-Analyst - Neutralisierungsdaten analysieren und verarbeiten", - "fr": "Analyste neutralisation - Analyser et traiter les données de neutralisation", - }, + "description": "Neutralisierungs-Analyst - Neutralisierungsdaten analysieren und verarbeiten", "accessRules": [ {"context": "UI", "item": "ui.feature.neutralization.playground", "view": True}, {"context": "UI", "item": "ui.feature.neutralization.attributes", "view": True}, @@ -163,7 +147,8 @@ def _syncTemplateRolesToDb() -> int: try: from modules.interfaces.interfaceDbApp import getRootInterface from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext - + from modules.datamodels.datamodelUtils import coerce_text_multilingual + rootInterface = getRootInterface() existingRoles = rootInterface.getRolesByFeatureCode(FEATURE_CODE) @@ -180,7 +165,7 @@ def _syncTemplateRolesToDb() -> int: else: newRole = Role( roleLabel=roleLabel, - description=roleTemplate.get("description", {}), + description=coerce_text_multilingual(roleTemplate.get("description", {})), featureCode=FEATURE_CODE, mandateId=None, featureInstanceId=None, diff --git a/modules/features/neutralization/routeFeatureNeutralizer.py b/modules/features/neutralization/routeFeatureNeutralizer.py index 2f36efef..bf396e3b 100644 --- a/modules/features/neutralization/routeFeatureNeutralizer.py +++ b/modules/features/neutralization/routeFeatureNeutralizer.py @@ -10,6 +10,8 @@ from modules.auth import limiter, getRequestContext, RequestContext # Import interfaces from .datamodelFeatureNeutralizer import DataNeutraliserConfig, DataNeutralizerAttributes, DataNeutralizationSnapshot from .neutralizePlayground import NeutralizationPlayground +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeFeatureNeutralizer") # Configure logger logger = logging.getLogger(__name__) @@ -22,7 +24,7 @@ def _assertFeatureInstancePathMatchesContext(featureInstanceIdFromPath: str, con if ctxId and pathId and pathId != ctxId: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Feature instance id in URL does not match request context (X-Instance-Id)", + detail=routeApiMsg("Feature instance id in URL does not match request context (X-Instance-Id)"), ) @@ -123,13 +125,13 @@ async def neutralize_file( if not file.filename or not file.filename.strip(): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="File name is required" + detail=routeApiMsg("File name is required") ) content = await file.read() if not content: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="File is empty" + detail=routeApiMsg("File is empty") ) service = NeutralizationPlayground( context.user, @@ -164,7 +166,7 @@ def neutralize_text( if not text: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Text content is required" + detail=routeApiMsg("Text content is required") ) service = NeutralizationPlayground( @@ -199,7 +201,7 @@ def resolve_text( if not text: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Text content is required" + detail=routeApiMsg("Text content is required") ) service = NeutralizationPlayground( @@ -320,7 +322,7 @@ async def process_sharepoint_files( if not source_path or not target_path: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Both source and target paths are required" + detail=routeApiMsg("Both source and target paths are required") ) service = NeutralizationPlayground( @@ -353,7 +355,7 @@ def batch_process_files( if not files_data: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Files data is required" + detail=routeApiMsg("Files data is required") ) service = NeutralizationPlayground( @@ -453,7 +455,7 @@ def _retriggerNeutralizationBody(context: RequestContext, fileId: str) -> Dict[s if not fileId: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="fileId is required", + detail=routeApiMsg("fileId is required"), ) service = NeutralizationPlayground( context.user, @@ -521,7 +523,7 @@ def cleanup_file_attributes( else: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to cleanup file attributes" + detail=routeApiMsg("Failed to cleanup file attributes") ) except HTTPException: diff --git a/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py b/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py index 4c0842d4..0911d0b7 100644 --- a/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py +++ b/modules/features/neutralization/serviceNeutralization/mainServiceNeutralization.py @@ -20,7 +20,7 @@ from modules.features.neutralization.interfaceFeatureNeutralizer import Interfac # Import all necessary classes and functions for neutralization from .subProcessCommon import CommonUtils, NeutralizationResult, NeutralizationAttribute from .subProcessText import TextProcessor, PlainText -from .subProcessList import ListProcessor, TableData +from .subProcessList import ListProcessor, NeutralizationTableData from .subProcessBinary import BinaryProcessor from .subProcessPdfInPlace import neutralize_pdf_in_place from .subPatterns import HeaderPatterns, DataPatterns, TextTablePatterns diff --git a/modules/features/neutralization/serviceNeutralization/subProcessList.py b/modules/features/neutralization/serviceNeutralization/subProcessList.py index 97721535..8f815e1e 100644 --- a/modules/features/neutralization/serviceNeutralization/subProcessList.py +++ b/modules/features/neutralization/serviceNeutralization/subProcessList.py @@ -15,7 +15,7 @@ from .subParseString import StringParser from .subPatterns import getPatternForHeader, HeaderPatterns @dataclass -class TableData: +class NeutralizationTableData: """Repräsentiert Tabellendaten""" headers: List[str] rows: List[List[str]] @@ -34,17 +34,17 @@ class ListProcessor: self.string_parser = StringParser(NamesToParse) self.header_patterns = HeaderPatterns.patterns - def _anonymizeTable(self, table: TableData) -> TableData: + def _anonymizeTable(self, table: NeutralizationTableData) -> NeutralizationTableData: """ Anonymize table data based on headers Args: - table: TableData object to anonymize + table: NeutralizationTableData object to anonymize Returns: - TableData: Anonymized table + NeutralizationTableData: Anonymized table """ - anonymizedTable = TableData( + anonymizedTable = NeutralizationTableData( headers=table.headers.copy(), rows=[row.copy() for row in table.rows], source_type=table.source_type @@ -76,7 +76,7 @@ class ListProcessor: Tuple of (processed_data, mapping, replaced_fields, processed_info) """ df = pd.read_csv(StringIO(content), encoding='utf-8') - table = TableData( + table = NeutralizationTableData( headers=df.columns.tolist(), rows=df.values.tolist(), source_type='csv' diff --git a/modules/features/realEstate/datamodelFeatureRealEstate.py b/modules/features/realEstate/datamodelFeatureRealEstate.py index 8f136056..c12090d1 100644 --- a/modules/features/realEstate/datamodelFeatureRealEstate.py +++ b/modules/features/realEstate/datamodelFeatureRealEstate.py @@ -8,7 +8,7 @@ from typing import List, Dict, Any, Optional, ForwardRef from enum import Enum from pydantic import BaseModel, Field from modules.datamodels.datamodelBase import PowerOnModel -from modules.shared.attributeUtils import registerModelLabels +from modules.shared.i18nRegistry import i18nModel from modules.shared.timeUtils import getUtcTimestamp import uuid @@ -109,6 +109,7 @@ class GeoPolylinie(BaseModel): ) +@i18nModel("Dokument") class Dokument(BaseModel): """Supporting data object for file and URL management with versioning.""" id: str = Field( @@ -117,24 +118,28 @@ class Dokument(BaseModel): frontend_type="text", frontend_readonly=True, frontend_required=False, + label="ID", ) mandateId: str = Field( description="ID of the mandate this document belongs to", frontend_type="text", frontend_readonly=True, frontend_required=False, + label="Mandats-ID", ) featureInstanceId: str = Field( description="ID of the feature instance this document belongs to", frontend_type="text", frontend_readonly=True, frontend_required=False, + label="Feature-Instanz-ID", ) label: str = Field( description="Document label", frontend_type="text", frontend_readonly=False, frontend_required=True, + label="Bezeichnung", ) versionsbezeichnung: Optional[str] = Field( None, @@ -369,6 +374,7 @@ class Gemeinde(BaseModel): ParzelleRef = ForwardRef('Parzelle') +@i18nModel("Parzelle") class Parzelle(PowerOnModel): """Represents a plot with all building law properties.""" id: str = Field( @@ -377,18 +383,21 @@ class Parzelle(PowerOnModel): frontend_type="text", frontend_readonly=True, frontend_required=False, + label="ID", ) mandateId: str = Field( description="ID of the mandate", frontend_type="text", frontend_readonly=True, frontend_required=False, + label="Mandats-ID", ) featureInstanceId: str = Field( description="ID of the feature instance", frontend_type="text", frontend_readonly=True, frontend_required=False, + label="Feature-Instanz-ID", ) # Grunddaten @@ -397,6 +406,7 @@ class Parzelle(PowerOnModel): frontend_type="text", frontend_readonly=False, frontend_required=True, + label="Bezeichnung", ) parzellenAliasTags: List[str] = Field( default_factory=list, @@ -595,6 +605,7 @@ class Parzelle(PowerOnModel): ) +@i18nModel("Projekt") class Projekt(PowerOnModel): """Core object representing a construction project.""" id: str = Field( @@ -603,24 +614,28 @@ class Projekt(PowerOnModel): frontend_type="text", frontend_readonly=True, frontend_required=False, + label="ID", ) mandateId: str = Field( description="ID of the mandate", frontend_type="text", frontend_readonly=True, frontend_required=False, + label="Mandats-ID", ) featureInstanceId: str = Field( description="ID of the feature instance", frontend_type="text", frontend_readonly=True, frontend_required=False, + label="Feature-Instanz-ID", ) label: str = Field( description="Project designation", frontend_type="text", frontend_readonly=False, frontend_required=True, + label="Bezeichnung", ) statusProzess: Optional[StatusProzess] = Field( None, @@ -628,6 +643,7 @@ class Projekt(PowerOnModel): frontend_type="select", frontend_readonly=False, frontend_required=False, + label="Prozessstatus", ) perimeter: Optional[GeoPolylinie] = Field( None, @@ -670,39 +686,3 @@ class Projekt(PowerOnModel): Parzelle.model_rebuild() Projekt.model_rebuild() - -# Register labels for frontend -registerModelLabels( - "Projekt", - {"en": "Project", "fr": "Projet", "de": "Projekt"}, - { - "id": {"en": "ID", "fr": "ID", "de": "ID"}, - "label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"}, - "statusProzess": {"en": "Process Status", "fr": "Statut du processus", "de": "Prozessstatus"}, - "mandateId": {"en": "Mandate ID", "fr": "ID du mandat", "de": "Mandats-ID"}, - "featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance", "de": "Feature-Instanz-ID"}, - }, -) - -registerModelLabels( - "Parzelle", - {"en": "Plot", "fr": "Parcelle", "de": "Parzelle"}, - { - "id": {"en": "ID", "fr": "ID", "de": "ID"}, - "label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"}, - "mandateId": {"en": "Mandate ID", "fr": "ID du mandat", "de": "Mandats-ID"}, - "featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance", "de": "Feature-Instanz-ID"}, - }, -) - -registerModelLabels( - "Dokument", - {"en": "Document", "fr": "Document", "de": "Dokument"}, - { - "id": {"en": "ID", "fr": "ID", "de": "ID"}, - "label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"}, - "mandateId": {"en": "Mandate ID", "fr": "ID du mandat", "de": "Mandats-ID"}, - "featureInstanceId": {"en": "Feature Instance ID", "fr": "ID de l'instance", "de": "Feature-Instanz-ID"}, - }, -) - diff --git a/modules/features/realEstate/mainRealEstate.py b/modules/features/realEstate/mainRealEstate.py index dfe310d5..0ae29159 100644 --- a/modules/features/realEstate/mainRealEstate.py +++ b/modules/features/realEstate/mainRealEstate.py @@ -10,14 +10,14 @@ import logging # Feature metadata for RBAC catalog FEATURE_CODE = "realestate" -FEATURE_LABEL = {"en": "Real Estate", "de": "Immobilien", "fr": "Immobilier"} +FEATURE_LABEL = "Immobilien" FEATURE_ICON = "mdi-home-city" # UI Objects for RBAC catalog (only map view) UI_OBJECTS = [ { "objectKey": "ui.feature.realestate.dashboard", - "label": {"en": "Map", "de": "Karte", "fr": "Carte"}, + "label": "Karte", "meta": {"area": "dashboard"} }, ] @@ -26,12 +26,12 @@ UI_OBJECTS = [ RESOURCE_OBJECTS = [ { "objectKey": "resource.feature.realestate.project.create", - "label": {"en": "Create Project", "de": "Projekt erstellen", "fr": "Créer projet"}, + "label": "Projekt erstellen", "meta": {"endpoint": "/api/realestate/project", "method": "POST"} }, { "objectKey": "resource.feature.realestate.project.delete", - "label": {"en": "Delete Project", "de": "Projekt löschen", "fr": "Supprimer projet"}, + "label": "Projekt löschen", "meta": {"endpoint": "/api/realestate/project/{projectId}", "method": "DELETE"} }, ] @@ -41,11 +41,7 @@ RESOURCE_OBJECTS = [ TEMPLATE_ROLES = [ { "roleLabel": "realestate-viewer", - "description": { - "en": "Real Estate Viewer - View property information (read-only)", - "de": "Immobilien-Betrachter - Immobilien-Informationen einsehen (nur lesen)", - "fr": "Visualiseur immobilier - Consulter les informations immobilières (lecture seule)", - }, + "description": "Immobilien-Betrachter - Immobilien-Informationen einsehen (nur lesen)", "accessRules": [ {"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True}, {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, @@ -53,11 +49,7 @@ TEMPLATE_ROLES = [ }, { "roleLabel": "realestate-user", - "description": { - "en": "Real Estate User - Create and manage own property records", - "de": "Immobilien-Benutzer - Eigene Immobilien-Daten erstellen und verwalten", - "fr": "Utilisateur immobilier - Créer et gérer ses propres données immobilières", - }, + "description": "Immobilien-Benutzer - Eigene Immobilien-Daten erstellen und verwalten", "accessRules": [ {"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True}, {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"}, @@ -66,11 +58,7 @@ TEMPLATE_ROLES = [ }, { "roleLabel": "realestate-admin", - "description": { - "en": "Real Estate Administrator - Full access to all property data and settings", - "de": "Immobilien-Administrator - Vollzugriff auf alle Immobiliendaten und Einstellungen", - "fr": "Administrateur immobilier - Accès complet aux données et paramètres", - }, + "description": "Immobilien-Administrator - Vollzugriff auf alle Immobiliendaten und Einstellungen", "accessRules": [ {"context": "UI", "item": None, "view": True}, {"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"}, @@ -80,11 +68,7 @@ TEMPLATE_ROLES = [ }, { "roleLabel": "realestate-manager", - "description": { - "en": "Real Estate Manager - Manage properties and tenants", - "de": "Immobilien-Verwalter - Immobilien und Mieter verwalten", - "fr": "Gestionnaire immobilier - Gérer les propriétés et locataires", - }, + "description": "Immobilien-Verwalter - Immobilien und Mieter verwalten", "accessRules": [ {"context": "UI", "item": "ui.feature.realestate.dashboard", "view": True}, {"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"}, @@ -154,6 +138,7 @@ def _syncTemplateRolesToDb() -> int: try: from modules.interfaces.interfaceDbApp import getRootInterface from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext + from modules.datamodels.datamodelUtils import coerce_text_multilingual rootInterface = getRootInterface() db = rootInterface.db @@ -174,7 +159,7 @@ def _syncTemplateRolesToDb() -> int: else: newRole = Role( roleLabel=roleLabel, - description=roleTemplate.get("description", {}), + description=coerce_text_multilingual(roleTemplate.get("description", {})), featureCode=FEATURE_CODE, mandateId=None, featureInstanceId=None, diff --git a/modules/features/realEstate/routeFeatureRealEstate.py b/modules/features/realEstate/routeFeatureRealEstate.py index 82fa55ba..58faca8e 100644 --- a/modules/features/realEstate/routeFeatureRealEstate.py +++ b/modules/features/realEstate/routeFeatureRealEstate.py @@ -59,6 +59,8 @@ from modules.aicore.aicorePluginTavily import AiTavily # Import attribute utilities for model schema from modules.shared.attributeUtils import getModelAttributeDefinitions +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeFeatureRealEstate") # Configure logger logger = logging.getLogger(__name__) @@ -339,7 +341,7 @@ def update_project( raise HTTPException(status_code=404, detail=f"Project '{projectId}' not found") updated = interface.updateProjekt(projectId, data) if not updated: - raise HTTPException(status_code=500, detail="Update failed") + raise HTTPException(status_code=500, detail=routeApiMsg("Update failed")) return updated @@ -360,7 +362,7 @@ def delete_project( if not projekt or str(getattr(projekt, "featureInstanceId", None)) != instanceId: raise HTTPException(status_code=404, detail=f"Project '{projectId}' not found") if not interface.deleteProjekt(projectId): - raise HTTPException(status_code=500, detail="Delete failed") + raise HTTPException(status_code=500, detail=routeApiMsg("Delete failed")) # ----- Parcels CRUD ----- @@ -496,7 +498,7 @@ def update_parcel( raise HTTPException(status_code=404, detail=f"Parcel '{parcelId}' not found") updated = interface.updateParzelle(parcelId, data) if not updated: - raise HTTPException(status_code=500, detail="Update failed") + raise HTTPException(status_code=500, detail=routeApiMsg("Update failed")) return updated @@ -517,7 +519,7 @@ def delete_parcel( if not parzelle or str(getattr(parzelle, "featureInstanceId", None)) != instanceId: raise HTTPException(status_code=404, detail=f"Parcel '{parcelId}' not found") if not interface.deleteParzelle(parcelId): - raise HTTPException(status_code=500, detail="Delete failed") + raise HTTPException(status_code=500, detail=routeApiMsg("Delete failed")) # ===== Helpers for Gemeinde/BZO routes ===== @@ -885,7 +887,7 @@ async def process_command( logger.warning(f"CSRF token missing for POST /api/realestate/command from user {context.user.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="CSRF token missing. Please include X-CSRF-Token header." + detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") ) # Basic CSRF token format validation @@ -893,7 +895,7 @@ async def process_command( logger.warning(f"Invalid CSRF token format for POST /api/realestate/command from user {context.user.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid CSRF token format" + detail=routeApiMsg("Invalid CSRF token format") ) # Validate token is hex string @@ -903,7 +905,7 @@ async def process_command( logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/command from user {context.user.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid CSRF token format" + detail=routeApiMsg("Invalid CSRF token format") ) logger.info(f"Processing command request from user {context.user.id} (mandate: {context.mandateId})") @@ -957,7 +959,7 @@ def get_available_tables( logger.warning(f"CSRF token missing for GET /api/realestate/tables from user {context.user.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="CSRF token missing. Please include X-CSRF-Token header." + detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") ) # Basic CSRF token format validation @@ -965,7 +967,7 @@ def get_available_tables( logger.warning(f"Invalid CSRF token format for GET /api/realestate/tables from user {context.user.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid CSRF token format" + detail=routeApiMsg("Invalid CSRF token format") ) # Validate token is hex string @@ -975,7 +977,7 @@ def get_available_tables( logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/tables from user {context.user.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid CSRF token format" + detail=routeApiMsg("Invalid CSRF token format") ) logger.info(f"Getting available tables for user {context.user.id} (mandate: {context.mandateId})") @@ -1066,7 +1068,7 @@ def get_table_data( logger.warning(f"CSRF token missing for GET /api/realestate/table/{table} from user {context.user.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="CSRF token missing. Please include X-CSRF-Token header." + detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") ) # Basic CSRF token format validation @@ -1074,7 +1076,7 @@ def get_table_data( logger.warning(f"Invalid CSRF token format for GET /api/realestate/table/{table} from user {context.user.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid CSRF token format" + detail=routeApiMsg("Invalid CSRF token format") ) # Validate token is hex string @@ -1084,7 +1086,7 @@ def get_table_data( logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/table/{table} from user {context.user.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid CSRF token format" + detail=routeApiMsg("Invalid CSRF token format") ) logger.info(f"Getting table data for '{table}' from user {context.user.id} (mandate: {context.mandateId})") @@ -1235,7 +1237,7 @@ async def create_table_record( logger.warning(f"CSRF token missing for POST /api/realestate/table/{table} from user {context.user.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="CSRF token missing. Please include X-CSRF-Token header." + detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") ) # Basic CSRF token format validation @@ -1243,7 +1245,7 @@ async def create_table_record( logger.warning(f"Invalid CSRF token format for POST /api/realestate/table/{table} from user {context.user.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid CSRF token format" + detail=routeApiMsg("Invalid CSRF token format") ) # Validate token is hex string @@ -1253,7 +1255,7 @@ async def create_table_record( logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/table/{table} from user {context.user.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid CSRF token format" + detail=routeApiMsg("Invalid CSRF token format") ) # Special handling for Projekt with parcel data @@ -1265,7 +1267,7 @@ async def create_table_record( if not label: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="label is required" + detail=routeApiMsg("label is required") ) status_prozess = data.get("statusProzess", "Eingang") @@ -1278,7 +1280,7 @@ async def create_table_record( if not isinstance(parzellen_data, list): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="parzellen must be an array" + detail=routeApiMsg("parzellen must be an array") ) elif "parzelle" in data: # Single parcel @@ -1289,7 +1291,7 @@ async def create_table_record( if not parzellen_data: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="parzelle or parzellen data is required" + detail=routeApiMsg("parzelle or parzellen data is required") ) # Use helper function to create project with parcel data @@ -1402,7 +1404,7 @@ def get_parcels_wfs( logger.error(f"Error fetching WFS parcels: {e}", exc_info=True) raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, - detail="Failed to fetch parcel data from WFS" + detail=routeApiMsg("Failed to fetch parcel data from WFS") ) @@ -1441,7 +1443,7 @@ async def search_parcel( logger.warning(f"CSRF token missing for GET /api/realestate/parcel/search from user {context.user.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="CSRF token missing. Please include X-CSRF-Token header." + detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") ) logger.info(f"Searching parcel for user {context.user.id} (mandate: {context.mandateId}) with location: {location}") @@ -1817,7 +1819,7 @@ async def parcel_selection_summary( if not csrf_token: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="CSRF token missing. Please include X-CSRF-Token header." + detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") ) parcels = body.get("parcels", []) if not parcels: @@ -1868,19 +1870,19 @@ async def add_adjacent_parcel( if not csrf_token: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="CSRF token missing. Please include X-CSRF-Token header." + detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") ) location = body.get("location") selected_parcels = body.get("selected_parcels", []) if not location or "x" not in location or "y" not in location: - raise HTTPException(status_code=400, detail="location with x,y required") + raise HTTPException(status_code=400, detail=routeApiMsg("location with x,y required")) loc_str = f"{location['x']},{location['y']}" connector = SwissTopoMapServerConnector() parcel_data = await connector.search_parcel(loc_str) if not parcel_data: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="No parcel found at this location" + detail=routeApiMsg("No parcel found at this location") ) extracted = connector.extract_parcel_attributes(parcel_data) attributes = parcel_data.get("attributes", {}) @@ -1932,7 +1934,7 @@ async def add_adjacent_parcel( if not is_parcel_adjacent_to_selection(new_parcel_response, selected_parcels): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Nur angrenzende Parzellen können hinzugefügt werden" + detail=routeApiMsg("Nur angrenzende Parzellen können hinzugefügt werden") ) bbox = parcel_data.get("bbox", []) map_view["zoom_bounds"] = { @@ -2020,21 +2022,21 @@ async def add_parcel_to_project( logger.warning(f"CSRF token missing for POST /api/realestate/projekt/{projekt_id}/add-parcel from user {context.user.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="CSRF token missing. Please include X-CSRF-Token header." + detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") ) # Validate CSRF token format if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid CSRF token format" + detail=routeApiMsg("Invalid CSRF token format") ) try: int(csrf_token, 16) except ValueError: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid CSRF token format" + detail=routeApiMsg("Invalid CSRF token format") ) logger.info(f"Adding parcel to project {projekt_id} for user {context.user.id} (mandate: {context.mandateId})") diff --git a/modules/features/teamsbot/mainTeamsbot.py b/modules/features/teamsbot/mainTeamsbot.py index ea6d3b01..02d7c333 100644 --- a/modules/features/teamsbot/mainTeamsbot.py +++ b/modules/features/teamsbot/mainTeamsbot.py @@ -12,24 +12,24 @@ logger = logging.getLogger(__name__) # Feature metadata FEATURE_CODE = "teamsbot" -FEATURE_LABEL = {"en": "Teams Bot", "de": "Teams Bot", "fr": "Teams Bot"} +FEATURE_LABEL = "Teams Bot" FEATURE_ICON = "mdi-headset" # UI Objects for RBAC catalog UI_OBJECTS = [ { "objectKey": "ui.feature.teamsbot.dashboard", - "label": {"en": "Dashboard", "de": "Dashboard", "fr": "Tableau de bord"}, + "label": "Dashboard", "meta": {"area": "dashboard"} }, { "objectKey": "ui.feature.teamsbot.sessions", - "label": {"en": "Sessions", "de": "Sitzungen", "fr": "Sessions"}, + "label": "Sitzungen", "meta": {"area": "sessions"} }, { "objectKey": "ui.feature.teamsbot.settings", - "label": {"en": "Settings", "de": "Einstellungen", "fr": "Paramètres"}, + "label": "Einstellungen", "meta": {"area": "settings", "admin_only": True} }, ] @@ -38,7 +38,7 @@ UI_OBJECTS = [ DATA_OBJECTS = [ { "objectKey": "data.feature.teamsbot.TeamsbotSession", - "label": {"en": "Session", "de": "Sitzung", "fr": "Session"}, + "label": "Sitzung", "meta": { "table": "TeamsbotSession", "fields": ["id", "meetingLink", "botName", "status", "startedAt", "endedAt"], @@ -48,7 +48,7 @@ DATA_OBJECTS = [ }, { "objectKey": "data.feature.teamsbot.TeamsbotTranscript", - "label": {"en": "Transcript", "de": "Transkript", "fr": "Transcription"}, + "label": "Transkript", "meta": { "table": "TeamsbotTranscript", "fields": ["id", "sessionId", "speaker", "text", "timestamp"], @@ -58,7 +58,7 @@ DATA_OBJECTS = [ }, { "objectKey": "data.feature.teamsbot.TeamsbotBotResponse", - "label": {"en": "Bot Response", "de": "Bot-Antwort", "fr": "Réponse du bot"}, + "label": "Bot-Antwort", "meta": { "table": "TeamsbotBotResponse", "fields": ["id", "sessionId", "responseText", "detectedIntent"], @@ -68,7 +68,7 @@ DATA_OBJECTS = [ }, { "objectKey": "data.feature.teamsbot.*", - "label": {"en": "All Teams Bot Data", "de": "Alle Teams Bot Daten", "fr": "Toutes les données Teams Bot"}, + "label": "Alle Teams Bot Daten", "meta": {"wildcard": True, "description": "Wildcard for all teamsbot data tables"} }, ] @@ -77,22 +77,22 @@ DATA_OBJECTS = [ RESOURCE_OBJECTS = [ { "objectKey": "resource.feature.teamsbot.session.start", - "label": {"en": "Start Session", "de": "Sitzung starten", "fr": "Démarrer session"}, + "label": "Sitzung starten", "meta": {"endpoint": "/api/teamsbot/{instanceId}/sessions", "method": "POST"} }, { "objectKey": "resource.feature.teamsbot.session.stop", - "label": {"en": "Stop Session", "de": "Sitzung beenden", "fr": "Arrêter session"}, + "label": "Sitzung beenden", "meta": {"endpoint": "/api/teamsbot/{instanceId}/sessions/{sessionId}/stop", "method": "POST"} }, { "objectKey": "resource.feature.teamsbot.session.delete", - "label": {"en": "Delete Session", "de": "Sitzung löschen", "fr": "Supprimer session"}, + "label": "Sitzung löschen", "meta": {"endpoint": "/api/teamsbot/{instanceId}/sessions/{sessionId}", "method": "DELETE"} }, { "objectKey": "resource.feature.teamsbot.config.edit", - "label": {"en": "Edit Configuration", "de": "Konfiguration bearbeiten", "fr": "Modifier configuration"}, + "label": "Konfiguration bearbeiten", "meta": {"endpoint": "/api/teamsbot/{instanceId}/config", "method": "PUT", "admin_only": True} }, ] @@ -101,11 +101,7 @@ RESOURCE_OBJECTS = [ TEMPLATE_ROLES = [ { "roleLabel": "teamsbot-admin", - "description": { - "en": "Teams Bot Administrator - Full access to all sessions and settings", - "de": "Teams Bot Administrator - Vollzugriff auf alle Sitzungen und Einstellungen", - "fr": "Administrateur Teams Bot - Accès complet aux sessions et paramètres" - }, + "description": "Teams Bot Administrator - Vollzugriff auf alle Sitzungen und Einstellungen", "accessRules": [ # Full UI access (all views including settings) {"context": "UI", "item": None, "view": True}, @@ -120,11 +116,7 @@ TEMPLATE_ROLES = [ }, { "roleLabel": "teamsbot-viewer", - "description": { - "en": "Teams Bot Viewer - View sessions and transcripts (read-only)", - "de": "Teams Bot Betrachter - Sitzungen und Transkripte ansehen (nur lesen)", - "fr": "Visualiseur Teams Bot - Consulter les sessions et transcriptions (lecture seule)", - }, + "description": "Teams Bot Betrachter - Sitzungen und Transkripte ansehen (nur lesen)", "accessRules": [ {"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.teamsbot.sessions", "view": True}, @@ -133,11 +125,7 @@ TEMPLATE_ROLES = [ }, { "roleLabel": "teamsbot-user", - "description": { - "en": "Teams Bot User - Can start/stop sessions and view transcripts", - "de": "Teams Bot Benutzer - Kann Sitzungen starten/stoppen und Transkripte einsehen", - "fr": "Utilisateur Teams Bot - Peut démarrer/arrêter des sessions et voir les transcriptions", - }, + "description": "Teams Bot Benutzer - Kann Sitzungen starten/stoppen und Transkripte einsehen", "accessRules": [ {"context": "UI", "item": "ui.feature.teamsbot.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.teamsbot.sessions", "view": True}, @@ -223,7 +211,8 @@ def _syncTemplateRolesToDb() -> int: try: from modules.interfaces.interfaceDbApp import getRootInterface from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext - + from modules.datamodels.datamodelUtils import coerce_text_multilingual + rootInterface = getRootInterface() existingRoles = rootInterface.getRolesByFeatureCode(FEATURE_CODE) templateRoles = [r for r in existingRoles if r.mandateId is None] @@ -239,7 +228,7 @@ def _syncTemplateRolesToDb() -> int: else: newRole = Role( roleLabel=roleLabel, - description=roleTemplate.get("description", {}), + description=coerce_text_multilingual(roleTemplate.get("description", {})), featureCode=FEATURE_CODE, mandateId=None, featureInstanceId=None, diff --git a/modules/features/teamsbot/routeFeatureTeamsbot.py b/modules/features/teamsbot/routeFeatureTeamsbot.py index c498a790..c2823a85 100644 --- a/modules/features/teamsbot/routeFeatureTeamsbot.py +++ b/modules/features/teamsbot/routeFeatureTeamsbot.py @@ -40,6 +40,8 @@ from .datamodelTeamsbot import ( # Import service from .service import TeamsbotService +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeFeatureTeamsbot") logger = logging.getLogger(__name__) @@ -71,7 +73,7 @@ def _extractTeamsMeetingUrl(rawInput: str) -> str: urls = re.findall(urlPattern, rawInput) if not urls: - raise HTTPException(status_code=400, detail="Kein gültiger Meeting-Link gefunden. Bitte einen Teams-Link eingeben.") + raise HTTPException(status_code=400, detail=routeApiMsg("Kein gültiger Meeting-Link gefunden. Bitte einen Teams-Link eingeben.")) # Step 2: Find the Teams URL (prefer direct teams.microsoft.com, then SafeLinks) teamsUrl = None @@ -101,7 +103,7 @@ def _extractTeamsMeetingUrl(rawInput: str) -> str: if not teamsUrl or "teams.microsoft.com" not in teamsUrl: raise HTTPException( status_code=400, - detail="Kein gültiger Teams-Meeting-Link gefunden. Der Link muss 'teams.microsoft.com' enthalten." + detail=routeApiMsg("Kein gültiger Teams-Meeting-Link gefunden. Der Link muss 'teams.microsoft.com' enthalten.") ) logger.info(f"Extracted meeting URL: {teamsUrl[:80]}... (from input length {len(rawInput)})") @@ -129,7 +131,7 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str: mandateId = instance.get("mandateId") if isinstance(instance, dict) else getattr(instance, "mandateId", None) if not mandateId: - raise HTTPException(status_code=500, detail="Feature instance has no mandateId") + raise HTTPException(status_code=500, detail=routeApiMsg("Feature instance has no mandateId")) return str(mandateId) @@ -463,7 +465,7 @@ async def deleteSession( # Don't delete active sessions currentStatus = session.get("status") if currentStatus in [TeamsbotSessionStatus.ACTIVE.value, TeamsbotSessionStatus.JOINING.value]: - raise HTTPException(status_code=400, detail="Cannot delete an active session. Stop it first.") + raise HTTPException(status_code=400, detail=routeApiMsg("Cannot delete an active session. Stop it first.")) interface.deleteSession(sessionId) logger.info(f"Teamsbot session {sessionId} deleted") @@ -639,7 +641,7 @@ async def listSystemBots( ): """List all system bot accounts for this mandate. Passwords are never returned.""" if not context.isSysAdmin: - raise HTTPException(status_code=403, detail="SysAdmin privileges required to manage system bots") + raise HTTPException(status_code=403, detail=routeApiMsg("SysAdmin privileges required to manage system bots")) mandateId = _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) bots = interface.getSystemBots(mandateId) @@ -655,7 +657,7 @@ async def createSystemBot( ): """Create a new system bot account. Password is encrypted before storage.""" if not context.isSysAdmin: - raise HTTPException(status_code=403, detail="SysAdmin privileges required to manage system bots") + raise HTTPException(status_code=403, detail=routeApiMsg("SysAdmin privileges required to manage system bots")) mandateId = _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) @@ -666,7 +668,7 @@ async def createSystemBot( if not email or not password: from fastapi import HTTPException - raise HTTPException(status_code=400, detail="Email and password are required") + raise HTTPException(status_code=400, detail=routeApiMsg("Email and password are required")) # Encrypt the password from modules.shared.configuration import encryptValue @@ -698,7 +700,7 @@ async def deleteSystemBot( ): """Delete a system bot account.""" if not context.isSysAdmin: - raise HTTPException(status_code=403, detail="SysAdmin privileges required to manage system bots") + raise HTTPException(status_code=403, detail=routeApiMsg("SysAdmin privileges required to manage system bots")) _validateInstanceAccess(instanceId, context) interface = _getInterface(context, instanceId) @@ -750,7 +752,7 @@ async def saveUserAccount( displayName = body.get("displayName") if not email or not password: - raise HTTPException(status_code=400, detail="Email and password are required") + raise HTTPException(status_code=400, detail=routeApiMsg("Email and password are required")) from modules.shared.configuration import encryptValue encryptedPassword = encryptValue(password, userId=userId, keyName="userAccountPassword") @@ -827,7 +829,7 @@ async def submitMfaCode( await queue.put({"action": mfaAction, "code": mfaCode}) return {"submitted": True} else: - raise HTTPException(status_code=404, detail="No active MFA challenge for this session") + raise HTTPException(status_code=404, detail=routeApiMsg("No active MFA challenge for this session")) # ========================================================================= @@ -925,7 +927,7 @@ async def testAuth( Does NOT join the meeting — only checks which page Teams serves. """ if not context.isSysAdmin: - raise HTTPException(status_code=403, detail="SysAdmin privileges required for auth testing (uses system bot credentials)") + raise HTTPException(status_code=403, detail=routeApiMsg("SysAdmin privileges required for auth testing (uses system bot credentials)")) import aiohttp mandateId = _validateInstanceAccess(instanceId, context) @@ -935,7 +937,7 @@ async def testAuth( body = await request.json() meetingUrl = body.get("meetingUrl") if not meetingUrl: - raise HTTPException(status_code=400, detail="meetingUrl is required") + raise HTTPException(status_code=400, detail=routeApiMsg("meetingUrl is required")) # Load system bot credentials: # 1. Use email/password from request body (direct override) @@ -1000,7 +1002,7 @@ async def testAuth( # Forward to browser bot service (single all-in-one call — may timeout with many variants) browserBotUrl = effectiveConfig._getEffectiveBrowserBotUrl() if not browserBotUrl: - raise HTTPException(status_code=503, detail="Browser Bot URL not configured") + raise HTTPException(status_code=503, detail=routeApiMsg("Browser Bot URL not configured")) browserBotUrl = browserBotUrl.rstrip("/") payload = { @@ -1037,14 +1039,14 @@ async def getTestAuthVariants( Frontend calls this once, then runs each variant individually. """ if not context.isSysAdmin: - raise HTTPException(status_code=403, detail="SysAdmin privileges required for auth testing") + raise HTTPException(status_code=403, detail=routeApiMsg("SysAdmin privileges required for auth testing")) import aiohttp _validateInstanceAccess(instanceId, context) effectiveConfig = _getInstanceConfig(instanceId) browserBotUrl = effectiveConfig._getEffectiveBrowserBotUrl() if not browserBotUrl: - raise HTTPException(status_code=503, detail="Browser Bot URL not configured") + raise HTTPException(status_code=503, detail=routeApiMsg("Browser Bot URL not configured")) browserBotUrl = browserBotUrl.rstrip("/") try: @@ -1073,7 +1075,7 @@ async def testAuthSingleVariant( Each call stays within Azure's 240s timeout. """ if not context.isSysAdmin: - raise HTTPException(status_code=403, detail="SysAdmin privileges required for auth testing (uses system bot credentials)") + raise HTTPException(status_code=403, detail=routeApiMsg("SysAdmin privileges required for auth testing (uses system bot credentials)")) import aiohttp mandateId = _validateInstanceAccess(instanceId, context) @@ -1084,7 +1086,7 @@ async def testAuthSingleVariant( variantId = body.get("variantId") meetingUrl = body.get("meetingUrl") if not variantId or not meetingUrl: - raise HTTPException(status_code=400, detail="variantId and meetingUrl are required") + raise HTTPException(status_code=400, detail=routeApiMsg("variantId and meetingUrl are required")) # Load credentials (same logic as testAuth) email = body.get("botEmail") @@ -1116,7 +1118,7 @@ async def testAuthSingleVariant( browserBotUrl = effectiveConfig._getEffectiveBrowserBotUrl() if not browserBotUrl: - raise HTTPException(status_code=503, detail="Browser Bot URL not configured") + raise HTTPException(status_code=503, detail=routeApiMsg("Browser Bot URL not configured")) browserBotUrl = browserBotUrl.rstrip("/") payload = { @@ -1157,12 +1159,12 @@ async def listSessionScreenshots( ): """List debug screenshots for a session. Proxied from Browser Bot filesystem.""" if not context.isSysAdmin: - raise HTTPException(status_code=403, detail="SysAdmin privileges required") + raise HTTPException(status_code=403, detail=routeApiMsg("SysAdmin privileges required")) _validateInstanceAccess(instanceId, context) effectiveConfig = _getInstanceConfig(instanceId) browserBotUrl = effectiveConfig._getEffectiveBrowserBotUrl() if not browserBotUrl: - raise HTTPException(status_code=503, detail="Browser Bot URL not configured") + raise HTTPException(status_code=503, detail=routeApiMsg("Browser Bot URL not configured")) import aiohttp browserBotUrl = browserBotUrl.rstrip("/") @@ -1194,16 +1196,16 @@ async def getScreenshotFile( ): """Serve a single debug screenshot image. Proxied from Browser Bot.""" if not context.isSysAdmin: - raise HTTPException(status_code=403, detail="SysAdmin privileges required") + raise HTTPException(status_code=403, detail=routeApiMsg("SysAdmin privileges required")) _validateInstanceAccess(instanceId, context) if not filename.endswith(".png") or ".." in filename or "/" in filename or "\\" in filename: - raise HTTPException(status_code=400, detail="Invalid filename") + raise HTTPException(status_code=400, detail=routeApiMsg("Invalid filename")) effectiveConfig = _getInstanceConfig(instanceId) browserBotUrl = effectiveConfig._getEffectiveBrowserBotUrl() if not browserBotUrl: - raise HTTPException(status_code=503, detail="Browser Bot URL not configured") + raise HTTPException(status_code=503, detail=routeApiMsg("Browser Bot URL not configured")) import aiohttp from fastapi.responses import Response as FastAPIResponse @@ -1216,7 +1218,7 @@ async def getScreenshotFile( imageBytes = await resp.read() return FastAPIResponse(content=imageBytes, media_type="image/png") else: - raise HTTPException(status_code=resp.status, detail="Screenshot not found") + raise HTTPException(status_code=resp.status, detail=routeApiMsg("Screenshot not found")) except aiohttp.ClientError as e: logger.error(f"Screenshot file error: {e}") raise HTTPException(status_code=503, detail=f"Browser Bot connection failed: {str(e)}") diff --git a/modules/features/trustee/datamodelFeatureTrustee.py b/modules/features/trustee/datamodelFeatureTrustee.py index 0889e361..8d0ed00c 100644 --- a/modules/features/trustee/datamodelFeatureTrustee.py +++ b/modules/features/trustee/datamodelFeatureTrustee.py @@ -7,15 +7,16 @@ from typing import Optional from pydantic import BaseModel, Field from modules.datamodels.datamodelBase import PowerOnModel -from modules.shared.attributeUtils import registerModelLabels +from modules.shared.i18nRegistry import i18nModel import uuid - +@i18nModel("Organisation") class TrusteeOrganisation(PowerOnModel): """Represents trustee organisations (companies) within the Trustee feature.""" id: str = Field( # Unique string label (PK), not UUID description="Unique organisation identifier (label)", json_schema_extra={ + "label": "ID", "frontend_type": "text", "frontend_readonly": False, # Editable at creation, then readonly "frontend_required": True @@ -24,6 +25,7 @@ class TrusteeOrganisation(PowerOnModel): label: str = Field( description="Company name", json_schema_extra={ + "label": "Bezeichnung", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True @@ -33,6 +35,7 @@ class TrusteeOrganisation(PowerOnModel): default=True, description="Whether the organisation is enabled", json_schema_extra={ + "label": "Aktiviert", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False @@ -42,6 +45,7 @@ class TrusteeOrganisation(PowerOnModel): default=None, description="Mandate ID (system-level organisation)", json_schema_extra={ + "label": "Mandat", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False @@ -51,6 +55,7 @@ class TrusteeOrganisation(PowerOnModel): default=None, description="Feature Instance ID for instance-level isolation", json_schema_extra={ + "label": "Feature-Instanz", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False @@ -59,25 +64,13 @@ class TrusteeOrganisation(PowerOnModel): # System attributes are automatically set by DatabaseConnector: # sysCreatedAt, sysModifiedAt, sysCreatedBy, sysModifiedBy (PowerOnModel) - -registerModelLabels( - "TrusteeOrganisation", - {"en": "Organisation", "fr": "Organisation", "de": "Organisation"}, - { - "id": {"en": "ID", "fr": "ID", "de": "ID"}, - "label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"}, - "enabled": {"en": "Enabled", "fr": "Activé", "de": "Aktiviert"}, - "mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"}, - "featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"}, - }, -) - - +@i18nModel("Rolle") class TrusteeRole(PowerOnModel): """Defines roles within the Trustee feature.""" id: str = Field( # Unique string label (PK), not UUID description="Unique role identifier (label)", json_schema_extra={ + "label": "ID", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True @@ -86,6 +79,7 @@ class TrusteeRole(PowerOnModel): desc: str = Field( description="Role description", json_schema_extra={ + "label": "Beschreibung", "frontend_type": "textarea", "frontend_readonly": False, "frontend_required": True @@ -95,6 +89,7 @@ class TrusteeRole(PowerOnModel): default=None, description="Mandate ID", json_schema_extra={ + "label": "Mandat", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False @@ -104,6 +99,7 @@ class TrusteeRole(PowerOnModel): default=None, description="Feature Instance ID for instance-level isolation", json_schema_extra={ + "label": "Feature-Instanz", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False @@ -111,25 +107,14 @@ class TrusteeRole(PowerOnModel): ) # System attributes are automatically set by DatabaseConnector - -registerModelLabels( - "TrusteeRole", - {"en": "Role", "fr": "Rôle", "de": "Rolle"}, - { - "id": {"en": "ID", "fr": "ID", "de": "ID"}, - "desc": {"en": "Description", "fr": "Description", "de": "Beschreibung"}, - "mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"}, - "featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"}, - }, -) - - +@i18nModel("Zugriff") class TrusteeAccess(PowerOnModel): """Defines user access to organisations with specific roles.""" id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Unique access ID", json_schema_extra={ + "label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False @@ -138,6 +123,7 @@ class TrusteeAccess(PowerOnModel): organisationId: str = Field( description="Reference to TrusteeOrganisation.id", json_schema_extra={ + "label": "Organisation", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, @@ -147,6 +133,7 @@ class TrusteeAccess(PowerOnModel): roleId: str = Field( description="Reference to TrusteeRole.id", json_schema_extra={ + "label": "Rolle", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, @@ -156,6 +143,7 @@ class TrusteeAccess(PowerOnModel): userId: str = Field( description="User ID assigned to this role", json_schema_extra={ + "label": "Benutzer", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, @@ -166,6 +154,7 @@ class TrusteeAccess(PowerOnModel): default=None, description="Optional reference to TrusteeContract.id. If None, access is for full organisation. If set, access is limited to this specific contract.", json_schema_extra={ + "label": "Vertrag (optional)", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, @@ -177,6 +166,7 @@ class TrusteeAccess(PowerOnModel): default=None, description="Mandate ID", json_schema_extra={ + "label": "Mandat", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False @@ -186,6 +176,7 @@ class TrusteeAccess(PowerOnModel): default=None, description="Feature Instance ID for instance-level isolation", json_schema_extra={ + "label": "Feature-Instanz", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False @@ -193,28 +184,14 @@ class TrusteeAccess(PowerOnModel): ) # System attributes are automatically set by DatabaseConnector - -registerModelLabels( - "TrusteeAccess", - {"en": "Access", "fr": "Accès", "de": "Zugriff"}, - { - "id": {"en": "ID", "fr": "ID", "de": "ID"}, - "organisationId": {"en": "Organisation", "fr": "Organisation", "de": "Organisation"}, - "roleId": {"en": "Role", "fr": "Rôle", "de": "Rolle"}, - "userId": {"en": "User", "fr": "Utilisateur", "de": "Benutzer"}, - "contractId": {"en": "Contract (optional)", "fr": "Contrat (optionnel)", "de": "Vertrag (optional)"}, - "mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"}, - "featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"}, - }, -) - - +@i18nModel("Vertrag") class TrusteeContract(PowerOnModel): """Defines customer contracts within organisations.""" id: str = Field( default_factory=lambda: str(uuid.uuid4()), description="Unique contract ID", json_schema_extra={ + "label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False @@ -223,6 +200,7 @@ class TrusteeContract(PowerOnModel): organisationId: str = Field( description="Reference to TrusteeOrganisation.id (immutable after creation)", json_schema_extra={ + "label": "Organisation", "frontend_type": "select", "frontend_readonly": False, # Editable at creation, then readonly "frontend_required": True, @@ -232,6 +210,7 @@ class TrusteeContract(PowerOnModel): label: str = Field( description="Label for the customer contract (e.g., 'Muster AG 2026')", json_schema_extra={ + "label": "Bezeichnung", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True @@ -241,6 +220,7 @@ class TrusteeContract(PowerOnModel): default=True, description="Whether the contract is enabled", json_schema_extra={ + "label": "Aktiviert", "frontend_type": "checkbox", "frontend_readonly": False, "frontend_required": False @@ -250,6 +230,7 @@ class TrusteeContract(PowerOnModel): default=None, description="Mandate ID", json_schema_extra={ + "label": "Mandat", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False @@ -259,6 +240,7 @@ class TrusteeContract(PowerOnModel): default=None, description="Feature Instance ID for instance-level isolation", json_schema_extra={ + "label": "Feature-Instanz", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False @@ -266,21 +248,6 @@ class TrusteeContract(PowerOnModel): ) # System attributes are automatically set by DatabaseConnector - -registerModelLabels( - "TrusteeContract", - {"en": "Contract", "fr": "Contrat", "de": "Vertrag"}, - { - "id": {"en": "ID", "fr": "ID", "de": "ID"}, - "organisationId": {"en": "Organisation", "fr": "Organisation", "de": "Organisation"}, - "label": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"}, - "enabled": {"en": "Enabled", "fr": "Activé", "de": "Aktiviert"}, - "mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"}, - "featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"}, - }, -) - - class TrusteeDocumentTypeEnum(str, Enum): """Document type for trustee documents (expense extraction, ingest, sync).""" INVOICE = "invoice" @@ -290,7 +257,7 @@ class TrusteeDocumentTypeEnum(str, Enum): UNKNOWN = "unknown" AUTO = "auto" - +@i18nModel("Dokument") class TrusteeDocument(PowerOnModel): """Contains document references for bookings. @@ -305,6 +272,7 @@ class TrusteeDocument(PowerOnModel): default_factory=lambda: str(uuid.uuid4()), description="Unique document ID", json_schema_extra={ + "label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False @@ -314,6 +282,7 @@ class TrusteeDocument(PowerOnModel): default=None, description="Reference to central Files table (Files.id)", json_schema_extra={ + "label": "Datei-Referenz", "frontend_type": "file_reference", "frontend_readonly": False, "frontend_required": False @@ -322,6 +291,7 @@ class TrusteeDocument(PowerOnModel): documentName: str = Field( description="File name (e.g., 'Beleg.pdf')", json_schema_extra={ + "label": "Dokumentname", "frontend_type": "text", "frontend_readonly": False, "frontend_required": True @@ -331,6 +301,7 @@ class TrusteeDocument(PowerOnModel): default="application/octet-stream", description="MIME type of the document", json_schema_extra={ + "label": "MIME-Typ", "frontend_type": "select", "frontend_readonly": False, "frontend_required": True, @@ -341,6 +312,7 @@ class TrusteeDocument(PowerOnModel): default=None, description="Source type (e.g., 'sharepoint', 'upload', 'email')", json_schema_extra={ + "label": "Quelltyp", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False @@ -350,6 +322,7 @@ class TrusteeDocument(PowerOnModel): default=None, description="Original source location (e.g., SharePoint path)", json_schema_extra={ + "label": "Quellort", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False @@ -359,6 +332,7 @@ class TrusteeDocument(PowerOnModel): default=None, description="Mandate ID (auto-set from context)", json_schema_extra={ + "label": "Mandat", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False, @@ -369,6 +343,7 @@ class TrusteeDocument(PowerOnModel): default=None, description="Feature Instance ID for instance-level isolation (auto-set from context)", json_schema_extra={ + "label": "Feature-Instanz", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False, @@ -379,6 +354,7 @@ class TrusteeDocument(PowerOnModel): default=None, description="Document type (e.g. invoice, expense_receipt, bank_document, contract); use TrusteeDocumentTypeEnum values", json_schema_extra={ + "label": "Dokumenttyp", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False @@ -388,6 +364,7 @@ class TrusteeDocument(PowerOnModel): default=None, description="External Beleg-ID in accounting system (e.g. RMA); set on first successful upload, reused on re-sync", json_schema_extra={ + "label": "Beleg-ID (Buchhaltung)", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False, @@ -396,25 +373,7 @@ class TrusteeDocument(PowerOnModel): ) # System attributes are automatically set by DatabaseConnector - -registerModelLabels( - "TrusteeDocument", - {"en": "Document", "fr": "Document", "de": "Dokument"}, - { - "id": {"en": "ID", "fr": "ID", "de": "ID"}, - "fileId": {"en": "File Reference", "fr": "Référence du fichier", "de": "Datei-Referenz"}, - "documentName": {"en": "Document Name", "fr": "Nom du document", "de": "Dokumentname"}, - "documentMimeType": {"en": "MIME Type", "fr": "Type MIME", "de": "MIME-Typ"}, - "sourceType": {"en": "Source Type", "fr": "Type de source", "de": "Quelltyp"}, - "sourceLocation": {"en": "Source Location", "fr": "Emplacement source", "de": "Quellort"}, - "mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"}, - "featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"}, - "documentType": {"en": "Document Type", "fr": "Type de document", "de": "Dokumenttyp"}, - "externalBelegId": {"en": "Beleg ID (Accounting)", "fr": "ID Beleg (Comptabilité)", "de": "Beleg-ID (Buchhaltung)"}, - }, -) - - +@i18nModel("Position") class TrusteePosition(PowerOnModel): """Contains booking positions (expense entries). @@ -425,6 +384,7 @@ class TrusteePosition(PowerOnModel): default_factory=lambda: str(uuid.uuid4()), description="Unique position ID", json_schema_extra={ + "label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False @@ -434,6 +394,7 @@ class TrusteePosition(PowerOnModel): default=None, description="Reference to TrusteeDocument.id (Beleg / primary document)", json_schema_extra={ + "label": "Dokument", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, @@ -444,6 +405,7 @@ class TrusteePosition(PowerOnModel): default=None, description="Reference to TrusteeDocument.id (Bank-Referenz / second document)", json_schema_extra={ + "label": "Bank-Referenz", "frontend_type": "select", "frontend_readonly": False, "frontend_required": False, @@ -454,6 +416,7 @@ class TrusteePosition(PowerOnModel): default=None, description="Value date (ISO format: YYYY-MM-DD)", json_schema_extra={ + "label": "Valutadatum", "frontend_type": "date", "frontend_readonly": False, "frontend_required": True @@ -463,6 +426,7 @@ class TrusteePosition(PowerOnModel): default=None, description="Transaction timestamp (UTC timestamp in seconds)", json_schema_extra={ + "label": "Transaktionszeitpunkt", "frontend_type": "timestamp", "frontend_readonly": False, "frontend_required": True @@ -472,6 +436,7 @@ class TrusteePosition(PowerOnModel): default="", description="Company name", json_schema_extra={ + "label": "Firma", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False @@ -481,6 +446,7 @@ class TrusteePosition(PowerOnModel): default="", description="Description", json_schema_extra={ + "label": "Beschreibung", "frontend_type": "textarea", "frontend_readonly": False, "frontend_required": False @@ -490,6 +456,7 @@ class TrusteePosition(PowerOnModel): default="", description="Tags (comma-separated)", json_schema_extra={ + "label": "Tags", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False @@ -514,6 +481,7 @@ class TrusteePosition(PowerOnModel): default=0.0, description="Booking amount", json_schema_extra={ + "label": "Buchungsbetrag", "frontend_type": "number", "frontend_readonly": False, "frontend_required": True @@ -538,6 +506,7 @@ class TrusteePosition(PowerOnModel): default=0.0, description="Original amount (manual input, no automatic currency conversion)", json_schema_extra={ + "label": "Originalbetrag", "frontend_type": "number", "frontend_readonly": False, "frontend_required": True @@ -547,6 +516,7 @@ class TrusteePosition(PowerOnModel): default=0.0, description="VAT percentage", json_schema_extra={ + "label": "MwSt-Prozentsatz", "frontend_type": "number", "frontend_readonly": False, "frontend_required": False @@ -556,6 +526,7 @@ class TrusteePosition(PowerOnModel): default=0.0, description="VAT amount (calculated: bookingAmount * vatPercentage / 100, can be manually overridden)", json_schema_extra={ + "label": "MwSt-Betrag", "frontend_type": "number", "frontend_readonly": False, "frontend_required": False @@ -565,6 +536,7 @@ class TrusteePosition(PowerOnModel): default=None, description="Debit account number (e.g. '4200' for expenses)", json_schema_extra={ + "label": "Soll-Konto", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False @@ -574,6 +546,7 @@ class TrusteePosition(PowerOnModel): default=None, description="Credit account number (e.g. '1020' for bank)", json_schema_extra={ + "label": "Haben-Konto", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False @@ -583,6 +556,7 @@ class TrusteePosition(PowerOnModel): default=None, description="Tax code for the accounting system", json_schema_extra={ + "label": "Steuercode", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False @@ -592,6 +566,7 @@ class TrusteePosition(PowerOnModel): default=None, description="Cost center identifier", json_schema_extra={ + "label": "Kostenstelle", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False @@ -601,6 +576,7 @@ class TrusteePosition(PowerOnModel): default=None, description="Booking reference (e.g. voucher number)", json_schema_extra={ + "label": "Buchungsreferenz", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False @@ -626,6 +602,7 @@ class TrusteePosition(PowerOnModel): default=None, description="IBAN of the payment recipient (from invoice / QR code)", json_schema_extra={ + "label": "Empfänger-IBAN", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False @@ -635,6 +612,7 @@ class TrusteePosition(PowerOnModel): default=None, description="Bank or account holder name of the payment recipient", json_schema_extra={ + "label": "Empfänger-Name", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False @@ -644,6 +622,7 @@ class TrusteePosition(PowerOnModel): default=None, description="BIC / SWIFT code of the recipient bank", json_schema_extra={ + "label": "Empfänger-BIC", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False @@ -653,6 +632,7 @@ class TrusteePosition(PowerOnModel): default=None, description="Structured payment reference (QR-Referenz, ESR, SCOR, Mitteilung)", json_schema_extra={ + "label": "Zahlungsreferenz", "frontend_type": "text", "frontend_readonly": False, "frontend_required": False @@ -662,6 +642,7 @@ class TrusteePosition(PowerOnModel): default=None, description="Payment due date (ISO format: YYYY-MM-DD)", json_schema_extra={ + "label": "Fälligkeitsdatum", "frontend_type": "date", "frontend_readonly": False, "frontend_required": False @@ -671,6 +652,7 @@ class TrusteePosition(PowerOnModel): default=None, description="Mandate ID (auto-set from context)", json_schema_extra={ + "label": "Mandat", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False, @@ -681,6 +663,7 @@ class TrusteePosition(PowerOnModel): default=None, description="Feature Instance ID for instance-level isolation (auto-set from context)", json_schema_extra={ + "label": "Feature-Instanz", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False, @@ -691,6 +674,7 @@ class TrusteePosition(PowerOnModel): default=None, description="External ID (UUID) of the synced record in the accounting system; set by sync, used for duplicate check", json_schema_extra={ + "label": "Buha-Sync-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False, @@ -698,283 +682,118 @@ class TrusteePosition(PowerOnModel): } ) -registerModelLabels( - "TrusteePosition", - {"en": "Position", "fr": "Position", "de": "Position"}, - { - "id": {"en": "ID", "fr": "ID", "de": "ID"}, - "documentId": {"en": "Document", "fr": "Document", "de": "Dokument"}, - "bankDocumentId": {"en": "Bank Reference", "fr": "Référence bancaire", "de": "Bank-Referenz"}, - "valuta": {"en": "Value Date", "fr": "Date de valeur", "de": "Valutadatum"}, - "transactionDateTime": {"en": "Transaction Date/Time", "fr": "Date/Heure de transaction", "de": "Transaktionszeitpunkt"}, - "company": {"en": "Company", "fr": "Entreprise", "de": "Firma"}, - "desc": {"en": "Description", "fr": "Description", "de": "Beschreibung"}, - "tags": {"en": "Tags", "fr": "Tags", "de": "Tags"}, - "bookingCurrency": {"en": "Booking Currency", "fr": "Devise de comptabilisation", "de": "Buchungswährung"}, - "bookingAmount": {"en": "Booking Amount", "fr": "Montant de comptabilisation", "de": "Buchungsbetrag"}, - "originalCurrency": {"en": "Original Currency", "fr": "Devise d'origine", "de": "Originalwährung"}, - "originalAmount": {"en": "Original Amount", "fr": "Montant d'origine", "de": "Originalbetrag"}, - "vatPercentage": {"en": "VAT Percentage", "fr": "Pourcentage TVA", "de": "MwSt-Prozentsatz"}, - "vatAmount": {"en": "VAT Amount", "fr": "Montant TVA", "de": "MwSt-Betrag"}, - "debitAccountNumber": {"en": "Debit Account", "fr": "Compte débit", "de": "Soll-Konto"}, - "creditAccountNumber": {"en": "Credit Account", "fr": "Compte crédit", "de": "Haben-Konto"}, - "taxCode": {"en": "Tax Code", "fr": "Code TVA", "de": "Steuercode"}, - "costCenter": {"en": "Cost Center", "fr": "Centre de coûts", "de": "Kostenstelle"}, - "bookingReference": {"en": "Booking Reference", "fr": "Référence de réservation", "de": "Buchungsreferenz"}, - "documentType": {"en": "Document Type", "fr": "Type de document", "de": "Dokumenttyp"}, - "payeeIban": {"en": "Payee IBAN", "fr": "IBAN bénéficiaire", "de": "Empfänger-IBAN"}, - "payeeName": {"en": "Payee Name", "fr": "Nom du bénéficiaire", "de": "Empfänger-Name"}, - "payeeBic": {"en": "Payee BIC/SWIFT", "fr": "BIC/SWIFT bénéficiaire", "de": "Empfänger-BIC"}, - "paymentReference": {"en": "Payment Reference", "fr": "Référence de paiement", "de": "Zahlungsreferenz"}, - "dueDate": {"en": "Due Date", "fr": "Date d'échéance", "de": "Fälligkeitsdatum"}, - "mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"}, - "featureInstanceId": {"en": "Feature Instance", "fr": "Instance de fonctionnalité", "de": "Feature-Instanz"}, - "accountingSyncId": {"en": "Accounting Sync ID", "fr": "ID sync comptabilité", "de": "Buha-Sync-ID"}, - }, -) - - # ── TrusteeData* tables (synced from external accounting apps for analysis) ── - +@i18nModel("Konto (Sync)") class TrusteeDataAccount(PowerOnModel): """Chart of accounts synced from external accounting system.""" - id: str = Field(default_factory=lambda: str(uuid.uuid4())) - accountNumber: str = Field(description="Account number (e.g. '1020')") - label: str = Field(default="", description="Account name") - accountType: Optional[str] = Field(default=None, description="asset / liability / equity / revenue / expense") - accountGroup: Optional[str] = Field(default=None, description="Account group/category") - currency: str = Field(default="CHF", description="Account currency") - isActive: bool = Field(default=True) - mandateId: Optional[str] = Field(default=None) - featureInstanceId: Optional[str] = Field(default=None) - - -registerModelLabels( - "TrusteeDataAccount", - {"en": "Account (Synced)", "de": "Konto (Sync)", "fr": "Compte (Sync)"}, - { - "id": {"en": "ID", "de": "ID", "fr": "ID"}, - "accountNumber": {"en": "Account Number", "de": "Kontonummer", "fr": "Numéro de compte"}, - "label": {"en": "Name", "de": "Bezeichnung", "fr": "Libellé"}, - "accountType": {"en": "Type", "de": "Typ", "fr": "Type"}, - "accountGroup": {"en": "Group", "de": "Gruppe", "fr": "Groupe"}, - "currency": {"en": "Currency", "de": "Währung", "fr": "Devise"}, - "isActive": {"en": "Active", "de": "Aktiv", "fr": "Actif"}, - "mandateId": {"en": "Mandate", "de": "Mandat", "fr": "Mandat"}, - "featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"}, - }, -) - + id: str = Field(default_factory=lambda: str(uuid.uuid4()), json_schema_extra={"label": "ID"}) + accountNumber: str = Field(description="Account number (e.g. '1020')", json_schema_extra={"label": "Kontonummer"}) + label: str = Field(default="", description="Account name", json_schema_extra={"label": "Bezeichnung"}) + accountType: Optional[str] = Field(default=None, description="asset / liability / equity / revenue / expense", json_schema_extra={"label": "Typ"}) + accountGroup: Optional[str] = Field(default=None, description="Account group/category", json_schema_extra={"label": "Gruppe"}) + currency: str = Field(default="CHF", description="Account currency", json_schema_extra={"label": "Währung"}) + isActive: bool = Field(default=True, json_schema_extra={"label": "Aktiv"}) + mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat"}) + featureInstanceId: Optional[str] = Field(default=None, json_schema_extra={"label": "Feature-Instanz"}) +@i18nModel("Buchung (Sync)") class TrusteeDataJournalEntry(PowerOnModel): """Journal entry header synced from external accounting system.""" - id: str = Field(default_factory=lambda: str(uuid.uuid4())) - externalId: Optional[str] = Field(default=None, description="ID in the source system") - bookingDate: Optional[str] = Field(default=None, description="Booking date (YYYY-MM-DD)") - reference: Optional[str] = Field(default=None, description="Booking reference / voucher number") - description: str = Field(default="", description="Booking text") - currency: str = Field(default="CHF") - totalAmount: float = Field(default=0.0, description="Total amount of entry") - mandateId: Optional[str] = Field(default=None) - featureInstanceId: Optional[str] = Field(default=None) - - -registerModelLabels( - "TrusteeDataJournalEntry", - {"en": "Journal Entry (Synced)", "de": "Buchung (Sync)", "fr": "Écriture (Sync)"}, - { - "id": {"en": "ID", "de": "ID", "fr": "ID"}, - "externalId": {"en": "External ID", "de": "Externe ID", "fr": "ID externe"}, - "bookingDate": {"en": "Date", "de": "Datum", "fr": "Date"}, - "reference": {"en": "Reference", "de": "Referenz", "fr": "Référence"}, - "description": {"en": "Description", "de": "Beschreibung", "fr": "Description"}, - "currency": {"en": "Currency", "de": "Währung", "fr": "Devise"}, - "totalAmount": {"en": "Amount", "de": "Betrag", "fr": "Montant"}, - "mandateId": {"en": "Mandate", "de": "Mandat", "fr": "Mandat"}, - "featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"}, - }, -) - + id: str = Field(default_factory=lambda: str(uuid.uuid4()), json_schema_extra={"label": "ID"}) + externalId: Optional[str] = Field(default=None, description="ID in the source system", json_schema_extra={"label": "Externe ID"}) + bookingDate: Optional[str] = Field(default=None, description="Booking date (YYYY-MM-DD)", json_schema_extra={"label": "Datum"}) + reference: Optional[str] = Field(default=None, description="Booking reference / voucher number", json_schema_extra={"label": "Referenz"}) + description: str = Field(default="", description="Booking text", json_schema_extra={"label": "Beschreibung"}) + currency: str = Field(default="CHF", json_schema_extra={"label": "Währung"}) + totalAmount: float = Field(default=0.0, description="Total amount of entry", json_schema_extra={"label": "Betrag"}) + mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat"}) + featureInstanceId: Optional[str] = Field(default=None, json_schema_extra={"label": "Feature-Instanz"}) +@i18nModel("Buchungszeile (Sync)") class TrusteeDataJournalLine(PowerOnModel): """Journal entry line (debit/credit) synced from external accounting system.""" - id: str = Field(default_factory=lambda: str(uuid.uuid4())) - journalEntryId: str = Field(description="FK → TrusteeDataJournalEntry.id") - accountNumber: str = Field(description="Account number") - debitAmount: float = Field(default=0.0) - creditAmount: float = Field(default=0.0) - currency: str = Field(default="CHF") - taxCode: Optional[str] = Field(default=None) - costCenter: Optional[str] = Field(default=None) - description: str = Field(default="") - mandateId: Optional[str] = Field(default=None) - featureInstanceId: Optional[str] = Field(default=None) - - -registerModelLabels( - "TrusteeDataJournalLine", - {"en": "Journal Line (Synced)", "de": "Buchungszeile (Sync)", "fr": "Ligne écriture (Sync)"}, - { - "id": {"en": "ID", "de": "ID", "fr": "ID"}, - "journalEntryId": {"en": "Journal Entry", "de": "Buchung", "fr": "Écriture"}, - "accountNumber": {"en": "Account", "de": "Konto", "fr": "Compte"}, - "debitAmount": {"en": "Debit", "de": "Soll", "fr": "Débit"}, - "creditAmount": {"en": "Credit", "de": "Haben", "fr": "Crédit"}, - "currency": {"en": "Currency", "de": "Währung", "fr": "Devise"}, - "taxCode": {"en": "Tax Code", "de": "Steuercode", "fr": "Code TVA"}, - "costCenter": {"en": "Cost Center", "de": "Kostenstelle", "fr": "Centre de coûts"}, - "description": {"en": "Description", "de": "Beschreibung", "fr": "Description"}, - "mandateId": {"en": "Mandate", "de": "Mandat", "fr": "Mandat"}, - "featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"}, - }, -) - + id: str = Field(default_factory=lambda: str(uuid.uuid4()), json_schema_extra={"label": "ID"}) + journalEntryId: str = Field(description="FK → TrusteeDataJournalEntry.id", json_schema_extra={"label": "Buchung"}) + accountNumber: str = Field(description="Account number", json_schema_extra={"label": "Konto"}) + debitAmount: float = Field(default=0.0, json_schema_extra={"label": "Soll"}) + creditAmount: float = Field(default=0.0, json_schema_extra={"label": "Haben"}) + currency: str = Field(default="CHF", json_schema_extra={"label": "Währung"}) + taxCode: Optional[str] = Field(default=None, json_schema_extra={"label": "Steuercode"}) + costCenter: Optional[str] = Field(default=None, json_schema_extra={"label": "Kostenstelle"}) + description: str = Field(default="", json_schema_extra={"label": "Beschreibung"}) + mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat"}) + featureInstanceId: Optional[str] = Field(default=None, json_schema_extra={"label": "Feature-Instanz"}) +@i18nModel("Kontakt (Sync)") class TrusteeDataContact(PowerOnModel): """Customer or vendor synced from external accounting system.""" - id: str = Field(default_factory=lambda: str(uuid.uuid4())) - externalId: Optional[str] = Field(default=None, description="ID in the source system") - contactType: str = Field(default="customer", description="customer / vendor / both") - contactNumber: Optional[str] = Field(default=None, description="Customer/vendor number") - name: str = Field(default="", description="Name / company") - address: Optional[str] = Field(default=None) - zip: Optional[str] = Field(default=None) - city: Optional[str] = Field(default=None) - country: Optional[str] = Field(default=None) - email: Optional[str] = Field(default=None) - phone: Optional[str] = Field(default=None) - vatNumber: Optional[str] = Field(default=None) - mandateId: Optional[str] = Field(default=None) - featureInstanceId: Optional[str] = Field(default=None) - - -registerModelLabels( - "TrusteeDataContact", - {"en": "Contact (Synced)", "de": "Kontakt (Sync)", "fr": "Contact (Sync)"}, - { - "id": {"en": "ID", "de": "ID", "fr": "ID"}, - "externalId": {"en": "External ID", "de": "Externe ID", "fr": "ID externe"}, - "contactType": {"en": "Type", "de": "Typ", "fr": "Type"}, - "contactNumber": {"en": "Number", "de": "Nummer", "fr": "Numéro"}, - "name": {"en": "Name", "de": "Name", "fr": "Nom"}, - "address": {"en": "Address", "de": "Adresse", "fr": "Adresse"}, - "zip": {"en": "ZIP", "de": "PLZ", "fr": "NPA"}, - "city": {"en": "City", "de": "Ort", "fr": "Ville"}, - "country": {"en": "Country", "de": "Land", "fr": "Pays"}, - "email": {"en": "Email", "de": "E-Mail", "fr": "E-mail"}, - "phone": {"en": "Phone", "de": "Telefon", "fr": "Téléphone"}, - "vatNumber": {"en": "VAT Number", "de": "MWST-Nr.", "fr": "N° TVA"}, - "mandateId": {"en": "Mandate", "de": "Mandat", "fr": "Mandat"}, - "featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"}, - }, -) - + id: str = Field(default_factory=lambda: str(uuid.uuid4()), json_schema_extra={"label": "ID"}) + externalId: Optional[str] = Field(default=None, description="ID in the source system", json_schema_extra={"label": "Externe ID"}) + contactType: str = Field(default="customer", description="customer / vendor / both", json_schema_extra={"label": "Typ"}) + contactNumber: Optional[str] = Field(default=None, description="Customer/vendor number", json_schema_extra={"label": "Nummer"}) + name: str = Field(default="", description="Name / company", json_schema_extra={"label": "Name"}) + address: Optional[str] = Field(default=None, json_schema_extra={"label": "Adresse"}) + zip: Optional[str] = Field(default=None, json_schema_extra={"label": "PLZ"}) + city: Optional[str] = Field(default=None, json_schema_extra={"label": "Ort"}) + country: Optional[str] = Field(default=None, json_schema_extra={"label": "Land"}) + email: Optional[str] = Field(default=None, json_schema_extra={"label": "E-Mail"}) + phone: Optional[str] = Field(default=None, json_schema_extra={"label": "Telefon"}) + vatNumber: Optional[str] = Field(default=None, json_schema_extra={"label": "MWST-Nr."}) + mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat"}) + featureInstanceId: Optional[str] = Field(default=None, json_schema_extra={"label": "Feature-Instanz"}) +@i18nModel("Kontosaldo (Sync)") class TrusteeDataAccountBalance(PowerOnModel): """Account balance per period, derived from journal lines or directly from accounting system.""" - id: str = Field(default_factory=lambda: str(uuid.uuid4())) - accountNumber: str = Field(description="Account number") - periodYear: int = Field(description="Fiscal year") - periodMonth: int = Field(default=0, description="Month (1-12); 0 = annual total") - openingBalance: float = Field(default=0.0) - debitTotal: float = Field(default=0.0) - creditTotal: float = Field(default=0.0) - closingBalance: float = Field(default=0.0) - currency: str = Field(default="CHF") - mandateId: Optional[str] = Field(default=None) - featureInstanceId: Optional[str] = Field(default=None) - - -registerModelLabels( - "TrusteeDataAccountBalance", - {"en": "Account Balance (Synced)", "de": "Kontosaldo (Sync)", "fr": "Solde compte (Sync)"}, - { - "id": {"en": "ID", "de": "ID", "fr": "ID"}, - "accountNumber": {"en": "Account", "de": "Konto", "fr": "Compte"}, - "periodYear": {"en": "Year", "de": "Jahr", "fr": "Année"}, - "periodMonth": {"en": "Month", "de": "Monat", "fr": "Mois"}, - "openingBalance": {"en": "Opening Balance", "de": "Eröffnungssaldo", "fr": "Solde d'ouverture"}, - "debitTotal": {"en": "Debit Total", "de": "Soll-Umsatz", "fr": "Total débit"}, - "creditTotal": {"en": "Credit Total", "de": "Haben-Umsatz", "fr": "Total crédit"}, - "closingBalance": {"en": "Closing Balance", "de": "Schlusssaldo", "fr": "Solde de clôture"}, - "currency": {"en": "Currency", "de": "Währung", "fr": "Devise"}, - "mandateId": {"en": "Mandate", "de": "Mandat", "fr": "Mandat"}, - "featureInstanceId": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance"}, - }, -) - + id: str = Field(default_factory=lambda: str(uuid.uuid4()), json_schema_extra={"label": "ID"}) + accountNumber: str = Field(description="Account number", json_schema_extra={"label": "Konto"}) + periodYear: int = Field(description="Fiscal year", json_schema_extra={"label": "Jahr"}) + periodMonth: int = Field(default=0, description="Month (1-12); 0 = annual total", json_schema_extra={"label": "Monat"}) + openingBalance: float = Field(default=0.0, json_schema_extra={"label": "Eröffnungssaldo"}) + debitTotal: float = Field(default=0.0, json_schema_extra={"label": "Soll-Umsatz"}) + creditTotal: float = Field(default=0.0, json_schema_extra={"label": "Haben-Umsatz"}) + closingBalance: float = Field(default=0.0, json_schema_extra={"label": "Schlusssaldo"}) + currency: str = Field(default="CHF", json_schema_extra={"label": "Währung"}) + mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat"}) + featureInstanceId: Optional[str] = Field(default=None, json_schema_extra={"label": "Feature-Instanz"}) +@i18nModel("Buchhaltungs-Konfiguration") class TrusteeAccountingConfig(PowerOnModel): """Per-instance accounting system configuration with encrypted credentials. Each feature instance can connect to exactly one accounting system. Credentials are stored encrypted (decrypted at runtime by the AccountingBridge). """ - id: str = Field(default_factory=lambda: str(uuid.uuid4())) - featureInstanceId: str = Field(description="FK -> FeatureInstance.id (1:1)") - connectorType: str = Field(description="Connector type key, e.g. 'rma', 'bexio', 'abacus'") - displayLabel: str = Field(default="", description="User-visible label for this integration") - encryptedConfig: str = Field(default="", description="Encrypted JSON blob with connector credentials") - isActive: bool = Field(default=True) - lastSyncAt: Optional[float] = Field(default=None, description="Timestamp of last sync attempt") - lastSyncStatus: Optional[str] = Field(default=None, description="Last sync result: success, error, partial") - lastSyncErrorMessage: Optional[str] = Field(default=None, description="Error message when lastSyncStatus is error") - cachedChartOfAccounts: Optional[str] = Field(default=None, description="JSON-serialised chart of accounts cache (list of {accountNumber, label, accountType})") - chartCachedAt: Optional[float] = Field(default=None, description="Timestamp when cachedChartOfAccounts was last refreshed") - mandateId: Optional[str] = Field(default=None) - - -registerModelLabels( - "TrusteeAccountingConfig", - {"en": "Accounting Configuration", "de": "Buchhaltungs-Konfiguration", "fr": "Configuration comptable"}, - { - "id": {"en": "ID", "fr": "ID", "de": "ID"}, - "featureInstanceId": {"en": "Feature Instance", "fr": "Instance", "de": "Feature-Instanz"}, - "connectorType": {"en": "System", "fr": "Système", "de": "System"}, - "displayLabel": {"en": "Label", "fr": "Libellé", "de": "Bezeichnung"}, - "isActive": {"en": "Active", "fr": "Actif", "de": "Aktiv"}, - "lastSyncAt": {"en": "Last Sync", "fr": "Dernière sync.", "de": "Letzte Synchronisation"}, - "lastSyncStatus": {"en": "Status", "fr": "Statut", "de": "Status"}, - "lastSyncErrorMessage": {"en": "Error", "fr": "Erreur", "de": "Fehlermeldung"}, - "cachedChartOfAccounts": {"en": "Cached Chart", "de": "Cached Kontoplan", "fr": "Plan comptable en cache"}, - "chartCachedAt": {"en": "Chart Cached At", "de": "Kontoplan-Cache-Zeitpunkt", "fr": "Horodatage cache plan comptable"}, - "mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"}, - }, -) - + id: str = Field(default_factory=lambda: str(uuid.uuid4()), json_schema_extra={"label": "ID"}) + featureInstanceId: str = Field(description="FK -> FeatureInstance.id (1:1)", json_schema_extra={"label": "Feature-Instanz"}) + connectorType: str = Field(description="Connector type key, e.g. 'rma', 'bexio', 'abacus'", json_schema_extra={"label": "System"}) + displayLabel: str = Field(default="", description="User-visible label for this integration", json_schema_extra={"label": "Bezeichnung"}) + encryptedConfig: str = Field(default="", description="Encrypted JSON blob with connector credentials", json_schema_extra={"label": "Verschlüsselte Konfiguration"}) + isActive: bool = Field(default=True, json_schema_extra={"label": "Aktiv"}) + lastSyncAt: Optional[float] = Field(default=None, description="Timestamp of last sync attempt", json_schema_extra={"label": "Letzte Synchronisation"}) + lastSyncStatus: Optional[str] = Field(default=None, description="Last sync result: success, error, partial", json_schema_extra={"label": "Status"}) + lastSyncErrorMessage: Optional[str] = Field(default=None, description="Error message when lastSyncStatus is error", json_schema_extra={"label": "Fehlermeldung"}) + cachedChartOfAccounts: Optional[str] = Field(default=None, description="JSON-serialised chart of accounts cache (list of {accountNumber, label, accountType})", json_schema_extra={"label": "Cached Kontoplan"}) + chartCachedAt: Optional[float] = Field(default=None, description="Timestamp when cachedChartOfAccounts was last refreshed", json_schema_extra={"label": "Kontoplan-Cache-Zeitpunkt"}) + mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat"}) +@i18nModel("Buchhaltungs-Synchronisation") class TrusteeAccountingSync(PowerOnModel): """Tracks which position was synced to which external system and when. Used for duplicate prevention, audit trail, and retry logic. """ - id: str = Field(default_factory=lambda: str(uuid.uuid4())) - positionId: str = Field(description="FK -> TrusteePosition.id") - featureInstanceId: str = Field(description="FK -> FeatureInstance.id") - connectorType: str = Field(description="Connector type at time of sync") - externalId: Optional[str] = Field(default=None, description="ID assigned by the external system") - externalReference: Optional[str] = Field(default=None, description="Reference in the external system") - syncStatus: str = Field(default="pending", description="pending | synced | error | cancelled") - syncDirection: str = Field(default="push", description="push (local->ext) or pull (ext->local)") - syncedAt: Optional[float] = Field(default=None, description="Timestamp of successful sync") - errorMessage: Optional[str] = Field(default=None) - bookingPayload: Optional[dict] = Field(default=None, description="Payload sent to the external system (audit)") - mandateId: Optional[str] = Field(default=None) + id: str = Field(default_factory=lambda: str(uuid.uuid4()), json_schema_extra={"label": "ID"}) + positionId: str = Field(description="FK -> TrusteePosition.id", json_schema_extra={"label": "Position"}) + featureInstanceId: str = Field(description="FK -> FeatureInstance.id", json_schema_extra={"label": "Feature-Instanz"}) + connectorType: str = Field(description="Connector type at time of sync", json_schema_extra={"label": "System"}) + externalId: Optional[str] = Field(default=None, description="ID assigned by the external system", json_schema_extra={"label": "Externe ID"}) + externalReference: Optional[str] = Field(default=None, description="Reference in the external system", json_schema_extra={"label": "Externe Referenz"}) + syncStatus: str = Field(default="pending", description="pending | synced | error | cancelled", json_schema_extra={"label": "Status"}) + syncDirection: str = Field(default="push", description="push (local->ext) or pull (ext->local)", json_schema_extra={"label": "Richtung"}) + syncedAt: Optional[float] = Field(default=None, description="Timestamp of successful sync", json_schema_extra={"label": "Synchronisiert am"}) + errorMessage: Optional[str] = Field(default=None, json_schema_extra={"label": "Fehler"}) + bookingPayload: Optional[dict] = Field(default=None, description="Payload sent to the external system (audit)", json_schema_extra={"label": "Buchungs-Payload"}) + mandateId: Optional[str] = Field(default=None, json_schema_extra={"label": "Mandat"}) - -registerModelLabels( - "TrusteeAccountingSync", - {"en": "Accounting Sync", "de": "Buchhaltungs-Synchronisation", "fr": "Synchronisation comptable"}, - { - "id": {"en": "ID", "fr": "ID", "de": "ID"}, - "positionId": {"en": "Position", "fr": "Position", "de": "Position"}, - "connectorType": {"en": "System", "fr": "Système", "de": "System"}, - "externalId": {"en": "External ID", "fr": "ID Externe", "de": "Externe ID"}, - "syncStatus": {"en": "Status", "fr": "Statut", "de": "Status"}, - "syncDirection": {"en": "Direction", "fr": "Direction", "de": "Richtung"}, - "syncedAt": {"en": "Synced At", "fr": "Synchronisé à", "de": "Synchronisiert am"}, - "errorMessage": {"en": "Error", "fr": "Erreur", "de": "Fehler"}, - "mandateId": {"en": "Mandate", "fr": "Mandat", "de": "Mandat"}, - }, -) diff --git a/modules/features/trustee/mainTrustee.py b/modules/features/trustee/mainTrustee.py index 2fd82bc5..65f5f7e4 100644 --- a/modules/features/trustee/mainTrustee.py +++ b/modules/features/trustee/mainTrustee.py @@ -12,7 +12,7 @@ logger = logging.getLogger(__name__) # Feature metadata FEATURE_CODE = "trustee" -FEATURE_LABEL = {"en": "Trustee", "de": "Treuhand", "fr": "Fiduciaire"} +FEATURE_LABEL = "Treuhand" FEATURE_ICON = "mdi-briefcase" # UI Objects for RBAC catalog @@ -20,37 +20,47 @@ FEATURE_ICON = "mdi-briefcase" UI_OBJECTS = [ { "objectKey": "ui.feature.trustee.dashboard", - "label": {"en": "Dashboard", "de": "Dashboard", "fr": "Tableau de bord"}, + "label": "Dashboard", "meta": {"area": "dashboard"} }, { "objectKey": "ui.feature.trustee.positions", - "label": {"en": "Positions", "de": "Positionen", "fr": "Positions"}, + "label": "Positionen", "meta": {"area": "positions"} }, { "objectKey": "ui.feature.trustee.documents", - "label": {"en": "Documents", "de": "Dokumente", "fr": "Documents"}, + "label": "Dokumente", "meta": {"area": "documents"} }, { "objectKey": "ui.feature.trustee.expense-import", - "label": {"en": "Expense Import", "de": "Spesen Import", "fr": "Import de dépenses"}, + "label": "Spesen Import", "meta": {"area": "expense-import"} }, { "objectKey": "ui.feature.trustee.scan-upload", - "label": {"en": "Scan / Upload", "de": "Scannen / Hochladen", "fr": "Scanner / Téléverser"}, + "label": "Scannen / Hochladen", "meta": {"area": "scan-upload"} }, + { + "objectKey": "ui.feature.trustee.analyse", + "label": "Analyse & Reporting", + "meta": {"area": "analyse"} + }, + { + "objectKey": "ui.feature.trustee.abschluss", + "label": "Abschluss & Prüfung", + "meta": {"area": "abschluss"} + }, { "objectKey": "ui.feature.trustee.settings", - "label": {"en": "Accounting Settings", "de": "Buchhaltungs-Einstellungen", "fr": "Paramètres comptables"}, + "label": "Buchhaltungs-Einstellungen", "meta": {"area": "settings", "admin_only": True} }, { "objectKey": "ui.feature.trustee.instance-roles", - "label": {"en": "Instance Roles & Permissions", "de": "Instanz-Rollen & Berechtigungen", "fr": "Rôles et permissions d'instance"}, + "label": "Instanz-Rollen & Berechtigungen", "meta": {"area": "admin", "admin_only": True} }, ] @@ -60,7 +70,7 @@ UI_OBJECTS = [ DATA_OBJECTS = [ { "objectKey": "data.feature.trustee.TrusteeOrganisation", - "label": {"en": "Organisation", "de": "Organisation", "fr": "Organisation"}, + "label": "Organisation", "meta": { "table": "TrusteeOrganisation", "fields": ["id", "label", "enabled"], @@ -70,7 +80,7 @@ DATA_OBJECTS = [ }, { "objectKey": "data.feature.trustee.TrusteePosition", - "label": {"en": "Position", "de": "Position", "fr": "Position"}, + "label": "Position", "meta": { "table": "TrusteePosition", "fields": ["id", "label", "description", "organisationId"], @@ -80,12 +90,12 @@ DATA_OBJECTS = [ }, { "objectKey": "data.feature.trustee.TrusteeDocument", - "label": {"en": "Document", "de": "Dokument", "fr": "Document"}, + "label": "Dokument", "meta": {"table": "TrusteeDocument", "fields": ["id", "filename", "mimeType", "fileSize", "uploadDate"]} }, { "objectKey": "data.feature.trustee.TrusteeAccountingConfig", - "label": {"en": "Accounting Config", "de": "Buchhaltungs-Konfiguration", "fr": "Config. comptable"}, + "label": "Buchhaltungs-Konfiguration", "meta": { "table": "TrusteeAccountingConfig", "fields": ["id", "connectorType", "displayLabel", "encryptedConfig", "isActive"], @@ -95,37 +105,37 @@ DATA_OBJECTS = [ }, { "objectKey": "data.feature.trustee.TrusteeAccountingSync", - "label": {"en": "Accounting Sync", "de": "Buchhaltungs-Synchronisation", "fr": "Sync. comptable"}, + "label": "Buchhaltungs-Synchronisation", "meta": {"table": "TrusteeAccountingSync", "fields": ["id", "positionId", "syncStatus", "externalId"]} }, { "objectKey": "data.feature.trustee.TrusteeDataAccount", - "label": {"en": "Accounts (Synced)", "de": "Kontenplan (Sync)", "fr": "Plan comptable (Sync)"}, + "label": "Kontenplan (Sync)", "meta": {"table": "TrusteeDataAccount", "fields": ["id", "accountNumber", "label", "accountType", "accountGroup", "currency", "isActive"]} }, { "objectKey": "data.feature.trustee.TrusteeDataJournalEntry", - "label": {"en": "Journal Entries (Synced)", "de": "Buchungen (Sync)", "fr": "Écritures (Sync)"}, + "label": "Buchungen (Sync)", "meta": {"table": "TrusteeDataJournalEntry", "fields": ["id", "externalId", "bookingDate", "reference", "description", "currency", "totalAmount"]} }, { "objectKey": "data.feature.trustee.TrusteeDataJournalLine", - "label": {"en": "Journal Lines (Synced)", "de": "Buchungszeilen (Sync)", "fr": "Lignes écriture (Sync)"}, + "label": "Buchungszeilen (Sync)", "meta": {"table": "TrusteeDataJournalLine", "fields": ["id", "journalEntryId", "accountNumber", "debitAmount", "creditAmount", "currency", "taxCode", "costCenter", "description"]} }, { "objectKey": "data.feature.trustee.TrusteeDataContact", - "label": {"en": "Contacts (Synced)", "de": "Kontakte (Sync)", "fr": "Contacts (Sync)"}, + "label": "Kontakte (Sync)", "meta": {"table": "TrusteeDataContact", "fields": ["id", "externalId", "contactType", "contactNumber", "name", "address", "zip", "city", "country", "email", "phone", "vatNumber"]} }, { "objectKey": "data.feature.trustee.TrusteeDataAccountBalance", - "label": {"en": "Account Balances (Synced)", "de": "Kontosalden (Sync)", "fr": "Soldes comptes (Sync)"}, + "label": "Kontosalden (Sync)", "meta": {"table": "TrusteeDataAccountBalance", "fields": ["id", "accountNumber", "periodYear", "periodMonth", "openingBalance", "debitTotal", "creditTotal", "closingBalance", "currency"]} }, { "objectKey": "data.feature.trustee.*", - "label": {"en": "All Trustee Data", "de": "Alle Treuhand-Daten", "fr": "Toutes les données fiduciaires"}, + "label": "Alle Treuhand-Daten", "meta": {"wildcard": True, "description": "Wildcard for all trustee data tables"} }, ] @@ -135,127 +145,379 @@ DATA_OBJECTS = [ RESOURCE_OBJECTS = [ { "objectKey": "resource.feature.trustee.documents.create", - "label": {"en": "Upload Document", "de": "Dokument hochladen", "fr": "Télécharger document"}, + "label": "Dokument hochladen", "meta": {"endpoint": "/api/trustee/{instanceId}/documents", "method": "POST"} }, { "objectKey": "resource.feature.trustee.documents.update", - "label": {"en": "Update Document", "de": "Dokument aktualisieren", "fr": "Modifier document"}, + "label": "Dokument aktualisieren", "meta": {"endpoint": "/api/trustee/{instanceId}/documents/{documentId}", "method": "PUT"} }, { "objectKey": "resource.feature.trustee.documents.delete", - "label": {"en": "Delete Document", "de": "Dokument löschen", "fr": "Supprimer document"}, + "label": "Dokument löschen", "meta": {"endpoint": "/api/trustee/{instanceId}/documents/{documentId}", "method": "DELETE"} }, { "objectKey": "resource.feature.trustee.positions.create", - "label": {"en": "Create Position", "de": "Position erstellen", "fr": "Créer position"}, + "label": "Position erstellen", "meta": {"endpoint": "/api/trustee/{instanceId}/positions", "method": "POST"} }, { "objectKey": "resource.feature.trustee.positions.update", - "label": {"en": "Update Position", "de": "Position aktualisieren", "fr": "Modifier position"}, + "label": "Position aktualisieren", "meta": {"endpoint": "/api/trustee/{instanceId}/positions/{positionId}", "method": "PUT"} }, { "objectKey": "resource.feature.trustee.positions.delete", - "label": {"en": "Delete Position", "de": "Position löschen", "fr": "Supprimer position"}, + "label": "Position löschen", "meta": {"endpoint": "/api/trustee/{instanceId}/positions/{positionId}", "method": "DELETE"} }, { "objectKey": "resource.feature.trustee.instance-roles.manage", - "label": {"en": "Manage Instance Roles", "de": "Instanz-Rollen verwalten", "fr": "Gérer les rôles d'instance"}, + "label": "Instanz-Rollen verwalten", "meta": {"endpoint": "/api/trustee/{instanceId}/instance-roles", "method": "ALL", "admin_only": True} }, { "objectKey": "resource.feature.trustee.accounting.manage", - "label": {"en": "Manage Accounting Integration", "de": "Buchhaltungs-Integration verwalten", "fr": "Gérer l'intégration comptable"}, + "label": "Buchhaltungs-Integration verwalten", "meta": {"endpoint": "/api/trustee/{instanceId}/accounting/config", "method": "ALL", "admin_only": True} }, { "objectKey": "resource.feature.trustee.accounting.sync", - "label": {"en": "Sync to Accounting", "de": "Buchhaltung synchronisieren", "fr": "Synchroniser la comptabilité"}, + "label": "Buchhaltung synchronisieren", "meta": {"endpoint": "/api/trustee/{instanceId}/accounting/sync", "method": "POST"} }, { "objectKey": "resource.feature.trustee.accounting.view", - "label": {"en": "View Sync Status", "de": "Sync-Status einsehen", "fr": "Voir le statut de synchronisation"}, + "label": "Sync-Status einsehen", "meta": {"endpoint": "/api/trustee/{instanceId}/accounting/sync-status", "method": "GET"} }, + { + "objectKey": "resource.feature.trustee.workflows.view", + "label": "Workflows einsehen", + "meta": {"endpoint": "/api/workflows/{instanceId}/workflows", "method": "GET"} + }, + { + "objectKey": "resource.feature.trustee.workflows.execute", + "label": "Workflows ausführen", + "meta": {"endpoint": "/api/workflows/{instanceId}/execute", "method": "POST"} + }, + { + "objectKey": "resource.feature.trustee.workflows.manage", + "label": "Workflows verwalten", + "meta": {"endpoint": "/api/workflows/{instanceId}/workflows", "method": "ALL", "admin_only": True} + }, ] # Template roles for this feature with AccessRules # Each role defines default UI and DATA permissions # Note: UI item=None means ALL views, specific items restrict to named views # IMPORTANT: item uses vollqualifizierte ObjectKeys (gemäss Navigation-API-Konzept) +QUICK_ACTION_CATEGORIES = [ + {"id": "import", "label": "Import & Verarbeitung", "sortOrder": 1}, + {"id": "analyse", "label": "Analyse & Reporting", "sortOrder": 2}, + {"id": "abschluss", "label": "Abschluss & Prüfung", "sortOrder": 3}, +] + +QUICK_ACTIONS = [ + { + "id": "trustee-process-receipts", + "label": "Belege verarbeiten", + "description": "Belege aus SharePoint importieren, klassifizieren und verbuchen", + "icon": "mdi-file-document-check-outline", + "color": "#4CAF50", + "category": "import", + "actionType": "link", + "config": {"targetView": "expense-import"}, + "requiredRoles": ["trustee-user", "trustee-accountant", "trustee-admin"], + "sortOrder": 1, + }, + { + "id": "trustee-sync-accounting", + "label": "Daten synchronisieren", + "description": "Buchhaltungsdaten aus dem externen System aktualisieren", + "icon": "mdi-sync", + "color": "#FF9800", + "category": "import", + "actionType": "link", + "config": {"targetView": "settings"}, + "requiredRoles": ["trustee-accountant", "trustee-admin"], + "sortOrder": 2, + }, + { + "id": "trustee-upload-receipt", + "label": "Beleg hochladen", + "description": "Beleg scannen oder als Datei hochladen", + "icon": "mdi-camera-document-outline", + "color": "#607D8B", + "category": "import", + "actionType": "link", + "config": {"targetView": "scan-upload"}, + "requiredRoles": ["trustee-user", "trustee-client", "trustee-accountant", "trustee-admin"], + "sortOrder": 3, + }, + { + "id": "trustee-budget-comparison", + "label": "Budget-Vergleich", + "description": "Soll/Ist-Vergleich der Buchhaltung mit Budget-Excel", + "icon": "mdi-chart-bar", + "color": "#2196F3", + "category": "analyse", + "actionType": "link", + "config": {"targetView": "analyse", "tab": "budget"}, + "requiredRoles": ["trustee-accountant", "trustee-admin"], + "sortOrder": 4, + }, + { + "id": "trustee-kpi-dashboard", + "label": "KPI-Dashboard", + "description": "Kennzahlen berechnen und visualisieren", + "icon": "mdi-view-dashboard-outline", + "color": "#9C27B0", + "category": "analyse", + "actionType": "link", + "config": {"targetView": "analyse", "tab": "kpi"}, + "requiredRoles": ["trustee-accountant", "trustee-admin"], + "sortOrder": 5, + }, + { + "id": "trustee-cashflow", + "label": "Cashflow-Rechnung", + "description": "Cashflow berechnen und analysieren", + "icon": "mdi-cash-multiple", + "color": "#009688", + "category": "analyse", + "actionType": "link", + "config": {"targetView": "analyse", "tab": "cashflow"}, + "requiredRoles": ["trustee-accountant", "trustee-admin"], + "sortOrder": 6, + }, + { + "id": "trustee-forecast", + "label": "Prognose erstellen", + "description": "Trend-Analyse und Prognose der nächsten Monate", + "icon": "mdi-chart-timeline-variant", + "color": "#E91E63", + "category": "analyse", + "actionType": "link", + "config": {"targetView": "analyse", "tab": "forecast"}, + "requiredRoles": ["trustee-accountant", "trustee-admin"], + "sortOrder": 7, + }, + { + "id": "trustee-year-end-check", + "label": "Jahresabschluss prüfen", + "description": "Automatische Prüfungen für den Jahresabschluss", + "icon": "mdi-clipboard-check-outline", + "color": "#795548", + "category": "abschluss", + "actionType": "link", + "config": {"targetView": "abschluss", "tab": "year-end"}, + "requiredRoles": ["trustee-accountant", "trustee-admin"], + "sortOrder": 8, + }, +] + + +# --------------------------------------------------------------------------- +# Template Workflows — bootstrapped into each new feature instance. +# Graphs use existing nodes: trigger.manual, trustee.refreshAccountingData, ai.prompt. +# The placeholder {{featureInstanceId}} is replaced by _copyTemplateWorkflows. +# --------------------------------------------------------------------------- + +def _buildAnalysisWorkflowGraph(prompt: str) -> Dict[str, Any]: + """Build a standard analysis graph: trigger → refreshAccountingData → ai.prompt.""" + return { + "nodes": [ + {"id": "trigger", "type": "trigger.manual", "label": "Start", "_method": "", "_action": "", "parameters": {}, "position": {"x": 0, "y": 0}}, + {"id": "refresh", "type": "trustee.refreshAccountingData", "label": "Daten laden", "_method": "trustee", "_action": "refreshAccountingData", + "parameters": {"featureInstanceId": "{{featureInstanceId}}", "forceRefresh": False}, "position": {"x": 250, "y": 0}}, + {"id": "analyse", "type": "ai.prompt", "label": "Analyse", "_method": "ai", "_action": "process", + "parameters": {"prompt": prompt, "simpleMode": False}, "position": {"x": 500, "y": 0}}, + ], + "connections": [ + {"source": "trigger", "sourcePort": 0, "target": "refresh", "targetPort": 0}, + {"source": "refresh", "sourcePort": 0, "target": "analyse", "targetPort": 0}, + ], + } + + +TEMPLATE_WORKFLOWS = [ + { + "id": "trustee-receipt-import", + "label": "Beleg-Import Pipeline", + "description": "Belege extrahieren, verarbeiten und in Buchhaltung synchronisieren", + "tags": ["feature:trustee", "template:trustee-receipt-import"], + "graph": { + "nodes": [ + {"id": "trigger", "type": "trigger.manual", "label": "Start", "_method": "", "_action": "", "parameters": {}, "position": {"x": 0, "y": 0}}, + {"id": "extract", "type": "trustee.extractFromFiles", "label": "Dokumente extrahieren", "_method": "trustee", "_action": "extractFromFiles", + "parameters": {"featureInstanceId": "{{featureInstanceId}}", "prompt": ""}, "position": {"x": 250, "y": 0}}, + {"id": "process", "type": "trustee.processDocuments", "label": "Verarbeiten", "_method": "trustee", "_action": "processDocuments", + "parameters": {"documentList": [], "featureInstanceId": "{{featureInstanceId}}"}, "position": {"x": 500, "y": 0}}, + {"id": "sync", "type": "trustee.syncToAccounting", "label": "Synchronisieren", "_method": "trustee", "_action": "syncToAccounting", + "parameters": {"documentList": [], "featureInstanceId": "{{featureInstanceId}}"}, "position": {"x": 750, "y": 0}}, + ], + "connections": [ + {"source": "trigger", "sourcePort": 0, "target": "extract", "targetPort": 0}, + {"source": "extract", "sourcePort": 0, "target": "process", "targetPort": 0}, + {"source": "process", "sourcePort": 0, "target": "sync", "targetPort": 0}, + ], + }, + }, + { + "id": "trustee-sync-accounting", + "label": "Buchhaltung synchronisieren", + "description": "Buchhaltungsdaten aus dem externen System aktualisieren", + "tags": ["feature:trustee", "template:trustee-sync-accounting"], + "graph": { + "nodes": [ + {"id": "trigger", "type": "trigger.manual", "label": "Start", "_method": "", "_action": "", "parameters": {}, "position": {"x": 0, "y": 0}}, + {"id": "refresh", "type": "trustee.refreshAccountingData", "label": "Daten aktualisieren", "_method": "trustee", "_action": "refreshAccountingData", + "parameters": {"featureInstanceId": "{{featureInstanceId}}", "forceRefresh": True}, "position": {"x": 250, "y": 0}}, + ], + "connections": [ + {"source": "trigger", "sourcePort": 0, "target": "refresh", "targetPort": 0}, + ], + }, + }, + { + "id": "trustee-budget-comparison", + "label": "Budget-Vergleich", + "description": "Soll/Ist-Vergleich der Buchhaltung mit Budget-Excel", + "tags": ["feature:trustee", "template:trustee-budget-comparison"], + "graph": _buildAnalysisWorkflowGraph( + "Ich möchte einen Budget-Soll/Ist-Vergleich durchführen. Bitte:\n" + "1. Frage mich nach der Budget-Datei (Excel) oder suche im Workspace nach einer Datei mit 'Budget' im Namen\n" + "2. Lade die aktuellen Buchhaltungsdaten (refreshTrusteeData falls nötig)\n" + "3. Vergleiche die Soll-Werte aus dem Budget mit den Ist-Werten aus der Buchhaltung pro Konto\n" + "4. Berechne die Abweichung (absolut und prozentual)\n" + "5. Erstelle ein Abweichungs-Chart (Balkendiagramm: Soll vs. Ist pro Konto)\n" + "6. Markiere kritische Abweichungen (>10%) und gib eine kurze Einschätzung" + ), + }, + { + "id": "trustee-kpi-dashboard", + "label": "KPI-Dashboard", + "description": "Kennzahlen berechnen und visualisieren", + "tags": ["feature:trustee", "template:trustee-kpi-dashboard"], + "graph": _buildAnalysisWorkflowGraph( + "Erstelle ein KPI-Dashboard basierend auf den aktuellen Buchhaltungsdaten. Berechne und visualisiere:\n" + "1. Bruttogewinn und Bruttogewinnmarge\n" + "2. EBIT (Betriebsergebnis)\n" + "3. Gewinnmarge (Reingewinn / Umsatz)\n" + "4. Eigenkapitalquote und Check auf hälftigen Kapitalverlust (OR Art. 725)\n" + "5. Liquiditätsgrad 1-3 (Cash Ratio, Quick Ratio, Current Ratio)\n" + "6. Überschuldungs-Check\n\n" + "Erstelle für jede Kennzahl einen kurzen Kommentar (gut/kritisch/Handlungsbedarf). " + "Erstelle mindestens 2 Charts: ein Übersichts-Chart der Margen und ein Liquiditäts-Chart." + ), + }, + { + "id": "trustee-cashflow", + "label": "Cashflow-Rechnung", + "description": "Cashflow berechnen und analysieren", + "tags": ["feature:trustee", "template:trustee-cashflow"], + "graph": _buildAnalysisWorkflowGraph( + "Erstelle eine Cashflow-Rechnung basierend auf den aktuellen Buchhaltungsdaten:\n" + "1. Operativer Cashflow: Starte vom Reingewinn, bereinige um nicht-cash-wirksame Positionen\n" + "2. Investitions-Cashflow: Investitionen in Sachanlagen, Finanzanlagen\n" + "3. Finanzierungs-Cashflow: Darlehensaufnahmen/-rückzahlungen, Dividenden, Kapitalerhöhungen\n" + "4. Netto-Cashflow und Veränderung der liquiden Mittel\n\n" + "Warne bei kritischen Werten. Erstelle ein Wasserfall-Chart oder gestapeltes Balkendiagramm." + ), + }, + { + "id": "trustee-forecast", + "label": "Prognose erstellen", + "description": "Trend-Analyse und Prognose der nächsten Monate", + "tags": ["feature:trustee", "template:trustee-forecast"], + "graph": _buildAnalysisWorkflowGraph( + "Erstelle eine Finanzprognose basierend auf den historischen Buchhaltungsdaten:\n" + "1. Analysiere die Umsatz- und Aufwandsentwicklung der letzten 6 Monate\n" + "2. Identifiziere Trends und Saisonalitäten\n" + "3. Prognostiziere Umsatz, Aufwand und Gewinn für die nächsten 3 Monate\n" + "4. Erstelle ein Chart mit Ist-Werten und Prognose-Korridor\n" + "5. Markiere Risiken\n\n" + "Nutze eine einfache lineare Extrapolation mit Saisonalitätskorrektur wo sinnvoll." + ), + }, + { + "id": "trustee-year-end-check", + "label": "Jahresabschluss prüfen", + "description": "Automatische Prüfungen für den Jahresabschluss", + "tags": ["feature:trustee", "template:trustee-year-end-check"], + "graph": _buildAnalysisWorkflowGraph( + "Führe eine automatische Jahresabschluss-Prüfung durch:\n" + "1. Saldovalidierung: Prüfe alle Bilanzkonten auf Plausibilität\n" + "2. Vorjahresvergleich: Vergleiche Bilanz- und ER-Positionen mit dem Vorjahr, markiere Abweichungen >20%\n" + "3. Abgrenzungen: Identifiziere potenzielle transitorische Aktiven/Passiven\n" + "4. Gesetzliche Prüfungen: Hälftiger Kapitalverlust (OR 725), Überschuldung, Mindestkapital\n" + "5. MWST-Plausibilisierung: Vorsteuer vs. geschätzter Aufwand, Umsatzsteuer vs. Umsatz\n\n" + "Erstelle eine Checkliste mit Status (OK / Warnung / Kritisch) pro Prüfpunkt." + ), + }, +] + + TEMPLATE_ROLES = [ { "roleLabel": "trustee-viewer", - "description": { - "en": "Trustee Viewer - View trustee data (read-only)", - "de": "Treuhand-Betrachter - Treuhand-Daten einsehen (nur lesen)", - "fr": "Visualiseur fiduciaire - Consulter les données fiduciaires (lecture seule)", - }, + "description": "Treuhand-Betrachter - Treuhand-Daten einsehen (nur lesen)", "accessRules": [ {"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.trustee.positions", "view": True}, {"context": "UI", "item": "ui.feature.trustee.documents", "view": True}, + {"context": "RESOURCE", "item": "resource.feature.trustee.workflows.view", "view": True}, {"context": "DATA", "item": None, "view": True, "read": "m", "create": "n", "update": "n", "delete": "n"}, ], }, { "roleLabel": "trustee-user", - "description": { - "en": "Trustee User - Create and manage own trustee records", - "de": "Treuhand-Benutzer - Eigene Treuhand-Daten erstellen und verwalten", - "fr": "Utilisateur fiduciaire - Créer et gérer ses propres données fiduciaires", - }, + "description": "Treuhand-Benutzer - Eigene Treuhand-Daten erstellen und verwalten", "accessRules": [ {"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.trustee.positions", "view": True}, {"context": "UI", "item": "ui.feature.trustee.documents", "view": True}, {"context": "UI", "item": "ui.feature.trustee.expense-import", "view": True}, + {"context": "RESOURCE", "item": "resource.feature.trustee.workflows.view", "view": True}, + {"context": "RESOURCE", "item": "resource.feature.trustee.workflows.execute", "view": True}, {"context": "DATA", "item": None, "view": True, "read": "m", "create": "m", "update": "m", "delete": "n"}, ], }, { "roleLabel": "trustee-admin", - "description": { - "en": "Trustee Administrator - Full access to all trustee data and settings", - "de": "Treuhand-Administrator - Vollzugriff auf alle Treuhand-Daten und Einstellungen", - "fr": "Administrateur fiduciaire - Accès complet aux données et paramètres fiduciaires", - }, + "description": "Treuhand-Administrator - Vollzugriff auf alle Treuhand-Daten und Einstellungen", "accessRules": [ {"context": "UI", "item": None, "view": True}, {"context": "DATA", "item": None, "view": True, "read": "a", "create": "a", "update": "a", "delete": "a"}, {"context": "RESOURCE", "item": "resource.feature.trustee.instance-roles.manage", "view": True}, + {"context": "RESOURCE", "item": "resource.feature.trustee.workflows.view", "view": True}, + {"context": "RESOURCE", "item": "resource.feature.trustee.workflows.execute", "view": True}, + {"context": "RESOURCE", "item": "resource.feature.trustee.workflows.manage", "view": True}, ], }, { "roleLabel": "trustee-accountant", - "description": { - "en": "Trustee Accountant - Manage accounting and financial data", - "de": "Treuhand-Buchhalter - Buchhaltungs- und Finanzdaten verwalten", - "fr": "Comptable fiduciaire - Gérer les données comptables et financières", - }, + "description": "Treuhand-Buchhalter - Buchhaltungs- und Finanzdaten verwalten", "accessRules": [ {"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.trustee.positions", "view": True}, {"context": "UI", "item": "ui.feature.trustee.documents", "view": True}, + {"context": "UI", "item": "ui.feature.trustee.analyse", "view": True}, + {"context": "UI", "item": "ui.feature.trustee.abschluss", "view": True}, {"context": "UI", "item": "ui.feature.trustee.settings", "view": True}, {"context": "DATA", "item": None, "view": True, "read": "g", "create": "g", "update": "g", "delete": "g"}, {"context": "RESOURCE", "item": "resource.feature.trustee.accounting.sync", "view": True}, {"context": "RESOURCE", "item": "resource.feature.trustee.accounting.view", "view": True}, + {"context": "RESOURCE", "item": "resource.feature.trustee.workflows.view", "view": True}, + {"context": "RESOURCE", "item": "resource.feature.trustee.workflows.execute", "view": True}, ], }, { "roleLabel": "trustee-client", - "description": { - "en": "Trustee Client - View own accounting data and documents", - "de": "Treuhand-Kunde - Eigene Buchhaltungsdaten und Dokumente einsehen", - "fr": "Client fiduciaire - Consulter ses propres données comptables et documents", - }, + "description": "Treuhand-Kunde - Eigene Buchhaltungsdaten und Dokumente einsehen", "accessRules": [ {"context": "UI", "item": "ui.feature.trustee.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.trustee.positions", "view": True}, @@ -293,6 +555,21 @@ def getTemplateRoles() -> List[Dict[str, Any]]: return TEMPLATE_ROLES +def getTemplateWorkflows() -> List[Dict[str, Any]]: + """Return template workflow definitions for bootstrap on instance creation.""" + return TEMPLATE_WORKFLOWS + + +def getQuickActions() -> List[Dict[str, Any]]: + """Return quick action definitions for the Trustee dashboard.""" + return QUICK_ACTIONS + + +def getQuickActionCategories() -> List[Dict[str, Any]]: + """Return quick action category definitions.""" + return QUICK_ACTION_CATEGORIES + + def getDataObjects() -> List[Dict[str, Any]]: """Return DATA objects for RBAC catalog registration.""" return DATA_OBJECTS @@ -358,7 +635,8 @@ def _syncTemplateRolesToDb() -> int: try: from modules.interfaces.interfaceDbApp import getRootInterface from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext - + from modules.datamodels.datamodelUtils import coerce_text_multilingual + rootInterface = getRootInterface() # Get existing template roles for this feature (Pydantic models) @@ -378,7 +656,7 @@ def _syncTemplateRolesToDb() -> int: # Create new template role newRole = Role( roleLabel=roleLabel, - description=roleTemplate.get("description", {}), + description=coerce_text_multilingual(roleTemplate.get("description", {})), featureCode=FEATURE_CODE, mandateId=None, # Global template featureInstanceId=None, diff --git a/modules/features/trustee/routeFeatureTrustee.py b/modules/features/trustee/routeFeatureTrustee.py index ca8caf90..f4068e66 100644 --- a/modules/features/trustee/routeFeatureTrustee.py +++ b/modules/features/trustee/routeFeatureTrustee.py @@ -37,6 +37,10 @@ from modules.datamodels.datamodelPagination import ( PaginationMetadata, normalize_pagination_dict, ) +from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext +from modules.shared.i18nRegistry import apiRouteContext + +routeApiMsg = apiRouteContext("routeFeatureTrustee") logger = logging.getLogger(__name__) @@ -116,6 +120,78 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext) -> str: return str(instance.mandateId) +# ============================================================================ +# QUICK ACTIONS ENDPOINT +# ============================================================================ + +@router.get("/{instanceId}/quick-actions") +@limiter.limit("60/minute") +def getQuickActions( + request: Request, + instanceId: str = Path(..., description="Feature Instance ID"), + language: str = Query(default="de", description="Language code for labels"), + context: RequestContext = Depends(getRequestContext), +) -> Dict[str, Any]: + """Return RBAC-filtered quick actions for the Trustee dashboard.""" + mandateId = _validateInstanceAccess(instanceId, context) + + from .mainTrustee import QUICK_ACTIONS, QUICK_ACTION_CATEGORIES + + userRoleLabels: set = set() + if context.hasSysAdminRole: + userRoleLabels.add("trustee-admin") + else: + rootInterface = getRootInterface() + featureAccesses = rootInterface.getFeatureAccessesForUser(str(context.user.id)) + for fa in featureAccesses: + if str(fa.featureInstanceId) == instanceId and fa.enabled: + roleIds = fa.roleIds if hasattr(fa, "roleIds") and fa.roleIds else [] + for rid in roleIds: + role = rootInterface.getRole(str(rid)) + if role and role.roleLabel: + userRoleLabels.add(role.roleLabel) + + def _resolveText(multilingual, lang: str) -> str: + if isinstance(multilingual, str): + return multilingual + if isinstance(multilingual, dict): + return multilingual.get(lang) or multilingual.get("en") or multilingual.get("de") or next(iter(multilingual.values()), "") + return "" + + filteredActions = [] + for action in QUICK_ACTIONS: + required = set(action.get("requiredRoles", [])) + if not userRoleLabels and not context.hasSysAdminRole: + continue + if context.hasSysAdminRole or required.intersection(userRoleLabels): + resolved = { + "id": action["id"], + "label": _resolveText(action.get("label", {}), language), + "description": _resolveText(action.get("description", {}), language), + "icon": action.get("icon", ""), + "color": action.get("color", ""), + "category": action.get("category", ""), + "actionType": action.get("actionType", ""), + "config": action.get("config", {}), + "sortOrder": action.get("sortOrder", 99), + } + if resolved["actionType"] == "agentPrompt" and "config" in resolved: + cfg = dict(resolved["config"]) + if "uploadHint" in cfg: + cfg["uploadHint"] = _resolveText(cfg["uploadHint"], language) + resolved["config"] = cfg + filteredActions.append(resolved) + + filteredActions.sort(key=lambda a: a["sortOrder"]) + + resolvedCategories = [ + {"id": c["id"], "label": _resolveText(c.get("label", {}), language), "sortOrder": c.get("sortOrder", 99)} + for c in QUICK_ACTION_CATEGORIES + ] + + return {"actions": filteredActions, "categories": resolvedCategories} + + # ============================================================================ # ATTRIBUTES ENDPOINT (for FormGeneratorTable) # ============================================================================ @@ -385,7 +461,7 @@ def create_organisation( interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) result = interface.createOrganisation(data.model_dump()) if not result: - raise HTTPException(status_code=400, detail="Failed to create organisation") + raise HTTPException(status_code=400, detail=routeApiMsg("Failed to create organisation")) return result @@ -408,7 +484,7 @@ def update_organisation( result = interface.updateOrganisation(orgId, data.model_dump(exclude={"id"})) if not result: - raise HTTPException(status_code=400, detail="Failed to update organisation") + raise HTTPException(status_code=400, detail=routeApiMsg("Failed to update organisation")) return result @@ -430,7 +506,7 @@ def delete_organisation( success = interface.deleteOrganisation(orgId) if not success: - raise HTTPException(status_code=400, detail="Failed to delete organisation") + raise HTTPException(status_code=400, detail=routeApiMsg("Failed to delete organisation")) return {"message": f"Organisation {orgId} deleted"} @@ -498,7 +574,7 @@ def create_role( interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) result = interface.createRole(data.model_dump()) if not result: - raise HTTPException(status_code=400, detail="Failed to create role") + raise HTTPException(status_code=400, detail=routeApiMsg("Failed to create role")) return result @@ -521,7 +597,7 @@ def update_role( result = interface.updateRole(roleId, data.model_dump(exclude={"id"})) if not result: - raise HTTPException(status_code=400, detail="Failed to update role") + raise HTTPException(status_code=400, detail=routeApiMsg("Failed to update role")) return result @@ -543,7 +619,7 @@ def delete_role( success = interface.deleteRole(roleId) if not success: - raise HTTPException(status_code=400, detail="Failed to delete role (may be in use)") + raise HTTPException(status_code=400, detail=routeApiMsg("Failed to delete role (may be in use)")) return {"message": f"Role {roleId} deleted"} @@ -641,7 +717,7 @@ def create_access( interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) result = interface.createAccess(data.model_dump()) if not result: - raise HTTPException(status_code=400, detail="Failed to create access") + raise HTTPException(status_code=400, detail=routeApiMsg("Failed to create access")) return result @@ -664,7 +740,7 @@ def update_access( result = interface.updateAccess(accessId, data.model_dump(exclude={"id"})) if not result: - raise HTTPException(status_code=400, detail="Failed to update access") + raise HTTPException(status_code=400, detail=routeApiMsg("Failed to update access")) return result @@ -686,7 +762,7 @@ def delete_access( success = interface.deleteAccess(accessId) if not success: - raise HTTPException(status_code=400, detail="Failed to delete access") + raise HTTPException(status_code=400, detail=routeApiMsg("Failed to delete access")) return {"message": f"Access {accessId} deleted"} @@ -769,7 +845,7 @@ def create_contract( interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) result = interface.createContract(data.model_dump()) if not result: - raise HTTPException(status_code=400, detail="Failed to create contract") + raise HTTPException(status_code=400, detail=routeApiMsg("Failed to create contract")) return result @@ -792,7 +868,7 @@ def update_contract( result = interface.updateContract(contractId, data.model_dump(exclude={"id"})) if not result: - raise HTTPException(status_code=400, detail="Failed to update contract (organisationId cannot be changed)") + raise HTTPException(status_code=400, detail=routeApiMsg("Failed to update contract (organisationId cannot be changed)")) return result @@ -814,7 +890,7 @@ def delete_contract( success = interface.deleteContract(contractId) if not success: - raise HTTPException(status_code=400, detail="Failed to delete contract") + raise HTTPException(status_code=400, detail=routeApiMsg("Failed to delete contract")) return {"message": f"Contract {contractId} deleted"} @@ -938,7 +1014,7 @@ def get_document_data( data = interface.getDocumentData(documentId) if not data: - raise HTTPException(status_code=404, detail="Document data not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Document data not found")) return StreamingResponse( io.BytesIO(data), @@ -995,7 +1071,7 @@ async def create_document( interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) result = interface.createDocument(body) if not result: - raise HTTPException(status_code=400, detail="Failed to create document") + raise HTTPException(status_code=400, detail=routeApiMsg("Failed to create document")) return result @@ -1025,7 +1101,7 @@ async def upload_document( interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) result = interface.createDocument(docData) if not result: - raise HTTPException(status_code=400, detail="Failed to create document") + raise HTTPException(status_code=400, detail=routeApiMsg("Failed to create document")) return result @@ -1048,7 +1124,7 @@ def update_document( result = interface.updateDocument(documentId, data.model_dump(exclude={"id"})) if not result: - raise HTTPException(status_code=400, detail="Failed to update document") + raise HTTPException(status_code=400, detail=routeApiMsg("Failed to update document")) return result @@ -1070,7 +1146,7 @@ def delete_document( success = interface.deleteDocument(documentId) if not success: - raise HTTPException(status_code=400, detail="Failed to delete document") + raise HTTPException(status_code=400, detail=routeApiMsg("Failed to delete document")) return {"message": f"Document {documentId} deleted"} @@ -1220,7 +1296,7 @@ def create_position( interface = getInterface(context.user, mandateId=mandateId, featureInstanceId=instanceId) result = interface.createPosition(data.model_dump()) if not result: - raise HTTPException(status_code=400, detail="Failed to create position") + raise HTTPException(status_code=400, detail=routeApiMsg("Failed to create position")) return result @@ -1243,7 +1319,7 @@ def update_position( result = interface.updatePosition(positionId, data.model_dump(exclude={"id"})) if not result: - raise HTTPException(status_code=400, detail="Failed to update position") + raise HTTPException(status_code=400, detail=routeApiMsg("Failed to update position")) return result @@ -1265,7 +1341,7 @@ def delete_position( success = interface.deletePosition(positionId) if not success: - raise HTTPException(status_code=400, detail="Failed to delete position") + raise HTTPException(status_code=400, detail=routeApiMsg("Failed to delete position")) return {"message": f"Position {positionId} deleted"} @@ -1398,7 +1474,7 @@ async def save_accounting_config( if not plainConfig: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="config is required for new integration (e.g. clientName, apiKey)." + detail=routeApiMsg("config is required for new integration (e.g. clientName, apiKey).") ) encryptedConfig = encryptValue(json.dumps(plainConfig), keyName="accountingConfig") @@ -1511,7 +1587,7 @@ async def sync_positions_to_accounting( positionIds = data.get("positionIds", []) if not positionIds: - raise HTTPException(status_code=400, detail="positionIds required") + raise HTTPException(status_code=400, detail=routeApiMsg("positionIds required")) results = await bridge.pushBatchToAccounting(instanceId, positionIds) failed = [r for r in results if not r.success] @@ -1678,8 +1754,6 @@ def get_positions_by_document( # ===== Instance Roles Management ===== # These endpoints allow feature admins to manage instance-specific roles and their AccessRules -from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext - def _validateInstanceAdmin(instanceId: str, context: RequestContext) -> str: """ @@ -1711,7 +1785,7 @@ def _validateInstanceAdmin(instanceId: str, context: RequestContext) -> str: if not hasAdminPermission: raise HTTPException( status_code=403, - detail="Keine Berechtigung zur Rollenverwaltung" + detail=routeApiMsg("Keine Berechtigung zur Rollenverwaltung") ) return mandateId diff --git a/modules/features/workspace/datamodelFeatureWorkspace.py b/modules/features/workspace/datamodelFeatureWorkspace.py index d7c292db..b01f0427 100644 --- a/modules/features/workspace/datamodelFeatureWorkspace.py +++ b/modules/features/workspace/datamodelFeatureWorkspace.py @@ -5,27 +5,32 @@ from typing import Optional from pydantic import BaseModel, Field from modules.datamodels.datamodelBase import PowerOnModel -from modules.shared.attributeUtils import registerModelLabels +from modules.shared.i18nRegistry import i18nModel import uuid +@i18nModel("Workspace Benutzereinstellungen") class WorkspaceUserSettings(PowerOnModel): - """Per-user workspace settings. None values mean 'use instance default'.""" - id: str = Field(default_factory=lambda: str(uuid.uuid4()), description="Primary key", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": False}) - userId: str = Field(description="User ID", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) - mandateId: str = Field(description="Mandate ID", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) - featureInstanceId: str = Field(description="Feature Instance ID", json_schema_extra={"frontend_type": "text", "frontend_readonly": True, "frontend_required": True}) - maxAgentRounds: Optional[int] = Field(default=None, description="Max agent rounds override (None = instance default)", json_schema_extra={"frontend_type": "number", "frontend_readonly": False, "frontend_required": False}) - - -registerModelLabels( - "WorkspaceUserSettings", - {"en": "Workspace User Settings", "de": "Workspace Benutzereinstellungen"}, - { - "id": {"en": "ID", "de": "ID"}, - "userId": {"en": "User ID", "de": "Benutzer-ID"}, - "mandateId": {"en": "Mandate ID", "de": "Mandanten-ID"}, - "featureInstanceId": {"en": "Feature Instance ID", "de": "Feature-Instanz-ID"}, - "maxAgentRounds": {"en": "Max Agent Rounds", "de": "Max. Agenten-Runden"}, - }, -) + """Benutzerspezifische Workspace-Einstellungen. None = Instanz-Standard.""" + id: str = Field( + default_factory=lambda: str(uuid.uuid4()), + description="Primary key", + json_schema_extra={"label": "ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": False}, + ) + userId: str = Field( + description="User ID", + json_schema_extra={"label": "Benutzer-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}, + ) + mandateId: str = Field( + description="Mandate ID", + json_schema_extra={"label": "Mandanten-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}, + ) + featureInstanceId: str = Field( + description="Feature Instance ID", + json_schema_extra={"label": "Feature-Instanz-ID", "frontend_type": "text", "frontend_readonly": True, "frontend_required": True}, + ) + maxAgentRounds: Optional[int] = Field( + default=None, + description="Max agent rounds override (None = instance default)", + json_schema_extra={"label": "Max. Agenten-Runden", "frontend_type": "number", "frontend_readonly": False, "frontend_required": False}, + ) diff --git a/modules/features/workspace/mainWorkspace.py b/modules/features/workspace/mainWorkspace.py index 5ef9b399..bb501f21 100644 --- a/modules/features/workspace/mainWorkspace.py +++ b/modules/features/workspace/mainWorkspace.py @@ -12,32 +12,28 @@ from typing import Dict, List, Any logger = logging.getLogger(__name__) FEATURE_CODE = "workspace" -FEATURE_LABEL = {"en": "AI Workspace", "de": "AI Workspace", "fr": "AI Workspace"} +FEATURE_LABEL = "AI Workspace" FEATURE_ICON = "mdi-brain" UI_OBJECTS = [ { "objectKey": "ui.feature.workspace.dashboard", - "label": {"en": "Dashboard", "de": "Dashboard", "fr": "Tableau de bord"}, + "label": "Dashboard", "meta": {"area": "dashboard"} }, { "objectKey": "ui.feature.workspace.editor", - "label": {"en": "Editor", "de": "Editor", "fr": "Editeur"}, + "label": "Editor", "meta": {"area": "editor"} }, { "objectKey": "ui.feature.workspace.settings", - "label": {"en": "Settings", "de": "Einstellungen", "fr": "Parametres"}, + "label": "Einstellungen", "meta": {"area": "settings"} }, { "objectKey": "ui.feature.workspace.rag-insights", - "label": { - "en": "Knowledge insights", - "de": "Wissens-Insights", - "fr": "Aperçu des connaissances", - }, + "label": "Wissens-Insights", "meta": {"area": "rag-insights"}, }, ] @@ -45,37 +41,37 @@ UI_OBJECTS = [ RESOURCE_OBJECTS = [ { "objectKey": "resource.feature.workspace.start", - "label": {"en": "Start Agent", "de": "Agent starten", "fr": "Demarrer agent"}, + "label": "Agent starten", "meta": {"endpoint": "/api/workspace/{instanceId}/start/stream", "method": "POST"} }, { "objectKey": "resource.feature.workspace.stop", - "label": {"en": "Stop Agent", "de": "Agent stoppen", "fr": "Arreter agent"}, + "label": "Agent stoppen", "meta": {"endpoint": "/api/workspace/{instanceId}/{workflowId}/stop", "method": "POST"} }, { "objectKey": "resource.feature.workspace.files", - "label": {"en": "Manage Files", "de": "Dateien verwalten", "fr": "Gerer fichiers"}, + "label": "Dateien verwalten", "meta": {"endpoint": "/api/workspace/{instanceId}/files", "method": "GET"} }, { "objectKey": "resource.feature.workspace.folders", - "label": {"en": "Manage Folders", "de": "Ordner verwalten", "fr": "Gerer dossiers"}, + "label": "Ordner verwalten", "meta": {"endpoint": "/api/workspace/{instanceId}/folders", "method": "GET"} }, { "objectKey": "resource.feature.workspace.datasources", - "label": {"en": "Data Sources", "de": "Datenquellen", "fr": "Sources de donnees"}, + "label": "Datenquellen", "meta": {"endpoint": "/api/workspace/{instanceId}/datasources", "method": "GET"} }, { "objectKey": "resource.feature.workspace.voice", - "label": {"en": "Voice Input/Output", "de": "Spracheingabe/-ausgabe", "fr": "Entree/sortie vocale"}, + "label": "Spracheingabe/-ausgabe", "meta": {"endpoint": "/api/workspace/{instanceId}/voice/*", "method": "POST"} }, { "objectKey": "resource.feature.workspace.edits", - "label": {"en": "Review File Edits", "de": "Datei-Aenderungen pruefen", "fr": "Verifier les modifications de fichiers"}, + "label": "Datei-Aenderungen pruefen", "meta": {"endpoint": "/api/workspace/{instanceId}/edit/*", "method": "POST"} }, ] @@ -83,11 +79,7 @@ RESOURCE_OBJECTS = [ TEMPLATE_ROLES = [ { "roleLabel": "workspace-viewer", - "description": { - "en": "Workspace Viewer - View workspace (read-only)", - "de": "Workspace Betrachter - Workspace ansehen (nur lesen)", - "fr": "Visualiseur Workspace - Consulter le workspace (lecture seule)" - }, + "description": "Workspace Betrachter - Workspace ansehen (nur lesen)", "accessRules": [ {"context": "UI", "item": "ui.feature.workspace.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.workspace.editor", "view": True}, @@ -98,11 +90,7 @@ TEMPLATE_ROLES = [ }, { "roleLabel": "workspace-user", - "description": { - "en": "Workspace User - Use AI workspace and tools", - "de": "Workspace Benutzer - AI Workspace und Tools nutzen", - "fr": "Utilisateur Workspace - Utiliser l'espace de travail AI et les outils" - }, + "description": "Workspace Benutzer - AI Workspace und Tools nutzen", "accessRules": [ {"context": "UI", "item": "ui.feature.workspace.dashboard", "view": True}, {"context": "UI", "item": "ui.feature.workspace.editor", "view": True}, @@ -120,11 +108,7 @@ TEMPLATE_ROLES = [ }, { "roleLabel": "workspace-admin", - "description": { - "en": "Workspace Admin - All UI and API actions; data is always scoped to own records (same privacy as users)", - "de": "Workspace Admin - Alle UI- und API-Aktionen; Daten immer nur eigene Datensätze (gleiche Privatsphäre wie User)", - "fr": "Administrateur Workspace - Toute l'UI et les API; donnees limitees a ses propres enregistrements" - }, + "description": "Workspace Admin - Alle UI- und API-Aktionen; Daten immer nur eigene Datensätze (gleiche Privatsphäre wie User)", "accessRules": [ {"context": "UI", "item": None, "view": True}, {"context": "RESOURCE", "item": None, "view": True}, @@ -194,6 +178,7 @@ def _syncTemplateRolesToDb() -> int: try: from modules.interfaces.interfaceDbApp import getRootInterface from modules.datamodels.datamodelRbac import Role, AccessRule, AccessRuleContext + from modules.datamodels.datamodelUtils import coerce_text_multilingual rootInterface = getRootInterface() @@ -211,7 +196,7 @@ def _syncTemplateRolesToDb() -> int: else: newRole = Role( roleLabel=roleLabel, - description=roleTemplate.get("description", {}), + description=coerce_text_multilingual(roleTemplate.get("description", {})), featureCode=FEATURE_CODE, mandateId=None, featureInstanceId=None, diff --git a/modules/features/workspace/routeFeatureWorkspace.py b/modules/features/workspace/routeFeatureWorkspace.py index 85188c52..9fb8ca40 100644 --- a/modules/features/workspace/routeFeatureWorkspace.py +++ b/modules/features/workspace/routeFeatureWorkspace.py @@ -29,6 +29,8 @@ from modules.interfaces.interfaceAiObjects import AiObjects from modules.serviceCenter.core.serviceStreaming import get_event_manager from modules.serviceCenter.services.serviceAgent.datamodelAgent import AgentEventTypeEnum, PendingFileEdit from modules.shared.timeUtils import parseTimestamp +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeFeatureWorkspace") logger = logging.getLogger(__name__) @@ -127,7 +129,7 @@ def _validateInstanceAccess(instanceId: str, context: RequestContext): raise HTTPException(status_code=404, detail=f"Feature instance {instanceId} not found") featureAccess = rootInterface.getFeatureAccess(str(context.user.id), instanceId) if not featureAccess or not featureAccess.enabled: - raise HTTPException(status_code=403, detail="Access denied to this feature instance") + raise HTTPException(status_code=403, detail=routeApiMsg("Access denied to this feature instance")) mandateId = str(instance.mandateId) if instance.mandateId else None instanceConfig = instance.config if hasattr(instance, "config") and instance.config else {} return mandateId, instanceConfig @@ -1178,10 +1180,10 @@ async def getFileContent( fileData = fileRecord if isinstance(fileRecord, dict) else fileRecord.model_dump() filePath = fileData.get("filePath") if not filePath: - raise HTTPException(status_code=404, detail="File has no stored path") + raise HTTPException(status_code=404, detail=routeApiMsg("File has no stored path")) import os if not os.path.isfile(filePath): - raise HTTPException(status_code=404, detail="File not found on disk") + raise HTTPException(status_code=404, detail=routeApiMsg("File not found on disk")) mimeType = fileData.get("mimeType", "application/octet-stream") with open(filePath, "rb") as fh: content = fh.read() @@ -1436,11 +1438,11 @@ async def listFeatureConnectionTables( rootIf = getRootInterface() inst = rootIf.getFeatureInstance(fiId) if not inst: - raise HTTPException(status_code=404, detail="Feature instance not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Feature instance not found")) mandateId = str(inst.mandateId) if inst.mandateId else None if wsMandateId and mandateId and mandateId != wsMandateId: - raise HTTPException(status_code=403, detail="Feature instance does not belong to workspace mandate") + raise HTTPException(status_code=403, detail=routeApiMsg("Feature instance does not belong to workspace mandate")) catalog = getCatalogService() try: @@ -1495,12 +1497,12 @@ async def listParentObjects( rootIf = getRootInterface() inst = rootIf.getFeatureInstance(fiId) if not inst: - raise HTTPException(status_code=404, detail="Feature instance not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Feature instance not found")) featureCode = inst.featureCode mandateId = str(inst.mandateId) if inst.mandateId else "" if wsMandateId and mandateId and mandateId != wsMandateId: - raise HTTPException(status_code=403, detail="Feature instance does not belong to workspace mandate") + raise HTTPException(status_code=403, detail=routeApiMsg("Feature instance does not belong to workspace mandate")) catalog = getCatalogService() parentObj = None @@ -1614,7 +1616,7 @@ async def createFeatureDataSource( inst = rootIf.getFeatureInstance(body.featureInstanceId) mandateId = str(inst.mandateId) if inst else (str(context.mandateId) if context.mandateId else "") if wsMandateId and mandateId and mandateId != wsMandateId: - raise HTTPException(status_code=403, detail="Feature instance does not belong to workspace mandate") + raise HTTPException(status_code=403, detail=routeApiMsg("Feature instance does not belong to workspace mandate")) fds = FeatureDataSource( featureInstanceId=body.featureInstanceId, @@ -1814,7 +1816,7 @@ async def synthesizeVoice( _validateInstanceAccess(instanceId, context) text = body.get("text", "") if not text: - raise HTTPException(status_code=400, detail="text is required") + raise HTTPException(status_code=400, detail=routeApiMsg("text is required")) return JSONResponse({"audio": None, "note": "TTS via browser Speech Synthesis API recommended"}) @@ -1858,7 +1860,7 @@ async def acceptEdit( try: success = dbMgmt.updateFileData(edit.fileId, edit.newContent.encode("utf-8")) if not success: - raise HTTPException(status_code=500, detail="Failed to update file data") + raise HTTPException(status_code=500, detail=routeApiMsg("Failed to update file data")) except HTTPException: raise except Exception as e: diff --git a/modules/interfaces/interfaceBootstrap.py b/modules/interfaces/interfaceBootstrap.py index 0f424438..b323cb92 100644 --- a/modules/interfaces/interfaceBootstrap.py +++ b/modules/interfaces/interfaceBootstrap.py @@ -25,6 +25,7 @@ from modules.datamodels.datamodelRbac import ( AccessRuleContext, Role, ) +from modules.datamodels.datamodelUtils import coerce_text_multilingual from modules.datamodels.datamodelUam import AccessLevel from modules.datamodels.datamodelMembership import ( UserMandate, @@ -547,7 +548,7 @@ def initRoles(db: DatabaseConnector) -> None: standardRoles = [ Role( roleLabel="admin", - description={"en": "Administrator - Manage users and resources within mandate scope", "de": "Administrator - Benutzer und Ressourcen im Mandanten verwalten", "fr": "Administrateur - Gérer les utilisateurs et ressources dans le périmètre du mandat"}, + description=coerce_text_multilingual("Administrator - Benutzer und Ressourcen im Mandanten verwalten"), mandateId=None, # Global template role featureInstanceId=None, featureCode=None, @@ -555,7 +556,7 @@ def initRoles(db: DatabaseConnector) -> None: ), Role( roleLabel="user", - description={"en": "User - Standard user with access to own records", "de": "Benutzer - Standard-Benutzer mit Zugriff auf eigene Datensätze", "fr": "Utilisateur - Utilisateur standard avec accès à ses propres enregistrements"}, + description="Benutzer - Standard-Benutzer mit Zugriff auf eigene Datensätze", mandateId=None, # Global template role featureInstanceId=None, featureCode=None, @@ -563,7 +564,7 @@ def initRoles(db: DatabaseConnector) -> None: ), Role( roleLabel="viewer", - description={"en": "Viewer - Read-only access to group records", "de": "Betrachter - Nur-Lese-Zugriff auf Gruppen-Datensätze", "fr": "Visualiseur - Accès en lecture seule aux enregistrements du groupe"}, + description=coerce_text_multilingual("Betrachter - Nur-Lese-Zugriff auf Gruppen-Datensätze"), mandateId=None, # Global template role featureInstanceId=None, featureCode=None, @@ -728,7 +729,7 @@ def copySystemRolesToMandate(db: DatabaseConnector, mandateId: str) -> int: newRole = Role( id=newRoleId, roleLabel=roleLabel, - description=templateRole.get("description", {}), + description=coerce_text_multilingual(templateRole.get("description", {})), mandateId=mandateId, featureInstanceId=None, featureCode=None, @@ -797,11 +798,7 @@ def _initSysAdminRole(db: DatabaseConnector, mandateId: str) -> Optional[str]: logger.info("Creating sysadmin role in root mandate") sysadminRole = Role( roleLabel="sysadmin", - description={ - "en": "System Administrator - Full administrative access across all mandates", - "de": "System-Administrator - Vollständiger administrativer Zugriff über alle Mandanten", - "fr": "Administrateur système - Accès administratif complet à tous les mandats" - }, + description=coerce_text_multilingual("System-Administrator - Vollständiger administrativer Zugriff über alle Mandanten"), mandateId=mandateId, featureInstanceId=None, featureCode=None, diff --git a/modules/interfaces/interfaceFeatures.py b/modules/interfaces/interfaceFeatures.py index 6616218d..ba0f0428 100644 --- a/modules/interfaces/interfaceFeatures.py +++ b/modules/interfaces/interfaceFeatures.py @@ -15,6 +15,7 @@ from typing import List, Dict, Any, Optional from modules.datamodels.datamodelFeatures import Feature, FeatureInstance from modules.datamodels.datamodelRbac import Role, AccessRule +from modules.datamodels.datamodelUtils import coerce_text_multilingual from modules.connectors.connectorDbPostgre import DatabaseConnector logger = logging.getLogger(__name__) @@ -198,6 +199,9 @@ class FeatureInterface: # Copy template roles if requested if copyTemplateRoles: self._copyTemplateRoles(featureCode, mandateId, instanceId) + + # Copy template workflows (if feature defines TEMPLATE_WORKFLOWS) + self._copyTemplateWorkflows(featureCode, mandateId, instanceId) cleanedRecord = dict(createdInstance) return FeatureInstance(**cleanedRecord) @@ -206,6 +210,72 @@ class FeatureInterface: logger.error(f"Error creating feature instance: {e}") raise ValueError(f"Failed to create feature instance: {e}") + def _copyTemplateWorkflows(self, featureCode: str, mandateId: str, instanceId: str) -> int: + """ + Copy feature-specific template workflows to a new instance. + + Loads TEMPLATE_WORKFLOWS from the feature module and creates + AutoWorkflow records in the graphicalEditor DB, scoped to + (mandateId, instanceId). The placeholder {{featureInstanceId}} + in graph parameters is replaced with the actual instanceId. + + Args: + featureCode: Feature code (e.g. "trustee") + mandateId: Mandate ID + instanceId: New FeatureInstance ID + + Returns: + Number of workflows copied + """ + import json + import importlib + + try: + featureModule = importlib.import_module(f"modules.features.{featureCode}.main{featureCode.capitalize()}") + getTemplateWorkflows = getattr(featureModule, "getTemplateWorkflows", None) + if not getTemplateWorkflows: + return 0 + + templateWorkflows = getTemplateWorkflows() + if not templateWorkflows: + return 0 + + from modules.features.graphicalEditor.interfaceFeatureGraphicalEditor import getGraphicalEditorInterface + from modules.auth.authModels import SystemUser + systemUser = SystemUser() + geInterface = getGraphicalEditorInterface(systemUser, mandateId, instanceId) + + copied = 0 + for template in templateWorkflows: + graphJson = json.dumps(template.get("graph", {})) + graphJson = graphJson.replace("{{featureInstanceId}}", instanceId) + graph = json.loads(graphJson) + + labelDict = template.get("label", {}) + label = labelDict.get("de") or labelDict.get("en") or str(labelDict) if isinstance(labelDict, dict) else str(labelDict) + + geInterface.createWorkflow({ + "label": label, + "graph": graph, + "tags": template.get("tags", [f"feature:{featureCode}"]), + "isTemplate": False, + "templateSourceId": template["id"], + "templateScope": "instance", + "active": True, + }) + copied += 1 + + if copied > 0: + logger.info(f"Feature '{featureCode}': Copied {copied} template workflows to instance {instanceId}") + return copied + + except ImportError: + logger.debug(f"No feature module found for '{featureCode}' — skipping workflow bootstrap") + return 0 + except Exception as e: + logger.warning(f"Error copying template workflows for '{featureCode}' instance {instanceId}: {e}") + return 0 + def _copyTemplateRoles(self, featureCode: str, mandateId: str, instanceId: str) -> int: """ Copy feature-specific template roles to a new instance. @@ -268,7 +338,7 @@ class FeatureInterface: newRole = Role( id=newRoleId, roleLabel=templateRole.get("roleLabel"), - description=templateRole.get("description", {}), + description=coerce_text_multilingual(templateRole.get("description", {})), featureCode=featureCode, mandateId=mandateId, featureInstanceId=instanceId, @@ -354,7 +424,7 @@ class FeatureInterface: newRole = Role( id=newRoleId, roleLabel=templateRole.get("roleLabel"), - description=templateRole.get("description", {}), + description=coerce_text_multilingual(templateRole.get("description", {})), featureCode=featureCode, mandateId=mandateId, featureInstanceId=featureInstanceId, diff --git a/modules/routes/routeAdmin.py b/modules/routes/routeAdmin.py index ed5bf42c..0f671f0a 100644 --- a/modules/routes/routeAdmin.py +++ b/modules/routes/routeAdmin.py @@ -13,6 +13,8 @@ from modules.shared.configuration import APP_CONFIG from modules.auth import limiter, getCurrentUser from modules.datamodels.datamodelUam import User from modules.interfaces.interfaceDbApp import getRootInterface +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeAdmin") # Static folder setup - using absolute path from app root baseDir = FilePath(__file__).parent.parent.parent # Go up to gateway root @@ -39,7 +41,7 @@ def root(request: Request) -> Dict[str, str]: allowedOrigins = APP_CONFIG.get("APP_ALLOWED_ORIGINS") if not allowedOrigins: raise HTTPException( - status_code=500, detail="APP_ALLOWED_ORIGINS configuration is required" + status_code=500, detail=routeApiMsg("APP_ALLOWED_ORIGINS configuration is required") ) return { @@ -59,17 +61,17 @@ def get_environment( apiBaseUrl = APP_CONFIG.get("APP_API_URL") if not apiBaseUrl: raise HTTPException( - status_code=500, detail="APP_API_URL configuration is required" + status_code=500, detail=routeApiMsg("APP_API_URL configuration is required") ) environment = APP_CONFIG.get("APP_ENV") if not environment: - raise HTTPException(status_code=500, detail="APP_ENV configuration is required") + raise HTTPException(status_code=500, detail=routeApiMsg("APP_ENV configuration is required")) instanceLabel = APP_CONFIG.get("APP_ENV_LABEL") if not instanceLabel: raise HTTPException( - status_code=500, detail="APP_ENV_LABEL configuration is required" + status_code=500, detail=routeApiMsg("APP_ENV_LABEL configuration is required") ) return { @@ -91,5 +93,5 @@ def options_route(request: Request, fullPath: str) -> Response: def favicon(request: Request) -> FileResponse: favicon_path = staticFolder / "favicon.ico" if not favicon_path.exists(): - raise HTTPException(status_code=404, detail="Favicon not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Favicon not found")) return FileResponse(str(favicon_path), media_type="image/x-icon") diff --git a/modules/routes/routeAdminFeatures.py b/modules/routes/routeAdminFeatures.py index d7f6b7a9..855a0f80 100644 --- a/modules/routes/routeAdminFeatures.py +++ b/modules/routes/routeAdminFeatures.py @@ -27,6 +27,8 @@ from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceFeatures import getFeatureInterface from modules.security.rbacCatalog import getCatalogService from modules.routes.routeNotifications import create_access_change_notification +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeAdminFeatures") logger = logging.getLogger(__name__) @@ -418,7 +420,7 @@ def list_feature_instances( if not context.mandateId: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="X-Mandate-Id header is required" + detail=routeApiMsg("X-Mandate-Id header is required") ) try: @@ -483,7 +485,7 @@ def get_feature_instance_filter_values( ) -> list: """Return distinct filter values for a column in feature instances.""" if not context.mandateId: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="X-Mandate-Id header is required") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=routeApiMsg("X-Mandate-Id header is required")) try: from modules.routes.routeDataUsers import _handleFilterValuesRequest rootInterface = getRootInterface() @@ -530,7 +532,7 @@ def get_feature_instance( if not context.hasSysAdminRole: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Access denied to this feature instance" + detail=routeApiMsg("Access denied to this feature instance") ) return instance.model_dump() @@ -563,14 +565,14 @@ def create_feature_instance( if not context.mandateId: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="X-Mandate-Id header is required" + detail=routeApiMsg("X-Mandate-Id header is required") ) # Check mandate admin permission if not _hasMandateAdminRole(context): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Mandate-Admin role required to create feature instances" + detail=routeApiMsg("Mandate-Admin role required to create feature instances") ) try: @@ -670,14 +672,14 @@ def delete_feature_instance( if not context.hasSysAdminRole: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Access denied to this feature instance" + detail=routeApiMsg("Access denied to this feature instance") ) # Check mandate admin permission if not _hasMandateAdminRole(context) and not context.hasSysAdminRole: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Mandate-Admin role required to delete feature instances" + detail=routeApiMsg("Mandate-Admin role required to delete feature instances") ) featureInterface.deleteFeatureInstance(instanceId) @@ -737,14 +739,14 @@ def updateFeatureInstance( if not context.hasSysAdminRole: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Access denied to this feature instance" + detail=routeApiMsg("Access denied to this feature instance") ) # Check mandate admin permission if not _hasMandateAdminRole(context) and not context.hasSysAdminRole: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Mandate-Admin role required to update feature instances" + detail=routeApiMsg("Mandate-Admin role required to update feature instances") ) # Build update data (only non-None values) @@ -763,7 +765,7 @@ def updateFeatureInstance( if not updated: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to update feature instance" + detail=routeApiMsg("Failed to update feature instance") ) # Clear chatbot config cache when config was updated for chatbot instances @@ -820,14 +822,14 @@ def sync_instance_roles( if not context.hasSysAdminRole: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Access denied to this feature instance" + detail=routeApiMsg("Access denied to this feature instance") ) # Check admin permission (Mandate-Admin or Feature-Admin) if not _hasMandateAdminRole(context) and not context.hasSysAdminRole: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Admin role required to sync roles" + detail=routeApiMsg("Admin role required to sync roles") ) result = featureInterface.syncRolesFromTemplate(instanceId, addOnly) @@ -1061,7 +1063,7 @@ def list_feature_instance_users( if not context.hasSysAdminRole: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Access denied to this feature instance" + detail=routeApiMsg("Access denied to this feature instance") ) # Get all FeatureAccess records for this instance (Pydantic models) @@ -1152,7 +1154,7 @@ def get_feature_instance_users_filter_values( raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Feature instance '{instanceId}' not found") if context.mandateId and str(instance.mandateId) != str(context.mandateId): if not context.hasSysAdminRole: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied to this feature instance") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Access denied to this feature instance")) featureAccesses = rootInterface.getFeatureAccessesByInstance(instanceId) result = [] for fa in featureAccesses: @@ -1217,14 +1219,14 @@ def add_user_to_feature_instance( if not context.hasSysAdminRole: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Access denied to this feature instance" + detail=routeApiMsg("Access denied to this feature instance") ) # Check admin permission if not _hasMandateAdminRole(context) and not context.hasSysAdminRole: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Admin role required to add users to feature instances" + detail=routeApiMsg("Admin role required to add users to feature instances") ) # Verify user exists @@ -1238,7 +1240,7 @@ def add_user_to_feature_instance( if not data.roleIds: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="At least one role is required to grant feature access" + detail=routeApiMsg("At least one role is required to grant feature access") ) from modules.datamodels.datamodelRbac import Role @@ -1325,14 +1327,14 @@ def remove_user_from_feature_instance( if not context.hasSysAdminRole: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Access denied to this feature instance" + detail=routeApiMsg("Access denied to this feature instance") ) # Check admin permission if not _hasMandateAdminRole(context) and not context.hasSysAdminRole: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Admin role required to remove users from feature instances" + detail=routeApiMsg("Admin role required to remove users from feature instances") ) # Find FeatureAccess record @@ -1341,7 +1343,7 @@ def remove_user_from_feature_instance( if not existingAccess: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="User does not have access to this feature instance" + detail=routeApiMsg("User does not have access to this feature instance") ) featureAccessId = str(existingAccess.id) @@ -1415,14 +1417,14 @@ def update_feature_instance_user_roles( if not context.hasSysAdminRole: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Access denied to this feature instance" + detail=routeApiMsg("Access denied to this feature instance") ) # Check admin permission if not _hasMandateAdminRole(context) and not context.hasSysAdminRole: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Admin role required to update user roles" + detail=routeApiMsg("Admin role required to update user roles") ) # Find FeatureAccess record @@ -1431,7 +1433,7 @@ def update_feature_instance_user_roles( if not existingAccess: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="User does not have access to this feature instance" + detail=routeApiMsg("User does not have access to this feature instance") ) featureAccessId = str(existingAccess.id) @@ -1523,7 +1525,7 @@ def get_feature_instance_available_roles( if not context.hasSysAdminRole: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Access denied to this feature instance" + detail=routeApiMsg("Access denied to this feature instance") ) # Get roles for this instance using interface method @@ -1619,7 +1621,7 @@ def _renameFeatureInstance( instance = featureInterface.getFeatureInstance(instanceId) if not instance: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feature instance not found") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=routeApiMsg("Feature instance not found")) userId = str(context.user.id) isInstanceAdmin = False @@ -1637,11 +1639,11 @@ def _renameFeatureInstance( break if not isInstanceAdmin: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Instance admin role required to rename") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Instance admin role required to rename")) updated = featureInterface.updateFeatureInstance(instanceId, {"label": data.label.strip()}) if not updated: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to update instance") + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=routeApiMsg("Failed to update instance")) return {"id": instanceId, "label": updated.label} diff --git a/modules/routes/routeAdminRbacExport.py b/modules/routes/routeAdminRbacExport.py index c499a147..c6e3671e 100644 --- a/modules/routes/routeAdminRbacExport.py +++ b/modules/routes/routeAdminRbacExport.py @@ -21,8 +21,11 @@ from pydantic import BaseModel, Field from modules.auth import limiter, getRequestContext, RequestContext, requireSysAdminRole from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelRbac import Role, AccessRule +from modules.datamodels.datamodelUtils import coerce_text_multilingual from modules.interfaces.interfaceDbApp import getRootInterface from modules.shared.timeUtils import getUtcTimestamp +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeAdminRbacExport") logger = logging.getLogger(__name__) @@ -165,7 +168,7 @@ async def import_global_rbac( if "roles" not in data: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Missing 'roles' field in import data" + detail=routeApiMsg("Missing 'roles' field in import data") ) rootInterface = getRootInterface() @@ -227,7 +230,7 @@ async def import_global_rbac( # Create new role newRole = Role( roleLabel=roleLabel, - description=roleData.get("description", {}), + description=coerce_text_multilingual(roleData.get("description", {})), featureCode=featureCode, mandateId=None, featureInstanceId=None, @@ -298,14 +301,14 @@ def export_mandate_rbac( if not context.mandateId: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="X-Mandate-Id header is required" + detail=routeApiMsg("X-Mandate-Id header is required") ) # Check mandate admin permission if not _hasMandateAdminRole(context): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Mandate-Admin role required to export RBAC" + detail=routeApiMsg("Mandate-Admin role required to export RBAC") ) try: @@ -392,14 +395,14 @@ async def import_mandate_rbac( if not context.mandateId: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="X-Mandate-Id header is required" + detail=routeApiMsg("X-Mandate-Id header is required") ) # Check mandate admin permission if not _hasMandateAdminRole(context): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Mandate-Admin role required to import RBAC" + detail=routeApiMsg("Mandate-Admin role required to import RBAC") ) try: @@ -417,7 +420,7 @@ async def import_mandate_rbac( if "roles" not in data: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Missing 'roles' field in import data" + detail=routeApiMsg("Missing 'roles' field in import data") ) rootInterface = getRootInterface() @@ -482,7 +485,7 @@ async def import_mandate_rbac( # Create new role at mandate level newRole = Role( roleLabel=roleLabel, - description=roleData.get("description", {}), + description=coerce_text_multilingual(roleData.get("description", {})), featureCode=featureCode, mandateId=str(context.mandateId), featureInstanceId=None, diff --git a/modules/routes/routeAdminRbacRules.py b/modules/routes/routeAdminRbacRules.py index 16336fae..14caf29c 100644 --- a/modules/routes/routeAdminRbacRules.py +++ b/modules/routes/routeAdminRbacRules.py @@ -23,6 +23,8 @@ from modules.datamodels.datamodelRbac import AccessRuleContext, AccessRule, Role from modules.datamodels.datamodelMembership import UserMandate from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.interfaces.interfaceDbApp import getInterface, getRootInterface +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeAdminRbacRules") # Configure logger logger = logging.getLogger(__name__) @@ -113,7 +115,7 @@ def get_permissions( if not interface.rbac: raise HTTPException( status_code=500, - detail="RBAC interface not available" + detail=routeApiMsg("RBAC interface not available") ) # MULTI-TENANT: Get permissions using context (mandateId/featureInstanceId) @@ -189,7 +191,7 @@ def get_all_permissions( if not interface.rbac: raise HTTPException( status_code=500, - detail="RBAC interface not available" + detail=routeApiMsg("RBAC interface not available") ) # Determine which contexts to fetch @@ -363,7 +365,7 @@ def get_access_rules( isSysAdmin = reqContext.hasSysAdminRole adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) if not isSysAdmin and not adminMandateIds: - raise HTTPException(status_code=403, detail="Admin role required") + raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required")) # Get interface - uses root interface for admin access interface = getRootInterface() @@ -488,11 +490,11 @@ def get_access_rules_by_role( isSysAdmin = reqContext.hasSysAdminRole adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) if not isSysAdmin and not adminMandateIds: - raise HTTPException(status_code=403, detail="Admin role required") + raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required")) # MandateAdmin: verify role belongs to their mandates if not isSysAdmin and not _isRoleInAdminMandates(roleId, adminMandateIds): - raise HTTPException(status_code=403, detail="Access denied: role not in your mandates") + raise HTTPException(status_code=403, detail=routeApiMsg("Access denied: role not in your mandates")) interface = getRootInterface() @@ -535,7 +537,7 @@ def get_access_rule( isSysAdmin = reqContext.hasSysAdminRole adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) if not isSysAdmin and not adminMandateIds: - raise HTTPException(status_code=403, detail="Admin role required") + raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required")) # Get interface - uses root interface for admin access interface = getRootInterface() @@ -550,7 +552,7 @@ def get_access_rule( # MandateAdmin: verify rule's role belongs to their mandates if not isSysAdmin and not _isRoleInAdminMandates(str(rule.roleId), adminMandateIds): - raise HTTPException(status_code=403, detail="Access denied: rule's role not in your mandates") + raise HTTPException(status_code=403, detail=routeApiMsg("Access denied: rule's role not in your mandates")) # Convert to dict for JSON serialization return rule.model_dump() @@ -586,7 +588,7 @@ def create_access_rule( isSysAdmin = reqContext.hasSysAdminRole adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) if not isSysAdmin and not adminMandateIds: - raise HTTPException(status_code=403, detail="Admin role required") + raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required")) # Get interface - uses root interface for admin access interface = getRootInterface() @@ -621,7 +623,7 @@ def create_access_rule( # MandateAdmin: verify the rule's role belongs to their mandates if not isSysAdmin and accessRule.roleId: if not _isRoleInAdminMandates(str(accessRule.roleId), adminMandateIds): - raise HTTPException(status_code=403, detail="Access denied: role not in your mandates") + raise HTTPException(status_code=403, detail=routeApiMsg("Access denied: role not in your mandates")) # Create rule createdRule = interface.createAccessRule(accessRule) @@ -666,7 +668,7 @@ def update_access_rule( isSysAdmin = reqContext.hasSysAdminRole adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) if not isSysAdmin and not adminMandateIds: - raise HTTPException(status_code=403, detail="Admin role required") + raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required")) # Get interface - uses root interface for admin access interface = getRootInterface() @@ -681,7 +683,7 @@ def update_access_rule( # MandateAdmin: verify existing rule's role belongs to their mandates if not isSysAdmin and not _isRoleInAdminMandates(str(existingRule.roleId), adminMandateIds): - raise HTTPException(status_code=403, detail="Access denied: rule's role not in your mandates") + raise HTTPException(status_code=403, detail=routeApiMsg("Access denied: rule's role not in your mandates")) # Validate and parse access rule data try: @@ -754,7 +756,7 @@ def delete_access_rule( isSysAdmin = reqContext.hasSysAdminRole adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) if not isSysAdmin and not adminMandateIds: - raise HTTPException(status_code=403, detail="Admin role required") + raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required")) # Get interface - uses root interface for admin access interface = getRootInterface() @@ -769,7 +771,7 @@ def delete_access_rule( # MandateAdmin: verify rule's role belongs to their mandates if not isSysAdmin and not _isRoleInAdminMandates(str(existingRule.roleId), adminMandateIds): - raise HTTPException(status_code=403, detail="Access denied: rule's role not in your mandates") + raise HTTPException(status_code=403, detail=routeApiMsg("Access denied: rule's role not in your mandates")) # Delete rule success = interface.deleteAccessRule(ruleId) @@ -835,7 +837,7 @@ def list_roles( isSysAdmin = reqContext.hasSysAdminRole adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) if not isSysAdmin and not adminMandateIds: - raise HTTPException(status_code=403, detail="Admin role required") + raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required")) interface = getRootInterface() @@ -1008,7 +1010,7 @@ def get_roles_filter_values( isSysAdmin = reqContext.hasSysAdminRole adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) if not isSysAdmin and not adminMandateIds: - raise HTTPException(status_code=403, detail="Admin role required") + raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required")) interface = getRootInterface() dbRoles = interface.getAllRoles(pagination=None) @@ -1083,12 +1085,12 @@ def create_role( isSysAdmin = reqContext.hasSysAdminRole adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) if not isSysAdmin and not adminMandateIds: - raise HTTPException(status_code=403, detail="Admin role required") + raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required")) # MandateAdmin: can only create roles in their own mandates if not isSysAdmin: if not role.mandateId or str(role.mandateId) not in adminMandateIds: - raise HTTPException(status_code=403, detail="Access denied: can only create roles in your own mandates") + raise HTTPException(status_code=403, detail=routeApiMsg("Access denied: can only create roles in your own mandates")) interface = getRootInterface() @@ -1142,7 +1144,7 @@ def get_role( isSysAdmin = reqContext.hasSysAdminRole adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) if not isSysAdmin and not adminMandateIds: - raise HTTPException(status_code=403, detail="Admin role required") + raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required")) interface = getRootInterface() @@ -1156,7 +1158,7 @@ def get_role( # MandateAdmin: verify role belongs to their mandates if not isSysAdmin: if not role.mandateId or str(role.mandateId) not in adminMandateIds: - raise HTTPException(status_code=403, detail="Access denied: role not in your mandates") + raise HTTPException(status_code=403, detail=routeApiMsg("Access denied: role not in your mandates")) return { "id": role.id, @@ -1203,7 +1205,7 @@ def update_role( isSysAdmin = reqContext.hasSysAdminRole adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) if not isSysAdmin and not adminMandateIds: - raise HTTPException(status_code=403, detail="Admin role required") + raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required")) interface = getRootInterface() @@ -1213,9 +1215,9 @@ def update_role( if not existingRole: raise HTTPException(status_code=404, detail=f"Role {roleId} not found") if existingRole.isSystemRole and not existingRole.mandateId: - raise HTTPException(status_code=403, detail="Access denied: cannot modify template/system roles") + raise HTTPException(status_code=403, detail=routeApiMsg("Access denied: cannot modify template/system roles")) if not existingRole.mandateId or str(existingRole.mandateId) not in adminMandateIds: - raise HTTPException(status_code=403, detail="Access denied: role not in your mandates") + raise HTTPException(status_code=403, detail=routeApiMsg("Access denied: role not in your mandates")) updatedRole = interface.updateRole(roleId, role) @@ -1267,7 +1269,7 @@ def delete_role( isSysAdmin = reqContext.hasSysAdminRole adminMandateIds = [] if isSysAdmin else _getAdminMandateIds(reqContext) if not isSysAdmin and not adminMandateIds: - raise HTTPException(status_code=403, detail="Admin role required") + raise HTTPException(status_code=403, detail=routeApiMsg("Admin role required")) interface = getRootInterface() @@ -1277,9 +1279,9 @@ def delete_role( if not existingRole: raise HTTPException(status_code=404, detail=f"Role {roleId} not found") if existingRole.isSystemRole and not existingRole.mandateId: - raise HTTPException(status_code=403, detail="Access denied: cannot delete template/system roles") + raise HTTPException(status_code=403, detail=routeApiMsg("Access denied: cannot delete template/system roles")) if not existingRole.mandateId or str(existingRole.mandateId) not in adminMandateIds: - raise HTTPException(status_code=403, detail="Access denied: role not in your mandates") + raise HTTPException(status_code=403, detail=routeApiMsg("Access denied: role not in your mandates")) success = interface.deleteRole(roleId) if not success: diff --git a/modules/routes/routeAdminUserAccessOverview.py b/modules/routes/routeAdminUserAccessOverview.py index 9b19fc41..6c122191 100644 --- a/modules/routes/routeAdminUserAccessOverview.py +++ b/modules/routes/routeAdminUserAccessOverview.py @@ -24,6 +24,8 @@ from modules.datamodels.datamodelMembership import ( ) from modules.datamodels.datamodelFeatures import FeatureInstance, Feature from modules.interfaces.interfaceDbApp import getRootInterface +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeAdminUserAccessOverview") # Configure logger logger = logging.getLogger(__name__) @@ -116,7 +118,7 @@ def listUsersForOverview( - List of user dictionaries with basic info """ if not _hasMandateAdminRole(context): - raise HTTPException(status_code=403, detail="Keine Berechtigung für die Benutzerzugriffsübersicht") + raise HTTPException(status_code=403, detail=routeApiMsg("Keine Berechtigung für die Benutzerzugriffsübersicht")) try: interface = getRootInterface() @@ -209,7 +211,7 @@ def getUserAccessOverview( - Resource access (what resources the user can use) """ if not _hasMandateAdminRole(context): - raise HTTPException(status_code=403, detail="Keine Berechtigung für die Benutzerzugriffsübersicht") + raise HTTPException(status_code=403, detail=routeApiMsg("Keine Berechtigung für die Benutzerzugriffsübersicht")) try: interface = getRootInterface() @@ -239,7 +241,7 @@ def getUserAccessOverview( break if not userInAdminMandate: - raise HTTPException(status_code=403, detail="Benutzer gehört nicht zu Ihrem Mandate") + raise HTTPException(status_code=403, detail=routeApiMsg("Benutzer gehört nicht zu Ihrem Mandate")) # Get user user = interface.getUser(userId) @@ -528,7 +530,7 @@ def getEffectivePermissions( if not context.hasSysAdminRole: # Check if user has admin role in any mandate if not _hasMandateAdminRole(context): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Admin role required")) try: interface = getRootInterface() @@ -550,7 +552,7 @@ def getEffectivePermissions( break if not adminMandateIds: - raise HTTPException(status_code=403, detail="Insufficient permissions") + raise HTTPException(status_code=403, detail=routeApiMsg("Insufficient permissions")) userInAdminMandate = False for mid in adminMandateIds: @@ -559,7 +561,7 @@ def getEffectivePermissions( break if not userInAdminMandate: - raise HTTPException(status_code=403, detail="Benutzer gehört nicht zu Ihrem Mandate") + raise HTTPException(status_code=403, detail=routeApiMsg("Benutzer gehört nicht zu Ihrem Mandate")) # Get user user = interface.getUser(userId) diff --git a/modules/routes/routeAttributes.py b/modules/routes/routeAttributes.py index e877e512..20ddb842 100644 --- a/modules/routes/routeAttributes.py +++ b/modules/routes/routeAttributes.py @@ -9,6 +9,9 @@ from modules.auth import limiter # Import the attribute definition and helper functions from modules.shared.attributeUtils import getModelClasses, getModelAttributeDefinitions, AttributeResponse, AttributeDefinition +from modules.shared.i18nRegistry import apiRouteContext + +routeApiMsg = apiRouteContext("routeAttributes") # Configure logger logger = logging.getLogger(__name__) @@ -42,8 +45,8 @@ def get_entity_attributes( # Check if entity type is known if entityType not in modelClasses: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=f"Entity type '{entityType}' not found." + status_code=status.HTTP_404_NOT_FOUND, + detail=routeApiMsg("Entitätstyp nicht gefunden.") + f" ({entityType})", ) # Get model class and derive attributes from it diff --git a/modules/routes/routeBilling.py b/modules/routes/routeBilling.py index 110f563c..944131d6 100644 --- a/modules/routes/routeBilling.py +++ b/modules/routes/routeBilling.py @@ -38,6 +38,9 @@ from modules.datamodels.datamodelBilling import ( BillingStatisticsChartData, BillingCheckResult, ) +from modules.shared.i18nRegistry import apiRouteContext + +routeApiMsg = apiRouteContext("routeBilling") # Configure logger logger = logging.getLogger(__name__) @@ -337,9 +340,9 @@ def _creditStripeSessionIfNeeded( amount_chf_str = metadata.get("amountChf", "0") if not session_id: - raise HTTPException(status_code=400, detail="Stripe session id missing") + raise HTTPException(status_code=400, detail=routeApiMsg("Stripe session id missing")) if not mandate_id: - raise HTTPException(status_code=400, detail="Invalid session metadata: mandateId missing") + raise HTTPException(status_code=400, detail=routeApiMsg("Invalid session metadata: mandateId missing")) existing_payment_tx = billingInterface.getPaymentTransactionByReferenceId(session_id) if existing_payment_tx: @@ -363,11 +366,11 @@ def _creditStripeSessionIfNeeded( if amount_total is not None: amount_chf = amount_total / 100.0 else: - raise HTTPException(status_code=400, detail="Invalid amount in Stripe session") + raise HTTPException(status_code=400, detail=routeApiMsg("Invalid amount in Stripe session")) settings = billingInterface.getSettings(mandate_id) if not settings: - raise HTTPException(status_code=404, detail="Billing settings not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Billing settings not found")) account = billingInterface.getOrCreateMandateAccount(mandate_id, initialBalance=0.0) @@ -537,10 +540,10 @@ def getStatistics( try: # Validate period if period not in ["day", "month", "year"]: - raise HTTPException(status_code=400, detail="Invalid period. Use 'day', 'month', or 'year'") + raise HTTPException(status_code=400, detail=routeApiMsg("Invalid period. Use 'day', 'month', or 'year'")) if period == "day" and not month: - raise HTTPException(status_code=400, detail="Month is required for 'day' period") + raise HTTPException(status_code=400, detail=routeApiMsg("Month is required for 'day' period")) billingInterface = getBillingInterface(ctx.user, ctx.mandateId) settings = billingInterface.getSettings(ctx.mandateId) @@ -642,13 +645,13 @@ def getSettingsAdmin( Access: SysAdmin (any mandate) or MandateAdmin (own mandate). """ if not _isAdminOfMandate(ctx, targetMandateId): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required for this mandate") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Admin role required for this mandate")) try: billingInterface = getBillingInterface(ctx.user, targetMandateId) settings = billingInterface.getSettings(targetMandateId) if not settings: - raise HTTPException(status_code=404, detail="Billing settings not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Billing settings not found")) return settings @@ -672,7 +675,7 @@ def createOrUpdateSettings( Access: SysAdmin (any mandate) or MandateAdmin (own mandate). """ if not _isAdminOfMandate(ctx, targetMandateId): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required for this mandate") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Admin role required for this mandate")) try: billingInterface = getBillingInterface(ctx.user, targetMandateId) existingSettings = billingInterface.getSettings(targetMandateId) @@ -742,12 +745,12 @@ def addCredit( settings = billingInterface.getSettings(targetMandateId) if not settings: - raise HTTPException(status_code=404, detail="Billing settings not found for this mandate") + raise HTTPException(status_code=404, detail=routeApiMsg("Billing settings not found for this mandate")) account = billingInterface.getOrCreateMandateAccount(targetMandateId, initialBalance=0.0) if creditRequest.amount == 0: - raise HTTPException(status_code=400, detail="Amount must not be zero") + raise HTTPException(status_code=400, detail=routeApiMsg("Amount must not be zero")) from modules.datamodels.datamodelBilling import BillingTransaction @@ -794,10 +797,10 @@ def createCheckoutSession( settings = billingInterface.getSettings(targetMandateId) if not settings: - raise HTTPException(status_code=404, detail="Billing settings not found for this mandate") + raise HTTPException(status_code=404, detail=routeApiMsg("Billing settings not found for this mandate")) if not _isAdminOfMandate(ctx, targetMandateId): - raise HTTPException(status_code=403, detail="Mandate admin role required to load mandate credit") + raise HTTPException(status_code=403, detail=routeApiMsg("Mandate admin role required to load mandate credit")) from modules.serviceCenter.services.serviceBilling.stripeCheckout import create_checkout_session redirect_url = create_checkout_session( @@ -832,7 +835,7 @@ def confirmCheckoutSession( stripe = _getStripeClient() session = stripe.checkout.Session.retrieve(confirmRequest.sessionId) if not session: - raise HTTPException(status_code=404, detail="Stripe Checkout Session not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Stripe Checkout Session not found")) from modules.shared.stripeClient import stripeToDict session_dict = stripeToDict(session) @@ -841,7 +844,7 @@ def confirmCheckoutSession( user_id = metadata.get("userId") or None if not mandate_id: - raise HTTPException(status_code=400, detail="Invalid session metadata: mandateId missing") + raise HTTPException(status_code=400, detail=routeApiMsg("Invalid session metadata: mandateId missing")) payment_status = session_dict.get("payment_status") if payment_status != "paid": @@ -850,10 +853,10 @@ def confirmCheckoutSession( billingInterface = getBillingInterface(ctx.user, mandate_id) settings = billingInterface.getSettings(mandate_id) if not settings: - raise HTTPException(status_code=404, detail="Billing settings not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Billing settings not found")) if not _isAdminOfMandate(ctx, mandate_id): - raise HTTPException(status_code=403, detail="Mandate admin role required") + raise HTTPException(status_code=403, detail=routeApiMsg("Mandate admin role required")) root_billing_interface = _getRootInterface() return _creditStripeSessionIfNeeded(root_billing_interface, session_dict, eventId=None) @@ -880,10 +883,10 @@ async def stripeWebhook( webhook_secret = APP_CONFIG.get("STRIPE_WEBHOOK_SECRET") if not webhook_secret: logger.error("STRIPE_WEBHOOK_SECRET not configured") - raise HTTPException(status_code=500, detail="Webhook not configured") + raise HTTPException(status_code=500, detail=routeApiMsg("Webhook not configured")) if not stripe_signature: - raise HTTPException(status_code=400, detail="Missing Stripe-Signature header") + raise HTTPException(status_code=400, detail=routeApiMsg("Missing Stripe-Signature header")) payload = await request.body() @@ -894,10 +897,10 @@ async def stripeWebhook( ) except ValueError as e: logger.warning(f"Stripe webhook invalid payload: {e}") - raise HTTPException(status_code=400, detail="Invalid payload") + raise HTTPException(status_code=400, detail=routeApiMsg("Invalid payload")) except Exception as e: logger.warning(f"Stripe webhook signature verification failed: {e}") - raise HTTPException(status_code=400, detail="Invalid signature") + raise HTTPException(status_code=400, detail=routeApiMsg("Invalid signature")) logger.info(f"Stripe webhook received: event={event.id}, type={event.type}") @@ -1243,7 +1246,7 @@ def getAccounts( Access: SysAdmin (any mandate) or MandateAdmin (own mandate). """ if not _isAdminOfMandate(ctx, targetMandateId): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required for this mandate") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Admin role required for this mandate")) try: billingInterface = getBillingInterface(ctx.user, targetMandateId) @@ -1291,7 +1294,7 @@ def getUsersForMandate( Used by billing admin to select users for credit assignment. """ if not _isAdminOfMandate(ctx, targetMandateId): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required for this mandate") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Admin role required for this mandate")) try: from modules.interfaces.interfaceDbApp import getInterface as getAppInterface @@ -1414,7 +1417,7 @@ def getTransactionsAdmin( ): """Get all transactions for a mandate with pagination support.""" if not _isAdminOfMandate(ctx, targetMandateId): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required for this mandate") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Admin role required for this mandate")) try: paginationParams: Optional[PaginationParams] = None if pagination: @@ -1461,7 +1464,7 @@ def getTransactionFilterValues( ): """Return distinct filter values for a column in mandate transactions.""" if not _isAdminOfMandate(ctx, targetMandateId): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required for this mandate") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Admin role required for this mandate")) try: crossFilterParams: Optional[PaginationParams] = None if pagination: diff --git a/modules/routes/routeClickup.py b/modules/routes/routeClickup.py index 1603fa23..07202791 100644 --- a/modules/routes/routeClickup.py +++ b/modules/routes/routeClickup.py @@ -12,6 +12,8 @@ from modules.auth import getCurrentUser, limiter from modules.datamodels.datamodelUam import AuthAuthority, User, UserConnection from modules.interfaces.interfaceDbApp import getInterface from modules.serviceHub import getInterface as getServices +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeClickup") logger = logging.getLogger(__name__) @@ -42,12 +44,12 @@ def _getUserConnection(interface, connection_id: str, user_id: str) -> Optional[ def _clickup_connection_or_404(interface, connection_id: str, user_id: str) -> UserConnection: connection = _getUserConnection(interface, connection_id, user_id) if not connection: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Connection not found") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=routeApiMsg("Connection not found")) authority = connection.authority.value if hasattr(connection.authority, "value") else str(connection.authority) if authority.lower() != AuthAuthority.CLICKUP.value: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Connection is not a ClickUp connection", + detail=routeApiMsg("Connection is not a ClickUp connection"), ) return connection @@ -57,7 +59,7 @@ def _svc_for_connection(current_user: User, connection: UserConnection): if not services.clickup.setAccessTokenFromConnection(connection): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Failed to set ClickUp access token", + detail=routeApiMsg("Failed to set ClickUp access token"), ) return services.clickup diff --git a/modules/routes/routeDataConnections.py b/modules/routes/routeDataConnections.py index d01992c5..5e7b2c7e 100644 --- a/modules/routes/routeDataConnections.py +++ b/modules/routes/routeDataConnections.py @@ -26,6 +26,8 @@ from modules.datamodels.datamodelPagination import PaginationParams, PaginatedRe from modules.interfaces.interfaceDbApp import getInterface from modules.shared.timeUtils import getUtcTimestamp, parseTimestamp from modules.interfaces.interfaceDbManagement import ComponentObjects +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeDataConnections") # Configure logger logger = logging.getLogger(__name__) @@ -414,7 +416,7 @@ def update_connection( if not connection: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Connection not found" + detail=routeApiMsg("Connection not found") ) # Update connection fields @@ -486,7 +488,7 @@ def connect_service( if not connection: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Connection not found" + detail=routeApiMsg("Connection not found") ) # Data-app OAuth (JWT state issued server-side in /auth/connect) @@ -542,7 +544,7 @@ def disconnect_service( if not connection: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Connection not found" + detail=routeApiMsg("Connection not found") ) # Update connection status @@ -592,7 +594,7 @@ def delete_connection( if not connection: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Connection not found" + detail=routeApiMsg("Connection not found") ) # Remove the connection - only need connectionId since permissions are verified diff --git a/modules/routes/routeDataFiles.py b/modules/routes/routeDataFiles.py index 17e0ef56..defc0d75 100644 --- a/modules/routes/routeDataFiles.py +++ b/modules/routes/routeDataFiles.py @@ -17,6 +17,8 @@ from modules.datamodels.datamodelFileFolder import FileFolder from modules.shared.attributeUtils import getModelAttributeDefinitions from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeDataFiles") # Configure logger logger = logging.getLogger(__name__) @@ -422,7 +424,7 @@ def create_folder( name = body.get("name", "") parentId = body.get("parentId") if not name: - raise HTTPException(status_code=400, detail="name is required") + raise HTTPException(status_code=400, detail=routeApiMsg("name is required")) try: mgmt = interfaceDbManagement.getInterface( currentUser, @@ -449,7 +451,7 @@ def rename_folder( """Rename a folder.""" newName = body.get("name", "") if not newName: - raise HTTPException(status_code=400, detail="name is required") + raise HTTPException(status_code=400, detail=routeApiMsg("name is required")) try: mgmt = interfaceDbManagement.getInterface( currentUser, @@ -554,7 +556,7 @@ def download_folder( fileEntries = _collectFiles(folderId, "") if not fileEntries: - raise HTTPException(status_code=404, detail="Folder is empty") + raise HTTPException(status_code=404, detail=routeApiMsg("Folder is empty")) buf = io.BytesIO() with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf: @@ -595,7 +597,7 @@ def batch_delete_items( recursiveFolders = bool(body.get("recursiveFolders", True)) if not isinstance(fileIds, list) or not isinstance(folderIds, list): - raise HTTPException(status_code=400, detail="fileIds and folderIds must be arrays") + raise HTTPException(status_code=400, detail=routeApiMsg("fileIds and folderIds must be arrays")) try: mgmt = interfaceDbManagement.getInterface( @@ -638,7 +640,7 @@ def batch_move_items( targetParentId = body.get("targetParentId") if not isinstance(fileIds, list) or not isinstance(folderIds, list): - raise HTTPException(status_code=400, detail="fileIds and folderIds must be arrays") + raise HTTPException(status_code=400, detail=routeApiMsg("fileIds and folderIds must be arrays")) try: mgmt = interfaceDbManagement.getInterface( @@ -683,7 +685,7 @@ def updateFileScope( raise HTTPException(status_code=400, detail=f"Invalid scope: {scope}. Must be one of {validScopes}") if scope == "global" and not context.hasSysAdminRole: - raise HTTPException(status_code=403, detail="Only sysadmins can set global scope") + raise HTTPException(status_code=403, detail=routeApiMsg("Only sysadmins can set global scope")) managementInterface = interfaceDbManagement.getInterface( context.user, @@ -875,14 +877,14 @@ def update_file( if file_info.get("scope") == "global" and not _hasSysAdminRole(str(currentUser.id)): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Only sysadmins can set global scope", + detail=routeApiMsg("Only sysadmins can set global scope"), ) # Check if user has access to the file using RBAC if not managementInterface.checkRbacPermission(FileItem, "update", fileId): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Not authorized to update this file" + detail=routeApiMsg("Not authorized to update this file") ) # Update the file @@ -890,7 +892,7 @@ def update_file( if not result: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to update file" + detail=routeApiMsg("Failed to update file") ) # Get updated file @@ -928,7 +930,7 @@ def delete_file( if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Error deleting the file" + detail=routeApiMsg("Error deleting the file") ) return {"message": f"File with ID {fileId} successfully deleted"} diff --git a/modules/routes/routeDataMandates.py b/modules/routes/routeDataMandates.py index cb6a3efc..91b5b1b6 100644 --- a/modules/routes/routeDataMandates.py +++ b/modules/routes/routeDataMandates.py @@ -32,6 +32,8 @@ from modules.datamodels.datamodelRbac import Role from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict from modules.routes.routeNotifications import create_access_change_notification from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import SubscriptionCapacityException +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeDataMandates") # ============================================================================= @@ -103,7 +105,7 @@ def get_mandates( if not adminMandateIds: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Admin role required" + detail=routeApiMsg("Admin role required") ) # Parse pagination parameter @@ -180,7 +182,7 @@ def get_mandate_filter_values( if not isSysAdmin: adminMandateIds = _getAdminMandateIds(context) if not adminMandateIds: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin role required") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Admin role required")) appInterface = interfaceDbApp.getRootInterface() @@ -248,7 +250,7 @@ def get_mandate( if mandateId not in adminMandateIds: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Admin role required for this mandate" + detail=routeApiMsg("Admin role required for this mandate") ) appInterface = interfaceDbApp.getRootInterface() @@ -289,7 +291,7 @@ def create_mandate( if not name or (isinstance(name, str) and name.strip() == ''): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Mandate name is required" + detail=routeApiMsg("Mandate name is required") ) # Get optional fields with defaults @@ -308,7 +310,7 @@ def create_mandate( if not newMandate: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to create mandate" + detail=routeApiMsg("Failed to create mandate") ) try: @@ -392,7 +394,7 @@ def update_mandate( if not updatedMandate: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to update mandate" + detail=routeApiMsg("Failed to update mandate") ) logger.info(f"Mandate {mandateId} updated by SysAdmin {currentUser.id}") @@ -438,7 +440,7 @@ def delete_mandate( if confirmName != mandateName: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Hard-delete requires X-Confirm-Name header matching the mandate name" + detail=routeApiMsg("Hard-delete requires X-Confirm-Name header matching the mandate name") ) try: @@ -487,7 +489,7 @@ def list_mandate_users( if not _hasMandateAdminRole(context, targetMandateId) and not context.hasSysAdminRole: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Mandate-Admin role required" + detail=routeApiMsg("Mandate-Admin role required") ) try: @@ -647,7 +649,7 @@ def get_mandate_users_filter_values( ) -> list: """Return distinct filter values for a column in mandate users.""" if not _hasMandateAdminRole(context, targetMandateId) and not context.hasSysAdminRole: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Mandate-Admin role required") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Mandate-Admin role required")) try: from modules.routes.routeDataUsers import _handleFilterValuesRequest @@ -714,7 +716,7 @@ def add_user_to_mandate( if not _hasMandateAdminRole(context, targetMandateId): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Mandate-Admin role required to add users" + detail=routeApiMsg("Mandate-Admin role required to add users") ) try: @@ -831,7 +833,7 @@ def remove_user_from_mandate( if not _hasMandateAdminRole(context, targetMandateId): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Mandate-Admin role required" + detail=routeApiMsg("Mandate-Admin role required") ) try: @@ -857,7 +859,7 @@ def remove_user_from_mandate( if _isLastMandateAdmin(rootInterface, targetMandateId, targetUserId): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Cannot remove the last admin from a mandate. Assign another admin first." + detail=routeApiMsg("Cannot remove the last admin from a mandate. Assign another admin first.") ) # Delete UserMandate (CASCADE will delete UserMandateRole entries) @@ -920,7 +922,7 @@ def update_user_roles_in_mandate( if not _hasMandateAdminRole(context, targetMandateId): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Mandate-Admin role required" + detail=routeApiMsg("Mandate-Admin role required") ) try: @@ -953,7 +955,7 @@ def update_user_roles_in_mandate( if _isLastMandateAdmin(rootInterface, targetMandateId, targetUserId): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Cannot remove admin role from the last admin. Assign another admin first." + detail=routeApiMsg("Cannot remove admin role from the last admin. Assign another admin first.") ) # Remove existing role assignments diff --git a/modules/routes/routeDataPrompts.py b/modules/routes/routeDataPrompts.py index f9246ab6..2644b7e3 100644 --- a/modules/routes/routeDataPrompts.py +++ b/modules/routes/routeDataPrompts.py @@ -14,6 +14,8 @@ import modules.interfaces.interfaceDbManagement as interfaceDbManagement from modules.datamodels.datamodelUtils import Prompt from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeDataPrompts") # Configure logger logger = logging.getLogger(__name__) @@ -173,7 +175,7 @@ def update_prompt( if not updatedPrompt: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Error updating the prompt" + detail=routeApiMsg("Error updating the prompt") ) return Prompt(**updatedPrompt) @@ -207,7 +209,7 @@ def delete_prompt( if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Error deleting the prompt" + detail=routeApiMsg("Error deleting the prompt") ) return {"message": f"Prompt with ID {promptId} successfully deleted"} \ No newline at end of file diff --git a/modules/routes/routeDataSources.py b/modules/routes/routeDataSources.py index e210d094..db4b9a4f 100644 --- a/modules/routes/routeDataSources.py +++ b/modules/routes/routeDataSources.py @@ -10,6 +10,8 @@ from modules.auth import limiter, getRequestContext, RequestContext from modules.auth.authentication import _hasSysAdminRole from modules.datamodels.datamodelDataSource import DataSource from modules.datamodels.datamodelFeatureDataSource import FeatureDataSource +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeDataSources") logger = logging.getLogger(__name__) @@ -52,7 +54,7 @@ def _updateDataSourceScope( raise HTTPException(status_code=400, detail=f"Invalid scope: {scope}. Must be one of {_VALID_SCOPES}") if scope == "global" and not _hasSysAdminRole(context.user): - raise HTTPException(status_code=403, detail="Only sysadmins can set global scope") + raise HTTPException(status_code=403, detail=routeApiMsg("Only sysadmins can set global scope")) try: from modules.interfaces.interfaceDbApp import getRootInterface diff --git a/modules/routes/routeDataUsers.py b/modules/routes/routeDataUsers.py index 23cd508f..42e65c70 100644 --- a/modules/routes/routeDataUsers.py +++ b/modules/routes/routeDataUsers.py @@ -24,6 +24,8 @@ from modules.auth import limiter, getRequestContext, RequestContext from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority from modules.interfaces.interfaceDbApp import getRootInterface from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeDataUsers") # Configure logger logger = logging.getLogger(__name__) @@ -297,7 +299,7 @@ def get_user_options( elif context.hasSysAdminRole: users = appInterface.getAllUsers() else: - raise HTTPException(status_code=403, detail="Access denied") + raise HTTPException(status_code=403, detail=routeApiMsg("Access denied")) return [ {"value": user.id, "label": user.fullName or user.username or user.email or user.id} @@ -420,7 +422,7 @@ def get_users( if not adminMandateIds: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="No admin access to any mandate" + detail=routeApiMsg("No admin access to any mandate") ) from modules.datamodels.datamodelMembership import UserMandate as UserMandateModel @@ -581,7 +583,7 @@ def get_user( if not userMandate: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="User not in your mandate" + detail=routeApiMsg("User not in your mandate") ) return user @@ -636,7 +638,7 @@ def create_user( if not userRole: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="No 'user' role found in system — cannot assign user to mandate" + detail=routeApiMsg("No 'user' role found in system — cannot assign user to mandate") ) appInterface.createUserMandate( @@ -667,7 +669,7 @@ def update_user( if not isSelfUpdate and not _isAdminForUser(context, userId): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Admin role required to update other users" + detail=routeApiMsg("Admin role required to update other users") ) # Use rootInterface for user lookup/update (avoids RBAC filtering on User table) @@ -687,7 +689,7 @@ def update_user( if not updatedUser: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Error updating the user" + detail=routeApiMsg("Error updating the user") ) return updatedUser @@ -709,7 +711,7 @@ def reset_user_password( if not _isAdminForUser(context, userId): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Admin role required to reset passwords" + detail=routeApiMsg("Admin role required to reset passwords") ) # Get user interface @@ -719,7 +721,7 @@ def reset_user_password( if len(newPassword) < 8: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Password must be at least 8 characters long" + detail=routeApiMsg("Password must be at least 8 characters long") ) # Reset password @@ -727,7 +729,7 @@ def reset_user_password( if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to reset password" + detail=routeApiMsg("Failed to reset password") ) # SECURITY: Automatically revoke all tokens for the user after password reset @@ -792,14 +794,14 @@ def change_password( if not appInterface.verifyPassword(currentPassword, context.user.passwordHash): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Current password is incorrect" + detail=routeApiMsg("Current password is incorrect") ) # Validate new password strength if len(newPassword) < 8: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="New password must be at least 8 characters long" + detail=routeApiMsg("New password must be at least 8 characters long") ) # Change password @@ -807,7 +809,7 @@ def change_password( if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to change password" + detail=routeApiMsg("Failed to change password") ) # SECURITY: Automatically revoke all tokens for the user after password change @@ -877,7 +879,7 @@ def send_password_link( if not _isAdminForUser(context, userId): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Admin role required to send password links" + detail=routeApiMsg("Admin role required to send password links") ) # Get user interface @@ -888,14 +890,14 @@ def send_password_link( if not targetUser: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="User not found" + detail=routeApiMsg("User not found") ) # Check if user has an email if not targetUser.email: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="User has no email address configured" + detail=routeApiMsg("User has no email address configured") ) # Use root interface for token operations @@ -942,7 +944,7 @@ def send_password_link( logger.warning(f"Failed to send password setup email to {targetUser.email}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to send email" + detail=routeApiMsg("Failed to send email") ) except HTTPException: @@ -1010,7 +1012,7 @@ def delete_user( if not userMandate: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Cannot delete user outside your mandate" + detail=routeApiMsg("Cannot delete user outside your mandate") ) # Delete UserMandate entries for this user first @@ -1022,7 +1024,7 @@ def delete_user( if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Error deleting the user" + detail=routeApiMsg("Error deleting the user") ) return {"message": f"User with ID {userId} successfully deleted"} diff --git a/modules/routes/routeGdpr.py b/modules/routes/routeGdpr.py index f923932e..fce8ab69 100644 --- a/modules/routes/routeGdpr.py +++ b/modules/routes/routeGdpr.py @@ -25,6 +25,8 @@ from modules.interfaces.interfaceDbApp import getRootInterface from modules.shared.timeUtils import getUtcTimestamp from modules.shared.auditLogger import audit_logger from modules.shared.gdprDeletion import deleteUserDataAcrossAllDatabases, buildDeletionSummary +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeGdpr") logger = logging.getLogger(__name__) @@ -316,14 +318,14 @@ def delete_account( if not confirmDeletion: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Deletion not confirmed. Set confirmDeletion=true to proceed." + detail=routeApiMsg("Deletion not confirmed. Set confirmDeletion=true to proceed.") ) # Prevent SysAdmin self-deletion (safety measure) if getattr(currentUser, "isSysAdmin", False): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="SysAdmin accounts cannot be self-deleted. Contact another SysAdmin." + detail=routeApiMsg("SysAdmin accounts cannot be self-deleted. Contact another SysAdmin.") ) try: diff --git a/modules/routes/routeI18n.py b/modules/routes/routeI18n.py index 31543f62..31813798 100644 --- a/modules/routes/routeI18n.py +++ b/modules/routes/routeI18n.py @@ -38,8 +38,11 @@ from modules.datamodels.datamodelNotification import NotificationType from modules.interfaces.interfaceDbManagement import getInterface as getMgmtInterface from modules.routes.routeNotifications import _createNotification from modules.shared.configuration import APP_CONFIG +from modules.shared.i18nRegistry import _loadCache as _reloadI18nCache, apiRouteContext from modules.shared.timeUtils import getUtcTimestamp +routeApiMsg = apiRouteContext("routeI18n") + logger = logging.getLogger(__name__) router = APIRouter( @@ -270,16 +273,28 @@ async def _translateBatch( finally: aiObjects.billingCallback = None + _matchCapitalization(keysToTranslate, result) return result +def _matchCapitalization(originals: Dict[str, str], translations: Dict[str, str]) -> None: + """Ensure translations preserve the capitalisation pattern of the original key.""" + for key, translated in translations.items(): + if not key or not translated: + continue + if key[0].isupper() and translated[0].islower(): + translations[key] = translated[0].upper() + translated[1:] + elif key[0].islower() and translated[0].isupper(): + translations[key] = translated[0].lower() + translated[1:] + + def _resolveMandateIdForAiI18n(request: Request, currentUser: User) -> str: userId = str(currentUser.id) memberIds = _userMemberMandateIds(currentUser) if not memberIds: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Mindestens eine Mandats-Mitgliedschaft ist für die AI-Nutzung erforderlich.", + detail=routeApiMsg("Mindestens eine Mandats-Mitgliedschaft ist für die AI-Nutzung erforderlich."), ) headerRaw = ( @@ -289,7 +304,7 @@ def _resolveMandateIdForAiI18n(request: Request, currentUser: User) -> str: if headerRaw not in memberIds: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="X-Mandate-Id ist kein Mandat Ihrer Mitgliedschaft.", + detail=routeApiMsg("X-Mandate-Id ist kein Mandat Ihrer Mitgliedschaft."), ) if _mandatePassesAiPoolBilling(currentUser, headerRaw, userId): return headerRaw @@ -298,7 +313,7 @@ def _resolveMandateIdForAiI18n(request: Request, currentUser: User) -> str: return mid raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, - detail="Nicht genügend AI-Guthaben (Mandats-Pool) für diese Aktion.", + detail=routeApiMsg("Nicht genügend AI-Guthaben (Mandats-Pool) für diese Aktion."), ) @@ -348,7 +363,7 @@ async def _readOptionalEntriesFromBody(request: Request) -> Optional[List[dict]] if not isinstance(entries, list): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Feld «entries» muss ein JSON-Array sein.", + detail=routeApiMsg("Feld «entries» muss ein JSON-Array sein."), ) result = [] for e in entries: @@ -363,11 +378,10 @@ async def _readOptionalEntriesFromBody(request: Request) -> Optional[List[dict]] def _syncXxMaster(db, userId: Optional[str], incomingEntries: List[dict]) -> Dict[str, Any]: - """Synchronise the xx base set with incoming entries (from build bundle or codebase scan). + """Synchronise the xx base set with incoming UI entries. - - Keys in incoming but not in DB -> add - - Keys in DB but not in incoming -> remove - - Keys in both -> update context (value) + Only touches entries whose context is "ui". Gateway entries (api.*, table.*) + written by _syncRegistryToDb at boot are preserved untouched. """ if not incomingEntries: logger.warning("i18n xx-sync: no entries — aborting") @@ -394,39 +408,45 @@ def _syncXxMaster(db, userId: Optional[str], incomingEntries: List[dict]) -> Dic row = dict(rows[0]) curEntries = _rowEntries(row) - curByKey = {e["key"]: e for e in curEntries} + + gatewayEntries = [e for e in curEntries if e.get("context", "ui") != "ui"] + curUiByKey = {e["key"]: e for e in curEntries if e.get("context", "ui") == "ui"} incomingByKey = {e["key"]: e for e in incomingEntries} incomingKeys = set(incomingByKey.keys()) - dbKeys = set(curByKey.keys()) + dbUiKeys = set(curUiByKey.keys()) - added = sorted(incomingKeys - dbKeys) - removed = sorted(dbKeys - incomingKeys) + added = sorted(incomingKeys - dbUiKeys) + removed = sorted(dbUiKeys - incomingKeys) - newEntries = [] - for e in incomingEntries: - newEntries.append({"context": e["context"], "key": e["key"], "value": e["value"]}) - for e in curEntries: - if e["key"] not in incomingKeys: - continue + newUiEntries = [ + {"context": e["context"], "key": e["key"], "value": e["value"]} + for e in incomingEntries + ] if not added and not removed and all( - curByKey.get(e["key"], {}).get("value") == e["value"] - and curByKey.get(e["key"], {}).get("context") == e["context"] + curUiByKey.get(e["key"], {}).get("value") == e["value"] + and curUiByKey.get(e["key"], {}).get("context") == e["context"] for e in incomingEntries ): - return {"added": [], "removed": [], "entriesCount": len(newEntries)} + total = len(newUiEntries) + len(gatewayEntries) + return {"added": [], "removed": [], "entriesCount": total} + + mergedEntries = gatewayEntries + newUiEntries now = getUtcTimestamp() - row["entries"] = newEntries + row["entries"] = mergedEntries if "keys" in row: del row["keys"] row["sysModifiedAt"] = now row["sysModifiedBy"] = userId db.recordModify(UiLanguageSet, "xx", row) - logger.info("i18n xx-master sync: +%d added, -%d removed, total=%d", len(added), len(removed), len(newEntries)) - return {"added": added, "removed": removed, "entriesCount": len(newEntries)} + logger.info( + "i18n xx-master sync: +%d added, -%d removed (ui=%d, gateway=%d, total=%d)", + len(added), len(removed), len(newUiEntries), len(gatewayEntries), len(mergedEntries), + ) + return {"added": added, "removed": removed, "entriesCount": len(mergedEntries)} # --- Public ----------------------------------------------------------------- @@ -439,6 +459,8 @@ async def list_language_codes(): out = [] for r in rows: entries = _rowEntries(r) + uiCount = sum(1 for e in entries if e.get("context", "ui") == "ui") + gatewayCount = len(entries) - uiCount out.append( { "code": r["id"], @@ -446,6 +468,8 @@ async def list_language_codes(): "status": r.get("status"), "isDefault": bool(r.get("isDefault")), "entriesCount": len(entries), + "uiCount": uiCount, + "gatewayCount": gatewayCount, } ) return sorted(out, key=lambda x: (not x.get("isDefault"), x["code"])) @@ -456,7 +480,7 @@ async def get_language_set(code: str): db = _publicMgmtDb() rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code}) if not rows: - raise HTTPException(status_code=404, detail="Sprachset nicht gefunden") + raise HTTPException(status_code=404, detail=routeApiMsg("Sprachset nicht gefunden")) return _row_to_public(rows[0]) @@ -472,7 +496,7 @@ def _validate_iso2_code(code: str) -> str: c = code.strip().lower() if not re.fullmatch(r"[a-z]{2}", c): raise HTTPException( - status_code=400, detail="Nur ISO-639-1 Zwei-Buchstaben-Codes erlaubt." + status_code=400, detail=routeApiMsg("Nur ISO-639-1 Zwei-Buchstaben-Codes erlaubt.") ) return c @@ -530,6 +554,7 @@ async def _run_create_language_job_async(userId: str, code: str, label: str, cur title="Sprachset erstellt", message=f"Die Sprache «{label}» ({code}) wurde per KI übersetzt{statusHint}.", ) + await _reloadI18nCache() logger.info("i18n create job done: code=%s, translated=%d/%d", code, len(translated), len(xxEntries)) except Exception as e: logger.exception("create language job failed: %s", e) @@ -551,16 +576,16 @@ async def create_language_set( mandateId = _resolveMandateIdForAiI18n(request, currentUser) code = _validate_iso2_code(body.code) if code == "xx": - raise HTTPException(status_code=400, detail="Das Basisset «xx» kann nicht manuell angelegt werden.") + raise HTTPException(status_code=400, detail=routeApiMsg("Das Basisset «xx» kann nicht manuell angelegt werden.")) db = _publicMgmtDb() existing = db.getRecordset(UiLanguageSet, recordFilter={"id": code}) if existing: - raise HTTPException(status_code=409, detail="Dieses Sprachset existiert bereits.") + raise HTTPException(status_code=409, detail=routeApiMsg("Dieses Sprachset existiert bereits.")) xxEntries = _loadMasterXxEntries(db) if not xxEntries: - raise HTTPException(status_code=503, detail="Basisset (xx) nicht vorhanden. Bitte zuerst UI-Keys einlesen.") + raise HTTPException(status_code=503, detail=routeApiMsg("Basisset (xx) nicht vorhanden. Bitte zuerst UI-Keys einlesen.")) resolvedLabel = (body.label or "").strip() if body.label else "" if not resolvedLabel: @@ -594,54 +619,59 @@ async def create_language_set( def _compute_language_sync_diff(db, code: str) -> dict: """Return key sync metrics before AI translate (no DB writes).""" if code == "xx": - raise HTTPException(status_code=400, detail="Das xx-Set wird separat synchronisiert.") + raise HTTPException(status_code=400, detail=routeApiMsg("Das xx-Set wird separat synchronisiert.")) rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code}) if not rows: - raise HTTPException(status_code=404, detail="Sprachset nicht gefunden") - xx_entries = _loadMasterXxEntries(db) - if not xx_entries: - raise HTTPException(status_code=503, detail="Basisset (xx) nicht vorhanden.") + raise HTTPException(status_code=404, detail=routeApiMsg("Sprachset nicht gefunden")) + xxEntries = _loadMasterXxEntries(db) + if not xxEntries: + raise HTTPException(status_code=503, detail=routeApiMsg("Basisset (xx) nicht vorhanden.")) row = dict(rows[0]) - cur_entries = _rowEntries(row) - cur_by_key = {e["key"]: e for e in cur_entries} - xx_by_key = {e["key"]: e for e in xx_entries} - master_keys = set(xx_by_key.keys()) - current_keys = set(cur_by_key.keys()) - added_count = len(master_keys - current_keys) - removed_count = len(current_keys - master_keys) + curEntries = _rowEntries(row) + masterIds = {_entryId(e) for e in xxEntries} + currentIds = {_entryId(e) for e in curEntries} return { "code": code, - "addedCount": added_count, - "removedCount": removed_count, - "masterEntryCount": len(master_keys), - "currentEntryCount": len(current_keys), + "addedCount": len(masterIds - currentIds), + "removedCount": len(currentIds - masterIds), + "masterEntryCount": len(masterIds), + "currentEntryCount": len(currentIds), } +def _entryId(e: dict) -> tuple: + """Composite identifier for an i18n entry: (key, context).""" + return (e["key"], e.get("context", "ui")) + + async def _syncLanguageWithXx(db, code: str, userId: Optional[str], adminUser: Optional[User] = None) -> dict: - """Synchronise a language set (incl. de) against the xx base set via AI.""" + """Synchronise a language set (incl. de) against the xx base set via AI. + + Entries are identified by (key, context) — the same text can appear + with different contexts (e.g. "ui" and "api.routeXyz"). + """ if code == "xx": - raise HTTPException(status_code=400, detail="Das xx-Set wird über 'UI-Keys einlesen' aktualisiert.") + raise HTTPException(status_code=400, detail=routeApiMsg("Das xx-Set wird über 'UI-Keys einlesen' aktualisiert.")) rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code}) if not rows: - raise HTTPException(status_code=404, detail="Sprachset nicht gefunden") + raise HTTPException(status_code=404, detail=routeApiMsg("Sprachset nicht gefunden")) xxEntries = _loadMasterXxEntries(db) if not xxEntries: - raise HTTPException(status_code=503, detail="Basisset (xx) nicht vorhanden.") + raise HTTPException(status_code=503, detail=routeApiMsg("Basisset (xx) nicht vorhanden.")) row = dict(rows[0]) curEntries = _rowEntries(row) - curByKey = {e["key"]: e for e in curEntries} - xxByKey = {e["key"]: e for e in xxEntries} + curById = {_entryId(e): e for e in curEntries} + xxById = {_entryId(e): e for e in xxEntries} - masterKeys = set(xxByKey.keys()) - currentKeys = set(curByKey.keys()) - removedKeys = sorted(currentKeys - masterKeys) - addedKeys = sorted(masterKeys - currentKeys) + masterIds = set(xxById.keys()) + currentIds = set(curById.keys()) + removedIds = currentIds - masterIds + addedIds = masterIds - currentIds translatedCount = 0 - if addedKeys: - toTranslate = {k: xxByKey[k].get("value", "") for k in addedKeys} + if addedIds: + toTranslate = {xxById[eid]["key"]: xxById[eid].get("value", "") for eid in addedIds} langLabel = row.get("label") or code billingCb = None if adminUser: @@ -650,28 +680,29 @@ async def _syncLanguageWithXx(db, code: str, userId: Optional[str], adminUser: O billingCb = _makeBillingCallback(adminUser, memberIds[0]) try: translated = await _translateBatch(toTranslate, langLabel, code, billingCallback=billingCb) - translatedCount = sum(1 for k in addedKeys if k in translated) + translatedCount = sum(1 for eid in addedIds if xxById[eid]["key"] in translated) except Exception as e: logger.error("AI translation during sync failed for %s: %s", code, e) translated = {} - for k in addedKeys: - curByKey[k] = { - "context": xxByKey[k]["context"], - "key": k, - "value": translated.get(k, f"[{k}]"), + for eid in addedIds: + xxEntry = xxById[eid] + curById[eid] = { + "context": xxEntry["context"], + "key": xxEntry["key"], + "value": translated.get(xxEntry["key"], f"[{xxEntry['key']}]"), } - for k in removedKeys: - del curByKey[k] + for eid in removedIds: + del curById[eid] - for k in masterKeys & currentKeys: - curByKey[k]["context"] = xxByKey[k]["context"] + for eid in masterIds & currentIds: + curById[eid]["context"] = xxById[eid]["context"] - newEntries = [curByKey[k] for k in sorted(curByKey.keys(), key=lambda x: x.lower())] + newEntries = sorted(curById.values(), key=lambda e: (e["key"].lower(), e.get("context", ""))) now = getUtcTimestamp() - untranslated = len(addedKeys) - translatedCount + untranslated = len(addedIds) - translatedCount row["entries"] = newEntries if "keys" in row: del row["keys"] @@ -681,8 +712,8 @@ async def _syncLanguageWithXx(db, code: str, userId: Optional[str], adminUser: O db.recordModify(UiLanguageSet, code, row) return { "code": code, - "added": addedKeys, - "removed": removedKeys, + "added": sorted({xxById[eid]["key"] for eid in addedIds}), + "removed": sorted({eid[0] for eid in removedIds}), "translated": translatedCount, "entriesCount": len(newEntries), } @@ -701,7 +732,9 @@ async def sync_xx_master( db = getMgmtInterface(adminUser, mandateId=None).db fromBody = await _readOptionalEntriesFromBody(request) entries = fromBody if fromBody is not None else _scanCodebaseKeys() - return _syncXxMaster(db, str(adminUser.id), entries) + result = _syncXxMaster(db, str(adminUser.id), entries) + await _reloadI18nCache() + return result @router.put("/sets/update-all") @@ -727,6 +760,7 @@ async def update_all_language_sets( continue res = await _syncLanguageWithXx(db, cid, str(adminUser.id), adminUser=adminUser) results.append(res) + await _reloadI18nCache() return {"xxSync": xxSync, "updated": results} @@ -738,7 +772,7 @@ async def get_language_sync_diff( """How many keys would be added/removed vs xx before running a full sync (SysAdmin).""" c = code.strip().lower() if c in ("update-all", "sync-xx", "sync-de"): - raise HTTPException(status_code=400, detail="Ungültiger Sprachcode.") + raise HTTPException(status_code=400, detail=routeApiMsg("Ungültiger Sprachcode.")) db = getMgmtInterface(adminUser, mandateId=None).db return _compute_language_sync_diff(db, c) @@ -750,11 +784,13 @@ async def update_language_set( ): c = code.strip().lower() if c in ("update-all", "sync-xx", "sync-de"): - raise HTTPException(status_code=400, detail="Ungültiger Sprachcode.") + raise HTTPException(status_code=400, detail=routeApiMsg("Ungültiger Sprachcode.")) if c == "xx": - raise HTTPException(status_code=400, detail="Das xx-Set wird über 'UI-Keys einlesen' aktualisiert.") + raise HTTPException(status_code=400, detail=routeApiMsg("Das xx-Set wird über 'UI-Keys einlesen' aktualisiert.")) db = getMgmtInterface(adminUser, mandateId=None).db - return await _syncLanguageWithXx(db, c, str(adminUser.id), adminUser=adminUser) + result = await _syncLanguageWithXx(db, c, str(adminUser.id), adminUser=adminUser) + await _reloadI18nCache() + return result @router.delete("/sets/{code}") @@ -768,7 +804,8 @@ async def delete_language_set( db = getMgmtInterface(adminUser, mandateId=None).db ok = db.recordDelete(UiLanguageSet, c) if not ok: - raise HTTPException(status_code=404, detail="Sprachset nicht gefunden") + raise HTTPException(status_code=404, detail=routeApiMsg("Sprachset nicht gefunden")) + await _reloadI18nCache() return {"deleted": c} @@ -780,7 +817,7 @@ async def download_language_set( db = _publicMgmtDb() rows = db.getRecordset(UiLanguageSet, recordFilter={"id": code.strip().lower()}) if not rows: - raise HTTPException(status_code=404, detail="Sprachset nicht gefunden") + raise HTTPException(status_code=404, detail=routeApiMsg("Sprachset nicht gefunden")) payload = _row_to_public(rows[0]) raw = json.dumps(payload, ensure_ascii=False, indent=2) return Response( @@ -828,7 +865,7 @@ async def import_language_sets( adminUser: User = Depends(requireSysAdminRole), ): if not file.filename or not file.filename.endswith(".json"): - raise HTTPException(status_code=400, detail="Nur .json-Dateien erlaubt.") + raise HTTPException(status_code=400, detail=routeApiMsg("Nur .json-Dateien erlaubt.")) try: raw = await file.read() @@ -837,7 +874,7 @@ async def import_language_sets( raise HTTPException(status_code=400, detail=f"Ungültiges JSON: {e}") if not isinstance(data, list): - raise HTTPException(status_code=400, detail="JSON muss ein Array von Sprachsets sein.") + raise HTTPException(status_code=400, detail=routeApiMsg("JSON muss ein Array von Sprachsets sein.")) db = getMgmtInterface(adminUser, mandateId=None).db now = getUtcTimestamp() @@ -893,4 +930,44 @@ async def import_language_sets( created.append(code) logger.info("i18n import: created=%s, updated=%s", created, updated) + await _reloadI18nCache() return {"created": created, "updated": updated, "totalProcessed": len(created) + len(updated)} + + +# --------------------------------------------------------------------------- +# Phase 7b: translate-field — on-demand translation for TextMultilingual fields +# --------------------------------------------------------------------------- + +_TRANSLATE_FIELD_MAX_LEN = 2000 + + +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) + + +@router.post("/translate-field") +async def translateField( + body: TranslateFieldRequest, + request: Request, + 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] + if not targets: + return {"translations": {}} + + mandateId = _resolveMandateIdForAiI18n(request, currentUser) + billingCb = _makeBillingCallback(currentUser, mandateId) + + results: Dict[str, str] = {} + for targetCode in targets: + targetLabel = _ISO_LABELS.get(targetCode, targetCode) + keysToTranslate = {body.sourceText: "TextMultilingual field"} + translated = await _translateBatch(keysToTranslate, targetLabel, targetCode, billingCb) + val = translated.get(body.sourceText, "") + if val: + results[targetCode] = val + + return {"translations": results} diff --git a/modules/routes/routeInvitations.py b/modules/routes/routeInvitations.py index 43d803e3..9354c31c 100644 --- a/modules/routes/routeInvitations.py +++ b/modules/routes/routeInvitations.py @@ -25,6 +25,8 @@ from modules.routes.routeDataUsers import _applyFiltersAndSort from modules.datamodels.datamodelInvitation import Invitation from modules.interfaces.interfaceDbApp import getRootInterface from modules.shared.timeUtils import getUtcTimestamp +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeInvitations") logger = logging.getLogger(__name__) @@ -161,7 +163,7 @@ def create_invitation( if not context.mandateId: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="X-Mandate-Id header is required for mandate-level invitations" + detail=routeApiMsg("X-Mandate-Id header is required for mandate-level invitations") ) mandateId = str(context.mandateId) # Validate roles are mandate-level (no featureInstanceId) @@ -188,12 +190,12 @@ def create_invitation( if str(context.mandateId) != mandateId: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Access denied to this mandate" + detail=routeApiMsg("Access denied to this mandate") ) if not _hasMandateAdminRole(context): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Mandate-Admin role required to create invitations" + detail=routeApiMsg("Mandate-Admin role required to create invitations") ) # Calculate expiration time @@ -427,14 +429,14 @@ def list_invitations( if not context.mandateId: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="X-Mandate-Id header is required" + detail=routeApiMsg("X-Mandate-Id header is required") ) # Check mandate admin permission if not _hasMandateAdminRole(context): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Mandate-Admin role required to list invitations" + detail=routeApiMsg("Mandate-Admin role required to list invitations") ) try: @@ -522,9 +524,9 @@ def get_invitation_filter_values( ) -> list: """Return distinct filter values for a column in invitations.""" if not context.mandateId: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="X-Mandate-Id header is required") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=routeApiMsg("X-Mandate-Id header is required")) if not _hasMandateAdminRole(context): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Mandate-Admin role required") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Mandate-Admin role required")) try: from modules.routes.routeDataUsers import _handleFilterValuesRequest rootInterface = getRootInterface() @@ -575,14 +577,14 @@ def revoke_invitation( if not context.mandateId: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="X-Mandate-Id header is required" + detail=routeApiMsg("X-Mandate-Id header is required") ) # Check mandate admin permission if not _hasMandateAdminRole(context): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Mandate-Admin role required to revoke invitations" + detail=routeApiMsg("Mandate-Admin role required to revoke invitations") ) try: @@ -601,14 +603,14 @@ def revoke_invitation( if str(invitation.mandateId) != str(context.mandateId): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Access denied to this invitation" + detail=routeApiMsg("Access denied to this invitation") ) # Already revoked? if invitation.revokedAt: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invitation is already revoked" + detail=routeApiMsg("Invitation is already revoked") ) # Revoke invitation @@ -781,14 +783,14 @@ def accept_invitation( if not invitation: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Invitation not found" + detail=routeApiMsg("Invitation not found") ) # Validate invitation if invitation.revokedAt: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invitation has been revoked" + detail=routeApiMsg("Invitation has been revoked") ) currentTime = getUtcTimestamp() @@ -796,7 +798,7 @@ def accept_invitation( if expiresAt < currentTime: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invitation has expired" + detail=routeApiMsg("Invitation has expired") ) currentUses = invitation.currentUses or 0 @@ -804,7 +806,7 @@ def accept_invitation( if currentUses >= maxUses: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invitation has reached maximum uses" + detail=routeApiMsg("Invitation has reached maximum uses") ) # Validate user matches - invitation is bound by username or email @@ -833,7 +835,7 @@ def accept_invitation( else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invitation has no target user or email" + detail=routeApiMsg("Invitation has no target user or email") ) mandateId = str(invitation.mandateId) if invitation.mandateId else None diff --git a/modules/routes/routeMessaging.py b/modules/routes/routeMessaging.py index 42e15f0e..c2e0766f 100644 --- a/modules/routes/routeMessaging.py +++ b/modules/routes/routeMessaging.py @@ -22,6 +22,8 @@ from modules.datamodels.datamodelMessaging import ( ) from modules.datamodels.datamodelUam import User from modules.datamodels.datamodelPagination import PaginationParams, PaginatedResponse, PaginationMetadata, normalize_pagination_dict +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeMessaging") # Configure logger logger = logging.getLogger(__name__) @@ -139,7 +141,7 @@ def update_subscription( if not updatedSubscription: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Error updating the subscription" + detail=routeApiMsg("Error updating the subscription") ) return MessagingSubscription(**updatedSubscription) @@ -166,7 +168,7 @@ def delete_subscription( if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Error deleting the subscription" + detail=routeApiMsg("Error deleting the subscription") ) return {"message": f"Subscription with ID {subscriptionId} successfully deleted"} @@ -263,7 +265,7 @@ def unsubscribe_user( if not success: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Registration not found" + detail=routeApiMsg("Registration not found") ) return {"message": f"Successfully unsubscribed from {subscriptionId} for channel {channel.value}"} @@ -339,7 +341,7 @@ def update_registration( if not updatedRegistration: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Error updating the registration" + detail=routeApiMsg("Error updating the registration") ) return MessagingSubscriptionRegistration(**updatedRegistration) @@ -366,7 +368,7 @@ def delete_registration( if not success: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Error deleting the registration" + detail=routeApiMsg("Error deleting the registration") ) return {"message": f"Registration with ID {registrationId} successfully deleted"} @@ -397,7 +399,7 @@ def trigger_subscription( if not _hasTriggerPermission(context): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Admin or Mandate-Admin role required to trigger subscriptions" + detail=routeApiMsg("Admin or Mandate-Admin role required to trigger subscriptions") ) # Get messaging service from request app state diff --git a/modules/routes/routeNotifications.py b/modules/routes/routeNotifications.py index a533a535..41d7fe26 100644 --- a/modules/routes/routeNotifications.py +++ b/modules/routes/routeNotifications.py @@ -22,6 +22,8 @@ from modules.datamodels.datamodelNotification import ( from modules.datamodels.datamodelRbac import Role from modules.interfaces.interfaceDbApp import getRootInterface from modules.shared.timeUtils import getUtcTimestamp +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeNotifications") logger = logging.getLogger(__name__) @@ -238,14 +240,14 @@ def markAsRead( if not notification: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Notification not found" + detail=routeApiMsg("Notification not found") ) # Verify ownership if str(notification.userId) != str(currentUser.id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Not authorized to access this notification" + detail=routeApiMsg("Not authorized to access this notification") ) # Update status @@ -332,21 +334,21 @@ def executeAction( if not notification: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Notification not found" + detail=routeApiMsg("Notification not found") ) # Verify ownership if str(notification.userId) != str(currentUser.id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Not authorized to access this notification" + detail=routeApiMsg("Not authorized to access this notification") ) # Check if already actioned if notification.status == NotificationStatus.ACTIONED.value: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Notification has already been actioned" + detail=routeApiMsg("Notification has already been actioned") ) # Validate action exists @@ -416,7 +418,7 @@ def _handleInvitationAction( if not invitationId: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="No invitation reference found" + detail=routeApiMsg("No invitation reference found") ) # Get the invitation (Pydantic model) @@ -425,7 +427,7 @@ def _handleInvitationAction( if not invitation: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Invitation not found" + detail=routeApiMsg("Invitation not found") ) # Verify user matches (username or email) @@ -436,18 +438,18 @@ def _handleInvitationAction( if currentUser.username != targetUsername: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="This invitation is for a different user" + detail=routeApiMsg("This invitation is for a different user") ) elif invitationEmail: if not currentUserEmail or currentUserEmail != invitationEmail: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="This invitation is for a different user" + detail=routeApiMsg("This invitation is for a different user") ) else: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invitation has no target user or email" + detail=routeApiMsg("Invitation has no target user or email") ) # Check if invitation is still valid @@ -456,13 +458,13 @@ def _handleInvitationAction( if expiresAt < currentTime: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invitation has expired" + detail=routeApiMsg("Invitation has expired") ) if invitation.revokedAt: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invitation has been revoked" + detail=routeApiMsg("Invitation has been revoked") ) currentUses = invitation.currentUses or 0 @@ -470,7 +472,7 @@ def _handleInvitationAction( if currentUses >= maxUses: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invitation has reached maximum uses" + detail=routeApiMsg("Invitation has reached maximum uses") ) if actionId == "accept": @@ -565,14 +567,14 @@ def deleteNotification( if not notification: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Notification not found" + detail=routeApiMsg("Notification not found") ) # Verify ownership if str(notification.userId) != str(currentUser.id): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Not authorized to delete this notification" + detail=routeApiMsg("Not authorized to delete this notification") ) # Mark as dismissed (soft delete) diff --git a/modules/routes/routeRealEstate.py b/modules/routes/routeRealEstate.py index a3466aca..aa3d98f4 100644 --- a/modules/routes/routeRealEstate.py +++ b/modules/routes/routeRealEstate.py @@ -64,6 +64,8 @@ from modules.routes.routeRealEstateScraping import ( # Import attribute utilities for model schema from modules.shared.attributeUtils import getModelAttributeDefinitions +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeRealEstate") # Configure logger logger = logging.getLogger(__name__) @@ -308,7 +310,7 @@ async def update_project( raise HTTPException(status_code=404, detail=f"Project '{projectId}' not found") updated = interface.updateProjekt(projectId, data) if not updated: - raise HTTPException(status_code=500, detail="Update failed") + raise HTTPException(status_code=500, detail=routeApiMsg("Update failed")) return updated @@ -329,7 +331,7 @@ async def delete_project( if not projekt or str(getattr(projekt, "featureInstanceId", None)) != instanceId: raise HTTPException(status_code=404, detail=f"Project '{projectId}' not found") if not interface.deleteProjekt(projectId): - raise HTTPException(status_code=500, detail="Delete failed") + raise HTTPException(status_code=500, detail=routeApiMsg("Delete failed")) # ----- Parcels CRUD ----- @@ -429,7 +431,7 @@ async def update_parcel( raise HTTPException(status_code=404, detail=f"Parcel '{parcelId}' not found") updated = interface.updateParzelle(parcelId, data) if not updated: - raise HTTPException(status_code=500, detail="Update failed") + raise HTTPException(status_code=500, detail=routeApiMsg("Update failed")) return updated @@ -450,7 +452,7 @@ async def delete_parcel( if not parzelle or str(getattr(parzelle, "featureInstanceId", None)) != instanceId: raise HTTPException(status_code=404, detail=f"Parcel '{parcelId}' not found") if not interface.deleteParzelle(parcelId): - raise HTTPException(status_code=500, detail="Delete failed") + raise HTTPException(status_code=500, detail=routeApiMsg("Delete failed")) # ============================================================================ @@ -495,7 +497,7 @@ async def process_command( logger.warning(f"CSRF token missing for POST /api/realestate/command from user {currentUser.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="CSRF token missing. Please include X-CSRF-Token header." + detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") ) # Basic CSRF token format validation @@ -503,7 +505,7 @@ async def process_command( logger.warning(f"Invalid CSRF token format for POST /api/realestate/command from user {currentUser.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid CSRF token format" + detail=routeApiMsg("Invalid CSRF token format") ) # Validate token is hex string @@ -513,7 +515,7 @@ async def process_command( logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/command from user {currentUser.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid CSRF token format" + detail=routeApiMsg("Invalid CSRF token format") ) logger.info(f"Processing command request from user {currentUser.id} (mandate: {currentUser.mandateId})") @@ -566,7 +568,7 @@ async def get_available_tables( logger.warning(f"CSRF token missing for GET /api/realestate/tables from user {currentUser.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="CSRF token missing. Please include X-CSRF-Token header." + detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") ) # Basic CSRF token format validation @@ -574,7 +576,7 @@ async def get_available_tables( logger.warning(f"Invalid CSRF token format for GET /api/realestate/tables from user {currentUser.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid CSRF token format" + detail=routeApiMsg("Invalid CSRF token format") ) # Validate token is hex string @@ -584,7 +586,7 @@ async def get_available_tables( logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/tables from user {currentUser.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid CSRF token format" + detail=routeApiMsg("Invalid CSRF token format") ) logger.info(f"Getting available tables for user {currentUser.id} (mandate: {currentUser.mandateId})") @@ -675,7 +677,7 @@ async def get_table_data( logger.warning(f"CSRF token missing for GET /api/realestate/table/{table} from user {currentUser.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="CSRF token missing. Please include X-CSRF-Token header." + detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") ) # Basic CSRF token format validation @@ -683,7 +685,7 @@ async def get_table_data( logger.warning(f"Invalid CSRF token format for GET /api/realestate/table/{table} from user {currentUser.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid CSRF token format" + detail=routeApiMsg("Invalid CSRF token format") ) # Validate token is hex string @@ -693,7 +695,7 @@ async def get_table_data( logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/table/{table} from user {currentUser.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid CSRF token format" + detail=routeApiMsg("Invalid CSRF token format") ) logger.info(f"Getting table data for '{table}' from user {currentUser.id} (mandate: {currentUser.mandateId})") @@ -844,7 +846,7 @@ async def create_table_record( logger.warning(f"CSRF token missing for POST /api/realestate/table/{table} from user {currentUser.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="CSRF token missing. Please include X-CSRF-Token header." + detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") ) # Basic CSRF token format validation @@ -852,7 +854,7 @@ async def create_table_record( logger.warning(f"Invalid CSRF token format for POST /api/realestate/table/{table} from user {currentUser.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid CSRF token format" + detail=routeApiMsg("Invalid CSRF token format") ) # Validate token is hex string @@ -862,7 +864,7 @@ async def create_table_record( logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/table/{table} from user {currentUser.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid CSRF token format" + detail=routeApiMsg("Invalid CSRF token format") ) # Special handling for Projekt with parcel data @@ -874,7 +876,7 @@ async def create_table_record( if not label: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="label is required" + detail=routeApiMsg("label is required") ) status_prozess = data.get("statusProzess", "Eingang") @@ -887,7 +889,7 @@ async def create_table_record( if not isinstance(parzellen_data, list): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="parzellen must be an array" + detail=routeApiMsg("parzellen must be an array") ) elif "parzelle" in data: # Single parcel (backward compatibility) @@ -898,7 +900,7 @@ async def create_table_record( if not parzellen_data: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="parzelle or parzellen data is required" + detail=routeApiMsg("parzelle or parzellen data is required") ) # Use helper function to create project with parcel data @@ -1073,7 +1075,7 @@ async def search_parcel( logger.warning(f"CSRF token missing for GET /api/realestate/parcel/search from user {currentUser.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="CSRF token missing. Please include X-CSRF-Token header." + detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") ) logger.info(f"Searching parcel for user {currentUser.id} (mandate: {currentUser.mandateId}) with location: {location}") @@ -2059,21 +2061,21 @@ async def add_parcel_to_project( logger.warning(f"CSRF token missing for POST /api/realestate/projekt/{projekt_id}/add-parcel from user {currentUser.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="CSRF token missing. Please include X-CSRF-Token header." + detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") ) # Validate CSRF token format if not isinstance(csrf_token, str) or len(csrf_token) < 16 or len(csrf_token) > 64: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid CSRF token format" + detail=routeApiMsg("Invalid CSRF token format") ) try: int(csrf_token, 16) except ValueError: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid CSRF token format" + detail=routeApiMsg("Invalid CSRF token format") ) logger.info(f"Adding parcel to project {projekt_id} for user {currentUser.id} (mandate: {currentUser.mandateId})") @@ -2294,7 +2296,7 @@ async def get_bzo_information( logger.warning(f"CSRF token missing for GET /api/realestate/bzo-information from user {currentUser.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="CSRF token missing. Please include X-CSRF-Token header." + detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") ) # Basic CSRF token format validation @@ -2302,7 +2304,7 @@ async def get_bzo_information( logger.warning(f"Invalid CSRF token format for GET /api/realestate/bzo-information from user {currentUser.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid CSRF token format" + detail=routeApiMsg("Invalid CSRF token format") ) # Validate token is hex string @@ -2312,7 +2314,7 @@ async def get_bzo_information( logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/bzo-information from user {currentUser.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid CSRF token format" + detail=routeApiMsg("Invalid CSRF token format") ) logger.info(f"Extracting BZO information for Gemeinde '{gemeinde}', Bauzone '{bauzone}' (user: {currentUser.id}, mandate: {currentUser.mandateId})") diff --git a/modules/routes/routeRealEstateScraping.py b/modules/routes/routeRealEstateScraping.py index 4b8d2d0d..abb54299 100644 --- a/modules/routes/routeRealEstateScraping.py +++ b/modules/routes/routeRealEstateScraping.py @@ -36,6 +36,8 @@ from modules.connectors.connectorOerebWfs import OerebWfsConnector # Import Tavily connector for BZO document search from modules.aicore.aicorePluginTavily import AiTavily +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeRealEstateScraping") # Configure logger logger = logging.getLogger(__name__) @@ -107,7 +109,7 @@ async def scrape_switzerland_route( logger.warning(f"CSRF token missing for POST /api/realestate/scrape-switzerland from user {currentUser.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="CSRF token missing. Please include X-CSRF-Token header." + detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") ) # Basic CSRF token format validation @@ -115,7 +117,7 @@ async def scrape_switzerland_route( logger.warning(f"Invalid CSRF token format for POST /api/realestate/scrape-switzerland from user {currentUser.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid CSRF token format" + detail=routeApiMsg("Invalid CSRF token format") ) # Validate token is hex string @@ -125,7 +127,7 @@ async def scrape_switzerland_route( logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/scrape-switzerland from user {currentUser.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid CSRF token format" + detail=routeApiMsg("Invalid CSRF token format") ) # Extract parameters from body with defaults @@ -137,19 +139,19 @@ async def scrape_switzerland_route( if grid_size <= 0 or grid_size > 10000: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="grid_size must be between 0 and 10000 meters" + detail=routeApiMsg("grid_size must be between 0 and 10000 meters") ) if max_concurrent <= 0 or max_concurrent > 200: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="max_concurrent must be between 1 and 200" + detail=routeApiMsg("max_concurrent must be between 1 and 200") ) if batch_size <= 0 or batch_size > 1000: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="batch_size must be between 1 and 1000" + detail=routeApiMsg("batch_size must be between 1 and 1000") ) logger.info( @@ -246,7 +248,7 @@ async def get_all_gemeinden( logger.warning(f"CSRF token missing for GET /api/realestate/gemeinden from user {currentUser.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="CSRF token missing. Please include X-CSRF-Token header." + detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") ) # Basic CSRF token format validation @@ -254,7 +256,7 @@ async def get_all_gemeinden( logger.warning(f"Invalid CSRF token format for GET /api/realestate/gemeinden from user {currentUser.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid CSRF token format" + detail=routeApiMsg("Invalid CSRF token format") ) # Validate token is hex string @@ -264,7 +266,7 @@ async def get_all_gemeinden( logger.warning(f"CSRF token is not a valid hex string for GET /api/realestate/gemeinden from user {currentUser.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid CSRF token format" + detail=routeApiMsg("Invalid CSRF token format") ) logger.info(f"Fetching all Gemeinden for user {currentUser.id} (mandate: {currentUser.mandateId}), only_current={only_current}") @@ -548,7 +550,7 @@ async def fetch_bzo_documents( logger.warning(f"CSRF token missing for POST /api/realestate/gemeinden/fetch-bzo-documents from user {currentUser.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="CSRF token missing. Please include X-CSRF-Token header." + detail=routeApiMsg("CSRF token missing. Please include X-CSRF-Token header.") ) # Basic CSRF token format validation @@ -556,7 +558,7 @@ async def fetch_bzo_documents( logger.warning(f"Invalid CSRF token format for POST /api/realestate/gemeinden/fetch-bzo-documents from user {currentUser.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid CSRF token format" + detail=routeApiMsg("Invalid CSRF token format") ) # Validate token is hex string @@ -566,7 +568,7 @@ async def fetch_bzo_documents( logger.warning(f"CSRF token is not a valid hex string for POST /api/realestate/gemeinden/fetch-bzo-documents from user {currentUser.id}") raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="Invalid CSRF token format" + detail=routeApiMsg("Invalid CSRF token format") ) logger.info(f"Starting BZO document fetch for user {currentUser.id} (mandate: {currentUser.mandateId})") diff --git a/modules/routes/routeSecurityAdmin.py b/modules/routes/routeSecurityAdmin.py index acba83b4..acc5cdc5 100644 --- a/modules/routes/routeSecurityAdmin.py +++ b/modules/routes/routeSecurityAdmin.py @@ -17,6 +17,8 @@ from modules.interfaces.interfaceDbApp import getRootInterface from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority from modules.datamodels.datamodelSecurity import Token from modules.shared.configuration import APP_CONFIG +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeSecurityAdmin") logger = logging.getLogger(__name__) @@ -132,7 +134,7 @@ def list_tokens( raise except Exception as e: logger.error(f"Error listing tokens: {str(e)}") - raise HTTPException(status_code=500, detail="Failed to list tokens") + raise HTTPException(status_code=500, detail=routeApiMsg("Failed to list tokens")) @router.post("/tokens/revoke/user") @@ -151,7 +153,7 @@ def revoke_tokens_by_user( authority = payload.get("authority") reason = payload.get("reason", "sysadmin revoke") if not userId: - raise HTTPException(status_code=400, detail="userId is required") + raise HTTPException(status_code=400, detail=routeApiMsg("userId is required")) appInterface = getRootInterface() # MULTI-TENANT: SysAdmin can revoke any user's tokens (no mandate restriction) @@ -167,7 +169,7 @@ def revoke_tokens_by_user( raise except Exception as e: logger.error(f"Error revoking tokens by user: {str(e)}") - raise HTTPException(status_code=500, detail="Failed to revoke tokens") + raise HTTPException(status_code=500, detail=routeApiMsg("Failed to revoke tokens")) @router.post("/tokens/revoke/session") @@ -187,7 +189,7 @@ def revoke_tokens_by_session( authority = payload.get("authority", "local") reason = payload.get("reason", "sysadmin session revoke") if not userId or not sessionId: - raise HTTPException(status_code=400, detail="userId and sessionId are required") + raise HTTPException(status_code=400, detail=routeApiMsg("userId and sessionId are required")) appInterface = getRootInterface() # MULTI-TENANT: SysAdmin can revoke any session (no mandate check) @@ -203,7 +205,7 @@ def revoke_tokens_by_session( raise except Exception as e: logger.error(f"Error revoking tokens by session: {str(e)}") - raise HTTPException(status_code=500, detail="Failed to revoke session tokens") + raise HTTPException(status_code=500, detail=routeApiMsg("Failed to revoke session tokens")) @router.post("/tokens/revoke/id") @@ -221,7 +223,7 @@ def revoke_token_by_id( tokenId = payload.get("tokenId") reason = payload.get("reason", "sysadmin revoke") if not tokenId: - raise HTTPException(status_code=400, detail="tokenId is required") + raise HTTPException(status_code=400, detail=routeApiMsg("tokenId is required")) appInterface = getRootInterface() # MULTI-TENANT: SysAdmin can revoke any token (no mandate check) ok = appInterface.revokeTokenById(tokenId, revokedBy=currentUser.id, reason=reason) @@ -230,7 +232,7 @@ def revoke_token_by_id( raise except Exception as e: logger.error(f"Error revoking token by id: {str(e)}") - raise HTTPException(status_code=500, detail="Failed to revoke token") + raise HTTPException(status_code=500, detail=routeApiMsg("Failed to revoke token")) @router.post("/tokens/revoke/mandate") @@ -249,7 +251,7 @@ def revoke_tokens_by_mandate( authority = payload.get("authority", "local") reason = payload.get("reason", "sysadmin mandate revoke") if not mandateId: - raise HTTPException(status_code=400, detail="mandateId is required") + raise HTTPException(status_code=400, detail=routeApiMsg("mandateId is required")) # MULTI-TENANT: SysAdmin can revoke tokens for any mandate appInterface = getRootInterface() @@ -271,7 +273,7 @@ def revoke_tokens_by_mandate( raise except Exception as e: logger.error(f"Error revoking tokens by mandate: {str(e)}") - raise HTTPException(status_code=500, detail="Failed to revoke mandate tokens") + raise HTTPException(status_code=500, detail=routeApiMsg("Failed to revoke mandate tokens")) @@ -295,7 +297,7 @@ def list_databases( return {"databases": databases} except Exception as e: logger.error(f"Failed to load databases from host: {e}") - raise HTTPException(status_code=500, detail="Failed to load databases from host") + raise HTTPException(status_code=500, detail=routeApiMsg("Failed to load databases from host")) @router.get("/databases/{database_name}/tables") @@ -310,7 +312,7 @@ def get_database_tables( MULTI-TENANT: SysAdmin-only (infrastructure management). """ if not database_name.startswith("poweron_"): - raise HTTPException(status_code=400, detail="Invalid database name format") + raise HTTPException(status_code=400, detail=routeApiMsg("Invalid database name format")) connector = None try: @@ -341,7 +343,7 @@ def drop_table( MULTI-TENANT: SysAdmin-only (infrastructure management). """ if not database_name.startswith("poweron_"): - raise HTTPException(status_code=400, detail="Invalid database name format") + raise HTTPException(status_code=400, detail=routeApiMsg("Invalid database name format")) connector = None try: @@ -354,7 +356,7 @@ def drop_table( WHERE table_schema = 'public' AND table_name = %s """, (table_name,)) if not cursor.fetchone(): - raise HTTPException(status_code=404, detail="Table not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Table not found")) # Drop the table cursor.execute(f'DROP TABLE IF EXISTS "{table_name}" CASCADE') @@ -369,7 +371,7 @@ def drop_table( logger.error(f"Error dropping table: {str(e)}") if connector and connector.connection: connector.connection.rollback() - raise HTTPException(status_code=500, detail="Failed to drop table") + raise HTTPException(status_code=500, detail=routeApiMsg("Failed to drop table")) finally: if connector: connector.close() @@ -389,7 +391,7 @@ def drop_database( dbName = payload.get("database") if not dbName or not dbName.startswith("poweron_"): - raise HTTPException(status_code=400, detail="Invalid database name") + raise HTTPException(status_code=400, detail=routeApiMsg("Invalid database name")) # Validate database exists try: @@ -425,7 +427,7 @@ def drop_database( logger.error(f"Error dropping database tables: {str(e)}") if connector and connector.connection: connector.connection.rollback() - raise HTTPException(status_code=500, detail="Failed to drop database tables") + raise HTTPException(status_code=500, detail=routeApiMsg("Failed to drop database tables")) finally: if connector: connector.close() diff --git a/modules/routes/routeSecurityClickup.py b/modules/routes/routeSecurityClickup.py index 3d1aeed5..ca787391 100644 --- a/modules/routes/routeSecurityClickup.py +++ b/modules/routes/routeSecurityClickup.py @@ -19,6 +19,8 @@ from modules.datamodels.datamodelUam import AuthAuthority, User, ConnectionStatu from modules.datamodels.datamodelSecurity import Token, TokenPurpose from modules.auth import getCurrentUser, limiter, SECRET_KEY, ALGORITHM from modules.shared.timeUtils import createExpirationTimestamp, getUtcTimestamp +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeSecurityClickup") logger = logging.getLogger(__name__) @@ -53,7 +55,7 @@ def _require_clickup_config(): if not CLIENT_ID or not CLIENT_SECRET or not REDIRECT_URI: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="ClickUp OAuth is not configured (Service_CLICKUP_CLIENT_ID, Service_CLICKUP_CLIENT_SECRET, Service_CLICKUP_OAUTH_REDIRECT_URI)", + detail=routeApiMsg("ClickUp OAuth is not configured (Service_CLICKUP_CLIENT_ID, Service_CLICKUP_CLIENT_SECRET, Service_CLICKUP_OAUTH_REDIRECT_URI)"), ) @@ -87,7 +89,7 @@ def auth_connect( connection = conn break if not connection: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="ClickUp connection not found") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=routeApiMsg("ClickUp connection not found")) state_jwt = _issue_oauth_state( { @@ -123,11 +125,11 @@ async def auth_connect_callback( """OAuth callback for ClickUp data connection.""" state_data = _parse_oauth_state(state) if state_data.get("flow") != _FLOW_CONNECT: - raise HTTPException(status_code=400, detail="Invalid OAuth flow for this callback") + raise HTTPException(status_code=400, detail=routeApiMsg("Invalid OAuth flow for this callback")) connection_id = state_data.get("connectionId") user_id = state_data.get("userId") if not connection_id or not user_id: - raise HTTPException(status_code=400, detail="Missing connection or user in OAuth state") + raise HTTPException(status_code=400, detail=routeApiMsg("Missing connection or user in OAuth state")) _require_clickup_config() diff --git a/modules/routes/routeSecurityGoogle.py b/modules/routes/routeSecurityGoogle.py index 2b380db0..6f227dcc 100644 --- a/modules/routes/routeSecurityGoogle.py +++ b/modules/routes/routeSecurityGoogle.py @@ -33,6 +33,8 @@ from modules.auth import ( from modules.auth.tokenManager import TokenManager from modules.auth.oauthProviderConfig import googleAuthScopes, googleDataScopes from modules.shared.timeUtils import createExpirationTimestamp, getUtcTimestamp, parseTimestamp +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeSecurityGoogle") logger = logging.getLogger(__name__) @@ -131,7 +133,7 @@ def _require_google_auth_config(): if not AUTH_CLIENT_ID or not AUTH_CLIENT_SECRET or not AUTH_REDIRECT_URI: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Google Auth OAuth is not configured (Service_GOOGLE_AUTH_*)", + detail=routeApiMsg("Google Auth OAuth is not configured (Service_GOOGLE_AUTH_*)"), ) @@ -139,7 +141,7 @@ def _require_google_data_config(): if not DATA_CLIENT_ID or not DATA_CLIENT_SECRET or not DATA_REDIRECT_URI: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Google Data OAuth is not configured (Service_GOOGLE_DATA_*)", + detail=routeApiMsg("Google Data OAuth is not configured (Service_GOOGLE_DATA_*)"), ) @@ -179,7 +181,7 @@ async def auth_login_callback( """OAuth callback for Google Auth app (login only).""" state_data = _parse_oauth_state(state) if state_data.get("flow") != _FLOW_LOGIN: - raise HTTPException(status_code=400, detail="Invalid OAuth flow for this callback") + raise HTTPException(status_code=400, detail=routeApiMsg("Invalid OAuth flow for this callback")) _require_google_auth_config() oauth = OAuth2Session(client_id=AUTH_CLIENT_ID, redirect_uri=AUTH_REDIRECT_URI) @@ -214,7 +216,7 @@ async def auth_login_callback( if user_info_response.status_code != 200: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to get user info from Google", + detail=routeApiMsg("Failed to get user info from Google"), ) user_info = user_info_response.json() @@ -310,7 +312,7 @@ def auth_connect( connection = conn break if not connection: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Google connection not found") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=routeApiMsg("Google connection not found")) state_jwt = _issue_oauth_state( { @@ -359,11 +361,11 @@ async def auth_connect_callback( """OAuth callback for Google Data app (UserConnection).""" state_data = _parse_oauth_state(state) if state_data.get("flow") != _FLOW_CONNECT: - raise HTTPException(status_code=400, detail="Invalid OAuth flow for this callback") + raise HTTPException(status_code=400, detail=routeApiMsg("Invalid OAuth flow for this callback")) connection_id = state_data.get("connectionId") user_id = state_data.get("userId") if not connection_id or not user_id: - raise HTTPException(status_code=400, detail="Missing connection or user in OAuth state") + raise HTTPException(status_code=400, detail=routeApiMsg("Missing connection or user in OAuth state")) _require_google_data_config() oauth = OAuth2Session(client_id=DATA_CLIENT_ID, redirect_uri=DATA_REDIRECT_URI) @@ -419,7 +421,7 @@ async def auth_connect_callback( if user_info_response.status_code != 200: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to get user info from Google", + detail=routeApiMsg("Failed to get user info from Google"), ) user_info = user_info_response.json() @@ -557,7 +559,7 @@ def logout( if not token: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="No token found", + detail=routeApiMsg("No token found"), ) try: @@ -568,7 +570,7 @@ def logout( logger.error(f"Failed to decode JWT on Google logout: {str(e)}") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid token", + detail=routeApiMsg("Invalid token"), ) revoked = 0 @@ -635,13 +637,13 @@ async def verify_token( if not google_connection: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="No Google connection found for current user", + detail=routeApiMsg("No Google connection found for current user"), ) current_token = TokenManager().getFreshToken(google_connection.id) if not current_token: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="No Google token found for this connection", + detail=routeApiMsg("No Google token found for this connection"), ) token_verification = await verify_google_token(current_token.tokenAccess) return { @@ -690,7 +692,7 @@ async def refresh_token( if not google_connection: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Requested Google connection not found for current user", + detail=routeApiMsg("Requested Google connection not found for current user"), ) else: for conn in connections: @@ -700,13 +702,13 @@ async def refresh_token( if not google_connection: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="No Google connection found for current user", + detail=routeApiMsg("No Google connection found for current user"), ) current_token = TokenManager().getFreshToken(google_connection.id) if not current_token: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="No Google token found for this connection", + detail=routeApiMsg("No Google token found for this connection"), ) expiresAtValue = parseTimestamp(current_token.expiresAt) google_connection.expiresAt = ( diff --git a/modules/routes/routeSecurityLocal.py b/modules/routes/routeSecurityLocal.py index 9ec4fc38..daa128e0 100644 --- a/modules/routes/routeSecurityLocal.py +++ b/modules/routes/routeSecurityLocal.py @@ -21,6 +21,8 @@ from modules.datamodels.datamodelUam import User, UserInDB, AuthAuthority, Manda from modules.datamodels.datamodelSecurity import Token, TokenPurpose from modules.shared.configuration import APP_CONFIG from modules.shared.timeUtils import getUtcTimestamp +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeSecurityLocal") # Configure logger logger = logging.getLogger(__name__) @@ -231,7 +233,7 @@ def login( if not csrf_token: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="CSRF token missing" + detail=routeApiMsg("CSRF token missing") ) # Get gateway interface with root privileges for authentication @@ -248,7 +250,7 @@ def login( if not user: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Invalid username or password", + detail=routeApiMsg("Invalid username or password"), headers={"WWW-Authenticate": "Bearer"}, ) @@ -280,7 +282,7 @@ def login( expires_at = datetime.fromtimestamp(payload.get("exp")) except Exception as e: logger.error(f"Failed to decode access token: {str(e)}") - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to finalize token") + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=routeApiMsg("Failed to finalize token")) # Get user-specific interface for token operations userInterface = getInterface(user) @@ -425,7 +427,7 @@ def register_user( if not user: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Failed to register user" + detail=routeApiMsg("Failed to register user") ) # Check for pending invitations BEFORE provisioning. @@ -581,32 +583,32 @@ def refresh_token( # Get refresh token from cookie refresh_token = request.cookies.get('refresh_token') if not refresh_token: - raise HTTPException(status_code=401, detail="No refresh token found") + raise HTTPException(status_code=401, detail=routeApiMsg("No refresh token found")) # Validate refresh token try: payload = jwt.decode(refresh_token, SECRET_KEY, algorithms=[ALGORITHM]) if payload.get("type") != "refresh": - raise HTTPException(status_code=401, detail="Invalid refresh token type") + raise HTTPException(status_code=401, detail=routeApiMsg("Invalid refresh token type")) except jwt.ExpiredSignatureError: - raise HTTPException(status_code=401, detail="Refresh token expired") + raise HTTPException(status_code=401, detail=routeApiMsg("Refresh token expired")) except jwt.JWTError: - raise HTTPException(status_code=401, detail="Invalid refresh token") + raise HTTPException(status_code=401, detail=routeApiMsg("Invalid refresh token")) # Get user information from refresh token payload user_id = payload.get("userId") if not user_id: - raise HTTPException(status_code=401, detail="Invalid refresh token - missing user ID") + raise HTTPException(status_code=401, detail=routeApiMsg("Invalid refresh token - missing user ID")) # Get user from database using the user ID from refresh token try: app_interface = getRootInterface() current_user = app_interface.getUser(user_id) if not current_user: - raise HTTPException(status_code=401, detail="User not found") + raise HTTPException(status_code=401, detail=routeApiMsg("User not found")) except Exception as e: logger.error(f"Failed to get user from database: {str(e)}") - raise HTTPException(status_code=500, detail="Failed to validate user") + raise HTTPException(status_code=500, detail=routeApiMsg("Failed to validate user")) # Create new token data # MULTI-TENANT: Token does NOT contain mandateId anymore @@ -627,7 +629,7 @@ def refresh_token( expires_at = datetime.fromtimestamp(payload.get("exp")) except Exception as e: logger.error(f"Failed to decode new access token: {str(e)}") - raise HTTPException(status_code=500, detail="Failed to create new token") + raise HTTPException(status_code=500, detail=routeApiMsg("Failed to create new token")) return { "type": "token_refresh_success", @@ -643,7 +645,7 @@ def refresh_token( raise except Exception as e: logger.error(f"Token refresh error: {str(e)}") - raise HTTPException(status_code=500, detail="Token refresh failed") + raise HTTPException(status_code=500, detail=routeApiMsg("Token refresh failed")) @router.post("/logout") @limiter.limit("30/minute") @@ -661,7 +663,7 @@ def logout(request: Request, response: Response, currentUser: User = Depends(get token = auth_header.split(" ", 1)[1].strip() if not token: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No token found") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=routeApiMsg("No token found")) try: payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) @@ -669,7 +671,7 @@ def logout(request: Request, response: Response, currentUser: User = Depends(get jti = payload.get("jti") except Exception as e: logger.error(f"Failed to decode JWT on logout: {str(e)}") - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid token") + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=routeApiMsg("Invalid token")) revoked = 0 if session_id: @@ -927,14 +929,14 @@ def password_reset( except ValueError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Ungültiger oder abgelaufener Reset-Link" + detail=routeApiMsg("Ungültiger oder abgelaufener Reset-Link") ) # Validate password strength if len(password) < 8: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Passwort muss mindestens 8 Zeichen lang sein" + detail=routeApiMsg("Passwort muss mindestens 8 Zeichen lang sein") ) rootInterface = getRootInterface() @@ -945,7 +947,7 @@ def password_reset( if not success: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Ungültiger oder abgelaufener Reset-Link" + detail=routeApiMsg("Ungültiger oder abgelaufener Reset-Link") ) # Log success @@ -968,7 +970,7 @@ def password_reset( logger.error(f"Error in password reset: {str(e)}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Passwort-Zurücksetzung fehlgeschlagen" + detail=routeApiMsg("Passwort-Zurücksetzung fehlgeschlagen") ) @@ -1005,10 +1007,10 @@ def _deleteNeutralizationMapping( rootIf = getRootInterface() records = rootIf.db.getRecordset(DataNeutralizerAttributes, recordFilter={"id": mappingId}) if not records: - raise HTTPException(status_code=404, detail="Mapping not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Mapping not found")) rec = records[0] recUserId = rec.get("userId") if isinstance(rec, dict) else getattr(rec, "userId", None) if recUserId != userId: - raise HTTPException(status_code=403, detail="Not your mapping") + raise HTTPException(status_code=403, detail=routeApiMsg("Not your mapping")) rootIf.db.recordDelete(DataNeutralizerAttributes, mappingId) return {"deleted": True, "id": mappingId} diff --git a/modules/routes/routeSecurityMsft.py b/modules/routes/routeSecurityMsft.py index d7fac372..72f7759a 100644 --- a/modules/routes/routeSecurityMsft.py +++ b/modules/routes/routeSecurityMsft.py @@ -34,6 +34,8 @@ from modules.auth import ( from modules.auth.tokenManager import TokenManager from modules.auth.oauthProviderConfig import msftAuthScopes, msftDataScopes, msftDataScopesForRefresh from modules.shared.timeUtils import createExpirationTimestamp, getUtcTimestamp, parseTimestamp +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeSecurityMsft") logger = logging.getLogger(__name__) @@ -80,7 +82,7 @@ def _require_msft_auth_config(): if not AUTH_CLIENT_ID or not AUTH_CLIENT_SECRET or not AUTH_REDIRECT_URI: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Microsoft Auth OAuth is not configured (Service_MSFT_AUTH_*)", + detail=routeApiMsg("Microsoft Auth OAuth is not configured (Service_MSFT_AUTH_*)"), ) @@ -88,7 +90,7 @@ def _require_msft_data_config(): if not DATA_CLIENT_ID or not DATA_CLIENT_SECRET or not DATA_REDIRECT_URI: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Microsoft Data OAuth is not configured (Service_MSFT_DATA_*)", + detail=routeApiMsg("Microsoft Data OAuth is not configured (Service_MSFT_DATA_*)"), ) @@ -140,7 +142,7 @@ async def auth_login_callback( ) -> HTMLResponse: state_data = _parse_oauth_state(state) if state_data.get("flow") != _FLOW_LOGIN: - raise HTTPException(status_code=400, detail="Invalid OAuth flow for this callback") + raise HTTPException(status_code=400, detail=routeApiMsg("Invalid OAuth flow for this callback")) _require_msft_auth_config() msal_app = msal.ConfidentialClientApplication( @@ -171,7 +173,7 @@ async def auth_login_callback( if user_info_response.status_code != 200: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to get user info from Microsoft", + detail=routeApiMsg("Failed to get user info from Microsoft"), ) user_info = user_info_response.json() @@ -256,7 +258,7 @@ def auth_connect( break if not connection: raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, detail="Microsoft connection not found" + status_code=status.HTTP_404_NOT_FOUND, detail=routeApiMsg("Microsoft connection not found") ) msal_app = msal.ConfidentialClientApplication( @@ -301,11 +303,11 @@ async def auth_connect_callback( ) -> HTMLResponse: state_data = _parse_oauth_state(state) if state_data.get("flow") != _FLOW_CONNECT: - raise HTTPException(status_code=400, detail="Invalid OAuth flow for this callback") + raise HTTPException(status_code=400, detail=routeApiMsg("Invalid OAuth flow for this callback")) connection_id = state_data.get("connectionId") user_id = state_data.get("userId") if not connection_id or not user_id: - raise HTTPException(status_code=400, detail="Missing connection or user in OAuth state") + raise HTTPException(status_code=400, detail=routeApiMsg("Missing connection or user in OAuth state")) _require_msft_data_config() msal_app = msal.ConfidentialClientApplication( @@ -343,7 +345,7 @@ async def auth_connect_callback( if user_info_response.status_code != 200: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to get user info from Microsoft", + detail=routeApiMsg("Failed to get user info from Microsoft"), ) user_info = user_info_response.json() @@ -465,7 +467,7 @@ def adminconsent(request: Request) -> RedirectResponse: if not redirect_uri: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Could not derive admin consent redirect URI from Service_MSFT_DATA_REDIRECT_URI", + detail=routeApiMsg("Could not derive admin consent redirect URI from Service_MSFT_DATA_REDIRECT_URI"), ) state_jwt = _issue_oauth_state({"flow": "admin_consent"}) scope_param = _msft_data_admin_consent_scope_param() @@ -528,7 +530,7 @@ def adminconsent_callback( state_data = _parse_oauth_state(state) if state_data.get("flow") != "admin_consent": - raise HTTPException(status_code=400, detail="Invalid OAuth flow for this callback") + raise HTTPException(status_code=400, detail=routeApiMsg("Invalid OAuth flow for this callback")) granted = str(admin_consent or "").strip().lower() in ("true", "1", "yes") if not granted: @@ -615,7 +617,7 @@ def logout( if not token: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="No token found", + detail=routeApiMsg("No token found"), ) try: @@ -626,7 +628,7 @@ def logout( logger.error(f"Failed to decode JWT on Microsoft logout: {str(e)}") raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail="Invalid token", + detail=routeApiMsg("Invalid token"), ) revoked = 0 @@ -720,7 +722,7 @@ async def refresh_token( if not msft_connection: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="Requested Microsoft connection not found for current user", + detail=routeApiMsg("Requested Microsoft connection not found for current user"), ) else: for conn in connections: @@ -730,13 +732,13 @@ async def refresh_token( if not msft_connection: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="No Microsoft connection found for current user", + detail=routeApiMsg("No Microsoft connection found for current user"), ) current_token = TokenManager().getFreshToken(msft_connection.id) if not current_token: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, - detail="No Microsoft token found for this connection", + detail=routeApiMsg("No Microsoft token found for this connection"), ) token_manager = TokenManager() refreshed_token = token_manager.refreshToken(current_token) @@ -760,7 +762,7 @@ async def refresh_token( } raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="Failed to refresh token", + detail=routeApiMsg("Failed to refresh token"), ) except HTTPException: raise diff --git a/modules/routes/routeSharepoint.py b/modules/routes/routeSharepoint.py index 9bf5b633..4ab80679 100644 --- a/modules/routes/routeSharepoint.py +++ b/modules/routes/routeSharepoint.py @@ -13,6 +13,8 @@ from modules.auth import limiter, getCurrentUser from modules.datamodels.datamodelUam import User, UserConnection from modules.interfaces.interfaceDbApp import getInterface from modules.serviceHub import getInterface as getServices +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeSharepoint") logger = logging.getLogger(__name__) @@ -111,7 +113,7 @@ async def get_sharepoint_sites( if not services.sharepoint.setAccessTokenFromConnection(connection): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Failed to set SharePoint access token. Connection may be expired or invalid." + detail=routeApiMsg("Failed to set SharePoint access token. Connection may be expired or invalid.") ) # Discover SharePoint sites @@ -164,7 +166,7 @@ async def list_sharepoint_folders( if not services.sharepoint.setAccessTokenFromConnection(connection): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Failed to set SharePoint access token. Connection may be expired or invalid." + detail=routeApiMsg("Failed to set SharePoint access token. Connection may be expired or invalid.") ) # Normalize folder path (empty string for root) @@ -229,7 +231,7 @@ async def getSharepointFolderOptions( if not services.sharepoint.setAccessTokenFromConnection(connection): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Failed to set SharePoint access token. Connection may be expired or invalid." + detail=routeApiMsg("Failed to set SharePoint access token. Connection may be expired or invalid.") ) # Mode 1: Return sites list if no siteId specified @@ -343,7 +345,7 @@ async def getSharepointFolderOptionsByReference( if not services.sharepoint.setAccessTokenFromConnection(connection): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, - detail="Failed to set SharePoint access token. Connection may be expired or invalid." + detail=routeApiMsg("Failed to set SharePoint access token. Connection may be expired or invalid.") ) # Mode 1: Return sites list if no siteId specified diff --git a/modules/routes/routeStore.py b/modules/routes/routeStore.py index ab50087c..e8ffac79 100644 --- a/modules/routes/routeStore.py +++ b/modules/routes/routeStore.py @@ -23,6 +23,8 @@ from modules.interfaces.interfaceFeatures import getFeatureInterface from modules.security.rbacCatalog import getCatalogService from modules.security.rbac import RbacClass from modules.security.rootAccess import getRootDbAppConnector +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeStore") logger = logging.getLogger(__name__) @@ -327,7 +329,7 @@ def activateStoreFeature( mandateId = data.mandateId if not _isUserAdminInMandate(db, userId, mandateId): - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not admin in target mandate") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Not admin in target mandate")) # ── 1. Resolve subscription & plan ────────────────────────────── from modules.datamodels.datamodelSubscription import MandateSubscription, BUILTIN_PLANS, SubscriptionStatusEnum @@ -353,7 +355,7 @@ def activateStoreFeature( ) raise HTTPException( status_code=status.HTTP_402_PAYMENT_REQUIRED, - detail="Kein aktives Abonnement. Bitte zuerst ein Abo abschliessen.", + detail=routeApiMsg("Kein aktives Abonnement. Bitte zuerst ein Abo abschliessen."), ) planKey = operative.get("planKey", "") @@ -382,7 +384,7 @@ def activateStoreFeature( ) if not instance: - raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to create feature instance") + raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=routeApiMsg("Failed to create feature instance")) instanceId = instance.get("id") if isinstance(instance, dict) else instance.id @@ -460,12 +462,12 @@ def deactivateStoreFeature( # Verify instance exists in mandate instances = db.getRecordset(FeatureInstance, recordFilter={"id": instanceId, "mandateId": mandateId}) if not instances: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Feature instance not found in mandate") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=routeApiMsg("Feature instance not found in mandate")) # Find user's FeatureAccess accesses = db.getRecordset(FeatureAccess, recordFilter={"userId": userId, "featureInstanceId": instanceId}) if not accesses: - raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No active access found") + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=routeApiMsg("No active access found")) featureAccessId = accesses[0].get("id") db.recordDelete(FeatureAccess, featureAccessId) diff --git a/modules/routes/routeSubscription.py b/modules/routes/routeSubscription.py index 3e25ec39..2583316d 100644 --- a/modules/routes/routeSubscription.py +++ b/modules/routes/routeSubscription.py @@ -23,6 +23,8 @@ 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 +routeApiMsg = apiRouteContext("routeSubscription") logger = logging.getLogger(__name__) @@ -53,7 +55,7 @@ def _assertMandateAdmin(context: RequestContext, mandateId: str) -> None: return except Exception: pass - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Mandate admin role required") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Mandate admin role required")) # ============================================================================= @@ -169,7 +171,7 @@ def activatePlan( ) mandateId = _resolveMandateId(context) if not mandateId: - raise HTTPException(status_code=400, detail="X-Mandate-Id header required") + raise HTTPException(status_code=400, detail=routeApiMsg("X-Mandate-Id header required")) _assertMandateAdmin(context, mandateId) try: @@ -195,7 +197,7 @@ def cancelSubscription( ) mandateId = _resolveMandateId(context) if not mandateId: - raise HTTPException(status_code=400, detail="X-Mandate-Id header required") + raise HTTPException(status_code=400, detail=routeApiMsg("X-Mandate-Id header required")) _assertMandateAdmin(context, mandateId) try: @@ -221,7 +223,7 @@ def reactivateSubscription( ) mandateId = _resolveMandateId(context) if not mandateId: - raise HTTPException(status_code=400, detail="X-Mandate-Id header required") + raise HTTPException(status_code=400, detail=routeApiMsg("X-Mandate-Id header required")) _assertMandateAdmin(context, mandateId) try: @@ -243,7 +245,7 @@ def forceCancel( ): """Sysadmin: immediately expire any non-terminal subscription.""" if not context.hasSysAdminRole: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Sysadmin role required") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Sysadmin role required")) from modules.serviceCenter.services.serviceSubscription.mainServiceSubscription import ( getService as getSubscriptionService, @@ -251,7 +253,7 @@ def forceCancel( from modules.interfaces.interfaceDbSubscription import _getRootInterface as getSubRootInterface sub = getSubRootInterface().getById(data.subscriptionId) if not sub: - raise HTTPException(status_code=404, detail="Subscription not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Subscription not found")) mandateId = sub["mandateId"] try: @@ -278,7 +280,7 @@ def verifyCheckout( """ mandateId = _resolveMandateId(context) if not mandateId: - raise HTTPException(status_code=400, detail="X-Mandate-Id header required") + raise HTTPException(status_code=400, detail=routeApiMsg("X-Mandate-Id header required")) _assertMandateAdmin(context, mandateId) try: @@ -288,7 +290,7 @@ def verifyCheckout( session = stripeToDict(rawSession) except Exception as e: logger.error("Failed to retrieve checkout session %s: %s", data.sessionId, e) - raise HTTPException(status_code=400, detail="Invalid session ID") + raise HTTPException(status_code=400, detail=routeApiMsg("Invalid session ID")) payStatus = session.get("payment_status") if session.get("status") != "complete": @@ -297,7 +299,7 @@ def verifyCheckout( return {"status": "pending", "message": "Checkout not yet completed"} if session.get("mode") != "subscription": - raise HTTPException(status_code=400, detail="Not a subscription checkout session") + raise HTTPException(status_code=400, detail=routeApiMsg("Not a subscription checkout session")) from modules.routes.routeBilling import _handleSubscriptionCheckoutCompleted @@ -421,7 +423,7 @@ def getAllSubscriptions( ): """SysAdmin: list ALL subscriptions across all mandates with enriched metadata.""" if not context.hasSysAdminRole: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Sysadmin role required") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Sysadmin role required")) paginationParams: Optional[PaginationParams] = None if pagination: @@ -467,7 +469,7 @@ def getFilterValues( ): """Return distinct values for a column, respecting all active filters except the requested one.""" if not context.hasSysAdminRole: - raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Sysadmin role required") + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=routeApiMsg("Sysadmin role required")) crossFilterParams: Optional[PaginationParams] = None if pagination: diff --git a/modules/routes/routeSystem.py b/modules/routes/routeSystem.py index 03c58a18..a6d535c1 100644 --- a/modules/routes/routeSystem.py +++ b/modules/routes/routeSystem.py @@ -12,7 +12,7 @@ Navigation API Konzept: import logging from typing import Dict, List, Any, Optional -from fastapi import APIRouter, Depends, Request, Query +from fastapi import APIRouter, Depends, Request from slowapi import Limiter from slowapi.util import get_remote_address @@ -130,11 +130,11 @@ def _getFeatureUiObjects(featureCode: str) -> List[Dict[str, Any]]: def _buildDynamicBlock( userId: str, - language: str, isSysAdmin: bool ) -> Optional[Dict[str, Any]]: """ Build the dynamic features block with mandates, features, and instances. + Labels are German base texts (i18n keys). Frontend translates via t(). Returns None if user has no feature instances. """ @@ -181,21 +181,29 @@ def _buildDynamicBlock( if featureKey not in featuresMap: feature = featureInterface.getFeature(instance.featureCode) - # Handle featureLabel - could be a dict or a Pydantic model (TextMultilingual) + # Handle featureLabel — TextMultilingual dict, plain str (German key), or legacy object if feature and hasattr(feature, 'label'): featureLabel = feature.label - # Convert Pydantic model to dict if needed if hasattr(featureLabel, 'model_dump'): featureLabel = featureLabel.model_dump() + elif isinstance(featureLabel, str): + pass elif not isinstance(featureLabel, dict): - # Fallback: try to access as attributes - featureLabel = {"de": getattr(featureLabel, 'de', instance.featureCode), "en": getattr(featureLabel, 'en', instance.featureCode)} + featureLabel = { + "de": getattr(featureLabel, 'de', instance.featureCode), + "en": getattr(featureLabel, 'en', instance.featureCode), + } else: featureLabel = {"de": instance.featureCode, "en": instance.featureCode} + if isinstance(featureLabel, str): + resolvedFeatureLabel = featureLabel + else: + resolvedFeatureLabel = featureLabel.get("de", featureLabel.get("en", instance.featureCode)) + featuresMap[featureKey] = { "uiComponent": f"feature.{instance.featureCode}", - "uiLabel": featureLabel.get(language, featureLabel.get("en", instance.featureCode)), + "uiLabel": resolvedFeatureLabel, "order": 10, "instances": [], "_mandateId": mandateId, @@ -228,9 +236,8 @@ def _buildDynamicBlock( # Build path for this view viewPath = f"/mandates/{mandateId}/{instance.featureCode}/{instance.id}/{viewName}" - # Get label in requested language label = uiObj.get("label", {}) - uiLabel = label.get(language, label.get("en", viewName)) + uiLabel = label.get("de", label.get("en", viewName)) if isinstance(label, dict) else label views.append({ "uiComponent": f"page.feature.{instance.featureCode}.{viewName}", @@ -347,7 +354,6 @@ def _getInstanceViewPermissions( def _filterItems( items: List[Dict[str, Any]], - language: str, isSysAdmin: bool, roleIds: List[str], hasGlobalPermission: bool @@ -361,19 +367,18 @@ def _filterItems( if item.get("sysAdminOnly") and not isSysAdmin: continue if item.get("public"): - filteredItems.append(_formatBlockItem(item, language)) + filteredItems.append(_formatBlockItem(item)) continue if isSysAdmin: - filteredItems.append(_formatBlockItem(item, language)) + filteredItems.append(_formatBlockItem(item)) continue if hasGlobalPermission or _checkUiPermission(roleIds, item["objectKey"]): - filteredItems.append(_formatBlockItem(item, language)) + filteredItems.append(_formatBlockItem(item)) filteredItems.sort(key=lambda i: i["order"]) return filteredItems def _buildStaticBlocks( - language: str, isSysAdmin: bool, roleIds: List[str], hasGlobalPermission: bool @@ -381,8 +386,8 @@ def _buildStaticBlocks( """ Build static navigation blocks from NAVIGATION_SECTIONS. - Returns list of blocks with items filtered by permissions. - Supports subgroups within sections. + Labels/titles are plain German strings (i18n base keys). + The frontend translates them via t(). """ blocks = [] @@ -397,12 +402,12 @@ def _buildStaticBlocks( filteredSubgroups = [] for subgroup in section["subgroups"]: subItems = _filterItems( - subgroup.get("items", []), language, isSysAdmin, roleIds, hasGlobalPermission + subgroup.get("items", []), isSysAdmin, roleIds, hasGlobalPermission ) if subItems: filteredSubgroups.append({ "id": subgroup["id"], - "title": subgroup["title"].get(language, subgroup["title"].get("en", subgroup["id"])), + "title": subgroup["title"], "order": subgroup.get("order", 50), "items": subItems, }) @@ -412,28 +417,28 @@ def _buildStaticBlocks( topLevelItems = [] if hasItems: topLevelItems = _filterItems( - section["items"], language, isSysAdmin, roleIds, hasGlobalPermission + section["items"], isSysAdmin, roleIds, hasGlobalPermission ) if filteredSubgroups or topLevelItems: blocks.append({ "type": "static", "id": section["id"], - "title": section["title"].get(language, section["title"].get("en", section["id"])), + "title": section["title"], "order": section.get("order", 50), "items": topLevelItems, "subgroups": filteredSubgroups, }) else: filteredItems = _filterItems( - section.get("items", []), language, isSysAdmin, roleIds, hasGlobalPermission + section.get("items", []), isSysAdmin, roleIds, hasGlobalPermission ) if filteredItems: blocks.append({ "type": "static", "id": section["id"], - "title": section["title"].get(language, section["title"].get("en", section["id"])), + "title": section["title"], "order": section.get("order", 50), "items": filteredItems, }) @@ -441,19 +446,19 @@ def _buildStaticBlocks( return blocks -def _formatBlockItem(item: Dict[str, Any], language: str) -> Dict[str, Any]: +def _formatBlockItem(item: Dict[str, Any]) -> Dict[str, Any]: """ - Format a navigation item for the new API response. + Format a navigation item for the API response. - Uses new field names: uiComponent, uiLabel, uiPath - Does NOT include icon (UI maps via uiComponent) + Labels are plain German strings (i18n base keys). + The frontend translates them via t(). """ objectKey = item["objectKey"] uiComponent = _objectKeyToUiComponent(objectKey) return { "uiComponent": uiComponent, - "uiLabel": item["label"].get(language, item["label"].get("en", item["id"])), + "uiLabel": item["label"], "uiPath": item["path"], "order": item.get("order", 50), "objectKey": objectKey, @@ -464,52 +469,15 @@ def _formatBlockItem(item: Dict[str, Any], language: str) -> Dict[str, Any]: @limiter.limit("60/minute") def get_navigation( request: Request, - language: str = Query("de", description="Language for labels (en, de, fr)"), reqContext: RequestContext = Depends(getRequestContext) ) -> Dict[str, Any]: """ Get unified navigation structure with blocks. - Single Source of Truth für Navigation - UI rendert nur was es erhält. + All labels are German base texts (i18n keys). + The frontend translates them via t(). Endpoint: GET /api/navigation - - Block order: - - System (10) - - Dynamic/Features (15) - only if user has feature instances - - Workflows (20) - - Basisdaten (30) - - Migrate (40) - - Administration (200) - - Response format: - { - "language": "de", - "blocks": [ - { - "type": "static", - "id": "system", - "title": "SYSTEM", - "order": 10, - "items": [ - { - "uiComponent": "page.system.home", - "uiLabel": "Übersicht", - "uiPath": "/", - "order": 10, - "objectKey": "ui.system.home" - } - ] - }, - { - "type": "dynamic", - "id": "features", - "title": "MEINE FEATURES", - "order": 15, - "mandates": [...] - } - ] - } """ try: isSysAdmin = reqContext.hasSysAdminRole @@ -526,11 +494,11 @@ def get_navigation( hasGlobalPermission = _checkUiPermission(roleIds, "_global_check") # Build static blocks from NAVIGATION_SECTIONS - blocks = _buildStaticBlocks(language, isSysAdmin, roleIds, hasGlobalPermission) + blocks = _buildStaticBlocks(isSysAdmin, roleIds, hasGlobalPermission) # Build dynamic block (features) if user has feature instances if userId: - dynamicBlock = _buildDynamicBlock(userId, language, isSysAdmin) + dynamicBlock = _buildDynamicBlock(userId, isSysAdmin) if dynamicBlock: blocks.append(dynamicBlock) @@ -538,14 +506,12 @@ def get_navigation( blocks.sort(key=lambda b: b["order"]) return { - "language": language, "blocks": blocks, } except Exception as e: logger.error(f"Error getting navigation: {e}") return { - "language": language, "blocks": [], "error": str(e), } diff --git a/modules/routes/routeVoiceGoogle.py b/modules/routes/routeVoiceGoogle.py index 309e59bb..6c5d99e4 100644 --- a/modules/routes/routeVoiceGoogle.py +++ b/modules/routes/routeVoiceGoogle.py @@ -18,6 +18,8 @@ from typing import Optional, Dict, Any, List from modules.auth import getCurrentUser, getRequestContext, RequestContext, limiter from modules.datamodels.datamodelUam import User from modules.interfaces.interfaceVoiceObjects import getVoiceInterface, VoiceObjects +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeVoiceGoogle") logger = logging.getLogger(__name__) router = APIRouter(prefix="/voice-google", tags=["Voice Google"]) @@ -132,7 +134,7 @@ async def detect_language( if not text.strip(): raise HTTPException( status_code=400, - detail="Empty text provided for language detection" + detail=routeApiMsg("Empty text provided for language detection") ) # Get voice interface @@ -176,7 +178,7 @@ async def translate_text( if not text.strip(): raise HTTPException( status_code=400, - detail="Empty text provided for translation" + detail=routeApiMsg("Empty text provided for translation") ) # Get voice interface @@ -306,7 +308,7 @@ async def text_to_speech( if not text.strip(): raise HTTPException( status_code=400, - detail="Empty text provided for text-to-speech" + detail=routeApiMsg("Empty text provided for text-to-speech") ) mandateId = str(getattr(context, "mandateId", "") or "") diff --git a/modules/routes/routeVoiceUser.py b/modules/routes/routeVoiceUser.py index 2f21662b..a3c3fda7 100644 --- a/modules/routes/routeVoiceUser.py +++ b/modules/routes/routeVoiceUser.py @@ -17,6 +17,8 @@ from modules.auth import getCurrentUser, limiter from modules.datamodels.datamodelUam import User, UserVoicePreferences, _normalizeTtsVoiceMap from modules.interfaces.interfaceDbApp import getRootInterface from modules.interfaces.interfaceVoiceObjects import getVoiceInterface +from modules.shared.i18nRegistry import apiRouteContext +routeApiMsg = apiRouteContext("routeVoiceUser") logger = logging.getLogger(__name__) @@ -176,7 +178,7 @@ def _resolveMandateIdForVoiceTestAi(request: Request, currentUser: User) -> str: if headerRaw not in memberIds: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail="X-Mandate-Id is not a mandate you belong to.", + detail=routeApiMsg("X-Mandate-Id is not a mandate you belong to."), ) if _mandatePassesAiPoolBilling(currentUser, headerRaw, userId): logger.info( @@ -294,7 +296,7 @@ async def _generateTtsSampleTextForLocale( logger.warning("Voice test AI sample empty or errorCount=%s", getattr(response, "errorCount", None)) raise HTTPException( status_code=status.HTTP_502_BAD_GATEWAY, - detail="Could not generate voice test sample text.", + detail=routeApiMsg("Could not generate voice test sample text."), ) if len(content) > 500: content = content[:500].rstrip() diff --git a/modules/routes/routeWorkflowDashboard.py b/modules/routes/routeWorkflowDashboard.py index 687f4206..c0c46a13 100644 --- a/modules/routes/routeWorkflowDashboard.py +++ b/modules/routes/routeWorkflowDashboard.py @@ -23,6 +23,9 @@ from modules.datamodels.datamodelPagination import PaginationParams from modules.features.graphicalEditor.datamodelFeatureGraphicalEditor import ( AutoRun, AutoStepLog, AutoWorkflow, AutoTask, ) +from modules.shared.i18nRegistry import apiRouteContext + +routeApiMsg = apiRouteContext("routeWorkflowDashboard") logger = logging.getLogger(__name__) limiter = Limiter(key_func=get_remote_address) @@ -239,11 +242,11 @@ def get_run_steps( """Get step logs for a specific run (with access check).""" db = _getDb() if not db._ensureTableExists(AutoRun): - raise HTTPException(status_code=404, detail="Run not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) runs = db.getRecordset(AutoRun, recordFilter={"id": runId}) if not runs: - raise HTTPException(status_code=404, detail="Run not found") + raise HTTPException(status_code=404, detail=routeApiMsg("Run not found")) run = dict(runs[0]) if not context.hasSysAdminRole: @@ -256,7 +259,7 @@ def get_run_steps( elif runMandate and userId and _isUserMandateAdmin(userId, runMandate): pass else: - raise HTTPException(status_code=403, detail="Access denied") + raise HTTPException(status_code=403, detail=routeApiMsg("Access denied")) if not db._ensureTableExists(AutoStepLog): return {"steps": []} diff --git a/modules/security/rbacCatalog.py b/modules/security/rbacCatalog.py index 14b87534..587f6fbd 100644 --- a/modules/security/rbacCatalog.py +++ b/modules/security/rbacCatalog.py @@ -8,7 +8,7 @@ Feature-Container register their RBAC objects via mainXxx.py at startup. """ import logging -from typing import Dict, List, Any, Optional +from typing import Dict, List, Any, Optional, Union from threading import Lock logger = logging.getLogger(__name__) @@ -43,7 +43,7 @@ class RbacCatalogService: self._initialized = True logger.info("RBAC Catalog Service initialized") - def registerUiObject(self, featureCode: str, objectKey: str, label: Dict[str, str], meta: Optional[Dict[str, Any]] = None) -> bool: + def registerUiObject(self, featureCode: str, objectKey: str, label: Union[str, Dict[str, str]], meta: Optional[Dict[str, Any]] = None) -> bool: """Register a UI object for a feature.""" try: self._uiObjects[objectKey] = {"objectKey": objectKey, "featureCode": featureCode, "label": label, "meta": meta or {}, "type": "UI"} @@ -84,7 +84,7 @@ class RbacCatalogService: logger.error(f"Failed to register DATA object {objectKey}: {e}") return False - def registerFeatureDefinition(self, featureCode: str, label: Dict[str, str], icon: str) -> bool: + def registerFeatureDefinition(self, featureCode: str, label: Union[str, Dict[str, str]], icon: str) -> bool: """Register a feature definition.""" try: self._featureDefinitions[featureCode] = {"code": featureCode, "label": label, "icon": icon} diff --git a/modules/serviceCenter/registry.py b/modules/serviceCenter/registry.py index 851e4894..64003d29 100644 --- a/modules/serviceCenter/registry.py +++ b/modules/serviceCenter/registry.py @@ -33,98 +33,98 @@ IMPORTABLE_SERVICES: Dict[str, Dict[str, Any]] = { "class": "TicketService", "dependencies": [], "objectKey": "service.ticket", - "label": {"en": "Ticket System", "de": "Ticket-System", "fr": "Système de tickets"}, + "label": "Ticket-System", }, "messaging": { "module": "modules.serviceCenter.services.serviceMessaging.mainServiceMessaging", "class": "MessagingService", "dependencies": [], "objectKey": "service.messaging", - "label": {"en": "Messaging", "de": "Nachrichten", "fr": "Messagerie"}, + "label": "Nachrichten", }, "billing": { "module": "modules.serviceCenter.services.serviceBilling.mainServiceBilling", "class": "BillingService", "dependencies": ["subscription"], "objectKey": "service.billing", - "label": {"en": "Billing", "de": "Abrechnung", "fr": "Facturation"}, + "label": "Abrechnung", }, "subscription": { "module": "modules.serviceCenter.services.serviceSubscription.mainServiceSubscription", "class": "SubscriptionService", "dependencies": [], "objectKey": "service.subscription", - "label": {"en": "Subscription", "de": "Abonnement", "fr": "Abonnement"}, + "label": "Abonnement", }, "sharepoint": { "module": "modules.serviceCenter.services.serviceSharepoint.mainServiceSharepoint", "class": "SharepointService", "dependencies": ["security"], "objectKey": "service.sharepoint", - "label": {"en": "SharePoint", "de": "SharePoint", "fr": "SharePoint"}, + "label": "SharePoint", }, "clickup": { "module": "modules.serviceCenter.services.serviceClickup.mainServiceClickup", "class": "ClickupService", "dependencies": ["security"], "objectKey": "service.clickup", - "label": {"en": "ClickUp", "de": "ClickUp", "fr": "ClickUp"}, + "label": "ClickUp", }, "chat": { "module": "modules.serviceCenter.services.serviceChat.mainServiceChat", "class": "ChatService", "dependencies": ["utils"], "objectKey": "service.chat", - "label": {"en": "Chat", "de": "Chat", "fr": "Chat"}, + "label": "Chat", }, "extraction": { "module": "modules.serviceCenter.services.serviceExtraction.mainServiceExtraction", "class": "ExtractionService", "dependencies": ["chat", "utils"], "objectKey": "service.extraction", - "label": {"en": "Extraction", "de": "Extraktion", "fr": "Extraction"}, + "label": "Extraktion", }, "generation": { "module": "modules.serviceCenter.services.serviceGeneration.mainServiceGeneration", "class": "GenerationService", "dependencies": ["utils", "chat"], "objectKey": "service.generation", - "label": {"en": "Generation", "de": "Generierung", "fr": "Génération"}, + "label": "Generierung", }, "ai": { "module": "modules.serviceCenter.services.serviceAi.mainServiceAi", "class": "AiService", "dependencies": ["chat", "utils", "extraction", "billing"], "objectKey": "service.ai", - "label": {"en": "AI", "de": "KI", "fr": "IA"}, + "label": "KI", }, "web": { "module": "modules.serviceCenter.services.serviceWeb.mainServiceWeb", "class": "WebService", "dependencies": ["ai", "chat", "utils"], "objectKey": "service.web", - "label": {"en": "Web Research", "de": "Web-Recherche", "fr": "Recherche Web"}, + "label": "Web-Recherche", }, "neutralization": { "module": "modules.features.neutralization.serviceNeutralization.mainServiceNeutralization", "class": "NeutralizationService", "dependencies": ["extraction", "generation"], "objectKey": "service.neutralization", - "label": {"en": "Neutralization", "de": "Neutralisierung", "fr": "Neutralisation"}, + "label": "Neutralisierung", }, "agent": { "module": "modules.serviceCenter.services.serviceAgent.mainServiceAgent", "class": "AgentService", "dependencies": ["ai", "chat", "utils", "extraction", "billing", "streaming", "knowledge"], "objectKey": "service.agent", - "label": {"en": "Agent", "de": "Agent", "fr": "Agent"}, + "label": "Agent", }, "knowledge": { "module": "modules.serviceCenter.services.serviceKnowledge.mainServiceKnowledge", "class": "KnowledgeService", "dependencies": ["ai"], "objectKey": "service.knowledge", - "label": {"en": "Knowledge Store", "de": "Wissensspeicher", "fr": "Base de connaissances"}, + "label": "Wissensspeicher", }, } diff --git a/modules/serviceCenter/services/serviceAi/subStructureFilling.py b/modules/serviceCenter/services/serviceAi/subStructureFilling.py index 6ba32dfd..3795f44d 100644 --- a/modules/serviceCenter/services/serviceAi/subStructureFilling.py +++ b/modules/serviceCenter/services/serviceAi/subStructureFilling.py @@ -18,6 +18,12 @@ from modules.datamodels.datamodelExtraction import ContentPart from modules.datamodels.datamodelAi import AiCallRequest, AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum from modules.workflows.processing.shared.stateTools import checkWorkflowStopped + +class _AiResponseFallback: + """Lightweight wrapper used when AI JSON parsing fails but raw content must be preserved.""" + def __init__(self, content): + self.content = content + logger = logging.getLogger(__name__) @@ -719,12 +725,8 @@ class StructureFiller: self.services.chat.progressLogUpdate(sectionOperationId, 0.8, "Validating generated content") - class _AiResponse: - def __init__(self, content): - self.content = content - responseElements = await self._processAiResponseForSection( - aiResponse=_AiResponse(aiResponseJson), + aiResponse=_AiResponseFallback(aiResponseJson), contentType=contentType, operationType=operationType, sectionId=sectionId, @@ -1032,17 +1034,10 @@ class StructureFiller: else: generatedElements = [] - class AiResponse: - def __init__(self, content): - self.content = content - - aiResponse = AiResponse(aiResponseJson) + aiResponse = _AiResponseFallback(aiResponseJson) except Exception as parseError: logger.error(f"Error parsing response from _callAiWithLooping for section {sectionId}: {str(parseError)}") - class AiResponse: - def __init__(self, content): - self.content = content - aiResponse = AiResponse(aiResponseJson) + aiResponse = _AiResponseFallback(aiResponseJson) generatedElements = [] self.services.chat.progressLogUpdate(sectionOperationId, 0.6, "Processing AI response") @@ -1200,17 +1195,10 @@ class StructureFiller: else: generatedElements = [] - class AiResponse: - def __init__(self, content): - self.content = content - - aiResponse = AiResponse(aiResponseJson) + aiResponse = _AiResponseFallback(aiResponseJson) except Exception as parseError: logger.error(f"Error parsing response from _callAiWithLooping for section {sectionId}: {str(parseError)}") - class AiResponse: - def __init__(self, content): - self.content = content - aiResponse = AiResponse(aiResponseJson) + aiResponse = _AiResponseFallback(aiResponseJson) generatedElements = [] self.services.chat.progressLogUpdate(sectionOperationId, 0.6, "Processing AI response") @@ -1467,17 +1455,10 @@ class StructureFiller: else: generatedElements = [] - class AiResponse: - def __init__(self, content): - self.content = content - - aiResponse = AiResponse(aiResponseJson) + aiResponse = _AiResponseFallback(aiResponseJson) except Exception as parseError: logger.error(f"Error parsing response from _callAiWithLooping for section {sectionId}: {str(parseError)}") - class AiResponse: - def __init__(self, content): - self.content = content - aiResponse = AiResponse(aiResponseJson) + aiResponse = _AiResponseFallback(aiResponseJson) generatedElements = [] self.services.chat.progressLogUpdate(sectionOperationId, 0.6, "Processing AI response") diff --git a/modules/shared/attributeUtils.py b/modules/shared/attributeUtils.py index 239e214d..ea92c0b8 100644 --- a/modules/shared/attributeUtils.py +++ b/modules/shared/attributeUtils.py @@ -36,42 +36,44 @@ class AttributeDefinition(BaseModel): placeholder: Optional[str] = None -# Global registry for model labels -MODEL_LABELS: Dict[str, Dict[str, Dict[str, str]]] = {} - - -def registerModelLabels(modelName: str, modelLabel: Dict[str, str], labels: Dict[str, Dict[str, str]]): - """ - Register labels for a model's attributes and the model itself. - - Args: - modelName: Name of the model class - modelLabel: Dictionary mapping language codes to model labels - e.g. {"en": "Prompt", "fr": "Invite"} - labels: Dictionary mapping attribute names to their translations - e.g. {"name": {"en": "Name", "fr": "Nom"}} - """ - MODEL_LABELS[modelName] = {"model": modelLabel, "attributes": labels} +def _getModelLabelEntry(modelName: str) -> Dict[str, Any]: + """Resolve label data produced by @i18nModel (see modules.shared.i18nRegistry.MODEL_LABELS).""" + try: + from modules.shared.i18nRegistry import MODEL_LABELS as i18nModelLabels + except ImportError: + return {} + return i18nModelLabels.get(modelName) or {} def getModelLabels(modelName: str, language: str = "en") -> Dict[str, str]: - """ - Get labels for a model's attributes in the specified language. + """Get labels for a model's attributes in the specified language. - Args: - modelName: Name of the model class - language: Language code (default: "en") - - Returns: - Dictionary mapping attribute names to their labels in the specified language + Reads @i18nModel registration (German base strings); non-German languages use the i18n cache. + Attribute values are strings; dict-shaped entries are still accepted for unusual callers. """ - modelData = MODEL_LABELS.get(modelName, {}) + modelData = _getModelLabelEntry(modelName) attributeLabels = modelData.get("attributes", {}) - return { - attr: translations.get(language, translations.get("en", attr)) - for attr, translations in attributeLabels.items() - } + result: Dict[str, str] = {} + for attr, translations in attributeLabels.items(): + if isinstance(translations, dict): + result[attr] = translations.get(language, translations.get("en", attr)) + elif isinstance(translations, str): + result[attr] = _resolveLabel(translations, language) + else: + result[attr] = attr + return result + + +def _resolveLabel(germanText: str, language: str) -> str: + """Resolve a German base label to the requested language via i18n cache.""" + if language == "de": + return germanText + try: + from modules.shared.i18nRegistry import _CACHE + return _CACHE.get(language, {}).get(germanText, germanText) + except ImportError: + return germanText def _mergedAttributeLabels(modelClass: Type[BaseModel], userLanguage: str) -> Dict[str, str]: @@ -87,19 +89,14 @@ def _mergedAttributeLabels(modelClass: Type[BaseModel], userLanguage: str) -> Di def getModelLabel(modelName: str, language: str = "en") -> str: - """ - Get the label for a model in the specified language. - - Args: - modelName: Name of the model class - language: Language code (default: "en") - - Returns: - Model label in the specified language, or model name if no label exists - """ - modelData = MODEL_LABELS.get(modelName, {}) + """Get the label for a model in the specified language (see getModelLabels).""" + modelData = _getModelLabelEntry(modelName) modelLabel = modelData.get("model", {}) - return modelLabel.get(language, modelLabel.get("en", modelName)) + if isinstance(modelLabel, dict): + return modelLabel.get(language, modelLabel.get("en", modelName)) + elif isinstance(modelLabel, str): + return _resolveLabel(modelLabel, language) + return modelName def getModelAttributeDefinitions(modelClass: Type[BaseModel] = None, userLanguage: str = "en") -> Dict[str, Any]: diff --git a/modules/shared/frontendTypes.py b/modules/shared/frontendTypes.py index ab3e6939..1a80aa40 100644 --- a/modules/shared/frontendTypes.py +++ b/modules/shared/frontendTypes.py @@ -137,6 +137,41 @@ CUSTOM_TYPE_DESCRIPTIONS: Dict[FrontendType, Dict[str, str]] = { "fr": "Tâche ClickUp", "de": "ClickUp-Aufgabe" }, + FrontendType.CASE_LIST: { + "en": "Case List", + "fr": "Liste de cas", + "de": "Fallunterscheidung" + }, + FrontendType.FIELD_BUILDER: { + "en": "Field Builder", + "fr": "Constructeur de champs", + "de": "Feld-Editor" + }, + FrontendType.KEY_VALUE_ROWS: { + "en": "Key-Value Rows", + "fr": "Lignes clé-valeur", + "de": "Schlüssel-Wert-Zeilen" + }, + FrontendType.CRON: { + "en": "Cron Expression", + "fr": "Expression cron", + "de": "Cron-Ausdruck" + }, + FrontendType.CONDITION: { + "en": "Condition", + "fr": "Condition", + "de": "Bedingung" + }, + FrontendType.MAPPING_TABLE: { + "en": "Mapping Table", + "fr": "Table de correspondance", + "de": "Zuordnungstabelle" + }, + FrontendType.FILTER_EXPRESSION: { + "en": "Filter Expression", + "fr": "Expression de filtre", + "de": "Filterausdruck" + }, } diff --git a/modules/shared/i18nRegistry.py b/modules/shared/i18nRegistry.py new file mode 100644 index 00000000..c44a65b1 --- /dev/null +++ b/modules/shared/i18nRegistry.py @@ -0,0 +1,666 @@ +# Copyright (c) 2025 Patrick Motsch +# All rights reserved. +""" +Gateway i18n registry: t(), @i18nModel, boot-sync, in-memory cache. + +All UI-visible texts in the gateway (HTTPException details, model labels, +API messages) are tagged with t() and registered at import time. +At boot, the registry is synced to the xx base set in the DB. +At runtime, t() returns the cached translation for the current request language. +""" + +from __future__ import annotations + +import logging +from contextvars import ContextVar +from dataclasses import dataclass, field as dataclass_field +from typing import Any, Dict, List, Optional, Type + +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Registry (populated at import time by t() and @i18nModel) +# --------------------------------------------------------------------------- + +@dataclass +class _I18nRegistryEntry: + context: str + value: str + + +_REGISTRY: Dict[str, _I18nRegistryEntry] = {} + +# --------------------------------------------------------------------------- +# Translation cache (populated at boot by _loadCache) +# --------------------------------------------------------------------------- + +_CACHE: Dict[str, Dict[str, str]] = {} + +# --------------------------------------------------------------------------- +# Per-request language (set by middleware) +# --------------------------------------------------------------------------- + +_CURRENT_LANGUAGE: ContextVar[str] = ContextVar("i18n_lang", default="de") + +# --------------------------------------------------------------------------- +# Model labels (backwards-compatible with getModelLabels / getModelLabel) +# --------------------------------------------------------------------------- + +MODEL_LABELS: Dict[str, Dict[str, Any]] = {} + + +# --------------------------------------------------------------------------- +# t() -- tag and translate +# --------------------------------------------------------------------------- + +def t(key: str, context: str = "api", value: str = "") -> str: + """Tag a UI-visible text for i18n and return the translation. + + At import time: registers the key with context and AI description. + At runtime: returns the cached translation for _CURRENT_LANGUAGE. + Falls back to the key itself (German base text) if no translation found. + """ + if key not in _REGISTRY: + _REGISTRY[key] = _I18nRegistryEntry(context=context, value=value) + lang = _CURRENT_LANGUAGE.get() + if lang == "de": + return key + return _CACHE.get(lang, {}).get(key, key) + + +def apiRouteContext(routeModuleName: str): + """Return a callable that registers + translates HTTPException details. + + The key is registered eagerly in ``_REGISTRY`` the moment ``_apiMsg(key)`` + is evaluated (module-level ``detail=routeApiMsg("…")`` runs at import time). + At runtime ``t()`` returns the cached translation for the current language. + """ + _ctx = f"api.{routeModuleName}" + + def _apiMsg(key: str, value: str = "") -> str: + if key not in _REGISTRY: + _REGISTRY[key] = _I18nRegistryEntry(context=_ctx, value=value) + return t(key, _ctx, value) + return _apiMsg + + +# --------------------------------------------------------------------------- +# @i18nModel -- class decorator for Pydantic models +# --------------------------------------------------------------------------- + +def i18nModel(modelLabel: str, aiContext: str = ""): + """Class decorator: registers model and field labels for i18n. + + 1. Registers t(modelLabel, "table.", aiContext or docstring) + 2. For each Field with json_schema_extra["label"]: + Registers t(label, "table..", field.description) + 3. Populates MODEL_LABELS for getModelLabels()/getModelLabel() in attributeUtils + """ + def _decorator(cls: Type[BaseModel]) -> Type[BaseModel]: + className = cls.__name__ + ctx = aiContext or _extractDocstringFirstLine(cls) + t(modelLabel, f"table.{className}", ctx) + + attributes: Dict[str, str] = {} + for fieldName, fieldInfo in cls.model_fields.items(): + extra = fieldInfo.json_schema_extra + if not isinstance(extra, dict): + continue + label = extra.get("label") + if label: + desc = fieldInfo.description or "" + t(label, f"table.{className}.{fieldName}", desc) + attributes[fieldName] = label + else: + attributes[fieldName] = fieldName + + MODEL_LABELS[className] = { + "model": modelLabel, + "attributes": attributes, + } + return cls + return _decorator + + +def _extractDocstringFirstLine(cls: type) -> str: + doc = cls.__doc__ + if not doc: + return "" + return doc.strip().split("\n")[0].strip() + + +# --------------------------------------------------------------------------- +# Language setter (called by middleware) +# --------------------------------------------------------------------------- + +def _setLanguage(lang: str): + """Set the language for the current request context.""" + _CURRENT_LANGUAGE.set(lang) + + +def _getLanguage() -> str: + """Get the language for the current request context.""" + return _CURRENT_LANGUAGE.get() + + +# --------------------------------------------------------------------------- +# Boot: scan route files for routeApiMsg("…") calls → register eagerly +# --------------------------------------------------------------------------- + +_ROUTE_API_MSG_RE = None # compiled lazily + +def _scanRouteApiMsgKeys(): + """Scan all gateway route/feature Python files for routeApiMsg("…") calls + and register the keys in _REGISTRY so they appear in the boot DB sync. + """ + import re + from pathlib import Path + + global _ROUTE_API_MSG_RE + if _ROUTE_API_MSG_RE is None: + _ROUTE_API_MSG_RE = re.compile( + r"""routeApiMsg\(\s*(['"])((?:\\.|(?!\1).)+)\1""", + ) + + gatewayRoot = Path(__file__).resolve().parents[1] + scanDirs = [gatewayRoot / "routes", gatewayRoot / "features"] + + _ctxRe = re.compile(r'''apiRouteContext\(\s*['"]([^'"]+)['"]\s*\)''') + + for scanDir in scanDirs: + if not scanDir.is_dir(): + continue + for pyFile in scanDir.rglob("*.py"): + try: + src = pyFile.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + ctxMatch = _ctxRe.search(src) + if not ctxMatch: + continue + ctx = f"api.{ctxMatch.group(1)}" + for m in _ROUTE_API_MSG_RE.finditer(src): + key = m.group(2).replace("\\'", "'").replace('\\"', '"') + if key and key not in _REGISTRY: + _REGISTRY[key] = _I18nRegistryEntry(context=ctx, value="") + + logger.info("i18n route scan: %d api.* keys in registry after scan", + sum(1 for e in _REGISTRY.values() if e.context.startswith("api."))) + + +def _registerNavLabels(): + """Register all navigation labels from NAVIGATION_SECTIONS as i18n keys. + + Called at boot before DB sync so that nav labels appear in the xx base set + and can be translated via the Admin UI. + """ + try: + from modules.system.mainSystem import NAVIGATION_SECTIONS + except ImportError: + logger.warning("i18n: could not import NAVIGATION_SECTIONS for nav label registration") + return + + count = 0 + for section in NAVIGATION_SECTIONS: + title = section.get("title", "") + if title and title not in _REGISTRY: + _REGISTRY[title] = _I18nRegistryEntry(context="nav", value="") + count += 1 + + for item in section.get("items", []): + label = item.get("label", "") + if label and label not in _REGISTRY: + _REGISTRY[label] = _I18nRegistryEntry(context="nav", value="") + count += 1 + + for subgroup in section.get("subgroups", []): + sgTitle = subgroup.get("title", "") + if sgTitle and sgTitle not in _REGISTRY: + _REGISTRY[sgTitle] = _I18nRegistryEntry(context="nav", value="") + count += 1 + for item in subgroup.get("items", []): + label = item.get("label", "") + if label and label not in _REGISTRY: + _REGISTRY[label] = _I18nRegistryEntry(context="nav", value="") + count += 1 + + logger.info("i18n nav labels: registered %d nav keys", count) + + +def _registerFeatureUiLabels(): + """Register FEATURE_LABEL and UI_OBJECTS labels from all feature modules (German i18n keys).""" + try: + from modules.system import mainSystem as _mainSystem + _fl = getattr(_mainSystem, "FEATURE_LABEL", None) + if isinstance(_fl, str) and _fl and _fl not in _REGISTRY: + _REGISTRY[_fl] = _I18nRegistryEntry(context="nav", value="") + except ImportError: + pass + + _featureModulePaths = ( + "modules.features.trustee.mainTrustee", + "modules.features.graphicalEditor.mainGraphicalEditor", + "modules.features.commcoach.mainCommcoach", + "modules.features.teamsbot.mainTeamsbot", + "modules.features.workspace.mainWorkspace", + "modules.features.realEstate.mainRealEstate", + "modules.features.neutralization.mainNeutralization", + "modules.features.chatbot.mainChatbot", + ) + added = 0 + for modPath in _featureModulePaths: + try: + mod = __import__(modPath, fromlist=["FEATURE_LABEL", "UI_OBJECTS"]) + except ImportError: + continue + fl = getattr(mod, "FEATURE_LABEL", None) + if isinstance(fl, str) and fl and fl not in _REGISTRY: + _REGISTRY[fl] = _I18nRegistryEntry(context="nav", value="") + added += 1 + for uiObj in getattr(mod, "UI_OBJECTS", []) or []: + lab = uiObj.get("label") + if isinstance(lab, str) and lab and lab not in _REGISTRY: + _REGISTRY[lab] = _I18nRegistryEntry(context="nav", value="") + added += 1 + elif isinstance(lab, dict): + base = lab.get("de") or lab.get("en") + if base and base not in _REGISTRY: + _REGISTRY[base] = _I18nRegistryEntry(context="nav", value="") + added += 1 + logger.info("i18n feature UI labels: %d new keys (nav context)", added) + + +def _registerRbacLabels(): + """Register DATA_OBJECTS, RESOURCE_OBJECTS labels and TEMPLATE_ROLES descriptions + from all feature modules and system module as i18n keys. + + context mapping: + - DATA_OBJECTS → rbac.data + - RESOURCE_OBJECTS → rbac.resource + - TEMPLATE_ROLES[].description (de) → rbac.role + - QUICK_ACTIONS[].label/description (de) → rbac.quickaction + - QUICK_ACTION_CATEGORIES[].label (de) → rbac.quickaction + """ + _systemModule = "modules.system.mainSystem" + _featureModulePaths = ( + _systemModule, + "modules.features.trustee.mainTrustee", + "modules.features.graphicalEditor.mainGraphicalEditor", + "modules.features.commcoach.mainCommcoach", + "modules.features.teamsbot.mainTeamsbot", + "modules.features.workspace.mainWorkspace", + "modules.features.realEstate.mainRealEstate", + "modules.features.neutralization.mainNeutralization", + "modules.features.chatbot.mainChatbot", + ) + + def _extractDe(obj) -> str: + if isinstance(obj, str): + return obj + if isinstance(obj, dict): + return obj.get("de") or obj.get("en") or "" + return "" + + added = 0 + for modPath in _featureModulePaths: + try: + mod = __import__(modPath, fromlist=[ + "DATA_OBJECTS", "RESOURCE_OBJECTS", "TEMPLATE_ROLES", + "QUICK_ACTIONS", "QUICK_ACTION_CATEGORIES", + ]) + except ImportError: + continue + + for dataObj in getattr(mod, "DATA_OBJECTS", []) or []: + key = _extractDe(dataObj.get("label")) + if key and key not in _REGISTRY: + _REGISTRY[key] = _I18nRegistryEntry(context="rbac.data", value="") + added += 1 + + for resObj in getattr(mod, "RESOURCE_OBJECTS", []) or []: + key = _extractDe(resObj.get("label")) + if key and key not in _REGISTRY: + _REGISTRY[key] = _I18nRegistryEntry(context="rbac.resource", value="") + added += 1 + + for role in getattr(mod, "TEMPLATE_ROLES", []) or []: + key = _extractDe(role.get("description")) + if key and key not in _REGISTRY: + _REGISTRY[key] = _I18nRegistryEntry(context="rbac.role", value="") + added += 1 + + for qa in getattr(mod, "QUICK_ACTIONS", []) or []: + for field in ("label", "description"): + key = _extractDe(qa.get(field)) + if key and key not in _REGISTRY: + _REGISTRY[key] = _I18nRegistryEntry(context="rbac.quickaction", value="") + added += 1 + + for cat in getattr(mod, "QUICK_ACTION_CATEGORIES", []) or []: + key = _extractDe(cat.get("label")) + if key and key not in _REGISTRY: + _REGISTRY[key] = _I18nRegistryEntry(context="rbac.quickaction", value="") + added += 1 + + logger.info("i18n rbac labels: %d new keys (rbac.* context)", added) + + +def _registerServiceCenterLabels(): + """Register service-center category labels and bootstrap role descriptions.""" + added = 0 + + def _extractDe(obj) -> str: + if isinstance(obj, str): + return obj + if isinstance(obj, dict): + return obj.get("de") or obj.get("en") or "" + return "" + + try: + from modules.serviceCenter.registry import IMPORTABLE_SERVICES + for svc in IMPORTABLE_SERVICES.values(): + key = _extractDe(svc.get("label")) + if key and key not in _REGISTRY: + _REGISTRY[key] = _I18nRegistryEntry(context="service", value="") + added += 1 + except ImportError: + pass + + _bootstrapRoleDescriptions = [ + "Administrator - Benutzer und Ressourcen im Mandanten verwalten", + "Benutzer - Standard-Benutzer mit Zugriff auf eigene Datensätze", + "Betrachter - Nur-Lese-Zugriff auf Gruppen-Datensätze", + "System-Administrator - Vollständiger administrativer Zugriff über alle Mandanten", + ] + for desc in _bootstrapRoleDescriptions: + if desc not in _REGISTRY: + _REGISTRY[desc] = _I18nRegistryEntry(context="rbac.role", value="") + added += 1 + + logger.info("i18n service/bootstrap labels: %d new keys", added) + + +def _registerNodeLabels(): + """Register all graph-editor node labels, descriptions, parameter descriptions, + output labels, port descriptions, category labels, and entry-point titles.""" + added = 0 + + def _extractDe(obj) -> str: + if isinstance(obj, str): + return obj + if isinstance(obj, dict): + return obj.get("de") or obj.get("en") or "" + return "" + + def _reg(key: str, ctx: str): + nonlocal added + if key and key not in _REGISTRY: + _REGISTRY[key] = _I18nRegistryEntry(context=ctx, value="") + added += 1 + + try: + from modules.features.graphicalEditor.nodeDefinitions import STATIC_NODE_TYPES + for nd in STATIC_NODE_TYPES: + _reg(_extractDe(nd.get("label")), "node.label") + _reg(_extractDe(nd.get("description")), "node.desc") + + for param in nd.get("parameters", []) or []: + _reg(_extractDe(param.get("description")), "node.param") + _reg(_extractDe(param.get("label")), "node.param") + + outLabels = nd.get("outputLabels") + if isinstance(outLabels, dict): + deList = outLabels.get("de") or outLabels.get("en") or [] + for lbl in deList: + _reg(lbl, "node.output") + elif isinstance(outLabels, list): + for lbl in outLabels: + _reg(lbl, "node.output") + except ImportError: + pass + + try: + from modules.features.graphicalEditor.portTypes import PORT_TYPE_CATALOG + for schema in PORT_TYPE_CATALOG.values(): + for field in getattr(schema, "fields", []) or []: + desc = getattr(field, "description", None) + if desc: + _reg(_extractDe(desc if isinstance(desc, (str, dict)) else None), "port.desc") + except ImportError: + pass + + _nodeCategoryLabels = [ + "Trigger", "Eingabe/Mensch", "Ablauf", "Daten", "KI", + "Datei", "E-Mail", "SharePoint", "ClickUp", "Treuhand", + ] + for lbl in _nodeCategoryLabels: + _reg(lbl, "node.category") + + _entryPointTitles = ["Jetzt ausführen", "Start"] + for lbl in _entryPointTitles: + _reg(lbl, "node.entry") + + logger.info("i18n node labels: %d new keys (node.*/port.* context)", added) + + +def _registerDatamodelOptionLabels(): + """Register all frontend_options labels from Pydantic datamodels and subscription plans.""" + added = 0 + + def _extractDe(obj) -> str: + if isinstance(obj, str): + return obj + if isinstance(obj, dict): + return obj.get("de") or obj.get("en") or "" + return "" + + def _reg(key: str, ctx: str): + nonlocal added + if key and key not in _REGISTRY: + _REGISTRY[key] = _I18nRegistryEntry(context=ctx, value="") + added += 1 + + _datamodelModules = ( + "modules.datamodels.datamodelRbac", + "modules.datamodels.datamodelChat", + "modules.datamodels.datamodelMessaging", + "modules.datamodels.datamodelNotification", + "modules.datamodels.datamodelUam", + "modules.datamodels.datamodelFiles", + "modules.datamodels.datamodelDataSource", + "modules.datamodels.datamodelFeatureDataSource", + "modules.datamodels.datamodelUiLanguage", + "modules.features.trustee.datamodelFeatureTrustee", + "modules.features.neutralization.datamodelFeatureNeutralizer", + ) + + for modPath in _datamodelModules: + try: + mod = __import__(modPath, fromlist=["__all__"]) + except ImportError: + continue + for attrName in dir(mod): + cls = getattr(mod, attrName, None) + if not isinstance(cls, type) or not issubclass(cls, BaseModel): + continue + for fieldName, fieldInfo in cls.model_fields.items(): + extra = (fieldInfo.json_schema_extra or {}) if hasattr(fieldInfo, "json_schema_extra") else {} + if not isinstance(extra, dict): + continue + options = extra.get("frontend_options") + if not isinstance(options, list): + continue + ctx = f"option.{cls.__name__}.{fieldName}" + for opt in options: + if isinstance(opt, dict): + _reg(_extractDe(opt.get("label")), ctx) + + try: + from modules.datamodels.datamodelSubscription import BUILTIN_PLANS + for plan in BUILTIN_PLANS.values(): + _reg(_extractDe(getattr(plan, "title", None)), "subscription.title") + _reg(_extractDe(getattr(plan, "description", None)), "subscription.desc") + except (ImportError, AttributeError): + pass + + logger.info("i18n datamodel option labels: %d new keys", added) + + +# --------------------------------------------------------------------------- +# Boot: sync registry to DB +# --------------------------------------------------------------------------- + +async def _syncRegistryToDb(): + """Boot hook: write all registered keys into UiLanguageSet(xx). + + 1. Scans route files for routeApiMsg("…") to eagerly register api.* keys. + 2. Registers navigation labels as nav.* keys. + 3. Registers feature UI labels (FEATURE_LABEL, UI_OBJECTS). + 4. Registers RBAC labels (DATA/RESOURCE/ROLE/QuickAction). + 5. Merges with existing UI keys (context="ui"), only touches gateway keys. + """ + _scanRouteApiMsgKeys() + _registerNavLabels() + _registerFeatureUiLabels() + _registerRbacLabels() + _registerServiceCenterLabels() + _registerNodeLabels() + _registerDatamodelOptionLabels() + + if not _REGISTRY: + logger.info("i18n registry: no keys to sync (empty registry)") + return + + from modules.datamodels.datamodelUiLanguage import UiLanguageSet + from modules.shared.configuration import APP_CONFIG + from modules.connectors.connectorDbPostgre import _get_cached_connector + from modules.shared.timeUtils import getUtcTimestamp + + db = _get_cached_connector( + dbHost=APP_CONFIG.get("DB_HOST", "localhost"), + dbDatabase="poweron_management", + dbUser=APP_CONFIG.get("DB_USER"), + dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"), + dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), + userId="__i18n_boot__", + ) + + rows = db.getRecordset(UiLanguageSet, recordFilter={"id": "xx"}) + + gatewayEntries = [ + {"context": entry.context, "key": key, "value": entry.value} + for key, entry in _REGISTRY.items() + ] + gatewayKeys = set(_REGISTRY.keys()) + + if not rows: + now = getUtcTimestamp() + rec = { + "id": "xx", + "label": "Basisset (Meta)", + "entries": gatewayEntries, + "status": "complete", + "isDefault": True, + "sysCreatedAt": now, + "sysCreatedBy": "__i18n_boot__", + "sysModifiedAt": now, + "sysModifiedBy": "__i18n_boot__", + } + db.recordCreate(UiLanguageSet, rec) + logger.info("i18n boot-sync: created xx set with %d gateway keys", len(gatewayEntries)) + return + + row = dict(rows[0]) + existingEntries: List[dict] = row.get("entries") or [] + if not isinstance(existingEntries, list): + existingEntries = [] + + uiEntries = [e for e in existingEntries if e.get("context", "") == "ui"] + + oldGatewayEntries = [ + e for e in existingEntries + if e.get("context", "") != "ui" + ] + oldGatewayByKey = {e["key"]: e for e in oldGatewayEntries} + + added = 0 + updated = 0 + removed = 0 + + newGatewayEntries: List[dict] = [] + for key, entry in _REGISTRY.items(): + newEntry = {"context": entry.context, "key": key, "value": entry.value} + old = oldGatewayByKey.get(key) + if old is None: + added += 1 + elif old.get("context") != entry.context or old.get("value") != entry.value: + updated += 1 + newGatewayEntries.append(newEntry) + + removed = len(set(oldGatewayByKey.keys()) - gatewayKeys) + + mergedEntries = uiEntries + newGatewayEntries + + if added == 0 and updated == 0 and removed == 0: + logger.info("i18n boot-sync: xx set up-to-date (%d gateway + %d ui keys)", len(newGatewayEntries), len(uiEntries)) + return + + now = getUtcTimestamp() + row["entries"] = mergedEntries + if "keys" in row: + del row["keys"] + row["sysModifiedAt"] = now + row["sysModifiedBy"] = "__i18n_boot__" + db.recordModify(UiLanguageSet, "xx", row) + + logger.info( + "i18n boot-sync: xx updated (+%d added, ~%d updated, -%d removed, total=%d gateway + %d ui)", + added, updated, removed, len(newGatewayEntries), len(uiEntries), + ) + + +# --------------------------------------------------------------------------- +# Boot: load translation cache +# --------------------------------------------------------------------------- + +async def _loadCache(): + """Boot hook: load all UiLanguageSets into the in-memory cache. + + After this, t() lookups are O(1) dict access with no DB calls. + """ + from modules.datamodels.datamodelUiLanguage import UiLanguageSet + from modules.shared.configuration import APP_CONFIG + from modules.connectors.connectorDbPostgre import _get_cached_connector + + db = _get_cached_connector( + dbHost=APP_CONFIG.get("DB_HOST", "localhost"), + dbDatabase="poweron_management", + dbUser=APP_CONFIG.get("DB_USER"), + dbPassword=APP_CONFIG.get("DB_PASSWORD_SECRET"), + dbPort=int(APP_CONFIG.get("DB_PORT", 5432)), + userId="__i18n_cache__", + ) + + rows = db.getRecordset(UiLanguageSet) + _CACHE.clear() + + for row in rows: + code = row.get("id", "") + if code == "xx": + continue + entries = row.get("entries") + if not isinstance(entries, list): + continue + langDict: Dict[str, str] = {} + for e in entries: + key = e.get("key", "") + val = e.get("value", "") + if key and val: + langDict[key] = val + if langDict: + _CACHE[code] = langDict + + logger.info("i18n cache loaded: %d languages, %d total keys", + len(_CACHE), sum(len(v) for v in _CACHE.values())) diff --git a/modules/shared/jsonContinuation.py b/modules/shared/jsonContinuation.py index dd71986e..22180b41 100644 --- a/modules/shared/jsonContinuation.py +++ b/modules/shared/jsonContinuation.py @@ -59,7 +59,7 @@ OVERLAP_MAX_CHARS: int = 1000 # ============================================================================= -class TokenType(Enum): +class JsonTokenType(Enum): """JSON Token Types""" OBJECT_START = "{" OBJECT_END = "}" @@ -77,9 +77,9 @@ class TokenType(Enum): @dataclass -class Token: +class JsonToken: """Represents a JSON token with position info""" - type: TokenType + type: JsonTokenType value: Any start_pos: int end_pos: int @@ -120,7 +120,7 @@ class JsonTokenizer: return self.jsonStr[self.pos] return None - def readString(self) -> Token: + def readString(self) -> JsonToken: """Read a JSON string token""" start_pos = self.pos self.pos += 1 # Skip opening quote @@ -142,15 +142,15 @@ class JsonTokenizer: value = raw[1:-1] # Remove quotes for value except: value = raw - return Token(TokenType.STRING, value, start_pos, self.pos, raw) + return JsonToken(JsonTokenType.STRING, value, start_pos, self.pos, raw) else: self.pos += 1 # String was truncated raw = self.jsonStr[start_pos:self.pos] - return Token(TokenType.TRUNCATED, raw[1:] if len(raw) > 1 else "", start_pos, self.pos, raw) + return JsonToken(JsonTokenType.TRUNCATED, raw[1:] if len(raw) > 1 else "", start_pos, self.pos, raw) - def readNumber(self) -> Token: + def readNumber(self) -> JsonToken: """Read a JSON number token""" start_pos = self.pos @@ -182,54 +182,54 @@ class JsonTokenizer: except ValueError: value = raw - return Token(TokenType.NUMBER, value, start_pos, self.pos, raw) + return JsonToken(JsonTokenType.NUMBER, value, start_pos, self.pos, raw) - def readKeyword(self) -> Token: + def readKeyword(self) -> JsonToken: """Read true, false, or null""" start_pos = self.pos - for keyword, token_type in [('true', TokenType.BOOLEAN), - ('false', TokenType.BOOLEAN), - ('null', TokenType.NULL)]: + for keyword, token_type in [('true', JsonTokenType.BOOLEAN), + ('false', JsonTokenType.BOOLEAN), + ('null', JsonTokenType.NULL)]: if self.jsonStr[self.pos:].startswith(keyword): self.pos += len(keyword) value = True if keyword == 'true' else (False if keyword == 'false' else None) - return Token(token_type, value, start_pos, self.pos, keyword) + return JsonToken(token_type, value, start_pos, self.pos, keyword) # Partial keyword (truncated) while self.pos < self.length and self.jsonStr[self.pos].isalpha(): self.pos += 1 raw = self.jsonStr[start_pos:self.pos] - return Token(TokenType.TRUNCATED, raw, start_pos, self.pos, raw) + return JsonToken(JsonTokenType.TRUNCATED, raw, start_pos, self.pos, raw) - def nextToken(self) -> Token: + def nextJsonToken(self) -> JsonToken: """Get the next token""" self.skipWhitespace() if self.pos >= self.length: - return Token(TokenType.EOF, None, self.pos, self.pos, "") + return JsonToken(JsonTokenType.EOF, None, self.pos, self.pos, "") char = self.jsonStr[self.pos] startPos = self.pos if char == '{': self.pos += 1 - return Token(TokenType.OBJECT_START, '{', startPos, self.pos, '{') + return JsonToken(JsonTokenType.OBJECT_START, '{', startPos, self.pos, '{') elif char == '}': self.pos += 1 - return Token(TokenType.OBJECT_END, '}', startPos, self.pos, '}') + return JsonToken(JsonTokenType.OBJECT_END, '}', startPos, self.pos, '}') elif char == '[': self.pos += 1 - return Token(TokenType.ARRAY_START, '[', startPos, self.pos, '[') + return JsonToken(JsonTokenType.ARRAY_START, '[', startPos, self.pos, '[') elif char == ']': self.pos += 1 - return Token(TokenType.ARRAY_END, ']', startPos, self.pos, ']') + return JsonToken(JsonTokenType.ARRAY_END, ']', startPos, self.pos, ']') elif char == ':': self.pos += 1 - return Token(TokenType.COLON, ':', startPos, self.pos, ':') + return JsonToken(JsonTokenType.COLON, ':', startPos, self.pos, ':') elif char == ',': self.pos += 1 - return Token(TokenType.COMMA, ',', startPos, self.pos, ',') + return JsonToken(JsonTokenType.COMMA, ',', startPos, self.pos, ',') elif char == '"': return self.readString() elif char == '-' or char.isdigit(): @@ -239,7 +239,7 @@ class JsonTokenizer: else: # Unknown character, treat as truncated self.pos += 1 - return Token(TokenType.TRUNCATED, char, startPos, self.pos, char) + return JsonToken(JsonTokenType.TRUNCATED, char, startPos, self.pos, char) @dataclass @@ -632,25 +632,25 @@ class JsonAnalyzer: in_value = False while True: - token = tokenizer.nextToken() + token = tokenizer.nextJsonToken() - if token.type == TokenType.EOF: + if token.type == JsonTokenType.EOF: break - if token.type == TokenType.TRUNCATED: + if token.type == JsonTokenType.TRUNCATED: # Return position before the truncated part break - if token.type in (TokenType.OBJECT_START, TokenType.ARRAY_START): + if token.type in (JsonTokenType.OBJECT_START, JsonTokenType.ARRAY_START): stack_depth += 1 in_value = True - elif token.type in (TokenType.OBJECT_END, TokenType.ARRAY_END): + elif token.type in (JsonTokenType.OBJECT_END, JsonTokenType.ARRAY_END): stack_depth -= 1 last_value_end = token.end_pos in_value = False - elif token.type == TokenType.STRING: + elif token.type == JsonTokenType.STRING: # Check if this is a key or a value saved_pos = tokenizer.pos tokenizer.skipWhitespace() @@ -662,11 +662,11 @@ class JsonAnalyzer: last_value_end = token.end_pos in_value = False - elif token.type in (TokenType.NUMBER, TokenType.BOOLEAN, TokenType.NULL): + elif token.type in (JsonTokenType.NUMBER, JsonTokenType.BOOLEAN, JsonTokenType.NULL): last_value_end = token.end_pos in_value = False - elif token.type == TokenType.COMMA: + elif token.type == JsonTokenType.COMMA: # After a comma, we've completed a value last_complete_pos = last_value_end @@ -714,12 +714,12 @@ class JsonAnalyzer: tokenizer = JsonTokenizer(self.jsonStr) while True: - token = tokenizer.nextToken() + token = tokenizer.nextJsonToken() - if token.type == TokenType.EOF or token.type == TokenType.TRUNCATED: + if token.type == JsonTokenType.EOF or token.type == JsonTokenType.TRUNCATED: break - if token.type == TokenType.OBJECT_START: + if token.type == JsonTokenType.OBJECT_START: frame = StackFrame( type="object", start_pos=token.start_pos, @@ -727,7 +727,7 @@ class JsonAnalyzer: ) self.stack.append(frame) - elif token.type == TokenType.ARRAY_START: + elif token.type == JsonTokenType.ARRAY_START: frame = StackFrame( type="array", start_pos=token.start_pos, @@ -735,24 +735,24 @@ class JsonAnalyzer: ) self.stack.append(frame) - elif token.type == TokenType.OBJECT_END: + elif token.type == JsonTokenType.OBJECT_END: if self.stack and self.stack[-1].type == "object": self.stack.pop() - elif token.type == TokenType.ARRAY_END: + elif token.type == JsonTokenType.ARRAY_END: if self.stack and self.stack[-1].type == "array": self.stack.pop() - elif token.type == TokenType.STRING: + elif token.type == JsonTokenType.STRING: # Could be a key or a value - self._handleStringToken(token, tokenizer) + self._handleStringJsonToken(token, tokenizer) - elif token.type == TokenType.COMMA: + elif token.type == JsonTokenType.COMMA: # Increment array index if self.stack and self.stack[-1].type == "array": self.stack[-1].index += 1 - def _handleStringToken(self, token: Token, tokenizer: JsonTokenizer): + def _handleStringJsonToken(self, token: JsonToken, tokenizer: JsonTokenizer): """Handle a string token (could be key or value)""" if self.stack and self.stack[-1].type == "object": # Check if this is a key (followed by colon) @@ -995,12 +995,12 @@ class JsonAnalyzer: current_key = None while True: - token = tokenizer.nextToken() + token = tokenizer.nextJsonToken() - if token.type == TokenType.EOF: + if token.type == JsonTokenType.EOF: break - if token.type == TokenType.TRUNCATED: + if token.type == JsonTokenType.TRUNCATED: # Mark the truncation point if stack: current = stack[-1] @@ -1020,7 +1020,7 @@ class JsonAnalyzer: }) break - if token.type == TokenType.OBJECT_START: + if token.type == JsonTokenType.OBJECT_START: obj = { 'type': 'object', 'key': current_key, @@ -1032,7 +1032,7 @@ class JsonAnalyzer: stack.append(obj) current_key = None - elif token.type == TokenType.ARRAY_START: + elif token.type == JsonTokenType.ARRAY_START: arr = { 'type': 'array', 'key': current_key, @@ -1044,19 +1044,19 @@ class JsonAnalyzer: stack.append(arr) current_key = None - elif token.type == TokenType.OBJECT_END: + elif token.type == JsonTokenType.OBJECT_END: if len(stack) > 1 and stack[-1].get('type') == 'object': stack[-1]['end_pos'] = token.end_pos stack[-1]['complete'] = True stack.pop() - elif token.type == TokenType.ARRAY_END: + elif token.type == JsonTokenType.ARRAY_END: if len(stack) > 1 and stack[-1].get('type') == 'array': stack[-1]['end_pos'] = token.end_pos stack[-1]['complete'] = True stack.pop() - elif token.type == TokenType.STRING: + elif token.type == JsonTokenType.STRING: # Check if it's a key saved_pos = tokenizer.pos tokenizer.skipWhitespace() @@ -1081,7 +1081,7 @@ class JsonAnalyzer: tokenizer.pos = saved_pos - elif token.type in (TokenType.NUMBER, TokenType.BOOLEAN, TokenType.NULL): + elif token.type in (JsonTokenType.NUMBER, JsonTokenType.BOOLEAN, JsonTokenType.NULL): value_node = { 'type': 'value', 'key': current_key, diff --git a/modules/system/mainSystem.py b/modules/system/mainSystem.py index 9d65ef5b..e3cfe2b0 100644 --- a/modules/system/mainSystem.py +++ b/modules/system/mainSystem.py @@ -15,7 +15,7 @@ logger = logging.getLogger(__name__) # System metadata FEATURE_CODE = "system" -FEATURE_LABEL = {"en": "System", "de": "System", "fr": "Système"} +FEATURE_LABEL = "System" FEATURE_ICON = "mdi-cog" # ============================================================================= @@ -38,13 +38,13 @@ NAVIGATION_SECTIONS = [ # ─── Meine Sicht (with top-level item + subgroups) ─── { "id": "system", - "title": {"en": "MY VIEW", "de": "MEINE SICHT", "fr": "MA VUE"}, + "title": "Meine Sicht", "order": 10, "items": [ { "id": "home", "objectKey": "ui.system.home", - "label": {"en": "Home", "de": "Übersicht", "fr": "Accueil"}, + "label": "Übersicht", "icon": "FaHome", "path": "/", "order": 10, @@ -55,13 +55,13 @@ NAVIGATION_SECTIONS = [ # ── Basisdaten ── { "id": "system-basedata", - "title": {"en": "Base Data", "de": "Basisdaten", "fr": "Données de base"}, + "title": "Basisdaten", "order": 20, "items": [ { "id": "connections", "objectKey": "ui.system.connections", - "label": {"en": "Connections", "de": "Verbindungen", "fr": "Connexions"}, + "label": "Verbindungen", "icon": "FaLink", "path": "/basedata/connections", "order": 10, @@ -69,7 +69,7 @@ NAVIGATION_SECTIONS = [ { "id": "files", "objectKey": "ui.system.files", - "label": {"en": "Files", "de": "Dateien", "fr": "Fichiers"}, + "label": "Dateien", "icon": "FaRegFileAlt", "path": "/basedata/files", "order": 20, @@ -77,7 +77,7 @@ NAVIGATION_SECTIONS = [ { "id": "prompts", "objectKey": "ui.system.prompts", - "label": {"en": "Prompts", "de": "Prompts", "fr": "Prompts"}, + "label": "Prompts", "icon": "FaLightbulb", "path": "/basedata/prompts", "order": 30, @@ -87,13 +87,13 @@ NAVIGATION_SECTIONS = [ # ── Nutzung ── { "id": "system-usage", - "title": {"en": "Usage", "de": "Nutzung", "fr": "Utilisation"}, + "title": "Nutzung", "order": 30, "items": [ { "id": "billing-admin", "objectKey": "ui.system.billingAdmin", - "label": {"en": "Billing", "de": "Abrechnung", "fr": "Facturation"}, + "label": "Abrechnung", "icon": "FaMoneyBillAlt", "path": "/billing/admin", "order": 10, @@ -101,7 +101,7 @@ NAVIGATION_SECTIONS = [ { "id": "statistics", "objectKey": "ui.system.statistics", - "label": {"en": "Statistics", "de": "Statistiken", "fr": "Statistiques"}, + "label": "Statistiken", "icon": "FaChartBar", "path": "/billing/transactions", "order": 20, @@ -109,7 +109,7 @@ NAVIGATION_SECTIONS = [ { "id": "automations", "objectKey": "ui.system.automations", - "label": {"en": "Automations", "de": "Automations", "fr": "Automations"}, + "label": "Automations", "icon": "FaRobot", "path": "/automations", "order": 30, @@ -117,7 +117,7 @@ NAVIGATION_SECTIONS = [ { "id": "store", "objectKey": "ui.system.store", - "label": {"en": "Store", "de": "Store", "fr": "Store"}, + "label": "Store", "icon": "FaStore", "path": "/store", "order": 40, @@ -126,7 +126,7 @@ NAVIGATION_SECTIONS = [ { "id": "settings", "objectKey": "ui.system.settings", - "label": {"en": "Settings", "de": "Einstellungen", "fr": "Paramètres"}, + "label": "Einstellungen", "icon": "FaCog", "path": "/settings", "order": 50, @@ -139,19 +139,19 @@ NAVIGATION_SECTIONS = [ # ─── Administration (with subgroups) ─── { "id": "admin", - "title": {"en": "ADMINISTRATION", "de": "ADMINISTRATION", "fr": "ADMINISTRATION"}, + "title": "Administration", "order": 200, "subgroups": [ # ── Wizards ── { "id": "admin-wizards", - "title": {"en": "Wizards", "de": "Wizards", "fr": "Assistants"}, + "title": "Wizards", "order": 10, "items": [ { "id": "admin-mandate-wizard", "objectKey": "ui.admin.mandateWizard", - "label": {"en": "Mandate Wizard", "de": "Mandanten-Wizard", "fr": "Assistant mandat"}, + "label": "Mandanten-Wizard", "icon": "FaMagic", "path": "/admin/mandate-wizard", "order": 10, @@ -160,7 +160,7 @@ NAVIGATION_SECTIONS = [ { "id": "admin-invitation-wizard", "objectKey": "ui.admin.invitationWizard", - "label": {"en": "Invitation Wizard", "de": "Einladungs-Wizard", "fr": "Assistant d'invitation"}, + "label": "Einladungs-Wizard", "icon": "FaEnvelopeOpenText", "path": "/admin/invitation-wizard", "order": 20, @@ -171,13 +171,13 @@ NAVIGATION_SECTIONS = [ # ── Users ── { "id": "admin-users-group", - "title": {"en": "Users", "de": "Benutzer", "fr": "Utilisateurs"}, + "title": "Benutzer", "order": 20, "items": [ { "id": "admin-users", "objectKey": "ui.admin.users", - "label": {"en": "Users", "de": "Benutzer", "fr": "Utilisateurs"}, + "label": "Benutzer", "icon": "FaUsers", "path": "/admin/users", "order": 10, @@ -186,7 +186,7 @@ NAVIGATION_SECTIONS = [ { "id": "admin-invitations", "objectKey": "ui.admin.invitations", - "label": {"en": "User Invitations", "de": "Benutzer-Einladungen", "fr": "Invitations utilisateurs"}, + "label": "Benutzer-Einladungen", "icon": "FaEnvelopeOpenText", "path": "/admin/invitations", "order": 20, @@ -195,7 +195,7 @@ NAVIGATION_SECTIONS = [ { "id": "admin-user-access-overview", "objectKey": "ui.admin.userAccessOverview", - "label": {"en": "User Access Overview", "de": "Benutzer-Zugriffsübersicht", "fr": "Aperçu des accès utilisateur"}, + "label": "Benutzer-Zugriffsübersicht", "icon": "FaClipboardList", "path": "/admin/user-access-overview", "order": 30, @@ -204,7 +204,7 @@ NAVIGATION_SECTIONS = [ { "id": "admin-subscriptions", "objectKey": "ui.admin.subscriptions", - "label": {"en": "Subscriptions", "de": "Abonnements", "fr": "Abonnements"}, + "label": "Abonnements", "icon": "FaFileContract", "path": "/admin/subscriptions", "order": 40, @@ -215,13 +215,13 @@ NAVIGATION_SECTIONS = [ # ── System ── { "id": "admin-system-group", - "title": {"en": "System", "de": "System", "fr": "Système"}, + "title": "System", "order": 30, "items": [ { "id": "admin-roles", "objectKey": "ui.admin.roles", - "label": {"en": "Roles", "de": "Rollen", "fr": "Rôles"}, + "label": "Rollen", "icon": "FaUserTag", "path": "/admin/mandate-roles", "order": 10, @@ -230,7 +230,7 @@ NAVIGATION_SECTIONS = [ { "id": "admin-mandate-role-permissions", "objectKey": "ui.admin.mandateRolePermissions", - "label": {"en": "Role Permissions", "de": "Rollen-Berechtigungen", "fr": "Permissions des rôles"}, + "label": "Rollen-Berechtigungen", "icon": "FaKey", "path": "/admin/mandate-role-permissions", "order": 20, @@ -239,7 +239,7 @@ NAVIGATION_SECTIONS = [ { "id": "admin-mandates", "objectKey": "ui.admin.mandates", - "label": {"en": "Mandates", "de": "Mandanten", "fr": "Mandats"}, + "label": "Mandanten", "icon": "FaBuilding", "path": "/admin/mandates", "order": 30, @@ -248,7 +248,7 @@ NAVIGATION_SECTIONS = [ { "id": "admin-user-mandates", "objectKey": "ui.admin.userMandates", - "label": {"en": "Mandate Members", "de": "Mandanten-Mitglieder", "fr": "Membres du mandat"}, + "label": "Mandanten-Mitglieder", "icon": "FaUserFriends", "path": "/admin/user-mandates", "order": 40, @@ -257,7 +257,7 @@ NAVIGATION_SECTIONS = [ { "id": "admin-access", "objectKey": "ui.admin.access", - "label": {"en": "Access Management", "de": "Zugriffsverwaltung", "fr": "Gestion des accès"}, + "label": "Zugriffsverwaltung", "icon": "FaBuilding", "path": "/admin/access", "order": 50, @@ -266,7 +266,7 @@ NAVIGATION_SECTIONS = [ { "id": "admin-feature-instances", "objectKey": "ui.admin.featureInstances", - "label": {"en": "Feature Instances", "de": "Feature-Instanzen", "fr": "Instances de features"}, + "label": "Feature-Instanzen", "icon": "FaCubes", "path": "/admin/feature-instances", "order": 60, @@ -275,7 +275,7 @@ NAVIGATION_SECTIONS = [ { "id": "admin-feature-roles", "objectKey": "ui.admin.featureRoles", - "label": {"en": "Feature Role Templates", "de": "Features Rollen-Vorlagen", "fr": "Modèles de rôles features"}, + "label": "Features Rollen-Vorlagen", "icon": "FaShieldAlt", "path": "/admin/feature-roles", "order": 70, @@ -285,7 +285,7 @@ NAVIGATION_SECTIONS = [ { "id": "admin-logs", "objectKey": "ui.admin.logs", - "label": {"en": "Logs", "de": "Logs", "fr": "Logs"}, + "label": "Logs", "icon": "FaFileAlt", "path": "/admin/logs", "order": 90, @@ -295,7 +295,7 @@ NAVIGATION_SECTIONS = [ { "id": "admin-languages", "objectKey": "ui.admin.languages", - "label": {"en": "UI Languages", "de": "UI-Sprachen", "fr": "Langues UI"}, + "label": "UI-Sprachen", "icon": "FaGlobe", "path": "/admin/languages", "order": 95, @@ -376,64 +376,64 @@ DATA_OBJECTS = [ # UAM (User Access Management) - mandantenübergreifend { "objectKey": "data.uam.UserInDB", - "label": {"en": "User", "de": "Benutzer", "fr": "Utilisateur"}, + "label": "Benutzer", "meta": {"table": "UserInDB", "namespace": "uam"} }, { "objectKey": "data.uam.AuthEvent", - "label": {"en": "Auth Event", "de": "Auth-Ereignis", "fr": "Événement d'auth"}, + "label": "Auth-Ereignis", "meta": {"table": "AuthEvent", "namespace": "uam"} }, { "objectKey": "data.uam.UserConnection", - "label": {"en": "Connection", "de": "Verbindung", "fr": "Connexion"}, + "label": "Verbindung", "meta": {"table": "UserConnection", "namespace": "uam"} }, { "objectKey": "data.uam.Mandate", - "label": {"en": "Mandate", "de": "Mandant", "fr": "Mandat"}, + "label": "Mandant", "meta": {"table": "Mandate", "namespace": "uam"} }, { "objectKey": "data.uam.UserMandate", - "label": {"en": "User Mandate", "de": "Benutzer-Mandant", "fr": "Mandat utilisateur"}, + "label": "Benutzer-Mandant", "meta": {"table": "UserMandate", "namespace": "uam"} }, { "objectKey": "data.uam.Invitation", - "label": {"en": "Invitation", "de": "Einladung", "fr": "Invitation"}, + "label": "Einladung", "meta": {"table": "Invitation", "namespace": "uam"} }, { "objectKey": "data.uam.Role", - "label": {"en": "Role", "de": "Rolle", "fr": "Rôle"}, + "label": "Rolle", "meta": {"table": "Role", "namespace": "uam"} }, { "objectKey": "data.uam.AccessRule", - "label": {"en": "Access Rule", "de": "Zugriffsregel", "fr": "Règle d'accès"}, + "label": "Zugriffsregel", "meta": {"table": "AccessRule", "namespace": "uam"} }, { "objectKey": "data.uam.FeatureInstance", - "label": {"en": "Feature Instance", "de": "Feature-Instanz", "fr": "Instance de feature"}, + "label": "Feature-Instanz", "meta": {"table": "FeatureInstance", "namespace": "uam"} }, # Chat - benutzer-eigen, kein Mandantenkontext { "objectKey": "data.chat.Prompt", - "label": {"en": "Prompt", "de": "Prompt", "fr": "Prompt"}, + "label": "Prompt", "meta": {"table": "Prompt", "namespace": "chat", "groupDisabled": True} }, { "objectKey": "data.chat.ChatWorkflow", - "label": {"en": "Chat Workflow", "de": "Chat-Workflow", "fr": "Workflow de chat"}, + "label": "Chat-Workflow", "meta": {"table": "ChatWorkflow", "namespace": "chat", "groupDisabled": True} }, # Files - benutzer-eigen { "objectKey": "data.files.FileItem", - "label": {"en": "File", "de": "Datei", "fr": "Fichier"}, + "label": "Datei", "meta": {"table": "FileItem", "namespace": "files", "groupDisabled": True} }, ] @@ -445,37 +445,37 @@ DATA_OBJECTS = [ RESOURCE_OBJECTS = [ { "objectKey": "resource.store.teamsbot", - "label": {"en": "Store: Teams Bot", "de": "Store: Teams Bot", "fr": "Store: Teams Bot"}, + "label": "Store: Teams Bot", "meta": {"category": "store", "featureCode": "teamsbot"} }, { "objectKey": "resource.store.workspace", - "label": {"en": "Store: AI Workspace", "de": "Store: AI Workspace", "fr": "Store: AI Workspace"}, + "label": "Store: AI Workspace", "meta": {"category": "store", "featureCode": "workspace"} }, { "objectKey": "resource.store.commcoach", - "label": {"en": "Store: CommCoach", "de": "Store: CommCoach", "fr": "Store: CommCoach"}, + "label": "Store: CommCoach", "meta": {"category": "store", "featureCode": "commcoach"} }, { "objectKey": "resource.system.api.auth", - "label": {"en": "Authentication API", "de": "Authentifizierungs-API", "fr": "API d'authentification"}, + "label": "Authentifizierungs-API", "meta": {"endpoint": "/api/auth/*"} }, { "objectKey": "resource.system.api.users", - "label": {"en": "Users API", "de": "Benutzer-API", "fr": "API des utilisateurs"}, + "label": "Benutzer-API", "meta": {"endpoint": "/api/users/*"} }, { "objectKey": "resource.system.api.mandates", - "label": {"en": "Mandates API", "de": "Mandanten-API", "fr": "API des mandats"}, + "label": "Mandanten-API", "meta": {"endpoint": "/api/mandates/*"} }, { "objectKey": "resource.system.api.rbac", - "label": {"en": "RBAC API", "de": "RBAC-API", "fr": "API RBAC"}, + "label": "RBAC-API", "meta": {"endpoint": "/api/rbac/*"} }, ] @@ -487,13 +487,13 @@ def _discoverAicoreProviderObjects() -> List[Dict[str, Any]]: Providers are discovered from the model registry at startup. """ providerLabels = { - "anthropic": {"en": "Anthropic (Claude)", "de": "Anthropic (Claude)", "fr": "Anthropic (Claude)"}, - "openai": {"en": "OpenAI (GPT)", "de": "OpenAI (GPT)", "fr": "OpenAI (GPT)"}, - "mistral": {"en": "Mistral (Le Chat)", "de": "Mistral (Le Chat)", "fr": "Mistral (Le Chat)"}, - "perplexity": {"en": "Perplexity", "de": "Perplexity", "fr": "Perplexity"}, - "tavily": {"en": "Tavily (Web Search)", "de": "Tavily (Websuche)", "fr": "Tavily (Recherche Web)"}, - "privatellm": {"en": "Private LLM", "de": "Private LLM", "fr": "LLM Privé"}, - "internal": {"en": "Internal", "de": "Intern", "fr": "Interne"}, + "anthropic": "Anthropic (Claude)", + "openai": "OpenAI (GPT)", + "mistral": "Mistral (Le Chat)", + "perplexity": "Perplexity", + "tavily": "Tavily (Websuche)", + "privatellm": "Private LLM", + "internal": "Intern", } try: @@ -503,7 +503,7 @@ def _discoverAicoreProviderObjects() -> List[Dict[str, Any]]: objects = [] for provider in providers: - label = providerLabels.get(provider, {"en": provider, "de": provider, "fr": provider}) + label = providerLabels.get(provider, provider) objects.append({ "objectKey": f"resource.aicore.{provider}", "label": label, diff --git a/modules/workflows/automation2/executionEngine.py b/modules/workflows/automation2/executionEngine.py index e7a6645f..97ac1918 100644 --- a/modules/workflows/automation2/executionEngine.py +++ b/modules/workflows/automation2/executionEngine.py @@ -32,6 +32,56 @@ from modules.workflows.automation2.runEnvelope import normalize_run_envelope logger = logging.getLogger(__name__) +_NODE_DEF_BY_ID: Dict[str, dict] = {} + + +def _getNodeDef(nodeType: str) -> Optional[dict]: + """Lookup static node definition by type id (cached).""" + if not _NODE_DEF_BY_ID: + for nd in STATIC_NODE_TYPES: + _NODE_DEF_BY_ID[nd["id"]] = nd + return _NODE_DEF_BY_ID.get(nodeType) + + +def _outputSchemaForNode(nodeType: str) -> Optional[str]: + """Return the output port schema name for a node type (port 0), or None.""" + nd = _getNodeDef(nodeType) + if not nd: + return None + ports = nd.get("outputPorts") + if isinstance(ports, dict): + p0 = ports.get(0) or ports.get("0") + if isinstance(p0, dict): + return p0.get("schema") + return None + + +def _isMergeNode(nodeType: str) -> bool: + return nodeType == "flow.merge" + + +def _allMergePredecessorsReady( + nodeId: str, + connectionMap: Dict[str, List], + nodeOutputs: Dict[str, Any], +) -> bool: + """For flow.merge: check that every connected predecessor has produced output or was skipped.""" + for src, _, _ in connectionMap.get(nodeId, []): + if src not in nodeOutputs: + return False + return True + + +def _normalizeResult(result: Any, nodeType: str) -> Any: + """Apply _normalizeToSchema if the node has a declared output schema.""" + schema = _outputSchemaForNode(nodeType) + if schema and schema != "Transit" and isinstance(result, dict): + try: + return _normalizeToSchema(result, schema) + except Exception: + pass + return result + def _getNodeTypeIds(services: Any = None) -> Set[str]: """Collect all known node type IDs from static definitions.""" @@ -261,6 +311,19 @@ async def executeGraph( nodeOutputs: Dict[str, Any] = dict(initialNodeOutputs or {}) is_resume = startAfterNodeId is not None + + if is_resume and initialNodeOutputs and startAfterNodeId: + resumedNode = next((n for n in nodes if n.get("id") == startAfterNodeId), None) + if resumedNode: + resumedType = resumedNode.get("type", "") + resumedOutput = initialNodeOutputs.get(startAfterNodeId) + if isinstance(resumedOutput, dict): + schema = _outputSchemaForNode(resumedType) + if schema and schema != "Transit": + try: + initialNodeOutputs[startAfterNodeId] = _normalizeToSchema(resumedOutput, schema) + except Exception as valErr: + logger.warning("executeGraph resume: schema validation failed for %s: %s", startAfterNodeId, valErr) if not runId and automation2_interface and workflowId and not is_resume: run_context = { "connectionMap": connectionMap, @@ -491,6 +554,20 @@ async def executeGraph( output={"iterationCount": len(items), "items": len(items)}, durationMs=int((time.time() - _stepStartMs) * 1000)) logger.info("executeGraph flow.loop done: %d iterations", len(items)) + elif _isMergeNode(nodeType): + if not _allMergePredecessorsReady(nodeId, connectionMap, nodeOutputs): + logger.info("executeGraph node %s (flow.merge): waiting — not all predecessors ready, deferring", nodeId) + nodeOutputs[nodeId] = None + continue + _stepStartMs = time.time() + _inputSnap = {} + for src, _, _ in connectionMap.get(nodeId, []): + if src in nodeOutputs: + _inputSnap[src] = nodeOutputs[src] + _stepId = _createStepLog(automation2_interface, runId, nodeId, nodeType, "running", _inputSnap) + result, retryCount = await _executeWithRetry(executor, node, context) + result = _normalizeResult(result, nodeType) + nodeOutputs[nodeId] = result else: _stepStartMs = time.time() _inputSnap = {} @@ -499,6 +576,7 @@ async def executeGraph( _inputSnap[src] = nodeOutputs[src] _stepId = _createStepLog(automation2_interface, runId, nodeId, nodeType, "running", _inputSnap) result, retryCount = await _executeWithRetry(executor, node, context) + result = _normalizeResult(result, nodeType) nodeOutputs[nodeId] = result _durMs = int((time.time() - _stepStartMs) * 1000) _tokens = result.get("tokensUsed", 0) if isinstance(result, dict) else 0 diff --git a/modules/workflows/processing/modes/modeAutomation.py b/modules/workflows/processing/modes/modeAutomation.py index 1d0121b9..f48d509e 100644 --- a/modules/workflows/processing/modes/modeAutomation.py +++ b/modules/workflows/processing/modes/modeAutomation.py @@ -8,7 +8,7 @@ import logging import uuid from typing import List, Dict, Any, Optional from modules.datamodels.datamodelChat import ( - TaskStep, TaskContext, TaskResult, ActionItem, TaskStatus, + TaskStep, TaskContext, ChatTaskResult, ActionItem, TaskStatus, TaskPlan, ActionResult ) from modules.datamodels.datamodelChat import ChatWorkflow @@ -169,7 +169,7 @@ class AutomationMode(BaseMode): return [] async def executeTask(self, taskStep: TaskStep, workflow: ChatWorkflow, context: TaskContext, - taskIndex: int = None, totalTasks: int = None) -> TaskResult: + taskIndex: int = None, totalTasks: int = None) -> ChatTaskResult: """ Execute task using Automation mode - executes predefined actions directly. No AI planning or review phases - actions are executed sequentially as defined. @@ -198,7 +198,7 @@ class AutomationMode(BaseMode): if not actions: logger.error(f"No actions found for task {taskIndex}, aborting") - return TaskResult( + return ChatTaskResult( taskId=taskStep.id, status=TaskStatus.FAILED, success=False, @@ -266,7 +266,7 @@ class AutomationMode(BaseMode): # Persist this action's result so next action can reference it via documentList if getattr(self, "processor", None) and result.documents: try: - from modules.datamodels.datamodelWorkflow import TaskResult as WorkflowTaskResult + from modules.datamodels.datamodelWorkflow import WorkflowTaskResult resultLabel = action.execResultLabel or f"action_{actionNumber}_result" actionResultWithLabel = ActionResult( success=result.success, @@ -306,7 +306,7 @@ class AutomationMode(BaseMode): taskStep, workflow, taskIndex, totalTasks, None ) - return TaskResult( + return ChatTaskResult( taskId=taskStep.id, status=TaskStatus.COMPLETED, success=True, @@ -323,7 +323,7 @@ class AutomationMode(BaseMode): taskStep, workflow, taskIndex, errorSummary ) - return TaskResult( + return ChatTaskResult( taskId=taskStep.id, status=TaskStatus.FAILED, success=False, @@ -335,7 +335,7 @@ class AutomationMode(BaseMode): logger.error(f"Error executing task {taskIndex}: {str(e)}") await self.messageCreator.createErrorMessage(taskStep, workflow, taskIndex, str(e)) - return TaskResult( + return ChatTaskResult( taskId=taskStep.id, status=TaskStatus.FAILED, success=False, diff --git a/modules/workflows/processing/modes/modeBase.py b/modules/workflows/processing/modes/modeBase.py index fe9a5da6..a8a3e048 100644 --- a/modules/workflows/processing/modes/modeBase.py +++ b/modules/workflows/processing/modes/modeBase.py @@ -7,7 +7,7 @@ from abc import ABC, abstractmethod import uuid import logging from typing import List, Dict, Any, Optional -from modules.datamodels.datamodelChat import TaskStep, TaskContext, TaskResult, ActionItem, TaskStatus +from modules.datamodels.datamodelChat import TaskStep, TaskContext, ChatTaskResult, ActionItem, TaskStatus from modules.datamodels.datamodelChat import ChatWorkflow from modules.workflows.processing.core.taskPlanner import TaskPlanner from modules.workflows.processing.core.actionExecutor import ActionExecutor @@ -29,7 +29,7 @@ class BaseMode(ABC): @abstractmethod - async def executeTask(self, taskStep: TaskStep, workflow: ChatWorkflow, context: TaskContext) -> TaskResult: + async def executeTask(self, taskStep: TaskStep, workflow: ChatWorkflow, context: TaskContext) -> ChatTaskResult: """Execute a task step - must be implemented by concrete modes""" pass diff --git a/modules/workflows/processing/modes/modeDynamic.py b/modules/workflows/processing/modes/modeDynamic.py index ab992cd4..67a32a64 100644 --- a/modules/workflows/processing/modes/modeDynamic.py +++ b/modules/workflows/processing/modes/modeDynamic.py @@ -10,7 +10,7 @@ import time from datetime import datetime, timezone from typing import List, Dict, Any from modules.datamodels.datamodelChat import ( - TaskStep, TaskContext, TaskResult, ActionItem, TaskStatus, + TaskStep, TaskContext, ChatTaskResult, ActionItem, TaskStatus, ActionResult, Observation, ObservationPreview, ReviewResult, ReviewContext ) from modules.datamodels.datamodelChat import ChatWorkflow @@ -48,7 +48,7 @@ class DynamicMode(BaseMode): # Dynamic mode generates actions one at a time in the execution loop return [] - async def executeTask(self, taskStep: TaskStep, workflow: ChatWorkflow, context: TaskContext) -> TaskResult: + async def executeTask(self, taskStep: TaskStep, workflow: ChatWorkflow, context: TaskContext) -> ChatTaskResult: """Execute task using Dynamic mode - iterative plan-act-observe-refine loop""" # Get task index from workflow state @@ -335,7 +335,7 @@ class DynamicMode(BaseMode): # Create task completion message (totalTasks not needed - removed from signature) await self.messageCreator.createTaskCompletionMessage(taskStep, workflow, taskIndex, None, completionReviewResult) - return TaskResult( + return ChatTaskResult( taskId=taskStep.id, status=status, success=success, diff --git a/modules/workflows/processing/workflowProcessor.py b/modules/workflows/processing/workflowProcessor.py index 3f83379b..99d8fd63 100644 --- a/modules/workflows/processing/workflowProcessor.py +++ b/modules/workflows/processing/workflowProcessor.py @@ -18,7 +18,7 @@ from modules.shared.jsonUtils import extractJsonString, repairBrokenJson, parseJ from modules.datamodels.datamodelWorkflow import UnderstandingResult if TYPE_CHECKING: - from modules.datamodels.datamodelWorkflow import TaskResult + from modules.datamodels.datamodelWorkflow import WorkflowTaskResult logger = logging.getLogger(__name__) @@ -109,7 +109,7 @@ class WorkflowProcessor: self.services.chat.progressLogFinish(operationId, False) raise - async def executeTask(self, taskStep: TaskStep, workflow: ChatWorkflow, context: TaskContext) -> datamodelChat.TaskResult: + async def executeTask(self, taskStep: TaskStep, workflow: ChatWorkflow, context: TaskContext) -> datamodelChat.ChatTaskResult: """Execute a task step using the appropriate mode""" import time diff --git a/modules/workflows/workflowManager.py b/modules/workflows/workflowManager.py index 3fa6a373..379283b8 100644 --- a/modules/workflows/workflowManager.py +++ b/modules/workflows/workflowManager.py @@ -942,7 +942,7 @@ The following is the user's original input message. Analyze intent, normalize th # Persist task result for cross-task/round document references # Convert ChatTaskResult to WorkflowTaskResult for persistence - from modules.datamodels.datamodelWorkflow import TaskResult as WorkflowTaskResult + from modules.datamodels.datamodelWorkflow import WorkflowTaskResult # Get final ActionResult from task execution (last action result) finalActionResult = None @@ -952,7 +952,6 @@ The following is the user's original input message. Analyze intent, normalize th # Use last action result from context finalActionResult = taskContext.previousActionResults[-1] - # Create WorkflowTaskResult for persistence if finalActionResult: workflowTaskResult = WorkflowTaskResult( taskId=taskStep.id, diff --git a/tests/unit/datamodels/test_workflow_models.py b/tests/unit/datamodels/test_workflow_models.py index ab73f10f..59e3736d 100644 --- a/tests/unit/datamodels/test_workflow_models.py +++ b/tests/unit/datamodels/test_workflow_models.py @@ -19,7 +19,7 @@ from modules.datamodels.datamodelWorkflow import ( RequestContext, UnderstandingResult, TaskDefinition, - TaskResult + WorkflowTaskResult ) from modules.datamodels.datamodelDocref import DocumentReferenceList, DocumentListReference from modules.datamodels.datamodelAi import OperationTypeEnum